Chrome V8 PWN从入门到精通

第一章 基础知识

1-1 主流浏览器及其Javascript Engine介绍

现代浏览器通常由渲染引擎(Rendering Engine)和 JavaScript 引擎(JavaScript Engine)组成。

浏览器 开发商 渲染引擎 / 内核 JS 引擎 特点
Chrome Google Blink (Chromium) V8 市场占有率最高,V8 引擎性能强劲,也是 Node.js 的运行时。
Firefox Mozilla Gecko SpiderMonkey 开源精神的代表,SpiderMonkey 是最早的 JS 引擎。
Safari Apple WebKit JavaScriptCore (JSC) iOS/macOS 默认浏览器,JSC 在 iOS 上有特殊的 JIT 权限处理。
Edge (新版) Microsoft Blink (Chromium) V8 放弃了自研的 EdgeHTML/ChakraCore,全面拥抱 Chromium 生态。

V8 是 Google 用 C++ 编写的开源高性能 JavaScript 和 WebAssembly 引擎。它被用于 Chrome 和 Node.js 等。

V8 的执行管线经历了多次迭代,目前的架构主要包含以下部分:

  1. Parser (解析器): 将 JavaScript 源代码解析成抽象语法树 (AST)。
  2. Ignition (解释器): 这是一个基于寄存器的解释器。它将 AST 转换为字节码 (Bytecode) 并执行。Ignition 的主要目的是减少内存消耗并加快启动速度。
  3. TurboFan (优化编译器): 当某些代码块(如热点函数)被频繁执行时,Ignition 会收集类型反馈信息 (Type Feedback),并将其发送给 TurboFan。TurboFan 利用这些信息生成高度优化的机器码。如果假设的类型信息错误(例如变量类型发生了变化),V8 会进行 Deoptimization (去优化),回退到 Ignition 执行字节码。
  • 历史组件 (已废弃)
    • Full-Codegen: 早期的基线编译器,直接生成未优化的机器码。
    • Crankshaft: 早期的优化编译器,后被 TurboFan 取代。

在浏览器安全研究(Browser Pwn)中,V8 是主要的攻击面之一。攻击者通常利用 V8 在优化过程中的逻辑错误(如边界检查消除错误、类型推断错误等)来实现内存破坏,最终达到远程代码执行 (RCE) 的目的。

1-2 v8环境搭建

安装 depot_tools

1
2
3
4
# linux
git config --global http.proxy 'socks5://127.0.0.1:10808'
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=$PATH:/path/to/depot_tools

获取V8源码

1
2
3
4
5
6
7
export http_proxy="socks5://127.0.0.1:10808"
export https_proxy="socks5://127.0.0.1:10808"
fetch v8 # 大约1.55G,所以可能有点久
cd v8
# 如果要编译特定版本的 V8(例如复现某个 CVE)
git checkout <commit-hash>
gclient sync

1-3 JS Object 基本概念

在 JavaScript 中,对象(Object)是属性(Property)的集合。理解 JS 对象在引擎层面的实现是漏洞利用的基础。

从规范角度看,JS 对象是一个关联数组(字典),将键(Key,通常是字符串或 Symbol)映射到值(Value)。

1
2
3
4
5
let obj = {
a: 1,
b: "test",
1: 100
};

在 V8 中,属性主要分为两类:

  • Named Properties (命名属性): 键为字符串的属性,例如 obj.a
  • Elements (索引属性): 键为整数的属性,例如 obj[1]

V8 对这两类属性采用不同的存储策略,以优化访问速度。

原型链 (Prototype Chain)

每个 JS 对象都有一个内部链接指向另一个对象,即它的原型(prototype)。该原型对象也有自己的原型,直到某个对象的原型为 null 为止。

当我们访问 obj.x 时:

  1. V8 首先查找 obj 自身是否有属性 x
  2. 如果没有,则沿着原型链查找 obj.__proto__
  3. 依次类推,直到找到或到达链尾。

对象的创建

1
2
3
4
5
6
7
8
9
let o1 = {}; // 字面量

function Point(x, y) {
this.x = x;
this.y = y;
}
let o2 = new Point(1, 2); // 构造函数

let o3 = Object.create(null); // Object.create

V8 中的 Hidden Class (Map)

为了加速属性访问,V8 引入了 Hidden Class(在 V8 源码中称为 Map)。

  • 具有相同属性结构(属性名、顺序相同)的对象共享同一个 Map。
  • Map 记录了属性名到内存偏移量的映射。
  • 当对象添加新属性时,Map 会发生“转换”(Transition),指向一个新的 Map。
1
2
3
let o = {};     // Map0
o.x = 1; // Map0 -> Map1 (添加 x)
o.y = 2; // Map1 -> Map2 (添加 y)

如果代码中反复执行相同的属性访问操作,V8 会利用 Inline Cache (IC) 缓存 Map 信息,从而快速定位属性,避免昂贵的哈希查找。

1-4 V8 通用 Object 结构

在 V8 内存(Heap)中,一个标准的 JSObject 通常由三个主要指针组成(在开启指针压缩的情况下是 32 位偏移量,否则是 64 位指针)。一个基本的 JSObject 在内存中通常长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
+-------------------+
| Map (指针) | <-- 描述对象的结构 (Hidden Class)
+-------------------+
| Properties (指针) | <-- 存储额外的命名属性 (Out-of-object properties)
+-------------------+
| Elements (指针) | <-- 存储索引属性 (Array elements)
+-------------------+
| In-object Prop 1 | <-- 对象内部直接存储的属性
+-------------------+
| In-object Prop 2 |
+-------------------+
| ... |
+-------------------+
组件名称 描述 关键点 / 攻击点
Map (Hidden Class) 对象头部的第一个字,描述对象的类型、大小、属性布局等信息。 攻击点: 覆盖 Map 指针可制造“类型混淆”(Type Confusion),通过错误的类型解析实现非法内存读写(如利用浮点数数组伪造对象指针)。
Properties (Backing Store) 存储对象的命名属性。属性较少时直接存在对象内(In-object),过多时指向外部的 FixedArray 属性存储位置的切换(In-object vs Out-of-object)是性能优化的关键。
Elements (Backing Store) 指向存储整数索引属性(数组元素)的 FixedArrayFixedDoubleArray 数组元素通常单独分配空间,不存储在对象内部。

指针压缩

在较新的 V8 版本(大约 2020 年以后)中,默认开启了指针压缩。

  • V8 堆被限制在 4GB 范围内。
  • 堆中的指针不再是 64 位的完整地址,而是 32 位的偏移量(Compressed Pointer)。
  • Decompression: Base Address (r13 寄存器) + Compressed Pointer
  • Smi (Small Integer): 仍然是 32 位,但低位标志位不同。
    • Smi: (Value << 1) | 0 (最低位为 0)
    • HeapObject Pointer: Address | 1 (最低位为 1,称为 Weak Bit 或 Tag Bit,但在压缩指针场景下,通常是指针的低位特征)

注意: 在指针压缩开启时,内存布局分析需要特别注意 32 位和 64 位的区别。

d8 中使用 %DebugPrint(obj) 可以查看对象的详细结构:

1
2
let obj = {a: 1};
%DebugPrint(obj);

输出会显示 Map 地址、Properties 地址、Elements 地址等。

1-5 JSArray

数组是 V8 中最重要的数据结构之一,也是利用 OOB (Out-of-Bound) 漏洞的核心载体。

JSArray 继承自 JSObject,但多了一个 length 属性。

1
2
3
4
5
6
7
8
9
+-------------------+
|       Map         |
+-------------------+
|   Properties     |
+-------------------+
|     Elements     | <-- 指向实际存储数据的 Backing Store
+-------------------+
|     Length       | <-- 数组长度 (Smi)
+-------------------+

V8 为了优化数组性能,根据数组中存储的数据类型,将 Elements 分为不同的种类。Map 中记录了当前的 Elements Kind。常见的 Elements Kinds:

Elements Kind 描述 示例
PACKED_SMI_ELEMENTS 数组中仅包含小整数 (Smi),且没有空洞。 [1, 2, 3]
PACKED_DOUBLE_ELEMENTS 数组中包含浮点数 (Double),且没有空洞。 [1.1, 2.2, 3.3]
PACKED_ELEMENTS 数组中包含对象或混合类型,且没有空洞。 [{}, "text"]
HOLEY_SMI_ELEMENTS 包含小整数 (Smi) 的数组,但存在空洞。 [1, , 3]
HOLEY_DOUBLE_ELEMENTS 包含浮点数 (Double) 的数组,且存在空洞。 [1.1, , 3.3]
HOLEY_ELEMENTS 包含对象或混合类型的数组,且存在空洞。 [{}, , "text"]

Dictionary Mode: 如果数组非常稀疏,V8 会将其转换为字典模式(Dictionary Elements),使用哈希表存储,效率较低。

Elements Kind 的转换是单向的,只能从“更具体”变为“更通用”。

1
2
3
4
PACKED_SMI -> PACKED_DOUBLE -> PACKED_ELEMENTS
| | |
v v v
HOLEY_SMI -> HOLEY_DOUBLE -> HOLEY_ELEMENTS
  • 一旦数组变成了 HOLEY,就很难变回 PACKED
  • 一旦变成了 DOUBLE,就不能变回 SMI
  • 一旦变成了 ELEMENTS ,就不能变回 DOUBLESMI

攻击中的利用

  • OOB 读取:
    • 如果是 PACKED_DOUBLE_ELEMENTS,OOB 读取会读到相邻内存的原始字节,可能泄露地址信息。
    • 如果是 PACKED_ELEMENTS,OOB 读取会将相邻内存的数据解释为对象指针,可能导致 Crash 或 Fake Object。
  • 类型混淆:
    • 如果我们将一个 PACKED_DOUBLE_ELEMENTS 的数组的 Map 修改为 PACKED_ELEMENTS 的 Map,V8 就会把数组里的浮点数当作指针去访问,从而实现 Fake Object(将任意地址伪造成对象)。
    • 反之,将 PACKED_ELEMENTS 改为 PACKED_DOUBLE_ELEMENTS,可以实现 Address Of(读取对象的地址)。

1-6 JS 数据类型转换

JavaScript 是弱类型语言,V8 在底层必须处理各种类型的自动转换和存储。

Tagged Pointers

为了区分整数和指针,V8 使用了标记位(Tag Bit)。在 64 位系统且开启指针压缩(Pointer Compression)的情况下:

  • Smi: 最低位为 0。
    • 内存值 = Integer_Value << 1
    • 例如:整数 1 在内存中是 0x00000002
  • HeapObject Pointer: 最低位为 1。
    • 内存值 = Compressed_Pointer | 1
    • 实际地址 = Base + (Memory_Value - 1)
    • 例如:一个指向 0x08240000 的压缩指针可能是 0x08240001

这种机制使得 V8 可以快速判断一个值是整数还是对象引用,而不需要查找类型信息。

BigInt 是特殊的原始类型,用于表示大整数。它存储在堆上,但在某些操作中可能被当作特殊对象处理。在 64 位系统中,V8 引入了 Int64 的优化支持。

在 JIT 优化过程中,如果编译器错误地估计了变量的类型范围,可能会省略必要的类型检查代码。

例如:

1
2
3
4
function foo(x) {
   // 编译器认为 x 始终是 Smi
   return x[0];
}

如果攻击者能让编译器相信 x 是 Smi,但实际上传入了一个对象,或者反之,就可能导致类型混淆。

V8 使用 IEEE 754 双精度浮点数 (64位) 存储 Number 类型(当它不是 Smi 时)。

  • 利用浮点数数组进行 OOB 读写时,读取到的 64 位数据就是内存中的原始比特位。
  • 我们需要工具函数(如 f2i, i2f)在浮点数和整数(及其 16 进制表示)之间进行转换,以便构造 Exploit。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 示例:将浮点数转换为 64 位整数表示
var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new BigUint64Array(buf);

function ftoi(val) {
f64_buf[0] = val;
return u64_buf[0];
}

function itof(val) {
u64_buf[0] = val;
return f64_buf[0];
}

1-7 WebAssembly

WebAssembly 是一种运行在现代 Web 浏览器中的二进制指令格式。它为 V8 漏洞利用提供了强大的辅助能力。

V8 使用两个编译器来处理 Wasm:

  • Liftoff: 基线编译器,快速编译,生成代码质量一般。
  • TurboFan: 优化编译器,生成高质量机器码。

当 Wasm 模块被实例化时,会创建一个 WasmInstanceObject。这个对象非常关键,因为它包含了一个指向 RWX (Read-Write-Execute) 内存页(在较新版本中可能是 RX,但有特殊的写入方式)的指针,用于存储编译后的机器码(Jump Table 等)。

注意: 随着安全性的提升,现代 V8(和操作系统配合)已经逐步取消了直接的 RWX 页面(W^X 策略)。但在某些版本或特定配置下,Wasm 仍然是写入 Shellcode 的最佳位置。

通常的利用思路:

  1. 创建一个 Wasm 实例,定义一个导出函数。
  2. 利用 OOB 或类型混淆漏洞,找到这个 WasmInstanceObject 在堆上的地址。
  3. 读取该对象内部的 jump_table_start 字段(指向存放机器码的内存区域)。
  4. 利用任意地址写(Arbitrary Write)能力,将 Shellcode 覆盖到该区域。
  5. 在 JS 中调用这个 Wasm 导出函数,从而执行 Shellcode。

Wasm 实例还维护了导入表(Imported Functions)和导出表。修改这些表中的指针也可以劫持控制流。

1
2
3
4
const wasmCode = new Uint8Array([...]); // Wasm 二进制数据
const wasmModule = new WebAssembly.Module(wasmCode);
const wasmInstance = new WebAssembly.Instance(wasmModule);
const f = wasmInstance.exports.main;

在利用中,我们通常会硬编码一个极简的 Wasm 模块,只包含一个空函数,用来作为 Shellcode 的容器。

1-8 GC

V8 的垃圾回收机制负责自动管理内存。理解 GC 对于保持堆布局的稳定性(Heap Feng Shui)至关重要。

分代回收 (Generational GC)

V8 将堆内存分为两代:

  • New Space (新生代): 存放生命周期短的对象。容量小(通常 1-8MB)。
  • Old Space (老生代): 存放生命周期长的对象。容量大。

Minor GC (Scavenger)

  • 针对 New Space
  • 使用 Cheney 算法(半空间复制算法)。
  • New Space 被分为 From-SpaceTo-Space
  • GC 时,将存活对象从 From 复制到 To,然后交换两个空间。
  • 对象晋升 (Promotion): 如果一个对象经历过两次 Minor GC 仍然存活,它会被移动到 Old Space。

第二章 oob 实战

2-1 题目分析

拿到一道 V8 Pwn 题目,通常会给出一个 diff 文件(Patch)和一个 d8 可执行文件(或者编译脚本)。第一步永远是分析 diff 文件,找出漏洞点。

Patch 文件通常展示了开发者修复漏洞的代码,或者题目作者故意引入漏洞的代码。

  • 被修改的文件: 是在 src/compiler (JIT 相关) 还是 src/builtins (内置函数)?
  • 被移除的检查: 是否删除了某个 CheckBoundsCheckMap 或者 Range 检查?
  • 新增的功能: 是否添加了一个新的内置函数,但没有正确处理参数类型?

示例

1
2
3
4
5
6
7
8
diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index ...
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -...
- if (index >= length) return;
+ // if (index >= length) return; // Vulnerability here!

在这个例子中,明显的边界检查被注释掉了,导致了 OOB (Out-of-Bound) 访问。

确定了修改位置后,找到对应的 JS API。

  • 如果修改了 Array.prototype.map,那么漏洞点就在数组的 map 方法中。
  • 如果修改了 JIT 优化阶段(如 typer.cc),则需要构造特定的代码模式来触发该优化。

PoC 的目的是触发漏洞,通常表现为 Crash (Segmentation Fault)。

对于 OOB 漏洞:

1
2
3
4
5
let arr = [1.1, 2.2, 3.3];
// 假设漏洞允许我们访问 index 100
let val = arr[100];
console.log(val);

如果运行这段代码导致 d8 崩溃或打印出奇怪的值(如极大的浮点数),说明漏洞触发成功。

使用 gdbwindbg 附加到 d8,观察崩溃时的上下文。

  • 检查寄存器状态。
  • 检查堆栈回溯 (Backtrace)。
  • 确认崩溃原因是否符合预期(例如访问了非法内存地址)。

2-2 构造类型混淆原语

在 V8 利用中,我们通常不直接使用漏洞,而是利用漏洞构造两个核心原语:addrOffakeObj

利用 OOB 读写能力,在一个对象数组(Object Array)和一个浮点数数组(Double Array)之间建立联系。

1. 核心思想

通常的布局(Heap Feng Shui):

1
2
3
let oob_arr = [1.1]; // 具有 OOB 能力的数组
let victim_arr = [2.2]; // 受害者数组(Double Array)
let obj_arr = [{}];     // 对象数组

通过调整分配顺序,让 victim_arrobj_arr 紧跟在 oob_arr 后面。

2. addrof (Address Of)

目标: 获取某个 JS 对象在堆上的真实地址。

实现步骤:

  1. 将目标对象放入 obj_arrobj_arr[0] = target_obj
  2. 利用 oob_arr 的 OOB 读能力,读取 obj_arr 的 Elements 区域。
  3. 由于 obj_arr 存储的是对象的指针(Tagged Pointer),而我们将它作为浮点数读取,所以我们会读到一个浮点数。
  4. 使用 f2i (Float to Int) 辅助函数,将这个浮点数转换为整数,再减去 Tag (1),就得到了对象的地址。
1
2
3
4
5
6
function addrof(obj) {
   obj_arr[0] = obj;
   // 假设 oob_arr[off] 正好指向 obj_arr[0] 的内存
   let val = oob_arr[off];
   return ftoi(val) & 0xffffffffn; // 转换为 32 位整数 (压缩指针)
}

3. fakeObj (Fake Object)

目标: 将一个任意地址(通常是我们构造的数据)伪造成一个 JS 对象。

实现步骤:

  1. 构造一个假的对象的内存结构(例如在浮点数数组中写入一些值,模拟 Map 指针等)。
  2. 将这个结构的地址(作为整数)转换为浮点数。
  3. 利用 oob_arr 的 OOB 写能力,将这个浮点数写入到 obj_arr 的 Elements 区域。
  4. 现在,V8 认为 obj_arr 中存储的是一个合法的对象指针。当我们访问 obj_arr[0] 时,V8 就会访问我们伪造的地址。
1
2
3
4
5
6
function fakeObj(addr) {
   let val = itof(addr);
   // 假设 oob_arr[off] 正好指向 obj_arr[0] 的内存
   oob_arr[off] = val;
   return obj_arr[0];
}

4. 验证

有了这两个原语,我们可以:

  • let addr = addrof({}) -> 得到对象地址。
  • let obj = fakeObj(addr) -> 应该返回一个正常的对象。
    如果这两个操作都能成功且不 Crash,说明原语构造成功。

2-3 任意地址读写(一)

2-4 任意地址读写(二)

2-5 任意地址读写(三)

2-6 WebAssembly 写 shellcode

2-7 劫持 __free_hook

2-8 如何在浏览器中调试 exp

第三章 沙箱及绕过

3-1 沙箱基础

3-2 沙箱内任意地址读写

3-3 立即数写 shellcode

3-4 修改 WasmInstance 全局变量

第四章 通用堆喷技术

4-1 v8 堆块管理结构

4-2 任意地址对象伪造

第五章 JIT 实战

5-1 JIT 基础

5-2 sea of nodes

5-3 JIT 常见优化

5-4 v8 流水线

5-5 34c3 V9

5-6 GoogleCTF2018 Just In Time

5-7 35c3 krautflare

第六章 Hole 实战

6-1 JSMap 数据结构

6-2 JSMap 添加操作

6-3 JSMap 删除操作

6-4 Hole 类型漏洞利用

6-5 CVE-2021-38003

6-6 CVE-2022-4174

6-7 2023 XCTF Final Hole