Math.expm1的一个issue
环境搭建
漏洞issue:https://bugs.chromium.org/p/chromium/issues/detail?id=880207
Ubuntu20.04LTS x64
1 | git checkout 7.0.290 |
为了能消除checkbounds节点,还需要在args.gn里加一句v8_untrusted_code_mitigations = false
1 | cat args.gn |
漏洞分析
漏洞代码:
1 | // src/compiler/typer.cc |
该函数是在turbofan的typer阶段对JSCall节点进行类型推断的时候调用的,可以看到如果调用的函数是MathExpm1,它将推断该JSCall的type为Type::Union(Type::PlainNumber(), Type::NaN(), t->zone());
,也就是说是 Type::PlainNumber() U Type::NaN()
。从以下定义:
1 |
|
可以看出-0,NaN都是比较特别的,而 Type::PlainNumber() U Type::NaN()
并为包含-0,但是MathExpm1实际在运行的时候的的确确会返回-0:
1 | V8 version 7.0.290 |
这导致turbofan对Math.expm1的返回类型判读出错,而该出错会继续影响到后续的优化阶段
poc
1 | function foo() { |
存在漏洞的版本会输出:
1 | true |
在优化后,返回了错误的结果
exploit
根据https://bugs.chromium.org/p/chromium/issues/detail?id=762874 issue里的手法,就是利用turbofan对数组范围的预测错误从而消除checkbounds节点来造成越界读写,比如:
1 | function foo(x){ |
大概就是这么个意思
回到漏洞,在turbofan执行的过程中,有3个阶段都会对节点的类型进行推断,分别是typer,load elimination和simplified lowering这3个阶段,而且两个阶段还会进行常量折叠,如果在前两个阶段Object.is的结果被标记为false,那Object.is节点就会被简单的替换成一个false的常量节点,这样就无法进行后续的利用,这点可以在turbolizer中看到:
Object.is这里是一个SameValue节点,在该阶段后,其就被换成了一个false的常量:
而为了在simplified lowering阶段消除checkbounds节点,我们应该要使其在前两个阶段(typer和load elimination)不被常量折叠,这时候就要依靠load elimination和simplified lowering两个阶段间的escape analysis阶段,该阶段简单来说就是分析该函数里的对象有没有逃逸(我的理解是没有逃出该函数的作用域),这样就不需要在堆中分配该对象,直接使用栈或者寄存器(这样会更高效),以下是一个简单的例子:
1 | function foo(){ |
会被替换成:
1 | function foo(){ |
所以我们把-0藏在一个对象中,修改后的poc:
1 | function foo(x) { |
运行后会输出一个不是0.1的浮点数,foo也不会因为out-of-bound而被deopt
foo(“0”)
这里有个细节就是我们用的是foo(“0”);来触发优化,而不是foo(-0),这样做的原因是,如果只是foo(-0)或者foo(0),就是纯数字的话,Math.expm1会变成NumberExpm1节点而不是JSCall了,而SameValue会在loadelimination阶段后变成false节点:
load elimination阶段:
这导致后续漏洞无法利用了,因为false节点会变成0;该替换是发生在loadelimination阶段的ConstantFoldingReducer里
经过调试发现,在loadelimination阶段的TypeNarrowingReducer里,samevalue节点的输入1,也就是LoadField类型被推断为-0而不是Number,导致后面samevalue直接被替换为false,因为Type::PlainNumber() U Type::NaN()
不包含-0;
而在换成了foo(“0”)之后,turbofan其实编译了两遍foo函数,第一次生成的也是NumberExpm1节点,但是由于我们是传入的参数为“0”,是一个字符串,所以第一次被优化的foo函数会被deopt,第二次turbofan编译foo函数的时候,它知道了参数不一定是Number,所以生成的就是JSCall节点(builtin的Math.exmp1函数可以接受任意类型的参数)
但是还有个疑问就是不知道为啥在第二次编译foo后,与samevalue节点相连的LoadField节点在TypeNarrowingReducer类型就被推断为Number了
漏洞利用
前面我们可以越界读了,同理我们也可以越界写:
1 | function foo(x) { |
gdb中:
1 | 4096 |
oob_arr的element就在它自己的低地址处,ArrayBuffer和oob_arr相邻,长度也被我们改为了0x1000
参考链接
https://bugs.chromium.org/p/chromium/issues/detail?id=880207