CVE-2026-43499 (GhostLock) : Stack-UAF 15 ans dans le rtmutex Linux — LPE + container escape à 97%
by cloud
CVE-2026-43499 (GhostLock) est une vulnérabilité de type stack Use-After-Free dans le sous-système rtmutex du noyau Linux, découverte par VEGA — le pipeline de recherche sécurité basé sur LLM de Nebula Security (nebusec.ai). Le bug remonte à 2011 (Linux 2.6.39) et a affecté toutes les distributions Linux pendant 15 ans sans aucune configuration noyau spéciale ni privilège requis. L’exploit atteint 97% de stabilité pour une élévation de privilèges complète + container escape, ce qui a valu 92 337 $ de récompense au kernelCTF de Google.
Caractéristiques principales :
- ✅ LPE complet + container escape — 97% de fiabilité
- ✅ 15 ans de fenêtre d’exploitation (Linux 2.6.39 → 7.1)
- ✅ Aucun privilège requis — pas même de user namespace
- ✅ Présent dans toutes les distributions Linux par défaut (
CONFIG_FUTEX_PI=y) - ✅ Correctif publié en avril 2026
TL;DR
| Champ | Valeur |
|---|---|
| CVE | CVE-2026-43499 |
| Alias | GhostLock (IonStack part II) |
| Impact | Critique — LPE noyau + container escape, 97% stable |
| Composant | kernel/locking/rtmutex.c — remove_waiter() |
| Type | CWE-824 — Use-After-Free (stack) |
| Auteur | VEGA (Nebula Security) — pipeline de recherche LLM |
| Récompense | 92 337 $ (kernelCTF) |
| Versions affectées | Linux 2.6.39 → 7.1-rc1 |
| Commit d’introduction | 8161239a8bcc (2011) |
| Commit de correction | 3bfdc63936dd (avril 2026) |
| Prérequis | Aucun privilège (juste CONFIG_FUTEX_PI=y, activé par défaut) |
| Code PoC | CyberMeowfia (projet open-source VEGA) |
1. La racine : remove_waiter() nettoie la mauvaise tâche
1.1 Le préfixe IonStack
GhostLock est la deuxième vulnérabilité issues d’IonStack, un projet de recherche par Nebula Security utilisant VEGA — un pipeline LLM de découverte de vulnérabilités qui a déjà identifié plus de 300 bugs dans le noyau Linux et plusieurs zero-days dans Chrome V8, totalisant plus de 100 000 $ de bug bounties.
1.2 La fonction remove_waiter()
Le rtmutex (Real-Time Mutex) est le système de mutex du noyau Linux avec Priority Inheritance (PI) — si une tâche basse priorité tient un lock demandé par une tâche haute priorité, sa priorité est temporairement augmentée. La fonction remove_waiter() nettoie après qu’un thread cesse d’attendre :
static void __sched remove_waiter(struct rt_mutex_base *lock,
struct rt_mutex_waiter *waiter)
{
...
raw_spin_lock(¤t->pi_lock);
rt_mutex_dequeue(lock, waiter);
current->pi_blocked_on = NULL; // ← LE BUG : devrait être waiter->task
raw_spin_unlock(¤t->pi_lock);
...
rt_mutex_adjust_prio_chain(owner, RT_MUTEX_MIN_CHAINWALK, lock,
next_lock, NULL, current); // ← devrait être waiter->task
...
}
Sur le chemin normal (slow path), current est bien la tâche qui possède le waiter — nettoyer current->pi_blocked_on est correct.
Sur le chemin proxy via rt_mutex_start_proxy_lock(), current est la tâche qui a demandé le requeue, pas la tâche qui attend réellement. Le waiter vit sur la stack d’une autre tâche endormie dans FUTEX_WAIT_REQUEUE_PI.
int __sched rt_mutex_start_proxy_lock(struct rt_mutex_base *lock,
struct rt_mutex_waiter *waiter,
struct task_struct *task)
{
int ret;
raw_spin_lock_irq(&lock->wait_lock);
ret = __rt_mutex_start_proxy_lock(lock, waiter, task);
if (unlikely(ret))
remove_waiter(lock, waiter); // ret == -EDEADLK ← rollback bugué
raw_spin_unlock_irq(&lock->wait_lock);
return ret;
}
Quand __rt_mutex_start_proxy_lock() retourne -EDEADLK (deadlock détecté), le rollback appelle remove_waiter() qui nettoie current->pi_blocked_on (le requeueur) au lieu de waiter->task->pi_blocked_on (la tâche réellement endormie).
Résultat : la tâche endormie garde pi_blocked_on pointant vers son propre stack frame — qui est libéré dès qu’elle retourne en userspace. C’est un stack Use-After-Free : un pointeur dangling vers de la mémoire stack noyau déjà libérée.
1.3 Pourquoi lockdep ne voit rien
Lockdep vérifie qu’un pi_lock est tenu, mais pas de qui. Vérouiller current->pi_lock au lieu de waiter->task->pi_lock passe donc inaperçu. Le bug n’est pas une simple inversion de lock — c’est une faille logique conceptuelle : la fonction est réutilisée dans un contexte pour lequel elle n’a jamais été écrite.
2. Déclencher le -EDEADLK
2.1 Le cycle de dépendance PI
Atteindre le chemin -EDEADLK nécessite de construire un cycle de dépendance PI avec 3 futex words et 3 threads :
| Futex | Type | Rôle |
|---|---|---|
f_pi_chain |
PI futex | Verrouillé par le waiter en premier |
f_pi_target |
PI futex | Verrouillé par l’owner en premier (cible du requeue) |
f_wait |
plain futex | Le waiter bloque dessus avec FUTEX_WAIT_REQUEUE_PI |
2.2 La séquence
1. WAITER prend f_pi_chain, puis bloque sur FUTEX_WAIT_REQUEUE_PI(f_wait → f_pi_target)
→ son rt_mutex_waiter est sur sa stack
2. OWNER prend f_pi_target, puis bloque sur f_pi_chain (que le waiter détient)
3. MAIN appelle FUTEX_CMP_REQUEUE_PI(f_wait → f_pi_target)
4. Le requeue proxy le waiter sur f_pi_target.
L'owner de f_pi_target est déjà bloqué derrière le waiter via f_pi_chain.
→ cycle: waiter → f_pi_target → owner → f_pi_chain → waiter
→ -EDEADLK ! Rollback via remove_waiter() → BUG
5. Le waiter se réveille avec pi_blocked_on → stack frame libérée (dangling)
La fenêtre du UAF est illimitée — il n’y a aucune pression temporelle. Le waiter est en userspace avec un pointeur dangling, et un sched_setattr() ultérieur qui parcourt la chaîne PI peut déclencher l’accès à tout moment.
Même un single-core CPU suffit pour déclencher le bug. Pas de race condition au sens classique.
3. L’exploit complet
3.1 Vue d’ensemble
Prefetch → Leak KASLR + physmap base
↓
GhostLock → Dangling rt_mutex_waiter (pi_blocked_on)
↓
Stack-UAF → prctl(PR_SET_MM_MAP) reclaim → fake waiter forgé sur l'ancienne stack
↓
Arb Write → rb-tree erase → inet6_protos[IPPROTO_UDP] = CEA pointer
↓
CEA Spray → Fake inet6_protocol + pivot slots + ROP stack à adresse connue
↓
CFH → send loopback IPv6 UDP → handler détourné → pivot
↓
DirtyMode → ROP flip core_pattern.mode → world-writable
↓
LPE → write |proc/%P/fd/666 → crash helper → root
3.2 Step 1 : Prefetch ASLR Leak
L’instruction prefetch sur une adresse donnée s’exécute en un nombre de cycles différent selon que l’adresse est mappée dans les page tables courantes. Un process non privilégié peut donc timé prefetch à travers la plage noyau et déterminer quelles adresses sont mappées.
Le noyau Linux n’a qu’environ 9 bits d’entropie pour la base de son image texte → quelques moyennes suffisent à récupérer la KASLR base avec ~100% de fiabilité.
Le physmap base (direct-map) est aussi leaké via prefetch, ce qui permet de calculer l’adresse du CPU entry area (CEA) :
cea_direct = physmap_base + CPU1_CEA_BASE
Le CEA a une adresse physique fixe — seule son adresse virtuelle est randomisée (depuis 6.2). Mais on n’a pas besoin de l’adresse virtuelle : son alias direct-map dérive directement du physmap leak.
3.3 Step 2 : Reclaim du stack-UAF via PR_SET_MM_MAP
L’objet dangling est le rt_mutex_waiter qui était sur la stack du waiter. Le thread waiter retourne du syscall futex et appelle immédiatement :
prctl(PR_SET_MM, PR_SET_MM_MAP, ...);
À l’intérieur, prctl_set_mm_map() copie un auxv fourni par l’utilisateur dans un buffer stack unsigned long user_auxv[AT_VECTOR_SIZE]. Ce buffer se trouve à approximativement la même profondeur stack que le waiter libéré — c’est un bloc de qwords contrôlés, naturellement aligné, qui tombe pile sur l’ancien objet.
L’auxv est lay-out de façon que les qwords qui chevauchent l’ancien waiter deviennent :
| Champ du waiter | Valeur forgée | But |
|---|---|---|
tree (rb node) |
Façonné pour que l’erase promeuve W0_BASE en racine |
Contrôler la cible du write |
task |
&init_task |
Déréférencement valide pour la chain walk |
lock |
&inet6_protos[IPPROTO_UDP] - 8 |
Cible du write |
wake_state |
0 |
— |
L’auxv est backed par un memfd positionné à cheval sur une page boundary. Un thread sibling race fallocate(PUNCH_HOLE) sur la page de fin pendant le prctl, ce qui élargit la fenêtre du copy_from_user. Le waiter forgé reste live sur la stack pendant que, sur un autre CPU, un thread consommateur déclenche sched_setattr() sur le waiter pour parcourir la chaîne PI.
3.4 Step 3 : Du fake waiter à une écriture contrôlée
La chain walk fait :
task->pi_blocked_on → fake waiter
fake waiter->lock → fake rt_mutex_base
rt_mutex_dequeue(lock, waiter) // rb_erase sur lock->waiters
rt_mutex_dequeue() est un rb-tree erase. Effacer une racine à un seul enfant écrit ce child dans le slot racine. En pointant lock vers target - 8, les champs rt_mutex_base sont alignés sur les données autour de la cible :
target - 8 → raw_spinlock_t wait_lock (doit lire comme "unlocked")
target → waiters.rb_root.rb_node (CE SLOT EST ÉCRIT)
target + 8 → waiters.rb_leftmost
target + 16 → owner
L’écriture elle-même est un single constrained store :
*(uint64_t *)target = W0_BASE; // 1 pointeur écrit
Contraintes sur la cible
*(u32 *)(target - 0x08) == 0 // wait_lock → unlocked
*(u64 *)(target + 0x08) == 0 // rb_leftmost → NULL
((*(u64 *)(target + 0x10)) & ~1ULL) == 0 // owner → NULL
3.5 Step 4 : inet6_protos[IPPROTO_UDP] comme cible
Un scan de data writable révèle de nombreuses tables de pointeurs dont les voisins satisfont les contraintes ci-dessus. inet6_protos[IPPROTO_UDP] est un choix idéal :
inet6_protos[16] == NULL // fake wait_lock → unlocked ✓
inet6_protos[17] == &udpv6_protocol // ← TARGET (IPPROTO_UDP = 17)
inet6_protos[18] == NULL // fake rb_leftmost ✓
inet6_protos[19] == NULL // fake owner ✓
Après le write, inet6_protos[IPPROTO_UDP] pointe vers la CEA page, où le noyau s’attend à trouver un inet6_protocol :
struct inet6_protocol {
int (*handler)(struct sk_buff *skb); // ← premier pivot gadget
int (*err_handler)(...);
unsigned int flags;
};
3.6 Step 5 : CEA spray + Control Flow Hijack
La CPU entry area est une structure per-CPU x86 qui tient les stacks et registres de gestion d’exceptions. Avant 6.2, elle était à une adresse complètement fixe. Après 6.2, son adresse virtuelle est randomisée — mais son offset physique reste fixe, donc son alias direct-map se calcule via le physmap leak.
Avant le trigger, W0 est spré-sprayé comme fake waiter + lock (task = &init_task, prio légitime, lock avec wait_lock unlocked) pour survivre à la chain walk entière. Après le write, le CEA est re-sprayé comme un fake inet6_protocol :
handler= premier pivot gadgetflags=INET6_PROTO_NOPOLICY | INET6_PROTO_FINAL
Trigger : connect() + write() vers ::1 (loopback IPv6 UDP) → le noyau déréférence handler → PC control.
3.7 Step 6 : Pivot + DirtyMode
La même fenêtre CEA compacte tient plusieurs objets simultanément : le fake inet6_protocol, des slots JOP/pivot, et la ROP stack finale. Sur le kernel lts-6.12.80, il n’y a pas de single stack pivot direct — la chaîne prend un load/call supplémentaire pour placer l’adresse CEA dans rbp, puis pivote avec mov rsp, rbp; pop rbp; ret.
L’exploit utilise DirtyMode comme étape finale plutôt qu’un long ROP complet :
static struct ctl_table coredump_sysctls[] = {
...
{ .procname = "core_pattern",
.data = core_pattern,
.maxlen = CORENAME_MAX_SIZE,
.mode = 0644, // ← 0644 → world-writable
.proc_handler = proc_dostring_coredump },
...
};
Le ROP écrit une valeur permissive dans coredump_sysctls[1].mode. N’importe quelle valeur avec le bit write (2nd LSB) suffit. Le ROP utilise pop reg; mov [reg], reg; ret + msleep pour parquer le thread hijacké.
3.8 Step 7 : LPE userspace pur
Une fois /proc/sys/kernel/core_pattern devenu world-writable, un process non privilégié :
- Écrit
|/proc/%P/fd/666 %Pdanscore_pattern - Crush un helper → le noyau exécute le binaire en root
- Shell root obtenu
Le tout en ~5 secondes sur le kernelCTF remote.
4. Le schéma complet de l’attaque
┌───────────────────────────────┐
│ Attaquant (user) │
│ │
│ 1. prefetch → KASLR + physmap│
│ 2. 3 futex + 3 threads │
│ → -EDEADLK → dangling ptr │
│ 3. prctl(PR_SET_MM_MAP) │
│ → reclaim stack → fake waiter│
│ 4. sched_setattr() → chain walk│
│ → rb_erase → arb write │
└──────────┬─────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ Noyau Linux │
│ │
│ remove_waiter() nettoie current │
│ au lieu de waiter->task │
│ ↓ │
│ pi_blocked_on → stack frame libérée │
│ ↓ │
│ prctl() → user_auxv sur la même stack │
│ → fake rt_mutex_waiter forgé │
│ ↓ │
│ chain walk → rt_mutex_dequeue(lock) │
│ = rb_erase → *(target) = W0_BASE │
│ target = inet6_protos[IPPROTO_UDP] │
│ ↓ │
│ inet6_protos[17] → CEA (fake handler) │
│ ↓ │
│ loopback IPv6 UDP → handler() détourné │
│ → pivot → ROP → DirtyMode │
│ → core_pattern.mode = world-writable │
└────────────────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ echo "|proc/%P/fd/666 %P" │
│ > /proc/sys/kernel/core_pattern│
│ → crash helper → ROOT SHELL │
└───────────────────────────────┘
5. Correctif et mitigation
5.1 Le patch upstream (commit 3bfdc63936dd)
Le correctif remplace current par waiter->task dans remove_waiter() :
static void __sched remove_waiter(struct rt_mutex_base *lock,
struct rt_mutex_waiter *waiter)
{
...
- raw_spin_lock(¤t->pi_lock);
- rt_mutex_dequeue(lock, waiter);
- current->pi_blocked_on = NULL;
- raw_spin_unlock(¤t->pi_lock);
+ struct task_struct *waiter_task = waiter->task;
+ ...
+ scoped_guard(raw_spinlock, &waiter_task->pi_lock) {
+ rt_mutex_dequeue(lock, waiter);
+ waiter_task->pi_blocked_on = NULL;
+ }
...
rt_mutex_adjust_prio_chain(owner, RT_MUTEX_MIN_CHAINWALK, lock,
- next_lock, NULL, current);
+ next_lock, NULL, waiter_task);
Le correctif ajoute aussi un commentaire crucial :
/*
* When invoked from rt_mutex_start_proxy_lock() waiter::task != current !
*/
5.2 Le fix alternatif de Nebula Security
Nebula a soumis un fix alternatif à security@kernel.org avant le patch v1, avec une approche différente — passer task en paramètre explicite de remove_waiter() au lieu de le lire depuis waiter->task :
static void __sched remove_waiter(struct rt_mutex_base *lock,
- struct rt_mutex_waiter *waiter)
+ struct rt_mutex_waiter *waiter,
+ struct task_struct *task)
{
...
- raw_spin_lock(¤t->pi_lock);
+ raw_spin_lock(&task->pi_lock);
rt_mutex_dequeue(lock, waiter);
- current->pi_blocked_on = NULL;
- raw_spin_unlock(¤t->pi_lock);
+ if (task->pi_blocked_on == waiter)
+ task->pi_blocked_on = NULL;
+ raw_spin_unlock(&task->pi_lock);
...
+ rt_mutex_adjust_prio_chain(owner, RT_MUTEX_MIN_CHAINWALK, lock,
+ next_lock, NULL, task);
Cette version ajoute une garde conditionnelle (if (task->pi_blocked_on == waiter)) pour ne nettoyer que si le pointeur pointe bien vers ce waiter — plus défensif.
5.3 Mitigations partielles
| Mitigation | Effet |
|---|---|
RANDOMIZE_KSTACK_OFFSET |
Le stack-reuse step devient un guess 1/32 (5-bit). Les deux targets kernelCTF l’ont off par défaut. |
STATIC_USERMODE_HELPER |
Fermerait le chemin DirtyMode. Mais la même idée se généralise à d’autres sysctl knobs. |
| KPTI | Ralentit le prefetch leak, mais prefetch + EntryBleed contourne encore. |
⚠️ Aucune mitigation complète en dehors du patch.
RANDOMIZE_KSTACK_OFFSETetSTATIC_USERMODE_HELPERne font que ralentir l’attaquant.
6. Leçons
6.1 Le pattern bug lifecycle
GhostLock suit un pattern classique de sécurité noyau : une fonction écrite pour un seul scénario (remove_waiter() nettoie après soi-même) qui est réutilisée par un nouveau chemin (rt_mutex_start_proxy_lock()) sans que l’hypothèse implicite (current == waiter->task) soit re-vérifiée.
6.2 Le rôle de l’IA dans la découverte
VEGA est un pipeline LLM de recherche sécurité qui a trouvé 300+ bugs dans le noyau Linux et plusieurs zero-days Chrome V8. GhostLock est un exemple de vulnérabilité deep — un bug de logique subtil, pas un simple dépassement de buffer — découverte par un système automatisé. L’avenir de la recherche sécurité s’oriente vers l’IA + humain en binôme.
6.3 Stack-UAF vs heap-UAF
La majorité des UAF noyau référencent la heap. Un stack-UAF est plus rare et plus insidieux : l’objet libéré n’est pas un slab cache освобождé — c’est un stack frame qui disparaît naturellement quand la fonction retourne. Il n’y a pas de KASAN pour détecter ça (le stack frame est réutilisé normalement), et pas de freelist à corrompre.
6.4 DirtyMode : minimiser le ROP
L’exploit illustre une technique élégante : plutôt que de faire un ROP complet pour obtenir un shell root, le ROP minimal fait une seule écriture qui change un bit de permission (core_pattern.mode), puis le reste de l’exploitation est pure userspace. C’est la technique DirtyMode — minimiser la surface du ROP pour maximiser la fiabilité.
7. Timeline
| Date | Événement |
|---|---|
| 2011 | Commit 8161239a8bcc introduit le bug (rtmutex rework, 2.6.39) |
| 2026-04-18 | Signalement + draft patch à security@kernel.org (Nebula) |
| 2026-04-20 | Bug corrigé dans mainline |
| 2026-05-04 | Fix v1 backporté |
| 2026-06-30 | Google acknowledge kernelCTF — 92 337 $ |
| 2026-07-07 | Publication du write-up (Nebula Security) |
8. Différence avec les précédentes vulnérabilités LPE
| Aspect | GhostLock (CVE-2026-43499) | Bonding 19y (CVE-2026-43456) | Bumsrakete (CVE-2026-45257) |
|---|---|---|---|
| Type | Stack-UAF (rtmutex) | Type confusion (bonding) | Race condition (sendfile+kTLS) |
| Âge | 15 ans (2011) | 19 ans (2007) | — |
| Prérequis | Aucun | CAP_NET_ADMIN (user ns) |
Accès non privilégié |
| OS | Linux (toutes distros) | Linux 2.6.24→6.12.77 | FreeBSD 13→15 |
| Fiabilité | 97% | >99% | ~100% |
| Découverte | VEGA (LLM pipeline) | syzkaller + humain | Chercheur solo |
| Récompense | 92 337 $ | >80 000 $ | — |
| Évasion | Container escape | — | Bypass schg |
Références
| Source | URL |
|---|---|
| Article original Nebula Security | https://nebusec.ai/research/ionstack-part-2/ |
| VEGA (AI security research) | https://nebusec.ai/vega/ |
| Bug List Nebula Security | https://nebusec.ai/buglist/ |
| Commit d’introduction | 8161239a8bcc |
| Commit de correction | 3bfdc63936dd |
| kernelCTF (Google) | https://google.github.io/security-research/kernelctf/rules.html |
| Précédent : Bonding 19y (CVE-2026-43456) | https://madpowah.github.io/2026/07/03/cve-2026-43456-bonding-type-confusion.html |
Have fun.
tags: security - linux - cve - exploit - lpe - kernel - rtmutex - futex - uaf - ghostlock - ionstack - container-escape