CVE-2015-3864漏洞利用分析(exploit_from_google)

前言

接下来要学习安卓的漏洞利用相关的知识了,网上搜了搜,有大神推荐 stagefright 系列的漏洞。于是开干,本文分析的是 googleexploit. 本文介绍的漏洞是 CVE-2015-3864 , 在 google的博客上也有对该 exploit 的研究。

我之前下载下来了:

pdf版本 的链接:在这里

exploit 的链接: https://www.exploit-db.com/exploits/38226/

分析环境:

1
Android 5.1 nexus4

正文

这个漏洞是一个文件格式相关漏洞,是由 mediaserver 在处理 MPEG4 文件时所产生的漏洞,漏洞的代码位于 libstagefright.so 这个库里面。

要理解并且利用 文件格式 类漏洞,我们就必须要非常清楚的了解目标文件的具体格式规范。

Part 1 文件格式学习

先来一张总体的格式图
paste image

mp4 文件由 box 组成,图中那些 free, stsc等都是box, box 里也可以包含 box ,这种 box 就叫 containerbox .

  • 每个 box 前四个字节为 boxsize

  • 第二个四字节为 boxtypebox typeftyp,moov,trak 等等好多种,moovcontainerbox ,包含 mvhdtrakbox

还有一些要注意的点。

  • box 中存储数据采用大端字节序存储
  • size 域为 0时,表示这是文件最后一个 box
  • size 为1 时,表示这是一个 large box ,在 type 域后面的 8 字节 作为该 box 的长度。

下面来看两个实例。

实例一

paste image

  • size 域为 00000014,所以该 box长度为 0x14 字节。
  • type 域为 66 74 79 70 所以 typefytp
  • 剩下的一些信息是一些与多媒体播放相关的一些信息。与漏洞利用无关,就不说了。

实例二

paste image

  • size 域为1,表示从该 box 开头偏移8字节开始的8字节为 size 字段, 所以该 box 的大小为 0xFFFFFFFFFFFFFF88
  • typetx3g

现在我们对该文件的格式已经有了一个大概的了解,这对于漏洞利用来说还不够,接下来我们要去看具体的解析该文件格式的代码是怎么实现的。

解析文件的具体代码位于 MPEG4Extractor.cpp 中的 MPEG4Extractor::parseChunk 函数里面。
该函数中的 chunk 对应的就是 box, 函数最开始先解析 typesize .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 开始4字节为 box 大小, 后面紧跟的 4 字节为 box type
uint64_t chunk_size = ntohl(hdr[0]);
uint32_t chunk_type = ntohl(hdr[1]); //大端序转换
off64_t data_offset = *offset + 8; // 找到 box 数据区的偏移
// 如果size区为1, 那么后面8字节作为size
if (chunk_size == 1) {
if (mDataSource->readAt(*offset + 8, &chunk_size, 8) < 8) {
return ERROR_IO;
}
chunk_size = ntoh64(chunk_size);
data_offset += 8;
if (chunk_size < 16) {
// The smallest valid chunk is 16 bytes long in this case.
return ERROR_MALFORMED;
}
} else if (chunk_size < 8) {
// The smallest valid chunk is 8 bytes long.
return ERROR_MALFORMED;
}

通过注释和代码,我们知道对于 size 的处理和前面所述是一致的。然后就会根据不同的 chunk_type ,进入不同的逻辑,
paste image

如果 box 中还包含 子 box 就会递归调用该函数进行解析。

Part 2 漏洞分析

CVE-2015-3864 漏洞产生的原因是,在处理 tx3g box时,对于获取的 size 字段处理不当,导致分配内存时出现整数溢出,进而造成了堆溢出。

paste image

size 为之前所解析的所有 tx3g box 的长度总和。chunk_size 为当前要处理的 tx3g box 的长度。然后 size + chunk_size 计算要分配的内存大小。 chunk_sizeuint64_t 类型的,chunk_size 我们在文件格式中我们所能控制的最大大小为 0xFFFFFFFFFFFFFFFF ( 看 part1 实例二 ) ,也是 64 位,但是我们还有一个 size 为可以控制,这样一相加,就会造成 整数溢出 , 导致分配小内存。而我们的 数据大小则远远大于分配的内存大小,进而造成堆溢出

Part 3 漏洞利用

概述

现在我们已经拥有了堆溢出的能力,如果是在 ptmalloc 中,可以修改下一个堆块的元数据来触发 crash ,甚至可能完成漏洞利用。不过从 android 5开始,安卓已经开始使用 jemalloc 作为默认的堆分配器。

jemalloc 中,小内存分配采用 regions 进行分配, region 之间是没有 元数据 的 (具体可以去网上搜 jemalloc 的分析的文章),所以 在 ctf 中常见的通过修改 堆块元数据 的漏洞利用方法在这里是没法用了。

不过所有事情都有两面性。region 间是直接相邻的,那我就可以很方便的修改相邻内存块的数据。 如果我们在 tx3g 对应内存块的后面放置一个含有关键数据结构的内存块,比如一个对象,在 含有虚函数 的类的 对象开始4字节(32位下),会存放一个 虚表指针 .

对象 调用 虚函数 时会从 虚表指针 指向的位置的 某个偏移(不同函数,偏移不同) 处取到相应的函数指针,然后跳过去执行。

如果我们修改对象的虚表指针,我们就有可能在程序调用虚函数时,控制程序的流程。

一些重要的 chunk_type(box type)

tx3g box

上一节提到,我们可以修改对象的虚表指针,以求能够控制程序的跳转。那我们就需要找到一个能够在解析 box 数据能时分配的对象。

MPEG4DataSource 就是这样一个类。

paste image

可以看到该对象继承自 DataSource, 同时还有几个虚函数。

我们可以在ida中看看虚表的构成。

paste image

可以看到 readAt 方法在虚表的第7项,也就是虚表偏移 0x1c 处。同时MPEG4DataSource在我这的大小为 0x20 .再看一下漏洞位置的代码。

paste image
可以看到如果当前解析的 tx3g box 不是第一个tx3g box(即size>0),会先调用 memcpy , 把之前所有 tx3g box中的数据拷贝到刚刚分配的内存。

如果我们先构造一个 tx3g ,其中包含的数据大于 0x20, 然后在构造一个 tx3g 构造大小使得 size+chunk_size = 0x20, 然后通过 memcpy 就可以覆盖 MPEG4DataSource 的虚表了。exploit 中就是这样干的。

pssh box

看看代码

paste image
划线位置说明了 pssh 的结构。

1
2
3
4
5
6
7
8
9
10
pssh 的结构
开始8字节 表示 该 box 的性质
00 00 00 40 70 73 73 68
size: 0x40,
type: pssh :
+ 0xc 开始 16字节 为 pssh.uuid
+ 0x1c开始4字节为 pssh.datalen
+ 0x20 开始为 pssh.data
可以查看 代码,搜索关键字: FOURCC('p', 's', 's', 'h')

这里先分配 pssh.datalen 大小的内存,然后把 pssh.data 拷贝到刚刚分配的内存。完了之后会把 分配到的 PsshInfo 结构体增加到 类属性值 Vector<PsshInfo> mPssh 中, mPsshMPEG4Extractor::~MPEG4Extractor()中才会被释放。

paste image

所以在解析完 MPEG4格式前,通过 pssh 分配的内存会一直在内存中。

avcC box 和 hvcC box
这两个 box 的处理基本一致,以 avcC 为例进行介绍。解析代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
case FOURCC('a', 'v', 'c', 'C'):
{
// 这是一块临时分配, buffer 为智能指针,在 函数返回时相应内存会被释放。
sp<ABuffer> buffer = new ABuffer(chunk_data_size);
if (mDataSource->readAt(
data_offset, buffer->data(), chunk_data_size) < chunk_data_size) {
return ERROR_IO;
}
// 在这里,会释放掉原来那个,新分配内存来容纳新的数据。
// 因此我们有了一个 分配,释放 内存能力
// setData 中会释放掉原来的buf, 新分配一个 chunk_data_size
mLastTrack->meta->setData(
kKeyAVCC, kTypeAVCC, buffer->data(), chunk_data_size);
*offset += chunk_size;
break;
}

首先根据 chunk_data_size 分配 ABufferbufferchunk_data_sizeboxsize 域指定,注意buffer是一个智能指针,在这里,它会在函数返回时释放。

ABuffer 中是直接调用的 malloc 分配的内存。
paste image
接下来读取数据到 buffer->data(), 最后调用 mLastTrack->meta->setData 保存数据到 meta, 在 setData 内部会先释放掉之前的内存,然后分配的内存,存放该数据,此时分配内存的大小还是chunk_data_size, 我们可控。

paste image

hvcC 的处理方式基本一样。所以通过这两个 box 我们可以 分配指定大小的内存,并且可以随时释放前面分配的那个内存块 。我们需要使用这个来布局tx3g内存块 和 MPEG4DataSource内存块。

修改对象虚表指针

下面结合exploit 和上一节的那几个关键 box ,分析通过布局内存,使得我们可以修改 MPEG4DataSource 的虚表指针。
为了便于说明,取了 exploit 中的用于 修改对象虚表指针的相关代码进行解析 ( 我调试过程做了部分修改 )
paste image

首先看到第7,8行,构造了第一个 tx3g box, 大小为 0x3a8, 后面在触发漏洞时,会先把这部分数据拷贝到分配到的小内存buffer中,然后会溢出到下一个 regionMPEG4DataSource内存块。使用 cyclic 可以在程序 crash 时,计算 bufferMPEG4DataSource 之间的距离。

13 行,调用了 memory_leak 函数, 该函数通过使用 pssh 来分配任意大小的内存,在这里分配的是 alloc_size ,即 0x20. 因为MPEG4DataSource 的大小为 0x20 ,就保证内存的分配会在同一个 run 中分配。这些这样这里分配了 40x20 的内存块,我认为是用来清理之前可能使用内存时,产生的内存碎片,确保后面内存分配按照我们的顺序进行分配。此时内存关系

1
| pssh | - | pssh |

1725 行,清理内存后,开始分配 avcChvcC, 大小也是 0x20, 然后在第 25 行又进行了内存碎片清理,原因在于我们在分配 avcChvcC时,会使用到 new ABuffer(chunk_data_size),这个临时的缓冲区,这个会在函数返回时被释放(请看智能指针相关知识)

paste image
同时多分配了几个 pssh 确保可以把 avcChvcC包围在中间。所以现在的内存关系是

1
| pssh | - | pssh | pssh | avcC | hvcC | pssh |

然后是 第 29 行, 再次分配 hvcC ,不过这次的大小 为 alloc_size * 2, 触发 hvcC 的释放,而且确保不会占用 刚刚释放的 内存.(jemalloc中 相同大小的内存在同一个run中分配)

1
| pssh | - | pssh | pssh | avcC | .... | pssh |

接下来构造 stblMPEG4DataSource 占据刚刚空出来的 内存。

1
| pssh | - | pssh | pssh | avcC | MPEG4DataSource | pssh |

接下来, 第 38 行用同样的手法分配释放 avcC

1
| pssh | - | pssh | pssh | .... | MPEG4DataSource | pssh |

然后使用整数溢出,计算得到第二个 tx3g 的长度值,使得最后分配到的内存大小为0x20, 用来占据刚刚空闲的 avcC 的 内存块,于是现在的内存布局,就会变成这样。

1
| pssh | - | pssh | pssh | tx3g | MPEG4DataSource | pssh |

然后在

paste image
就会溢出修改了 MPEG4DataSource 的虚表指针。然后在下面的 readAt 函数调用出会 crash.

我测试时得好几次才能成功一次,估计和内存碎片相关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Thread 10 received signal SIGSEGV, Segmentation fault.
0xb66b57cc in android::MPEG4Extractor::parseChunk (this=this@entry=0xb74e2138, offset=offset@entry=0xb550ca98, depth=depth@entry=0x2) at frameworks/av/media/libstagefright/MPEG4Extractor.cpp:1905
1905 if ((size_t)(mDataSource->readAt(*offset, buffer + size, chunk_size))
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[ registers ]────
$r0 : 0xb74e27b8 → 0x61616169 ("iaaa"?)
$r1 : 0xb74e2bb8 → 0x00000000
$r2 : 0x61616169 ("iaaa"?)
$r3 : 0x00000000
$r4 : 0xb550c590 → 0x00000428
$r5 : 0xfffffbf8
$r6 : 0xb550c580 → 0xb74e5c98 → 0x28040000
$r7 : 0xb550c570 → 0xfffffbf8
$r8 : 0xb74e2138 → 0xb6749f18 → 0xb66b2841 → <android::MPEG4Extractor::~MPEG4Extractor()+1> ldr r3, [pc, #188] ; (0xb66b2900 <android::MPEG4Extractor::~MPEG4Extractor()+192>)
$r9 : 0x74783367 ("g3xt"?)
$r10 : 0xb550ca98 → 0x01000a98
$r11 : 0xb74e2790 → 0x28040000
$r12 : 0x00000000
$sp : 0xb550c530 → 0xb74e2bb8 → 0x00000000
$lr : 0xb66b57bd → <android::MPEG4Extractor::parseChunk(long+0> ldr r1, [r4, #0]
$pc : 0xb66b57cc → <android::MPEG4Extractor::parseChunk(long+0> ldr r6, [r2, #28]
$cpsr : [THUMB fast interrupt overflow carry ZERO negative]
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
$r0 : 0x00000000
$r1 : 0xb74e2bb8 → 0x00000000
$r2 : 0x61616169 ("iaaa"?)
$r3 : 0x00000000
$r4 : 0xb550c590 → 0x00000428
$r5 : 0xfffffbf8
$r6 : 0xb550c580 → 0xb74e5c98 → 0x28040000
$r7 : 0xb550c570 → 0xfffffbf8
$r8 : 0xb74e2138 → 0xb6749f18 → 0xb66b2841 → <android::MPEG4Extractor::~MPEG4Extractor()+1> ldr r3, [pc, #188] ; (0xb66b2900 <android::MPEG4Extractor::~MPEG4Extractor()+192>)
$r9 : 0x74783367 ("g3xt"?)
$r10 : 0xb550ca98 → 0x01000a98
$r11 : 0xb74e2790 → 0x28040000
$r12 : 0x00000000
$sp : 0xb550c530 → 0xb74e2bb8 → 0x00000000
$lr : 0xb66b57bd → <android::MPEG4Extractor::parseChunk(long+0> ldr r1, [r4, #0]
$pc : 0xb66b57cc → <android::MPEG4Extractor::parseChunk(long+0> ldr r6, [r2, #28]
$cpsr : [THUMB fast interrupt overflow carry ZERO negative]

可以看到断在了<android::MPEG4Extractor::parseChunk(long+0> ldr r6, [r2, #28],去 ida 里面找到对应的位置。

paste image
r2存放的就是虚表指针,可以确定成功修改了 虚函数表指针。

paste image

偏移也符合预期。

堆喷射

上面我们已经成功修改了MPEG4DataSource 的虚表指针,并在虚函数调用时触发了 crash .

我们现在能够修改对象的 虚表指针,并且能够触发虚函数调用。我们需要在一个可预测的内存地址精准的布置我们的数据,然后把虚表指针修改到这里,在 exploit 中使用了

1
2
3
4
spray_size = 0x100000
spray_count = 0x10
sample_table(heap_spray(spray_size) * spray_count)

来进行堆喷射

heap_spray 函数 就是使用 pssh 来喷射的内存。每次分配 0x100 页,共分配了 0x10 次。 exploit 作者在 博客中写道,这样就可以在可预测的内存地址中定位到特定数据。在这里就是 用于 stack_pivotgadget.

对于这一点,我很疑惑,有大佬可以告诉我为什么可以这样吗? 或者有没有相关的 paper 来介绍为什么可以在 可预测的地址 精确的布置我们的数据

最后

这个 exploit 写的确实强悍,提示我在进行漏洞利用时,要关注各种可能分配内存的地方,灵活的使用代码中的内存分配,来布局内存。 同时研究一个漏洞要把相关知识给补齐。对于这个漏洞就是 MPEG4 的文件格式和 相关的处理代码了。

一些tips:

  • 使用 gef + gdb-multiarch 来调试 , pwndbg 我用着非常卡, gef 就不会
  • 调试过程尽量使用脚本减少重复工作量。

使用的一些脚本。

使用 gdbserver attach mediaserver 并转发端口的脚本

1
2
3
4
5
adb root
adb forward tcp:1234 tcp:1234
a=`adb shell "ps | grep mediaserver" | awk '{printf $2}'`
echo $a
adb shell "gdbserver --attach :1234 $a"

gdb 的调试脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
set arch armv5
gef-remote 127.0.0.1:1234
set solib-search-path debug_so/
directory android-5.1.0_r3/
gef config context.layout "regs -source"
set logging file log.txt
set logging on
break frameworks/av/media/libstagefright/MPEG4Extractor.cpp:1897
break frameworks/av/media/libstagefright/MPEG4Extractor.cpp:1630
break frameworks/av/media/libstagefright/MPEG4Extractor.cpp:1647
break frameworks/av/media/libstagefright/MPEG4Extractor.cpp:884
commands 1
p chunk_size
p buffer
c
end
commands 2
p buffer
end
commands 3
p buffer
c
end
commands 4
hexdump dword mDataSource 0x4
c
end

参考:

https://census-labs.com/media/shadow-infiltrate-2017.pdf

https://googleprojectzero.blogspot.hk/

http://blog.csdn.net/zhuweigangzwg/article/details/17222951

使用 PhantomJS 来实现 CTF 中的 XSS 题目

零:CTF、XSS 的概念

CTF 在这个博客中提到的已经很多了,它是一类信息安全竞赛,在比赛中,选手通过各种方式,从题目给出的文件或者网址中,获取到某一特定格式的字符串。

CTF 中比较常见的一个题型就是 XSS(跨站脚本攻击),大概的原理就是服务端没有正确过滤用户的输入,导致用户提交的畸形字符串被插入到网页中并被解析成 JavaScript 执行。在 CTF 中,XSS 一般用来拿管理员的 Cookie,伪造身份登录后台,再进行后续的渗透(顺便提一下,现在大部分网站的敏感 Cookie 都被设成了 HTTP Only,因此 XSS 是没法拿到的,需要用其它的方法)。

一个非常简单的反射型 XSS 注入如下(为了突出重点,我就不把页面写的这么完整了,一般的 CTF 题目也鲜有很符合规范的页面):

1
2
3
4
5
<html>
<body>
Hello <?php echo $_GET['name']; ?>!
</body>
</html>

如果我们输入的网址中,name 参数值为 rex<script>alert(1)</script>,那么整个网页会变成这样:

1
2
3
4
5
<html>
<body>
Hello rex<script>alert(1)</script>!
</body>
</html>

页面上就会有一个弹框。当然,如果能成功 alert(1),那么一般来说大概应该可能有其它方法来获取 Cookie,因此比较简单的 XSS 的检测方式通常是看页面上能否 alert(1)。

当然,XSS 还有其它方法,例如在一个论坛上发帖内容为 <img src=# onerror=alert(1)>,而这个论坛也没做输入过滤,那么这段恶意代码就会一直保留在这个帖子里,基本每个点进来的人都会遭殃。此为存储型 XSS。

就算服务端做了一些过滤,黑客也可能会绕过。例如服务端的过滤如下:

1
2
3
function escape($str) {
return preg_replace('/<script>/', '', $str);
}

想绕过的话,只需要使用 <scr<script>ipt>alert(1)</script> 即可,左边被过滤之后剩下的刚好又拼接成了一个 <script> 标签。

有一个很好玩的网站:alert(1) to win,是我在大一的时候某只姓三的学长给我的。这个网站给了你 escape 函数,你的目标就是输入 input,使其通过 escape 函数之后依旧可以 alert 出数字 1(注意是数字 1,不是字符串 1)。这个网站的题目对于目前的我来说还是比较难的,如果大家有兴趣,可以去挑战一下。

一:PhantomJS 的概念

我之前对电脑的认识是非常肤浅的。第一次听说虚拟机居然还可以跑在命令行下的时候,我心想:虚拟机软件本身没有图形界面,那你该怎么显示虚拟机里面的图形呢?后来特么又看到了 PhantomJS,居然是个没有图形界面的浏览器!当时还心想,这玩意又没法给人看,会有啥用啊……

后来接触了爬虫之后才逐渐理解了这玩意的用途。它是一个通过命令行和 API 操作的、没有图形界面的浏览器,专注于自动化测试、爬虫等不需要人们浏览,但需要获取数据的一些场合。

如果觉得 PhantomJS 官方的文档太多懒得看,针对一些简单的编程,看阮老师的这篇文章也可以:PhantomJS – JavaScript 标准参考教程(alpha)

二:基于 PhantomJS 的 CTF-XSS-Checker 的实现

我的思路大概是参照了上面的网站实现的,但是上面的 escape 函数是返回了一个过滤之后的字符串,而我打算直接用 eval 方法。

先放一下界面好啦!可以看到,上面网站中的 escape 函数被我改成了 check,里面会有一句 eval

0

由于 Node.js 与 PhantomJS 的交互最为简单,因此后端使用 Node.js 来编写。思路其实很简单:启动一个服务器,针对前端的静态文件直接返回文件内容(当然,这一点也可以用 Nginx 代劳),针对题目生成对应的题目网页,针对 /check 路由根据 POST body 进行 XSS 判断。

具体的路由逻辑我就不写了,毕竟即使不会开服务器,不会写路由,使用 koa 等框架也能很轻松地实现。这里重点说一下前后端的检验流程。写一个网页解释器实在是太难,而且也不值得,所以最简单的方法就是不如就让它 alert 成功,只不过我们修改一下 alert 函数罢了。

前端先生成一个隐藏的 iframe,通过劫持里面的 onerrorconsole.logalert 等函数来处理,通过 HTML5 Message API 在父页面和 iframe 之间传递信息。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
window.onerror = function (a) {
parent.postMessage({
error: a.toString()
}, "*");
};
window.console = window.console || {};
window.console.log = function (a) {
parent.postMessage({
console: a
}, "*");
};
window.alert = function (a) {
if (a === 1)
parent.postMessage({
success: 1
}, "*");
else if (a == 1)
parent.postMessage({
warning: "You should alert *NUMBER* 1."
}, "*");
else {
parent.postMessage({
warning: "You need to alert 1."
}, "*");
}
};
window.onmessage = function (a) {
try {
check(a.data);
} catch(e) {
parent.postMessage({
error: e.toString().split("\\n")[0]
}, "*");
};
};

然后父页面通过返回的数据来处理就可以了,例如 onerror 的时候就将下面的黄条变红,并显示传过来的信息,如果 success 了,就将数据发给服务端进行验证。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// script 就是上面说的要嵌入 iframe 里面的代码
iframe.src = 'data:text/html,' + encodeURIComponent(problemText.replace(/\n\s*/g, '')) + script;
iframe.onload = function () {
this.contentWindow.postMessage(textarea.value, '*');
};
// 父页面通过 iframe 传回来的信息进行相应的处理
window.onmessage = function (e) {
var d = e.data;
console.log(d);
if (d.success !== undefined) {
tab.className = 'rs-tab rs-tab-success';
tab.innerText = 'Local check passed, running server check...';
// server check
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
tab.innerText = 'Server response: \'' + xhr.responseText + '\'.';
}
}
};
xhr.open('POST', '/check', true);
xhr.send(JSON.stringify({
id: location.pathname.match(/^\/(\d+)$/)[1],
ans: textarea.value,
}));
} else if (d.warning !== undefined) {
tab.className = 'rs-tab rs-tab-warning';
tab.innerText = d.warning;
} else if (d.error !== undefined) {
tab.className = 'rs-tab rs-tab-danger';
tab.innerText = d.error;
output.innerText = '';
} else if (d.console !== undefined) {
output.innerText = d.console;
}
};

这样本地的检验就可以啦!去看看服务端的 /check 是怎么写的。由于服务端是接收 JSON 返回 JSON 的,因此如果出了结果,直接输出一段 JSON 即可。假设我们已经想办法获取到了用户输入(上面那段代码中的 ans)、检验函数(之前提到的 check),那么可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var input = /* 获取到的 ans */;
var outputStr = '';
function output(obj) {
outputStr = JSON.stringify(obj);
}
window.onerror = function (a) {
output({ error: a.toString() });
}
window.alert = function (a) {
if (a === 1) {
output({ success: 1 });
} else if (a == 1) {
output({ error: "You should alert *NUMBER* 1." });
} else {
output({ error: "Server check failed, you need to alert 1." });
}
};
/* 在这儿注入 check 函数的实现 */
try {
check(input);
} catch (e) {
output({ error: e.toString().split("\\n")[0] });
} finally {
return outputStr;
}

说了这么多流程,终于要用到 PhantomJS 啦!我们需要用它创建一个页面,执行上面的代码,获取返回结果,并且在用户提交耗资源的操作(例如死循环)时及时将其关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
var phantom = require('phantom');
var phInstance = null;
var exitted = false;
phantom.create()
.then(instance => {
phInstance = instance;
return instance.createPage();
})
.then(page => {
var script = /* 上面提到的 script */;
var evaluation = page.evaluateJavaScript(script);
evaluation.then(function (html) {
html = JSON.parse(html);
if (html.success) {
res.write('Check passed, flag: ' + /* 对应题目的 flag */);
res.end();
} else {
res.write(html.error);
res.end();
}
if (!exitted) {
phInstance.exit();
exitted = true;
}
});
})
.catch(error => {
console.log(error); // eslint-disable-line no-console
if (!exitted) {
phInstance.exit();
exitted = true;
res.write('PhantomJS error');
res.end();
}
});
// prevent time limit exceeded
setTimeout(function () {
if (!exitted) {
phInstance.exit();
exitted = true;
res.write('TLE');
res.end();
}
}, 5000);
}

最后配上一点样式,就大功告成啦!

1

2

3


P.S. 即将到来的 NUAACTF 会使用这个程序来设计 XSS 题目。当然,题目与 Flag 均不是本文中放出来的这些。

P.S.S. Chrome 目前也推出了 Headless 模式,而且支持 Chrome 的全部特性。PhantomJS 作者表示自己要失业了……23333

2016 全国大学生网络安全邀请赛暨第二届上海市大学生网络安全大赛决赛酱油记

这次比赛的形式跟暑假那次一样,也是攻防赛,选手需要维护三个服务,其中一个是二进制,两个是 Web。不过这次的网络跟上次的不一样,三个服务都处于选手自己的内网环境(172.16.9.X,每道题是一个 X)中,只对外网(172.16.5.X,X = 队名 + 9)暴露了必须的端口。不过思路还是一样,先把代码 Dump 下来,然后看看里面有什么漏洞,然后尝试利用。这次比赛不提供外网,而且只有四个小时(十分钟一轮),因此可能会困难一些。

我们队伍三个人,就我是搞 Web 的,剩下的两个学弟都是搞 PWN 的,因此果断让他俩去搞 PWN 了,我一个人慢慢看 Web。这篇文章就对这两道 Web 题逐题分析吧,最后再写写我踩过的坑,因为现场的时候,一道题卡住了就去做另一道,如果按照时间写的话可能会很乱。

Web1

Web1 是搭在 Windows 2003 上的,需要远程桌面连接过去。打开 Web 目录之后发现两个项目,一个是开在 8081 上的 HDwiki,另一个是开在 8082 上的 finecms。我只看了 HDwiki 这一块,看到 index.php 里面第一句就是 @eval($_POST['HDwiki']),这特么是个菜刀啊!然而我当时太傻逼,以至于没有用菜刀来验证,而是直接向其 post 各种各样的信息,结果发现均没法执行。在这儿耽误了好长时间,比赛结束后才想起来有可能是开了 WAF,如果是这样,那么普通的菜刀可能并不好使了,需要有绕过 WAF 功能的才可以。

Web2

Web2 开了两个端口 8081 和 8082,然而看源码发现 8082 只有一个 index.html 文件,里面的内容就是 8082,所以这个端口是没意义的。把 8081 端口对应的文件下下来,发现是个 CodeIgniter 的项目。首先将 ENVIRONMENT 改成 production,然后在 production 中禁用全部的报错。然后在 static/test-run/debug.php 中发现了一段代码:

1
2
3
4
5
6
$conn = @mysql_connect($db['default']['hostname'], $db['default']['username'], $db['default']['password']) or die("connect failed" . mysql_error());
@mysql_select_db($db['default']['database'], $conn);
$result = @mysql_query('select init from system', $conn);
eval('$system_info='.@mysql_fetch_row($result)[0].';');
echo "We are ".$system_info['owner'] ."<br>";
echo "Our taget is " .$system_info['taget']."<br>";

并搞不懂,不过可以看出来一点:它说数据库的 system 表中会有一个数据,于是看到 system 表,里面只有一个 init 字段,写了 ownermember_keytaget(居然不是 target),然而并没输出 member_key,可见其重要性。于是全局搜索一下,在 application/controllers/Download.php 中发现了这样一段:

1
2
3
function do_download($file){
force_download(APPPATH . pathcoder($file,$this->system_model->sys_select()['member_key']), NULL);
}

跳到 force_download 函数,发现它并没有检验传入的文件名,刚好 flag 相对于 index.php 的路径(index.php 是框架的单一入口)是 ../flag,因此可以直接访问 /download/do_download/xxx.html 即可下载到 flag,其中 xxx 是经过加密后的 ../flag。于是看到 pathcoder 函数:

1
2
3
4
5
$ckey_length = 4;
$key = md5($key);
$keya = md5(substr($key, 0, 16));
$keyb = md5(substr($key, 16, 16));
// ....

只有解密算法,没有加密算法,这是让我们自己反推?然而这一串 md5……

愣了一会儿,突然反应过来,这段代码不就是 uc_authcodediscuz 中的对称加密算法)么?!对比了一下,跟 uc_authcodeDECODE 模式完全一样,当然,key 就是 member_key 了。然而我用 uc_authcode 加密之后却没法用 pathcoder 解密,不知道是什么情况,由于已经没有时间了,因此只能作罢。

踩过的坑

不要随便删文件

我一开始在 HDwiki 中看到了一个 check.php,与业务逻辑没有任何关系,只是查看文件 md5 的,因此就顺手将其重命名为 check.php233 了,结果我们的 Web1 被判定为 down,于是才知道这是主办方的存活检测(哪有这样做检测的啊),于是将其恢复之后就好了。

不要随便 die

我在 Web2 中发现了那个 debug.php,最下面有一个可能是变量覆盖的漏洞,我就在这之前添加了一句 die();,结果又被判定为 down 了……

不要随便改权限

之前想到 static 文件夹可能会有执行权限,于是我执行了 chmod -x static(嗯,没有递归),结果被判定为 down 了……然而我一直没反应过来(只想着 down 可能是文件哪里改坏了),因此导致我们 down 了 18 轮,基本全部的失分都是在这里。

Web 可能有 WAF,可能有函数限制?

不光是 HDwiki 的那个菜刀用不了,我自己写的一些一句话也用不了。而且靶机中没有 tcpdump,于是我还想跟暑假一样在一开始输出 $_SERVER['PHP_SELF'] 和全部的 header、body 来分析流量,然而不管是 fwrite 还是 fputs 甚至是 file_put_contents 都失效了。由于没有经验,所以我不知道是否还有别的抓流量的方法,这直接导致了我没法重放其它队伍的攻击流量(不然可以自己写个脚本得点分了)。


最终的结果是,我们由于防守的好(也可能是时间太短,其它漏洞都没人找到),只被 3years 打了一轮 Web1,剩下的失分都是因为 down。如果有上面这些经验的话,就不至于失掉那么多分,名次还会再往前进一波。

0

2016 全国大学生网络安全邀请赛暨第二届上海市大学生网络安全大赛 Writeup

天知道为啥这比赛的名字这么长……还是写一写我过的那些题吧!

[Web] 仔细

打开发现是一个伪 nginx 测试页面,然后扫了一下发现有个 /log 文件夹,打开发现里面记录了 access.log,猜想里面有访问记录,于是下载下来直接搜 200,搜到一条记录:

1
http://xxx.xxx.xxx.xxx:15322/wojiushiHouTai888/denglU.php?username=admin&password=af3a-6b2115c9a2c0&submit=%E7%99%BB%E5%BD%95

打开发现里面有 flag:flag{ff11025b-ed80-4c42-afc1-29b4c41010cb}

[Web] 威胁(1)

根据提示“管理于2016年建成该系统”,生成一个 2016 年所有日期的字典,最终爆破出密码是 20160807,登进去发现 flag:flag{2eeba717-0d8e-4a7e-9026-e8e573afb99b}

[Web] 威胁(2)

源代码注释中提示用户名为 test,密码为 123456,然而最后猜出来真正的用户名应该是 guest,登录之后发现 flag:flag{6db7d9f2-e7cf-4986-b116-e1810e6e4176}

[Web] 物超所值

点击网页中的 SHOP NOW 按钮提示金钱不足,抓包没发现数据,说明是前端验证。查看网页源代码发现了点击按钮执行的函数,直接在 console 里面执行“验证成功”的句子:document.getElementById('Shop').submit(),依旧提示金钱不足,抓包发现提交了 id=25535&Price=10000 这段数据,将 Price 改为 0.01,在网页中发现了一行代码:

1
confirm('Purchase success,flag is:flag{e6985c27-0353-4dc4-83dc-1833426779a0}');

[Web] 分析

打开是个静态页面,扫了一下发现了 http://xxx.xxx.xxx.xxx:1999/administrator.php,在页面最下方的注释中发现账号 administrator administrator,登录后提示 IP 不在许可范围内,抓包看了一下发现一段注释:<!--许可ip:localhost/127.0.0.1-->,于是修改 HTTP 头添加 X-Forwarded-For: 127.0.0.1,得出结果:flag{33edd0c8-3647-4e09-8976-286ed779e5d3}

[Web] 抢金币

一开始以为是暑假某场比赛的原题,结果发现直接输入验证码抢劫会被抓,被抓之后就没法买 flag 了。后来发现,只要先让服务器生成一遍验证码,再直接抢,发的包中不带 code 参数,就可以(也仅可以)抢劫一次且不被抓。于是写脚本每隔一秒发两个请求(一个生成验证码,一个抢劫),等金币大于 1000 之后访问 getflag.php,即可得到 flag:flag{defee21d-4e09-41fa-aab4-052bd3d406c6}

[Crypto] 洋葱

这题简直能用“恶心”来形容。下载下来的附件是一个 7zip 文件,可以查看文件列表,但打开文件和解压都需要密码。文件列表中有四个文件:CRC32 Collision.7zpwd1.txtpwd2.txtpwd3.txt,猜想后三个拼起来就是压缩包的密码。根据第一个文件名的提示,应该是用 CRC32 的碰撞来解。三个密码文件的 CRC32 分别是 7C2DF918A58A19264DAD5967,大小均为六个字节,于是尝试用 hashcat 来跑所有长度为六位的明文,然而跑出来的并不像最终答案。后来想到 CRC32 的碰撞是非常多的,因此得想办法跑出所有的解。经队友助攻,将 hashcat64.exe0x4d79a 处的 0001 5000 改成 0005 5000 即可。三个 CRC32 一共输出了 509 个解,人工分析了一下,分别找出了一个最可能是解的答案,拼起来即可得到解压密码:_CRC32_i5_n0t_s4f3

接下来的 CRC32 Collision.7z 文件解压之后有 Find password.7zciphertext.txtkeys.txt 几个文件,是一个维吉尼亚密码,其中给了一万个 key,有一个是正确的……随手写了段程序分别用这一万个 key 对密文解密,猜想英文中的 the 出现频率比较高,于是直接在结果中搜 the(首尾带空格),看到一段话:

1
the vigenere cipher is a method of encrypting alphabetic text by using a series of different caesar ciphers based on the letters of a keyword it is a simple form of polyalphabetic substitution so password is vigenere cipher funny

因此密码为 vigenere cipher funny,从而进入下一层。

这一层的提示中给了一个不完整的明文 *7*5-*4*3? 和不完整的 sha1 值 619c20c*a4de755*9be9a8b*b7cbfa5*e8b4365*,其中每一个星号对应一个可打印字符。对 sha1 中的星号枚举 [0-9a-f] 并将其放到字典中,对明文中的星号枚举 ASCII 为 33~127 的字符并将其 sha1 后查找是否在字典中出现,最终得出答案:I7~5-s4F3? 619c20c4a4de75519be9a8b7b7cbfa54e8b4365b,进入下一层。

本层题目有两个提示,一个是 Hello World ;-),另一个是 两个程序 md5 相同,百度搜索 md5 相同 Hello World,可以搜到两个程序:HelloWorld-colliding.exeGoodbyeWorld-colliding.exe,其中第一个输出是 Hello World ;-),第二个循环输出 Goodbye World :-(,这个就是解压密码。但是原则上来说,有无数个具有不同输出的程序都可以跟第一个程序的 md5 相同,因此感觉这一层出的有问题。

最后一层给了一个 flag.encrsa_public_key.pem,可以通过 RsaCtfTool 直接生成私钥:

1
2
$ python RsaCtfTool.py --pkey rsa_public_key.pem --pri > private.key
$ openssl rsautl -decrypt -in flag.enc -inkey private.pem -out flag.txt

最终得出 flag:flag{W0rld_Of_Crypt0gr@phy}

[Misc] 面具

将图片下载下来分析一下:

1
2
3
4
5
6
7
8
9
$ binwalk C4n_u_find_m3_DB75A15F92D15B504D791F1C02B8815C.jpg
DECIMAL HEX DESCRIPTION
-------------------------------------------------------------------------------------------------------
12 0xC TIFF image data, little-endian
478718 0x74DFE Zip archive data, at least v2.0 to extract, compressed size: 153767, uncompressed size: 3145728, name: "flag.vmdk"
632637 0x9A73D End of Zip archive
$ dd if=C4n_u_find_m3_DB75A15F92D15B504D791F1C02B8815C.jpg of=flag.zip bs=1 skip=478718

解压 flag.zip 是一个 vmdk 文件,使用高版本的 WinHex 打开后,点击菜单中的“专业工具→将镜像文件转换为磁盘”,然后找到 分区1,里面有两个文件夹 key_part_onekey_part_two,第一个文件夹里面有一段 Brainfuck 代码,运行结果为 flag{N7F5_AD5;第二个里面没有有意义的文件,但是根目录下还有个曾经被删除过的文件(WinHex 提示:曾经存在的,数据不完整),打开是一段 Ook 程序,运行结果为 _i5_funny!},拼起来即可得到 flag:flag{N7F5_AD5_i5_funny!}


下面是队友们做出来的题。

[Basic] 签到题

rar 解压,密码 ichunqiu&dabiaojie,flag{ctf_1s_interesting}。

[Web] 跳

访问首页得到测试账号 testtest,猜测 admintest。拿到 token,访问 admin.php 得 flag。

[Crypto] 简单点

Brainfuck 解密得到 flag{url_lao_89}

[Misc] 大可爱

把那张图 binwalk 解压出来,得到 29.zlib。解压 zlib:

1
2
3
4
5
6
7
import zlib
with open("29.zlib","rb") as fp:
s = fp.read()
ds = zlib.decompress(s)
dsss = zlib.decompress(ds[0x28D28D:])
with open("decode","wb") as fp:
fp.write(dsss.decode("hex"))

之后再 binwalk 解压,得到一个加密的 zip 文件,密码在注释:M63qNFgUIB3hEgu3C5==,解压得 flag:flag{PnG_zLiB_dEc0mPrEsS}

[Reverse] re400

先用 IDA 载入 re400.static,发现这程序:

  1. 没有引用动态库,给我们这一堆没有符号的动态库的出题人是想作甚?
  2. 什么符号都没有…还是先跑一下,看有什么提示吧。

运行程序之后,随便输入一些数据,程序打印出 Wrong.,然后就退出了。IDA 里搜一下 Wrong. 这个字符串,看一下所在函数,基本可以推断出这个 sub_400CF0 就是 main 函数,大概的代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
const char *expect = "OSHzTJ4pwFgRG6eS6y3xVOOEGcbE5rzwqTs7VCK6ACQLuiTamZpXcQ==";
int main() {
char input[256];
read(0, input, 256);
if (check(input, expect, strlen(expect)) {
puts("Right.");
} else {
puts("Wrong.");
}
return 0;
}

那个 expect 怎么看都像是 base64 编码的字符串,试着解码后,只知道它的内容长度是 40,没看出什么规律。

然后重点是 check 函数(sub_401510)到底是怎么检查输入数据的。但点开 check 函数后,里面一大堆 call 指令实在看得有点慌…点开第一个 call sub_429AE0 就看到一堆 xmm 寄存器,正常写出来的代码,基本不会被编译出用 xmm 寄存器的,所以猜测这应该是个 libc 的库函数。另找一个带有符号的 libc,用 IDA + bindiff 插件,尝试匹配其中的无名函数。虽然会有一些误判,大胆猜测一下,给一些函数重命名:

1
2
3
4
5
* sub_429AE0: strlen
* sub_402730: malloc
* sub_42C990: memset
* sub_432520: strcpy
* sub_402710: free

开始一边运行,一边分析 check 函数:

1
2
3
4
5
6
7
8
9
0x40153E - 0x40154C: 判断了 strlen(input) <= 5,如果成立的话,就直接退出了。因此输入的长度必须大于 5
0x401552 - 0x401576: 是一个以 rax 为循环变量的循环,检查了输入的前5个字符是否满足 [0-9A-Z]{5}
0x401578 - 0x4015E5: 申请了两块内存空间并初始化,然后把栈上的两块空间清零
0x4015EE - 0x4015FF: 循环,把输入的前五个字节复制到栈内存
0x401601 - 0x40163B: 有三个 call,先看第一个 call sub_401CA0,看到 0x10325476 之类的数字,md5 无疑了,仔细阅读一下,这块代码是在计算输入数据前五个字节的 md5
0x401648 - 0x401662: 又一个循环,循环变量应该是 r15,每次递增 8,与输入长度 r12d 进行比较,然后 call sub_401B30 的参数有三个:
* lea rdi, [rbx+r15] arg[0]:输入数据
* lea rsi, [rsp+30h] arg[1]:上一步算出来的 md5 的前半部分
* lea rdx, [rbp+r15] arg[2]:第一块 malloc(strlen(input)+16) 的空间

至于那个 sub_401B30 到底是做什么的,(一开始)实在没搞懂,暂时不管它,接着分析代码。

1
2
3
4
0x401674 - 0x4016AD: 看到 call 了 malloc 和 memset,然后有一个 call sub_401120,分析它参数:
* mov rdi, rbp arg[0]:上一步用 sub_401B30 算出来的结果
* mov rsi, r13 arg[1]:刚刚 malloc 的空间
* mov edx, r12d arg[2]:strlen(input),aligned 8

调试时,运行过这个 call 后,发现 arg[1] 的空间里是一个 base64 编码的字符串,解码后就是 arg[0] 的内容,确定这个 sub_4011120 应该就是 b64encode(src, dst, len)。剩下的代码,就是把这个 base64 编码的字符串和 expect 比较了。至此,剩下一个关键的 sub_401B30

花了好几个小时,重写出里面调用到的几个子函数,每个函数都用到了几十到几百字节的非线性变换。虽然不是特别难的事情,但工作量太大了…直到开始重写最后一个子函数的时候,注意到这个循环的流程是这样的:

1
2
3
4
5
6
7
8
* call sub_401930
* call sub_4018C0
* call sub_401AA0
* loop xor
* call sub_401930
* call sub_4018C0
* call sub_401AA0
* loop xor

为啥我联想到了密码学里的 Feistel 网络…然后再看这个函数最底下的那个

1
.text:0000000000401C6F call sub_4018C0

sub_4018C0 是一个相对简单的变换,我想,这该不会是 DES 加密吧?验证一下,这果然就是 DES。

(`□′)╯┴┴
(╯°Д°)╯︵ ┻━┻
(╯#-_-)╯~~~~~╧═╧

算法就是:base64(DES.new(key=md5(input[:5]).digest()[:8], mode=DES.MODE_ECB).encrypt(input))

写一个程序,穷举输入前五个字节,能找到唯一匹配 expect 的输入:SHSECflag{675ac45bc131a1b7c145b605f4ba5}

附求解程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#include <mbedtls/des.h>
#include <mbedtls/md5.h>
int main(int argc, char *argv[]) {
int pos = atoi(argv[1]);
const uint8_t *t = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
uint8_t plain[8];
uint8_t cipher[9];
uint8_t key[16];
uint8_t target[] = {0x39,0x21,0xf3,0x4c,0x9e,0x29,0xc0,0x58,0x11,0x1b,0xa7,0x92,0xeb,0x2d,0xf1,0x54,0xe3,0x84,0x19,0xc6,0xc4,0xe6,0xbc,0xf0,0xa9,0x3b,0x3b,0x54,0x22,0xba,0x00,0x24,0x0b,0xba,0x24,0xda,0x99,0x9a,0x57,0x71};
mbedtls_des_context des;
cipher[8] = 0;
for (uint8_t a0 = 0; a0 < 36; a0++) {
plain[0] = t[a0];
for (uint8_t a1 = 0; a1 < 36; a1++) {
plain[1] = t[a1];
printf("status %c%c\r", plain[0], plain[1]);
fflush(stdout);
for (uint8_t a2 = 0; a2 < 36; a2++) {
plain[2] = t[a2];
for (uint8_t a3 = 0; a3 < 36; a3++) {
plain[3] = t[a3];
for (uint8_t a4 = 0; a4 < 36; a4++) {
plain[4] = t[a4];
mbedtls_md5(plain, 5, key);
mbedtls_des_init(&des);
mbedtls_des_setkey_dec(&des, key);
mbedtls_des_crypt_ecb(&des, target, cipher);
if (memcmp(cipher, plain, 5) == 0) {
printf("found %s\n", cipher);
for (size_t t = 0; t < sizeof(target); t += 8) {
mbedtls_des_crypt_ecb(&des, target + t, cipher);
printf("%s", cipher);
}
printf("\n\n");
}
}
}
}
}
}
return 0;
}