
Sfruttamento tecnico per CVE-2025-43529, una vulnerabilità del compilatore DFG JIT di WebKit che consente use-after-free tramite una barriera di store mancante nella GC concorrente, con primitive di sfruttamento complete per iOS e macOS.
Apple ha recentemente pubblicato iOS 26.2 e iPadOS 26.2, insieme a un bollettino di sicurezza che include correzioni per vulnerabilità di WebKit. Un bug nel compilatore DFG JIT (CVE-2025-43529) mi ha colpito, quindi ho deciso di approfondirlo.
Il compilatore JIT ha riconosciuto correttamente che un nodo Phi (dove più percorsi di controllo confluiscono) era sfuggito (escaped), ma non è riuscito a notare che anche i nodi Upsilon del Phi erano sfuggiti. Per questo motivo, la compilazione DFG StoreBarrierInsertionPhase ha saltato l'inserimento di una Store Barrier, un meccanismo chiave per la sicurezza della memoria. Di conseguenza, il GC concorrente può perdere oggetti che avrebbe dovuto scansionare, il che può portare a un use-after-free.
Puoi trovare il commit della patch qui.
Ho confermato che il mio exploit funziona su iOS 26.1, iPadOS 26.1 e macOS Tahoe 26.0.1.
JSC utilizza un modello di GC generazionale per gestire l'heap in modo efficiente. In questo modello, la memoria è suddivisa in Eden (nuovo spazio) e old space (vecchio spazio) in base all'età degli oggetti. Tutti gli oggetti appena allocati iniziano in Eden. Quando Eden si riempie, viene attivato un GC di Eden e qualsiasi oggetto sopravvissuto viene promosso a old space. La pulizia degli oggetti in old space richiede un GC completo.
Affinché il GC generazionale funzioni, il GC deve classificare gli oggetti come "già scansionati", "da scansionare" o "da riscansionare".
In JSC, questo viene tracciato utilizzando il cellState di un oggetto. (Tutti gli oggetti gestiti dal GC ereditano da JSCell.)
StructureID m_structureID;
union {
uint32_t m_blob;
struct {
IndexingType m_indexingTypeAndMisc;
JSType m_type;
TypeInfo::InlineTypeFlags m_flags;
CellState m_cellState;
};
};
cellState è 1 byte e può essere uno di tre colori: Black (0), White (1) e Grey (2).
White indica un oggetto appena allocato in eden. Nel ciclo GC corrente, non è ancora stato marcato. Se rimane in questo stato fino alla fine del ciclo GC, l'oggetto verrà raccolto.
Black significa che il GC ha già terminato di marcare l'oggetto o lo sta marcando in questo momento. È sostanzialmente considerato vivo, anche se il bit isMarked potrebbe essere ancora disattivato.
Grey indica un oggetto che deve ancora essere scansionato. Più precisamente, era originariamente Black, ma è stato intercettato dalla write barrier e aggiunto al remembered set. In altre parole, i suoi riferimenti sono cambiati, quindi il GC deve riscansionarlo.
JSC ha anche il Concurrent GC (GC concorrente), che consente all'applicazione di continuare a girare mentre la memoria viene recuperata. Se il GC sta marcando un oggetto in background e l'applicazione modifica lo stato di quell'oggetto nello stesso momento, può verificarsi una race condition.
Per prevenire ciò, sono necessarie garanzie di ordinamento. Come "scrivi (store) A, poi leggi (load) B" che avvengono in questo esatto ordine. Ma su ARM64, per motivi di prestazioni, la CPU può riordinare le operazioni di memoria. Ciò significa che il GC potrebbe leggere un valore errato.
Quindi JSC utilizza una classe di dipendenza per fare affidamento sulle dipendenze dati della CPU oppure utilizza speciali istruzioni ARM64 come STLR e LDAR per imporre l'ordinamento. STLR garantisce che letture/scritture precedenti diventino visibili prima dello store (un release store). LDAR garantisce che letture/scritture successive non possano anticipare il load (un acquire load). Quando un altro thread legge un oggetto, LDAR è accoppiato a STLR per poter osservare in modo sicuro i dati più recenti.
DMB non è una singola istruzione speciale per un singolo accesso. È una barriera che impone l'ordinamento tra tutti gli accessi di memoria circostanti. Garantisce che le operazioni di memoria prima del DMB diventino visibili prima delle operazioni successive.
JSC ha in totale tre livelli JIT. Per bilanciare la velocità di esecuzione con il costo di compilazione (memoria/tempo), applica ottimizzazioni e sposta il codice al livello successivo in base alla frequenza di esecuzione.
Baseline JIT è il primo compilatore JIT. Si concentra sull'arrivare rapidamente al codice nativo con un basso overhead di compilazione. DFG JIT è lo stadio successivo a Baseline JIT, dove iniziano le ottimizzazioni serie.
Al livello DFG, le istruzioni JavaScript vengono convertite in un grafo composto da nodi IR DFG. Utilizzando le informazioni sui tipi che raccoglie, il compilatore esegue speculazioni per rimuovere operazioni non necessarie.
Nella pipeline di ottimizzazione DFG di JSC, la StoreBarrierInsertionPhase inserisce una StoreBarrier dopo i nodi che scrivono in memoria, come PutByOffset.
CVE-2025-43529 è una vulnerabilità causata dal mancato inserimento di una StoreBarrier quando avrebbe dovuto essere inserita durante StoreBarrierInsertionPhase.
Una StoreBarrier è un nodo che funge da write barrier. Viene utilizzata per preservare la correttezza nelle race condition con il thread di marcatura.
Lo scenario del nodo DFG vulnerabile descritto nel commit della patch è il seguente:
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, vengono creati due nuovi oggetti e l'esecuzione si dirama verso BB#2 o BB#3. BB#2 poi ricade in BB#3. La parte interessante in BB#3 è il nodo Phi. Significa che f è o c o e, e la scelta è determinata dai nodi Upsilon a monte. In BB#3, PutByOffset significa aggiungere un valore a una proprietà di un oggetto.
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;
}
Se usi l'opzione --dumpFTLDisassembly=true, puoi ispezionare l'assembly dopo la compilazione FTL.
// 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)