文章首发于安全KER https://www.anquanke.com/post/id/224195
0x00 前言
刚开始接触v8方面的漏洞利用,就从这题分享一下我学习的过程。
0x01 前置知识
JIT
简单来说,JS引擎在解析javascript代码时,如果发现js里有段代码一直在做重复的类似操作,比如一个循环语句,且重复次数超过某个阈值,那么就会将这段JS代码翻译为本机的汇编代码,以提高代码的执行速度,这就叫JIT优化,如下的js代码可以触发v8的JIT优化
1 | for (var i=0;i<0x20000;i++) { |
使用./d8 1.js -print-opt-code
,可以查看JIT代码
MAP
map是一个对象,在v8里,每一个js对象内部都有一个map对象指针,v8通过map的值来判断这个js对象到底是哪种类型。
类型混淆(Type Confusion)
如果map的类型发生错误,将会发生类型混淆,比如原本一个存double数值的数组对象,map变成了对象数组的类型,那么再次访问其元素时,取出的不再是一个double值,而是该double值作为地址指向的对象。因此可以用来伪造对象,只需伪造一个ArrayBuffer对象,即可实现任意地址读写。类型混淆往往跟JIT编译后的代码有关,某些情况下JIT即时编译的代码里的判断条件可能考虑的不充分便会发生类型混淆。
V8的数组
V8的数组是一个对象,其条目仍然是一个对象,数据存在条目对象的里,如果是DOUBLE_ELEMENTS
类型,则element对象的数据区直接保存这个double值(64位),如果是其他类型,则将数据包装为一个对象,element对象的数据区将保存这个对象的地址。
element的类型当中,以PACKED
开头的代表这是一个密集型数组(快数组)
;以HOLEY
开头的数组为稀疏数组(慢数组)
数组常见的几种element类型变化情况如下,类型变化只能沿着箭头方向进行,一旦从一种类型变为另一种类型,就不能再逆回去了。
Array(0)和new Array(0)和[]的区别
使用如下代码测试
1 | var a = Array(0); |
测试结果
1 | Array(0): 0x15ed08148565: [JSArray] |
我们看到,Array(0)和new Array(0)产生的对象在不考虑JIT的情况下
是一样的,而[]类型为PACKED_SMI_ELEMENTS,如果考虑了JIT,那么情况会变得复杂,稍后的题中将遇到这种情况。
0x02 漏洞分析
切入点
题目给了我们一个diff文件,以及经过patch后编译的chrome浏览器和v8引擎。其中diff文件如下
1 | diff --git a/src/compiler/load-elimination.cc b/src/compiler/load-elimination.cc |
首先,patch点出现在ReduceTransitionElementsKind
和ReduceTransitionAndStoreElement
函数中,从源文件路径知道这个类跟JIT编译器有关,在某些情况下会影响到编译出的代码。经过个人的研究,发现 ReduceTransitionElementsKind
的作用是为了加快elements
的类型转换,如果在一段会被JIT优化的js代码段中对数组的element进行类型转换操作,就会调用这个函数来构建相关的汇编代码。
小实验
首先b ReduceTransitionElementsKind
和bReduceTransitionAndStoreElement
设置断点,运行如下的测试代码
1 | var a; |
发现确实能够断下来,我们再试试这两段代码,发现都不能下断
1 | var a; |
1 | var a; |
为了解释其中的原因,我们查看一下JIT的汇编代码(截取部分)
第一段js代码的JIT汇编中,Array(0)的创建过程
1 | 0x33ea00084f47 87 49b8e80d0beb79550000 REX.W movq r8,0x5579eb0b0de8 ;; external reference (Heap::NewSpaceAllocationTopAddress()) |
可以看到,在这里,Array(0)初始为了PACKED_SMI_ELEMENTS
类型的数组,因此对其条目赋予double值时,会发生类型转换。
接下来,我们看第二段js代码的JIT代码中创建new Array(0)的部分
1 | 0x57300084f47 87 49b8e83d22e5f8550000 REX.W movq r8,0x55f8e5223de8 ;; external reference (Heap::NewSpaceAllocationTopAddress()) |
可以看到,new Array(0)一开始就是HOLEY_DOUBLE_ELEMENTS
类型,可以满足a[0] = 1.1的操作,不需要再做类型转换。
接下来,我们看第三段js代码的JIT代码中创建[]的部分,发现[]一开始就是PACKED_DOUBLE_ELEMENTS
类型,可以满足a[0] = 1.1的操作,不需要再做类型转换。
1 | 0x30bd00084f47 87 49b8e80d40d4c6550000 REX.W movq r8,0x55c6d4400de8 ;; external reference (Heap::NewSpaceAllocationTopAddress()) |
实验总结
从上面的实验来看,数组的elements类型在JIT下和普通js下是不一样的,JIT会对其进行优化。其中如果是new Array(0)
和[]
创建的数组,那么其数组的elements初始时的类型就已经是目标数据的类型了。因此就不需要再调用ReduceTransitionElementsKind
和ReduceTransitionAndStoreElement
进行类型转换。因此在利用中,我们应该使用Array(0)的方式来创建数组。
漏洞分析
patch了AliasStateInfo alias_info(state, object, source_map);
和state = state->KillMaps(object, zone());
,我们先来看看KillMaps
的源码
1 | LoadElimination::AbstractState const* LoadElimination::AbstractState::KillMaps( |
继续看Kill
的源码,如果有两个node指向同一个对象,则创建了新map。
1 | LoadElimination::AbstractElements const* |
从上面的源码来看,如果有两个node指向的是同一个对象
,那么state = state->KillMaps(object, zone());
就会更新两个node的checkmap,这样后续生成JIT代码时,用不同的node去操作源对象也不会发生问题。为了进一步验证猜想,我们用gdb调试一下。
gdb设置参数,其中–no-enable-slow-asserts是为了能够使用p state->Print()
来查看checkmaps的信息,否则会报错。
1 | set args --allow-natives-syntax ./3.js --no-enable-slow-asserts |
测试代码
1 | function opt(a,b) { |
执行SetMaps之前,因为有a[0] = 1.1;b[0] = 1.1;
,所以它们之前已经是HOLEY_DOUBLE_ELEMENTS
类型
执行之后,由于没有KillMaps,因此b仍然保留为HOLEY_DOUBLE_ELEMENTS
类型
如果接下来执行 b[0] = 1.1;
,按理来说是以HOLEY_DOUBLE_ELEMENTS
的方式向elements里写了一个double值,由于它指向的对象已经变成了HOLEY_ELEMENTS
类型,那么再次从中取元素时,double值被当成对象指针对待,因此通过控制double值,能够使得取出的值作为指针能正好指向我们可控的内存区,那么我们就可以伪造对象了。
然而实际情况是,执行 b[0] = 1.1;
时,仍然是以HOLEY_ELEMENTS
的方式写入,即将1.1包装为一个HeapNumber,然后保存指针到elements。在JIT编译的时候,末尾的两句a[0] = {};
和b[0] = 1.1;
不能同时出现,否则JIT编译器收集到的信息比较充分会使得漏洞利用失败,因此应该想办法让这两句的编译时期分开,由此可以加一个条件判断,这样,两句在编译时期不会同时出现。
1 | function opt(a,b,f1,f2) { |
通过%DebugPrint(a)
查看对象a
1 | DebugPrint: 0x2aa8083dc8e1: [JSArray] |
可以看到,1.1这个double值被误认为是对象指针,由此可以伪造对象。
JIT代码分析
首先poc的前面一大部分操作都是为了生成有问题的JIT代码,LoadElimination::ReduceTransitionElementsKind
是在编译器编译时调用的,而不是JIT代码运行时调用的。JIT编译完成后就不需要再调用这个进行转换了,因为转换的操作已经固化成汇编的形式了。如下是截取的有问题的JIT代码(关键部分)
1 | 0x353900085199 2d9 45398528010000 cmpl [r13+0x128] (root (heap_number_map)),r8 |
我们再看一下在正常的v8引擎中相同部分编译的JIT代码
1 | 0x320a00084f30 70 48b9253930080a320000 REX.W movq rcx,0x320a08303925 ;; object: 0x320a08303925 <Map(HOLEY_DOUBLE_ELEMENTS)> |
可以知道,漏洞的v8的JIT编译的代码正是因为少了这一句map类型的比较,从而导致了类型混淆。
1 | 0x320a000851b7 2f7 394fff cmpl [rdi-0x1],rcx |
漏洞利用
现在的v8存在指针压缩机制(pointer compression),在这种机制下,指针都用4字节来表示,即将指针的基址单独仅存储一次,然后每个指针只需存后4字节即可,因为前2字节一样,这样可以节省空间。这种机制下,堆地址是可以预测的,我们可以申请一个较大的堆空间,这样它的地址在同一台机子上就很稳定基本不变(会随系统的内存以及其他一些配置变化),在不同机子上有微小变化,可以枚举爆破。
只需要伪造一个ArrayBuffer,即可实现任意地址读写,由于本题的v8是linux下的,因此比较好利用,直接泄露栈地址然后劫持栈做ROP即可。
1 | <!DOCTYPE html> |
0x03 感想
通过这一题,学习了v8方面的很多知识,对JIT也有了一定的了解
0x04 参考
强网杯2020线下GooExec
你可能不知道的v8数组优化
深入理解Js数组
(v8 source)elements-kind.h
Google Chrome V8 JIT - ‘LoadElimination::ReduceTransitionElementsKind’ Type Confusion