第一章 基础知识
1-1 主流浏览器及其Javascript Engine介绍
现代浏览器通常由渲染引擎(Rendering Engine)和 JavaScript 引擎(JavaScript Engine)组成。
| 浏览器 | 开发商 | 渲染引擎 / 内核 | JS 引擎 | 特点 |
|---|---|---|---|---|
| Chrome | 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 的执行管线经历了多次迭代,目前的架构主要包含以下部分:
- Parser (解析器): 将 JavaScript 源代码解析成抽象语法树 (AST)。
- Ignition (解释器): 这是一个基于寄存器的解释器。它将 AST 转换为字节码 (Bytecode) 并执行。Ignition 的主要目的是减少内存消耗并加快启动速度。
- TurboFan (优化编译器): 当某些代码块(如热点函数)被频繁执行时,Ignition 会收集类型反馈信息 (Type Feedback),并将其发送给 TurboFan。TurboFan 利用这些信息生成高度优化的机器码。如果假设的类型信息错误(例如变量类型发生了变化),V8 会进行 Deoptimization (去优化),回退到 Ignition 执行字节码。
- 历史组件 (已废弃)
- Full-Codegen: 早期的基线编译器,直接生成未优化的机器码。
- Crankshaft: 早期的优化编译器,后被 TurboFan 取代。
在浏览器安全研究(Browser Pwn)中,V8 是主要的攻击面之一。攻击者通常利用 V8 在优化过程中的逻辑错误(如边界检查消除错误、类型推断错误等)来实现内存破坏,最终达到远程代码执行 (RCE) 的目的。
1-2 v8环境搭建
安装 depot_tools
1 | # linux |
获取V8源码
1 | export http_proxy="socks5://127.0.0.1:10808" |
1-3 JS Object 基本概念
在 JavaScript 中,对象(Object)是属性(Property)的集合。理解 JS 对象在引擎层面的实现是漏洞利用的基础。
从规范角度看,JS 对象是一个关联数组(字典),将键(Key,通常是字符串或 Symbol)映射到值(Value)。
1 | let obj = { |
在 V8 中,属性主要分为两类:
- Named Properties (命名属性): 键为字符串的属性,例如
obj.a。 - Elements (索引属性): 键为整数的属性,例如
obj[1]。
V8 对这两类属性采用不同的存储策略,以优化访问速度。
原型链 (Prototype Chain)
每个 JS 对象都有一个内部链接指向另一个对象,即它的原型(prototype)。该原型对象也有自己的原型,直到某个对象的原型为 null 为止。
当我们访问 obj.x 时:
- V8 首先查找
obj自身是否有属性x。 - 如果没有,则沿着原型链查找
obj.__proto__。 - 依次类推,直到找到或到达链尾。
对象的创建
1 | let o1 = {}; // 字面量 |
V8 中的 Hidden Class (Map)
为了加速属性访问,V8 引入了 Hidden Class(在 V8 源码中称为 Map)。
- 具有相同属性结构(属性名、顺序相同)的对象共享同一个 Map。
- Map 记录了属性名到内存偏移量的映射。
- 当对象添加新属性时,Map 会发生“转换”(Transition),指向一个新的 Map。
1 | let o = {}; // Map0 |
如果代码中反复执行相同的属性访问操作,V8 会利用 Inline Cache (IC) 缓存 Map 信息,从而快速定位属性,避免昂贵的哈希查找。
1-4 V8 通用 Object 结构
在 V8 内存(Heap)中,一个标准的 JSObject 通常由三个主要指针组成(在开启指针压缩的情况下是 32 位偏移量,否则是 64 位指针)。一个基本的 JSObject 在内存中通常长这样:
1 | +-------------------+ |
| 组件名称 | 描述 | 关键点 / 攻击点 |
|---|---|---|
| Map (Hidden Class) | 对象头部的第一个字,描述对象的类型、大小、属性布局等信息。 | 攻击点: 覆盖 Map 指针可制造“类型混淆”(Type Confusion),通过错误的类型解析实现非法内存读写(如利用浮点数数组伪造对象指针)。 |
| Properties (Backing Store) | 存储对象的命名属性。属性较少时直接存在对象内(In-object),过多时指向外部的 FixedArray。 |
属性存储位置的切换(In-object vs Out-of-object)是性能优化的关键。 |
| Elements (Backing Store) | 指向存储整数索引属性(数组元素)的 FixedArray 或 FixedDoubleArray。 |
数组元素通常单独分配空间,不存储在对象内部。 |
指针压缩
在较新的 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,但在压缩指针场景下,通常是指针的低位特征)
- Smi:
注意: 在指针压缩开启时,内存布局分析需要特别注意 32 位和 64 位的区别。
在 d8 中使用 %DebugPrint(obj) 可以查看对象的详细结构:
1 | let obj = {a: 1}; |
输出会显示 Map 地址、Properties 地址、Elements 地址等。
1-5 JSArray
数组是 V8 中最重要的数据结构之一,也是利用 OOB (Out-of-Bound) 漏洞的核心载体。
JSArray 继承自 JSObject,但多了一个 length 属性。
1 | +-------------------+ |
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 | PACKED_SMI -> PACKED_DOUBLE -> PACKED_ELEMENTS |
- 一旦数组变成了
HOLEY,就很难变回PACKED。 - 一旦变成了
DOUBLE,就不能变回SMI。 - 一旦变成了
ELEMENTS,就不能变回DOUBLE或SMI。
攻击中的利用
- 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 | function foo(x) { |
如果攻击者能让编译器相信 x 是 Smi,但实际上传入了一个对象,或者反之,就可能导致类型混淆。
V8 使用 IEEE 754 双精度浮点数 (64位) 存储 Number 类型(当它不是 Smi 时)。
- 利用浮点数数组进行 OOB 读写时,读取到的 64 位数据就是内存中的原始比特位。
- 我们需要工具函数(如
f2i,i2f)在浮点数和整数(及其 16 进制表示)之间进行转换,以便构造 Exploit。
1 | // 示例:将浮点数转换为 64 位整数表示 |
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 的最佳位置。
通常的利用思路:
- 创建一个 Wasm 实例,定义一个导出函数。
- 利用 OOB 或类型混淆漏洞,找到这个
WasmInstanceObject在堆上的地址。 - 读取该对象内部的
jump_table_start字段(指向存放机器码的内存区域)。 - 利用任意地址写(Arbitrary Write)能力,将 Shellcode 覆盖到该区域。
- 在 JS 中调用这个 Wasm 导出函数,从而执行 Shellcode。
Wasm 实例还维护了导入表(Imported Functions)和导出表。修改这些表中的指针也可以劫持控制流。
1 | const wasmCode = new Uint8Array([...]); // Wasm 二进制数据 |
在利用中,我们通常会硬编码一个极简的 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-Space和To-Space。 - GC 时,将存活对象从 From 复制到 To,然后交换两个空间。
- 对象晋升 (Promotion): 如果一个对象经历过两次 Minor GC 仍然存活,它会被移动到 Old Space。
第二章 oob 实战
2-1 题目分析
拿到一道 V8 Pwn 题目,通常会给出一个 diff 文件(Patch)和一个 d8 可执行文件(或者编译脚本)。第一步永远是分析 diff 文件,找出漏洞点。
Patch 文件通常展示了开发者修复漏洞的代码,或者题目作者故意引入漏洞的代码。
- 被修改的文件: 是在
src/compiler(JIT 相关) 还是src/builtins(内置函数)? - 被移除的检查: 是否删除了某个
CheckBounds、CheckMap或者Range检查? - 新增的功能: 是否添加了一个新的内置函数,但没有正确处理参数类型?
示例
1 | diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc |
在这个例子中,明显的边界检查被注释掉了,导致了 OOB (Out-of-Bound) 访问。
确定了修改位置后,找到对应的 JS API。
- 如果修改了
Array.prototype.map,那么漏洞点就在数组的 map 方法中。 - 如果修改了 JIT 优化阶段(如
typer.cc),则需要构造特定的代码模式来触发该优化。
PoC 的目的是触发漏洞,通常表现为 Crash (Segmentation Fault)。
对于 OOB 漏洞:
1 | let arr = [1.1, 2.2, 3.3]; |
如果运行这段代码导致 d8 崩溃或打印出奇怪的值(如极大的浮点数),说明漏洞触发成功。
使用 gdb 或 windbg 附加到 d8,观察崩溃时的上下文。
- 检查寄存器状态。
- 检查堆栈回溯 (Backtrace)。
- 确认崩溃原因是否符合预期(例如访问了非法内存地址)。
2-2 构造类型混淆原语
在 V8 利用中,我们通常不直接使用漏洞,而是利用漏洞构造两个核心原语:addrOf 和 fakeObj。
利用 OOB 读写能力,在一个对象数组(Object Array)和一个浮点数数组(Double Array)之间建立联系。
1. 核心思想
通常的布局(Heap Feng Shui):
1 | let oob_arr = [1.1]; // 具有 OOB 能力的数组 |
通过调整分配顺序,让 victim_arr 和 obj_arr 紧跟在 oob_arr 后面。
2. addrof (Address Of)
目标: 获取某个 JS 对象在堆上的真实地址。
实现步骤:
- 将目标对象放入
obj_arr:obj_arr[0] = target_obj。 - 利用
oob_arr的 OOB 读能力,读取obj_arr的 Elements 区域。 - 由于
obj_arr存储的是对象的指针(Tagged Pointer),而我们将它作为浮点数读取,所以我们会读到一个浮点数。 - 使用
f2i(Float to Int) 辅助函数,将这个浮点数转换为整数,再减去 Tag (1),就得到了对象的地址。
1 | function addrof(obj) { |
3. fakeObj (Fake Object)
目标: 将一个任意地址(通常是我们构造的数据)伪造成一个 JS 对象。
实现步骤:
- 构造一个假的对象的内存结构(例如在浮点数数组中写入一些值,模拟 Map 指针等)。
- 将这个结构的地址(作为整数)转换为浮点数。
- 利用
oob_arr的 OOB 写能力,将这个浮点数写入到obj_arr的 Elements 区域。 - 现在,V8 认为
obj_arr中存储的是一个合法的对象指针。当我们访问obj_arr[0]时,V8 就会访问我们伪造的地址。
1 | function fakeObj(addr) { |
4. 验证
有了这两个原语,我们可以:
let addr = addrof({})-> 得到对象地址。let obj = fakeObj(addr)-> 应该返回一个正常的对象。
如果这两个操作都能成功且不 Crash,说明原语构造成功。