Adobe漏洞CVE-2018-4490分析
漏洞描述
CVE-2018-4490是Adobe在2018年5月修复的一个Adobe Reader DC的0 day漏洞(官方通告为Double Free漏洞,实际为任意地址释放),改漏洞配合Win32k提权漏洞CVE-2018-8120一起使用实现沙箱逃逸,实现任意代码执行。
受影响版本:Adobe Acrobat and Reader versions 2018.011.20038 and earlier, 2017.011.30079 and earlier, and 2015.006.30417 and earlier
环境搭建
样本:HASH:bd23ad33accef14684d42c32769092a
Windows7 SP1 x86,Windbgx86
Adobe Acrobat Reader Pro DC 2018.011.20035,JP2Klib.lib版本为1.2.2.39492
这里在Windows7 SP1 X64,Windows10 2004中均可以进行调试,在Windows10中调试时需要注意内存地址0d0e0048
可能被占用,需要在安全中心关闭 “验证堆完整性”。
漏洞利用代码分析
使用PDFStramDumper打开漏洞样本,在最后发现了漏洞利用的JS代码
首先我们来看一下调用fun1
之前的部分,前面的dlldata
是漏洞触发之后运行的载荷,用于提权等操作。
1 | var spraylen = 0x10000-24; |
在调用myfun1
函数之前,构造了a1
和sprayarr
两个ArrayNuffer
,通过堆喷进行了内存布局,这里需要注意的是sprayarr
赋值完毕之后对a1
进行了间隔释放,构造出了内存空洞。
完成内存部署之后调用了myfun1,myfun2
函数,推测触发漏洞的是var f1 = this.getField("Button1");
,因为执行完毕之后重新申请一块大小为0x2000-24
大小的ArrayBuffer
之后,sprayarr
中某一个堆块的长度发生了改变,有原来的0x1000
增加为了0x2000
。
由于sprayarr中某一个堆块的大小增加,变为两倍,因此可以修改相邻的堆块的大小(之前sprayarr是连续申请的内存区域),从脚本中可以看出,其将sprayarr的相邻堆块的大小改为了0x66666666
大小,攻击者即可以通过该超长的堆块对全局内存进行读写(myread和mywrite为全局读写函数)。泄露dll库的基址之后,通过伪造bookMarkRoot对象执行rop和载荷。
漏洞调试分析
数据结构分析
Windows中每一个堆块的前面都有8字节的HEAP_ENTRY结构体用于堆的管理。
Array
漏洞样本中用到了两个对象一个是Uint32Array
,一个是ArrayBuffer
,我们先来观察一下这两个对象。在app.alert(1)
之后添加下列代码,更改代码的操作可以通过PDFStreamdumper中的updata_current_stream来进行。
1 | var sprayarr2 = new Array(0x100); |
运行样本,在弹窗之后使用Windbg
附加到进程中,搜索我们设置的tag
Adobe中的对象的存储是通过Value结构体来保存的,除了double和超过32位的整数之外,其他的结构均为高四字节保存类型,低4字节保存值或者实际对象的指针。
1 | JS_ENUM_HEADER(JSValueType, uint8_t) |
我们对应viewContent[0]
的类型中的0xffffff81
中的1
即表示JSVAL_TYPE_INT32
类型,同样剩下的三个均为数组因此类型值均为0xffffff87
中的0x7
。我们看一下sprayarr
数组
我们发现每一个Array对象通过一个0x28大小的结构来存储,其中偏移0xc的位置存储了指向Array内容的指针,偏移0x2c的位置存储了整个Array的大小。
我们查看sprayarr
中存储内容的时候发现,Array.buffer
的内容的起始地址之前应该还存在0x10
字节的内容,通过分析得到0x4
偏移处存储的是Array.buffer
中使用的空间的大小,而0xc
偏移处标记的是Array.buffer
的整体大小。因此Array.buffer
的对象的数据结构如下
ArrayBuffer
接下来我们通过sparyarr
中的元素看一下ArrayBuffer
对象,每一个ArrayBuffer
对象占用0x7a9e4b8-0x7a9e420=0x98
大小的空间,其中偏移0xc
的位置存储了指向ArrayBuffer.buffer
的数据指针。
同Array.buffer
的数据结构相同,ArrayBuffer.buffer
结构中也存在0x10
大小的头部,其中偏移0x4
的位置中存储了用户请求的大小,这里申请的大小为0x10000-24=0xffe8
。注意到此时的ArrayBuffer.buffer
的堆块大小已经是0x10000
大小了。ArrayBuffer
的数据结构如下
我们看一下HEAP_ENTRY的结构
HEAP_ENTRY中第一个四字节存储的是该堆块的大小和前一个堆块的大小,低地址的一个WORD存储的是该堆块的大小也就是0x91ed,高地址的一个WORD存储的是前一个堆块的大小也就是0x7b11,但是当前的存储结果(堆块大小=存储值*堆分配粒度 这里是0x91ed*8)与实际的堆块大小
0x10000
不同,这是因为堆头数据进行了编码。我们可以手动解码首先找到该堆块所在的堆段
我们查看堆结构HEAP中偏移0x50的内容,也就是表示Encoding部分的内容,(EncodeFlagMask表示是否开启了编码,Encoding字段就是用来编码的,编码方式是将Encoding结构与堆头数据进行XOR)
可以看到我们计算出了该堆块的实际大小为0x10000
Unit32Array
接下来我们看一下a1
执行到此处,对a1中奇数下标的对象的释放已经完成,可以看到a1内存中右侧表示对象指针的数据均为0也就是null。已经形成了内存空洞。由于内存是连续申请的,因此从偶数项的内存差值来看,每一个Unit32Array对象的大小为0x58字节
申请的Unit32Array的大小为252,也就是0xfc,从三个相邻的Unit32Array结构来看(第二个已经被释放),其偏移0x20位置存储的是byteLength即以字为单位的大小,而偏移0x40的位置存储的是申请的大小,偏移0x50的位置存储的是Unit32Array.buffer的指针,和前两个数据结构一样,该buffer前面也存在0x10大小的头部
头部中偏移0x4的位置存储的是byteLength,偏移0x8的位置存储的是指向Unit32Array结构体的指针。此时我们也可以看到攻击者提前写入的两个堆块指针。因此Unit32Array结构体如下所示
从后面的对Unit32Array的分析中我们可以得知,其前8字节存储的两个内存指针分别指向上一层结构体和该结构体全局变量地址存储的地址,该全局变量中存储了一些函数指针,推测为结构体相关的虚函数表。
调试分析
漏洞代码定位
首先开启全页堆(对堆漏洞的调试有很大的帮助),定位漏洞触发点。gflags
1 | gflags -p /enable Acrobat.exe /full |
应用程序验证器提供了页堆(PageHeap)机制进行堆破坏的跟踪。页堆机制分为两种一种是普通页堆(Normal PageHeap),一种是全页堆(Full PageHeap)。
普通堆块和页堆块的差别是增加了页堆元数据。同时我们可以从下图中看到页堆可以通过填充模式来检测堆破坏。
可以通过
_DPH_BLOCK_INFORMATION
来查看页堆元数据的具体内容,比较有用的是0x18
位置的栈回溯,可以方便调试者快速定位异常函数全页堆则是在每一个堆块的前后均增加一个不可访问的内存页(防护页),用来检测堆的上溢和下溢
程序运行崩溃之后栈回溯情况如下
我们可以看到发生访问违例发生在堆管理器中,程序正在尝试释放堆块,这很有可能是发生了堆破坏。我们注意到释放的堆地址为0xd0d0d0d0
。这个地址比较特殊,因为这是在开启全页堆之后堆块的后置填充区的填充内容。因此可能发生了堆内存的越界访问。
我们看到调用释放内存的函数是JP2KLib!JP2KCopyRect+0xbae6
,在IDA中看一下此处位置的代码逻辑。
可以看到这是一个循环释放内存的过程,sub_10066FEA
函数即调用了free
函数。将代码整理一下可以得到
1 | // base = *(*(v112 + 0x48) + 0xc) |
do,while
循环的逻辑如下
逻辑很清晰了,base是一个堆块地址的数组,在这个循环中依次释放数组中保存的堆块地址。在windbg中看一下运行过程。关闭全页堆,设置延迟求解的断点
1 | sxe ld JP2KLIB |
结果如下
max_count
的值为0xff
,我们发现只有在最后释放了两个堆块0x0d0e0048,0x0d0f0048
,而这个两个地址是存在于漏洞利用代码中的。接下来我们分析一下为什么会释放这两个地址的堆块。
1 | bu JP2KLib+50567 "r eax; r ecx; .if(ecx>=fd){}.else{g;};"//获取base和当前的count |
eax
即表示的是base
的值即堆块数组的起始地址,而base
被分配的大小是0x3f4
也就是while
循环可以遍历0-0x3f4
之间的内容,而这里max_count
的最大值为0xff
,while
循环可以遍历到0-0xff*4
即0-0x3fc
之间的内容,因此会越界访问两个内存指针,也就是8字节的内容。
而这两个地址恰好是攻击者精心布局之后的地址。
恶意JP2K
那么这个eax
指向的内存是怎么分配出来的呢,通过IDA的追踪我们发现eax
指向的内存来自函数sub_10066EFD
,这里将分配得到的内存记为base
进入到函数中,我们发现其返回值来自于一个函数指针的调用
对应的汇编指令如下
这里是一个间接函数调用,通过调试发现似乎所有的内存申请都是通过该间接调用来完成的。为了找到0x3f4
和0xff
的数据来源,我们通过在base+0x48
和*(base+0x48)+0xc
处下内存断点,找到相关的函数。首先在该函数处(4F692)下断点,得到分配的内存地址之后,在偏移0x48
的位置下内存断点。找到base+0x48
偏移位置分配内存的函数。也可以开启UST (gflags /i Acrobat.exe +ust)
通过分析!heap -p -a alloc_heap_address
产生的StackTrace
来完成。
1 | 0:000> u 5f0612b5 l20 |
这里看到分配了两个空间大小,分配空间的函数是sub10066EFD
,该函数有两个参数,第一个参数是空间的大小。首先是第一个分配,可以看到空间大小的参数来自于ebx
,ebx
的数值来源于ecx/4
,向上回溯可以看到ecx
的值来自于ebp-0x10
位置的值,大小为0x3f4
即最终调用10066EFD(0xfd, 4)
,返回的内存大小是
随后将分配的内存地址保存在ebp-0x8
的位置,接下来分配了0x20
大小的内存空间,将地址保存在了base+0x48
的位置,对*(base+0x48)+0xc
的位置下内存断点,可以找到内存分配的位置
我们可以看到该位置的内存就是保存在ebp-0x8
位置的内存地址
那么0x3f4
是怎么来的呢,我们继续向上回溯,发现该值来自于ebp-0x14
与ebp-0x4
之间的差值
而两个位置的变量的值来自于函数3FD93
,我们看一下函数
a1
也就是ebp-0x14
处的值来自于函数1000B31B
,a3=8
或者a3=16
,最终调试之后发现a3=8
。我们看一下函数1000B21A
可以看到返回值来自于esi+0x10
的取值。在调用函数40DF0
处下断点,之后在1003FDB2
处下断点,这个时候调用B31B
函数就会返回0x3fc
,我们跟进调试一下。B31B
函数根据传递的参数决定读取的字节数(4),将所有的数值相加即得到最终的返回结果。从B21A
函数的反汇编代码来看,函数的返回值主要是读取的*(*(esi+0x10))
处存储的一个字节的值。
从多次调试的结果来看,esi
指向的是一个JP2k
的对象,偏移0xc
的位置存储了图片文件数据的起始地址,偏移0x10
的位置存储了当前读取的位置,偏移0x14
存储了文件结束的位置,偏移0x1c
处存储了已经读取的数据的大小。
最终读取了文件偏移0x2a-0x2d
处的四字节。那么最终申请的内存空间的大小即0x3fc-0x8=0x3f4
。
接下来看一下0xff
是怎么获取的。在*(base+0x48)+0xc
处下内存断点,找到赋值的代码段。
我们可以看到0xff
的数值来自于esi+0x18
处存储的一个字节,从上面对于B21A
函数的分析来看,esi+0x18
处存储的数据是*(*(esi+0x10))
处的数据,也就是JP2K
文件中的数据。这里推测是由于pclr
之后应该仍然存在数据,强行截断之后造成越界读取。
到这里就知道了造成越界释放两个内存指针的数据0x3f4,0xff
均来自于攻击者精心构造的恶意数据。
这里需要注意的是PdfStreamDumper好像无法观察到JP2K图片的二进制数据。
重用已经释放的内存
我们在内存释放的位置下断点
断点断在越界访问释放内存的位置
1 | bu JPKLIB+5056E |
此时两个堆块都处于占用状态,每一个堆块的大小为0x2000*8=0x10000
(该堆段的分配粒度为8bytes,!heap 440000 -v
),接下来释放第一个堆块0x0d0e0048
释放第二个堆块0x0d0f0048
我们发现两个堆块发生了合并,合并后的大小为0x20000
。在fun1
函数中0x200
数组的申请可能是为了消耗内存使得脚本申请的内存空间落在a1数组中。
1 | var f1 = this.getField("Button1");//导致任意地址释放的漏洞触发脚本 |
这里其实我们可以发现分配给eax的内存恰好是a1中存在的某个hole (重启调试,分配的内存值改变0x7bcda10)
通过下面的代码将上述0x20000
的堆块进行重用。
1 | for(var i1=1;i1<0x40;i1++) |
看到sprayarr
数组的第一个元素就重用了堆块。这里之所以是0x0d0e0058
,是因为其要减去0x10
的Buffer
头,再减去0x8
的HeapEntry。此时释放前0x0d0e0048,0d0f0048
分别代表一个长度为0x1000-24
大小的ArrayBuffer
,在UAF之后变成了一个代表长度为0x20000-24
大小的ArrayBuffer
。从代码中可以得到此时sprayaar
数组中的某一个ArrayBuffer
(0x0d0e0048)已经被修改为了0x2000-24
,长度改变之后就可以通过该ArrayBuffer
越界访问修改临近的ArrayBuffer
(0x0d0f0048)的长度为0x66666666
,实现全局内存的读写。
泄露Escript.api基址
接下来我们观察一下后续代码的执行。我们对viewContent中的设置如下
1 | viewContent[0] = 0x12345678; |
内存中的值如下
在获得全局读写能力之后,攻击者尝试获取动态链接库的地址,我们看一下地址的获取过程
1 | var arr1 = new Array(0x10000); |
首先,创建了一个arr1
的Array
对象,将sprayarr[i]
之后的内存块创建Unit32Array
对象,设置第一个元素为索引值。这里的i1
就是sprayarr
中0x0d0f0048
的索引值。从0d0f0058
开始偏移0x30000
的内存地址开始查找两个值,这里其实就是从sprayarr[i+3]
的位置开始查找,最终查找到的偏移值是0x3fff4
发现最终符合要求的是0d0f0048
之后的低四个堆块,由于是一个Unit32Array.buffer
结构,因此我们可以得到一个指向Unit32Array
结构的指针。 mydv.setUint32
最终修改的是biga.getUint32(i2+4,true) - spraypos +0x50 - 0x10 + mydv.base = 0x1ac97138 - 0x0d0f0058 + 0x50 - 0x10 + 0x0d0f0058
即Unit32Array
偏移0x40
位置的值(表示空间大小的变量),可以看到这里已经被改为了0x10000
。将Unit32Array.buffer
的指针赋值给myarraybase
。
从ida
中我们可以得知0x65a83f5c
是全局变量存储的位置,该位置存储了一些函数指针(推测应该是结构体相关的方法函数指针),通过这些函数指针即可以推算出Escript.api
的加载基址。这里通过分析,我们可以得知Unit32Array
数据结构中的前8字节存储的两个指针,第一个指针指向的是其上一层的数据结构(以a1为例,即Array数据结构)的虚函数表,第二个函数指针则指向该结构的虚函数表。
劫持程序流,ROP执行载荷
1 | var bkm = this.bookmarkRoot; |
myarray
的值是0x0d0f0058
。也就是攻击者将rop
和载荷写入到了0d0f0058
内存空间中,rop
和载荷之间存在着滑板指令。攻击者更改了虚函数表中的内容(这里通过截断初始代码构造出pop esp)
0x6b68389f-0x6b640000+dll_base
代码内容为(这里重启调试,基址改变69de0000 )
我们在rop
的第一条指令,0x6b707d06-0x6b640000+dll_base
和0x6b68389f-0x6b640000+dll_base
分别下断点。对objescript
指向的内存位置下内存访问断点。
函数断在了objescript+0x598
的代码调用处,也就是在执行的之后对象会调用虚函数表中偏移0x598
位置的函数。注意到这里的eax
值即为objescript
的值。
在IDA
中查看69e325d3
部分的代码
发生调用的是一个间接调用,我们对返回值69e325d3
下断点
发现其真正调用的函数是69e6eed4
,我们看一下这个函数,在该函数中我们发现了明显的劫持程序流的代码部分
首先将虚表指针赋值到eax中,接着调用偏移0x598
处的函数,也就劫持了程序流。寄存器交换之后即可以重定向程序流到代码0x6b707d06-0x6b640000+dll_base
位置,代码中仍然存在pop esp
进一步改变程序流到ROP
的第二个指令0x6b78845b,0x6b78845a...
最终执行载荷。
总结
漏洞触发和利用的流程如下
- 通过两个
Array
对象a1
和sprayarr
创建大量的Unit32Array
(buffer大小为0x3f0,堆块可用空间0x400)和ArrayBuffer
(buffer大小为0xffe8,堆块可用空间0xfff8)对象,将Unit32Array
中的249
和250
中的元素设置为选定的内存地址(这里攻击者选定的是0x0d0e0048,0x0d0f0048
)。 - 对
a1
中的Unit32Array
进行间隔释放,造成内存空洞,便于之后对JP2K
文件解析时申请的内存落到a1
数组范围中。 - 调用漏洞触发脚本,通过加载特定的
JP2K
图片,特定的大小使得分配的内存(大小为0x3f4
)落到之前a1
释放的某一个Unit32Array.buffer
的内存空间中,而0xff使得函数越界释放两个内存指针。这两个内存指针是a1
中提前布局好的位于偏移0x3f4,0x3f8
位置的脏数据(0x0d0e0040,0d0f0048
指针)。两个堆块释放之后合并,堆块大小为0x20000
,可用空间0x1fff8
。注意到此时sprayarr
数组中仍然存在指向0x0d0e0040,0d0f0048
内存空间的指针 - 通过
sprayarr2
数组分配0x20000
大小的堆块(ArrayBuffer
),对上述合并的堆块进行重用。一旦分配成功sprayarr
中0d0e0048
的ArrayBuffer.buffer
中长度就会被改为0x1ffe8
,此时既可以通过该buffer
访问0d0e0058-0d100040
。通过对该常长的buffer
修改临近的堆块0d0f0048
中的长度为0x66666666
,此时即可通过0d0f0048
表示的ArrayBuffer.buffer
访问全局内存。 - 由于
Unit32Array.buffer
结构体中存在指向父结构Unit32Array
的指针,且Unit32Array
等结构体中存在指向相关虚函数表的内存指针,因此将sprayarr
中0d0f0048
之后的buffer
重新定义为Unit32Array
结构体,找到相关的虚函数表泄露Escript.api
库的基址 - 伪造
bookMarkRoot
对象,覆盖execute
中执行的某个虚表指针为ROP
地址(xchg eax,ebp
),对选自Escript.api
库中的ROP
地址进行重定位,将ROP
和载荷写入到已知地址的地址空间中,通过两次ebp
更改执行流,最终执行ROP和载荷。
补充
攻击者为什么可以确定堆喷之后0d0e0048
就是一个ArrayBuffer.buffer
的起始位置。申请如此巨大的内存的时候,堆管理器一定会commit
一块大的堆段,堆段的起始位置的后四位一定为0
,而堆段需要一个0x40
大小的HEAP_SEGMENT
结构进行管理,因此分配的堆块的起始地址为xxxx0040
,ArrayBuffer.buffer
申请的大小为0x10000-24
,但是实际分配的堆块的大小为0x10000
字节,因此后续分配的堆块都将遵循xxxx0040
的格式,选择其中相邻的两个即可。
参考
CVE-2018-4990 Acrobat Reader堆内存越界访问释放漏洞分析
CVE-2018-4990 Adobe Reader 代码执行漏洞利用分析
Adobe, Me and an Arbitrary Free :: Analyzing the CVE-2018-4990 Zero-Day Exploit