
Technischer Exploit für CVE-2025-43529, eine WebKit-DFG-JIT-Compiler-Schwachstelle, die Use-After-Free über fehlende Store-Barriere im gleichzeitigen GC ermöglicht, mit vollständigen Exploit-Primitiven für iOS und macOS.
Apple hat kürzlich iOS 26.2 und iPadOS 26.2 veröffentlicht, zusammen mit einem Sicherheitshinweis, der Fixes für WebKit-Schwachstellen enthält. Ein Bug im DFG-JIT-Compiler (CVE-2025-43529) ist mir besonders aufgefallen, also habe ich beschlossen, mich näher damit zu befassen.
Der JIT-Compiler hat zwar korrekt erkannt, dass ein Phi-Knoten (an dem mehrere Kontrollflusspfade zusammenlaufen) entkommen ist, aber er hat übersehen, dass auch die Upsilon-Knoten des Phi entkommen sind. Dadurch übersprang die DFG-Compilation StoreBarrierInsertionPhase das Einfügen einer Store Barrier – einem wichtigen Memory-Safety-Mechanismus. Infolgedessen kann der Concurrent GC Objekte übersehen, die er hätte scannen müssen, was zu einem Use-after-Free führen kann.
Den Patch-Commit findest du hier.
Ich habe bestätigt, dass mein Exploit unter iOS 26.1, iPadOS 26.1 und macOS Tahoe 26.0.1 funktioniert.
JSC verwendet ein generationenbasiertes GC-Modell, um den Heap effizient zu verwalten. In diesem Modell wird der Speicher basierend auf dem Objektalter in Eden (New Space) und Old Space aufgeteilt. Alle neu allokierten Objekte starten in Eden. Wenn Eden voll ist, wird ein Eden-GC ausgelöst und alle überlebenden Objekte werden in den Old Space befördert. Das Aufräumen von Old-Space-Objekten erfordert einen Full GC.
Damit der generationenbasierte GC funktioniert, muss der GC Objekte als „bereits gescannt", „muss gescannt werden" oder „muss erneut gescannt werden" klassifizieren. In JSC wird dies über den cellState eines Objekts verfolgt. (Alle vom GC verwalteten Objekte erben von JSCell.)
StructureID m_structureID;
union {
uint32_t m_blob;
struct {
IndexingType m_indexingTypeAndMisc;
JSType m_type;
TypeInfo::InlineTypeFlags m_flags;
CellState m_cellState;
};
};
cellState ist 1 Byte groß und kann eine von drei Farben haben: Black (0), White (1) und Grey (2).
White bedeutet, dass ein Objekt gerade in Eden allokiert wurde. Im aktuellen GC-Zyklus wurde es noch nicht markiert. Wenn es bis zum Ende des GC-Zyklus in diesem Zustand bleibt, wird das Objekt eingesammelt.
Black bedeutet, dass der GC das Objekt bereits vollständig markiert hat oder gerade dabei ist, es zu markieren. Es wird im Grunde als lebendig behandelt, auch wenn das isMarked-Bit möglicherweise noch nicht gesetzt ist.
Grey bedeutet, dass ein Objekt noch gescannt werden muss. Genauer gesagt: Es war ursprünglich Black, wurde aber von der Write Barrier erfasst und zum Remembered Set hinzugefügt. Mit anderen Worten: Seine Referenzen haben sich geändert, also muss der GC es erneut scannen.
JSC verfügt außerdem über einen Concurrent GC, der es der Anwendung erlaubt, weiterzulaufen, während Speicher freigegeben wird. Wenn der GC im Hintergrund ein Objekt markiert und die Anwendung gleichzeitig den Zustand dieses Objekts ändert, kann eine Race Condition entstehen.
Um das zu verhindern, braucht man Ordnungsgarantien – zum Beispiel, dass „Schreiben (Store) A, dann Lesen (Load) B" in genau dieser Reihenfolge passiert. Aber auf ARM64 kann die CPU Speicheroperationen aus Leistungsgründen umordnen. Das bedeutet, der GC könnte den falschen Wert lesen.
Deshalb verwendet JSC eine dependency class, um sich auf die Datenabhängigkeiten der CPU zu verlassen, oder es verwendet spezielle ARM64-Anweisungen wie STLR und LDAR, um die Reihenfolge zu erzwingen. STLR stellt sicher, dass frühere Reads/Writes vor dem Store sichtbar werden (ein Release-Store). LDAR stellt sicher, dass spätere Reads/Writes nicht vor den Load wandern können (ein Acquire-Load). Wenn ein anderer Thread ein Objekt liest, wird LDAR mit STLR kombiniert, sodass er die aktuellsten Daten sicher beobachten kann.
DMB ist keine einzelne spezielle Anweisung für einen Zugriff. Sie ist eine Barriere, die die Reihenfolge über alle Speicherzugriffe um sie herum erzwingt. Sie stellt sicher, dass Speicheroperationen vor dem DMB sichtbar werden, bevor Operationen danach ausgeführt werden.
JSC hat insgesamt drei JIT-Stufen. Um die Ausführungsgeschwindigkeit gegen die Compilationskosten (Speicher/Zeit) abzuwägen, wendet es Optimierungen an und verschiebt Code basierend auf seiner Ausführungshäufigkeit auf die nächste Stufe.
Der Baseline JIT ist der erste JIT-Compiler. Er konzentriert sich darauf, schnell nativen Code zu erzeugen, mit geringem Compile-Overhead.
Der DFG JIT ist die nächste Stufe nach dem Baseline JIT, auf der die eigentliche Optimierung beginnt.
Auf der DFG-Stufe werden JavaScript-Anweisungen in einen Graphen aus DFG-IR-Knoten umgewandelt. Mithilfe der gesammelten Typinformationen führt der Compiler Spekulationen durch, um unnötige Operationen zu entfernen.
In der DFG-Optimierungs-Pipeline von JSC fügt die StoreBarrierInsertionPhase nach Knoten, die in den Speicher schreiben, wie zum Beispiel PutByOffset, eine StoreBarrier ein.
CVE-2025-43529 ist eine Schwachstelle, die dadurch verursacht wird, dass während der StoreBarrierInsertionPhase keine StoreBarrier eingefügt wird, obwohl eine hätte eingefügt werden müssen.
Eine StoreBarrier ist ein Knoten, der wie eine Write Barrier funktioniert. Sie wird verwendet, um die Korrektheit bei Races mit dem Marking-Thread zu gewährleisten.
Das anfällige DFG-Knoten-Szenario, das im Patch-Commit beschrieben wird, sieht wie folgt aus:
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 werden zwei neue Objekte erstellt und die Ausführung verzweigt entweder zu BB#2 oder zu BB#3. BB#2 fällt dann in BB#3 durch (Fall-through). Das Interessante an BB#3 ist der Phi-Knoten. Er bedeutet, dass f entweder c oder e ist; diese Wahl wird von den vorgelagerten Upsilon-Knoten bestimmt. In BB#3 bedeutet PutByOffset, dass einem Objekt ein Wert als Eigenschaft hinzugefügt wird.
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;
}
Wenn du die Option --dumpFTLDisassembly=true verwendest, kannst du die Assembly nach der FTL-Compilation untersuchen.