
Introduction
Le noyau Linux est considéré comme l’un des logiciels les plus audités au monde. Il équipe aujourd’hui une immense variété de systèmes : serveurs d’entreprise, infrastructures cloud, équipements réseau, smartphones Android, systèmes embarqués et environnements industriels.
Pourtant, même dans un composant aussi mature, certaines classes de vulnérabilités peuvent rester silencieuses pendant plus d’une décennie.
La vulnérabilité GhostLock (CVE-2026-43499) appartient précisément à cette catégorie.
Elle représente un exemple particulièrement intéressant de faille kernel moderne : elle ne provient pas d’un dépassement mémoire classique, d’un parsing insuffisant ou d’une validation utilisateur absente, mais d’une erreur logique dans la gestion du cycle de vie d’objets de synchronisation internes au noyau.
Le défaut se situe dans le sous-système futex (Fast Userspace muTEX), plus précisément dans la gestion des Priority Inheritance futexes (PI futex) utilisés pour éviter certains problèmes de priorité dans les environnements temps réel.
Le problème fondamental peut être résumé ainsi :
Le noyau Linux manipule un objet représentant un thread bloqué, mais utilise le contexte du thread actuellement exécuté au lieu du thread réellement associé à cet objet.
Cette confusion, apparemment anodine, entraîne une désynchronisation de structures internes du noyau. Dans certaines conditions concurrentes, cette incohérence permet l’apparition d’un dangling pointer, pouvant évoluer vers un use-after-free kernel.
1. Pourquoi GhostLock est une vulnérabilité intéressante
Les vulnérabilités kernel critiques suivent souvent un schéma assez similaire que l’on pourrait résumer comme suit :
Bug logique
|
v
Etat interne incohérent
|
v
Corruption mémoire
|
v
Primitive exploitable
|
v
Contrôle du noyau
|
v
Privilèges root
Cependant, GhostLock est particulièrement intéressante pour plusieurs raisons que je vais m’efforcer de détailler dans la suite de cet article.
1.1 Une vulnérabilité extrêmement ancienne …
Le code vulnérable est présent depuis déjà plusieurs années (et peut-être même exploité dans la nature bien avant sa révélation publique ?).
Cette dernière illustre une problématique majeure du développement kernel (ainsi que de tant d’autres développements par ailleurs) :
- le code peut être correct dans 99,99 % des scénarios
- le bug peut nécessiter une combinaison précise d’événements concurrents
- les tests fonctionnels classiques peuvent ne jamais déclencher l’état fautif
1.2 … qui touche un mécanisme fondamental …
Les futex ne sont pas une fonctionnalité exotique, ils sont utilisés indirectement par énormément de logiciels comme :
- threads POSIX
- bibliothèques C/C++
- runtimes Java
- serveurs multithreadés
- moteurs de bases de données
- applications temps réel
Les Fast Userpace Mutex sont donc des éléments critiques et omniprésents dans une architecture basé sur Linux.
1.3 … et exploite une erreur conceptuelle
La vulnérabilité ne vient pas d’une « simple » absence de contrôle « bénigne » telle que
if (pointer == NULL)
ou bien
if (size > buffer)
mais d’une mauvaise hypothèse plus « générale », plus « haut niveau » à savoir que « le thread courant est forcément le thread associé à l’objet manipulé ».
Dans un noyau fortement concurrent, cette hypothèse est dangereuse, et il existe malheureusement une exception un peu particulière !
2. Comprendre les futex Linux
Avant d’analyser GhostLock, il faut comprendre ce que sont les futex et pourquoi ils existent.
2.1 Le problème historique des verrous
Les applications multithreadées utilisent des mécanismes de synchronisation tels que :
- des mutex (mutual exclusion ou exclusion mutuelle)
- des sémaphores
- des spinlocks
- des conditions variables.
Prenons l’exemple suivant : sur ma machine j’ai deux threads qui s’exécutent simultanément et partagent une même ressource.
Ressource partagée
|
+------------+------------+
| |
Thread A Thread B
|
|
verrou acquis
Dans le cas présenté, le Thread B doit attendre que le A libère le verrou afin de pouvoir accéder à la ressource partagée.
La solution naïve serait alors une attente « active » pendant laquelle le Thread B vérifierai en permanence si le verrou est toujours « pris » comme illustré ci-dessous :
Thread B
boucle infinie :
while(lock == locked)
{
attendre;
}
Ce mécanisme n’est pas très optimisé car il consomme énormément de CPU, il nous faut donc utiliser / trouver un autre mécanisme.
2.2 L’idée du futex
C’est à ce moment-là qu’intervient le futex.
Les futex optimisent la synchronisation entre threads ou processus en limitant les appels au noyau. Lorsqu’une ressource partagée (comme une variable data) est libre, l’accès est géré directement en espace utilisateur, sans intervention du noyau. Le noyau n’est sollicité que lorsqu’un conflit d’accès apparaît, c’est-à-dire lorsqu’un thread doit attendre qu’un autre libère la ressource.
C’est ce fonctionnement qui différencie les futex des sémaphores System V : ces derniers impliquent systématiquement un appel au noyau, même lorsqu’aucun conflit n’existe, ce qui les rend généralement moins performants.
Pour illustrer le fonctionnement je vous propose le schéma ci-dessous :

2.3 Architecture simplifiée
Lorsqu’un thread utilisateur utilise l’appel système futex() pour demander un service au noyau, il lui transmet notamment l’adresse de la variable futex (uaddr), l’opération à effectuer (op) ainsi que d’autres paramètres.
Le noyau utilise l’adresse uaddr pour générer une clé unique appelée futex_key, qui permet d’identifier le futex concerné. Lorsqu’un thread doit être mis en attente sur un futex (par exemple avec l’opération FUTEX_WAIT), le noyau crée une structure futex_q représentant ce thread bloqué et l’ajoute dans une table de hachage appelée futex_queues.
Chaque tâche en attente possède sa propre structure futex_q, ce qui signifie qu’un même futex peut être associé à plusieurs futex_q si plusieurs threads attendent sa libération. En revanche, les files d’attente (futex_queues) sont partagées entre plusieurs futex : plusieurs futex_key peuvent être regroupées dans la même entrée de la table de hachage.
Cette architecture est illustrée ci-dessous :

3. Les Priority Inheritance Futex (PI futex)
GhostLock apparaît dans une fonctionnalité particulière : la Priority Inheritance.
3.1 Le problème de l’inversion de priorité
Considérons le cas d’un mutex basé sur un futex où un thread L de priorité donnée a pris la main sur le futex et qu’un thread H plus prioritaire se retrouve en attente sur ce même futex. Si un thread M de priorité intermédiaire entre les deux précédents vient à se réveiller, il y a de fortes chances que l’ordonnanceur de Linux préempte le thread L pour donner le CPU au thread M. Cela revient à dire que le thread H, normalement plus prioritaire que les deux autres, se retrouve privé de CPU à cause du thread L qui détient le futex et du thread M qui a pris la main car il n’est pas en attente sur le futex.
Ce phénomène s’appelle l’inversion de priorité. Il est rédhibitoire dans un environnement temps réel, car il nuit au déterminisme du système. Le listing suivant est un peu long, mais il illustre bien ce cas de figure :
[...]
#define futex_wait(addr, val) \
syscall(SYS_futex, addr, FUTEX_WAIT, val, NULL)
#define futex_wakeup(addr, nb) \
syscall(SYS_futex, addr, FUTEX_WAKE, nb)
int futex_var;
unsigned int nb_loops;
int policy = SCHED_FIFO;
pthread_barrier_t barrier;
static void nop(void)
{
// Sur Intel
asm("rep; nop;");
//asm("nop;");
}
[...]
void *thread_L(void *p)
{
unsigned int i;
struct sched_param param;
cpu_set_t cpu;
CPU_ZERO(&cpu);
CPU_SET(0, &cpu);
pthread_setaffinity_np(pthread_self(), sizeof(cpu), &cpu);
param.sched_priority = 1;
pthread_setschedparam(pthread_self(), policy, ¶m);
pthread_barrier_wait(&barrier);
for (i = 0; i < nb_loops; i ++)
{
nop();
}
futex_var = 1;
futex_wakeup(&futex_var, 1);
printf("Thread_L termine\n");
return NULL;
}
void *thread_M(void *p)
{
unsigned int i;
struct sched_param param;
cpu_set_t cpu;
CPU_ZERO(&cpu);
CPU_SET(0, &cpu);
pthread_setaffinity_np(pthread_self(), sizeof(cpu), &cpu);
param.sched_priority = 50;
pthread_setschedparam(pthread_self(), policy, ¶m);
pthread_barrier_wait(&barrier);
for (i = 0; i < nb_loops; i ++)
{
nop();
}
printf("Thread_M termine\n");
return NULL;
}
void *thread_H(void *p)
{
struct timeval t0, t1, t;
unsigned int latence;
struct sched_param param;
cpu_set_t cpu;
CPU_ZERO(&cpu);
CPU_SET(0, &cpu);
pthread_setaffinity_np(pthread_self(), sizeof(cpu), &cpu);
param.sched_priority = 97;
pthread_setschedparam(pthread_self(), policy, ¶m);
pthread_barrier_wait(&barrier);
gettimeofday(&t0, NULL);
futex_wait(&futex_var, 0);
gettimeofday(&t1, NULL);
timersub(&t1, &t0, &t);
latence = t.tv_sec * 1000 + t.tv_usec / 1000000;
printf("Latence : %u ms\n", latence);
printf("Thread_H termine\n");
return NULL;
}
int main(int ac, char *av[])
{
pthread_t tid1, tid2, tid3;
struct sched_param param;
cpu_set_t cpu;
CPU_ZERO(&cpu);
CPU_SET(0, &cpu);
pthread_setaffinity_np(pthread_self(), sizeof(cpu), &cpu);
param.sched_priority = 99;
sched_setscheduler(0, policy, ¶m);
// Calibration
nb_loops = calibration(atoi(av[1]));
printf("Nombre d'iterations pour %u ms : %u\n", atoi(av[1]), nb_loops);
pthread_barrier_init(&barrier, NULL, 3);
pthread_create(&tid1, NULL, thread_L, NULL);
pthread_create(&tid2, NULL, thread_M, NULL);
pthread_create(&tid3, NULL, thread_H, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_join(tid3, NULL);
return 0;
}
Le programme reçoit en paramètre un nombre de millisecondes qu’il convertit en nombre d’itérations pour occuper le CPU pendant cette durée (variable globale nb_loops mise à jour par une boucle de calibration). Le thread L de priorité 1, qui a la main sur le futex, effectue une attente active en effectuant toutes ces itérations. Le thread H se met en attente jusqu’au passage du futex à la valeur 1. Le thread M se met aussi en attente active pendant le nombre d’itérations demandées. Comme le thread L a une priorité égale à 1, qui est inférieure à la priorité du thread M (dont la priorité est égale à 50) et que le thread H, plus prioritaire que les deux premiers, est en attente sur le futex, le thread M prend très rapidement la main pour exécuter sa boucle active. Quand il se termine, l’ordonnanceur de Linux donne la main au thread L pour qu’il termine sa boucle active avant de passer le futex à la valeur 1 pour réveiller le thread H. Ce dernier affiche son temps de latence (durée d’attente sur le futex), qui est logiquement le résultat de la durée des boucles actives des thread L et thread M.
Dans l’exemple d’exécution qui suit, le programme recevant 10000 millisecondes en paramètre, fait attendre le thread H environ 19000 millisecondes (cumul des durées des boucles actives de 10000 millisecondes exécutées dans thread M et thread L). Sur certains systèmes, cet exemple peut nécessiter les droits du super-utilisateur (e.g. commande sudo) pour fonctionner, car il utilise des threads temps réel :
$ gcc futex_pi.c -o futex_pi -lpthread
$ sudo ./futex_pi 10000
Nombre d'iterations pour 10000 ms : 505283000
Thread_M termine
Latence : 19000 ms
Thread_H termine
Thread_L termine
Nous sommes donc bien confrontés à un phénomène d’inversion de priorité : le thread H a dû non seulement attendre la fin du thread L, mais aussi la fin du thread M, alors qu’il est plus prioritaire que ce dernier (priorité 97 contre 50) !
(source : connect.ed-diamond.com)
3.2 Solution : Priority Inheritance
Pour éviter le phénomène d’inversion de priorité, il aurait fallu que le thread L ait une priorité supérieure à celle du thread M et bien évidemment inférieure à celle du thread H (par exemple 51 au lieu de 1 dans le cas vu précédent).
En modifiant les priorités, on se retrouve alors avec une exécution « corrigée ».
$ gcc futex_pi_1.c -o futex_pi_1 -lpthread
$ sudo ./futex_pi_1 10000
Nombre d'iterations pour 10000 ms : 505267000
Latence : 9000 ms
Thread_H termine
Thread_L termine
Thread_M termine
La solution trouvée est donc la suivante : le noyau augmente temporairement la priorité du thread propriétaire du verrou. On appelle ce mécanisme l’héritage de priorité.
Le thread L voit sa priorité augmenter temporairement au même niveau que celle du thread de priorité la plus importante en attente sur ce même futex (le thread H ici), pendant toute la durée pendant laquelle le thread conserve le verrou, puis, dès qu’il le libère, il retrouve sa priorité initiale.
Ce mécanisme permet d’éviter le phénomène d’inversion de priorité car le thread qui possède le verrou initialement se retrouve (temporairement) au niveau de priorité correspondant au thread en attente le plus élevée de la queue, donc le seul thread à pouvoir préempter sera celui qui possède ce même niveau de priorité.
3.3 Les PI futex dans Linux
Contrairement au futex classique, un PI futex doit maintenir des structures kernel supplémentaires.

On retrouve notamment les structures :
- futex_q
struct futex_q {
struct plist_node list;
struct task_struct *task;
spinlock_t *lock_ptr;
futex_wake_fn *wake;
void *wake_data;
union futex_key key;
struct futex_pi_state *pi_state;
struct rt_mutex_waiter *rt_waiter;
union futex_key *requeue_pi_key;
u32 bitset;
atomic_t requeue_state;
struct futex_private_hash *drop_fph;
#ifdef CONFIG_PREEMPT_RT
struct rcuwait requeue_wait;
#endif
} __randomize_layout;
- futex_pi_state
struct futex_pi_state {
/*
* list of 'owned' pi_state instances - these have to be
* cleaned up in do_exit() if the task exits prematurely:
*/
struct list_head list;
/*
* The PI object:
*/
struct rt_mutex_base pi_mutex;
struct task_struct *owner;
refcount_t refcount;
union futex_key key;
} __randomize_layout;
- rt_mutex
struct rt_mutex_base {
raw_spinlock_t wait_lock;
struct rb_root_cached waiters __guarded_by(&wait_lock);
struct task_struct *owner __guarded_by(&wait_lock);
};
struct rt_mutex {
struct rt_mutex_base rtmutex;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};
Cette complexité supplémentaire est précisément l’endroit où GhostLock surgit.
4. Structures internes importantes
Nous allons maintenant descendre au niveau des structures C du noyau.
(Les extraits suivants sont simplifiés pour la compréhension et ne représentent pas forcément la totalité des champs présents dans une version précise du noyau.)
4.1 task_struct
Chaque thread Linux possède une structure task_struct. Il s’agit de l’une des structures les plus importantes du noyau.
Version simplifiée de la structure :
struct task_struct
{
volatile long state;
pid_t pid;
struct sched_entity se;
/*
* Gestion PI futex
*/
struct rt_mutex_waiter *pi_blocked_on;
struct futex_pi_state *pi_state_cache;
};
Champ critique : pi_blocked_on
Ce champ représente le verrou PI sur lequel ce thread est actuellement bloqué.
Schéma simplifié :
task_struct
+----------------+
| PID |
+----------------+
| scheduler info |
+----------------+
| |
| pi_blocked_on | --------+
| | |
+----------------+ |
|
v
rt_mutex_waiter
4.2 rt_mutex
Le PI futex s’appuie sur le mécanisme interne RT mutex.
Voici sa structure (simplifiée) :
struct rt_mutex
{
raw_spinlock_t wait_lock;
struct rb_root_cached waiters;
struct task_struct *owner;
};
Il contient :
- Un owner qui correspond au thread qui possède actuellement le verrou.
- Un waiters correspondant aux différents threads attendant la possibilité de prendre le verrou.
4.3 rt_mutex_waiter
Cette structure représente une attente individuelle.
struct rt_mutex_waiter
{
struct rb_node tree_entry;
struct task_struct *task;
struct rt_mutex *lock;
};
On peut résumer cette structure aux 2 champs suivants :
- Le pointeur sur la tâche : « task »
- Le pointeur sur le verrou : « lock »
Dans le cas d’une exploitation par GhostLock, le champ important est le pointeur sur la tâche :
struct task_struct *task;
car il indique précisément quel thread est représenté par ce waiter.
5. Le rôle de futex_requeue()
Pour comprendre GhostLock, il faut comprendre une primitive particulière des futex :
futex_requeue()
Cette opération permet de déplacer des threads en attente d’un futex vers un autre futex.
L’objectif historique est l’optimisation de certains mécanismes de synchronisation.
Petit aparté, une vulnérabilité a été trouvée en 2014 qui exploitait une faiblesse dans le mécanisme de « requeue » : la CVE-2014-3153 qui permettait alors une élévation de privilège locale.
5.1 Pourquoi déplacer des attentes ?
Prenons une application utilisant une file d’attente, une condition variable et un mutex.
Un thread peut être en attente sur une condition, et lorsqu’un événement survient, plutôt que de réveiller brutalement tous les threads, Linux peut déplacer les attentes de manière à ne réveiller qu’un seul thread.
Solution sans « requeue » :

On remarque dans ce cas que tous les threads en attente du mutex et d’une condition sont alors réveillés en même temps, et se mettent tous en attente de la libération du verrou qui se fera alors par priorité.
Avec l’utilisation du mécanisme de « requeue » cela donne :

Cela évite des réveils inutiles.
5.2 Requeue classique vs PI requeue
Le problème apparaît lorsque le futex possède un mécanisme de Priority Inheritance (PI futex).
Dans un futex classique, une entrée d’attente ressemble essentiellement à :
Adresse mémoire
|
v
Thread dormant
Mais comme je l’ai indiqué précédemment, avec l’utilisation de la PI, il y a des structures plus complexes en jeu :
Adresse mémoire
|
v
futex_pi_state
|
v
rt_mutex
|
v
task_struct
Il existe donc beaucoup plus d’états internes devant rester cohérents.
6. Le rôle de remove_waiter()
Lorsqu’un thread quitte une file d’attente, le noyau doit nettoyer les structures associées.
C’est le rôle de :
remove_waiter()
Conceptuellement :
Thread bloqué
|
v
rt_mutex_waiter
|
v
Suppression propre
La fonction doit garantir plusieurs invariants :
- Le waiter disparaît de l’arbre d’attente.
- Le lien avec le thread est supprimé.
- Le mécanisme d’héritage de priorité est recalculé.
- Aucun pointeur kernel ne doit rester vers un objet détruit.
6.1 L’invariant critique
Lorsqu’un thread est associé à un waiter :
task_struct
|
|
v
rt_mutex_waiter
la relation doit rester :
waiter->task == thread représenté
Autrement dit c’est « waiter->task » qui est la source de vérité.
7. La confusion current vs waiter->task
C’est ici que se situe le défaut logique.
Dans Linux, une fonction exécutée dans le noyau possède une variable globale spéciale :
current
Elle représente le thread qui exécute actuellement du code kernel.
Par exemple :
Utilisateur A
|
|
syscall
|
v
Kernel
current = A
Dans ce cas « current » correspond bien au thread manipulé donc tout va bien.
Mais dans un scénario de synchronisation :
Thread A
|
|
v
Objet waiter
Thread B
|
|
v
syscall
Kernel
Le noyau exécute maintenant du code au nom de B, donc nous avons d’un côté :
current = B
et d’un autre :
waiter->task = A
Les deux ne représentent plus la même chose, et c’est là qu’il ne faut pas tout « mélanger ».
8. Le bug conceptuel
Le code vulnérable avait une logique équivalente à :
void remove_waiter(
struct rt_mutex *lock,
struct rt_mutex_waiter *waiter)
{
...
current->pi_blocked_on = NULL;
}
L’intention du développeur était :
Le thread dont on retire l’attente doit être marqué comme n’attendant plus.
Mais l’implémentation réelle a transformé l’intention en :
Le thread exécutant cette fonction doit être marqué comme n’attendant plus.
Ce qui est totalement différent.
8.1 Ce qui devrait être fait
La logique correcte est :
waiter->task->pi_blocked_on = NULL;
Parce que « waiter->task » est le vrai propriétaire.
9. Scénario temporel simplifié
Regardons une exécution possible.
Étape 1 : Le Thread A attend
Thread A
task_struct A
pi_blocked_on
|
|
v
rt_mutex_waiter
On a à ce moment un état cohérent :
A --> waiter
Étape 2 : Le Thread B effectue un requeue
Thread B
futex_requeue()
Le noyau manipule le waiter de A.
Maintenant :
current = B
waiter->task = A
Étape 3 : Appel à la fonction remove_waiter()
Avec « l’ancienne logique » on met à jour l’état du « current » :
current->pi_blocked_on = NULL;
En résulte la modification du thread B :
Thread B
pi_blocked_on = NULL
Mais pour autant c’est bien le thread A qui va libérer ses ressources :
Thread A
pi_blocked_on
|
|
v
objet waiter qui va disparaître
Étape 4 : Libération de l’objet
Le waiter est désormais détruit.
Avant sa destruction on avait :
A
|
|
v
waiter
Ensuite, après libération on se retrouve avec :
A
|
|
v
adresse mémoire invalide
Le noyau possède maintenant une référence « morte ».
10. Pourquoi parle-t-on de Use-After-Free
Un use-after-free apparaît lorsque :
- Une zone mémoire est libérée.
- Un pointeur continue de la référencer.
- Le programme réutilise ce pointeur.
Exemple côté user space « classique » :
struct object *obj;
obj = malloc(sizeof(struct object));
free(obj);
/*
* Erreur :
* obj pointe maintenant
* vers une zone invalide
*/
obj->value = 42;
Dans le noyau :
Mémoire kernel libérée
|
v
Réutilisation possible
|
v
Nouvelle structure kernel
Le danger est alors beaucoup plus important.
11. Pourquoi un pointeur kernel invalide est critique
Le noyau fonctionne avec un niveau de confiance maximal, et un pointeur incorrect peut provoquer :
Lecture incorrecte
|
v
Crash kernel
mais peut également mener à :
Ecriture incorrecte
|
v
Modification d'une structure sensible
Les structures kernel contiennent souvent :
- pointeurs de fonction
- informations de permissions
- références vers d’autres objets
- états du scheduler
Une corruption « maitrisée » peut donc conduire à un comportement totalement contrôlé par un utilisateur.
12. Difficulté de détection
Cette vulnérabilité est un exemple classique de bug concurrent.
Elle dépend de plusieurs conditions :
Thread A
|
| attente PI futex
|
v
Thread B
|
| requeue
|
v
remove_waiter()
avec :
current != waiter->task
Ce type de bug est extrêmement difficile à détecter avec des tests unitaires classiques, une analyse statique ou encore via une revue humaine simple.
12.1 Pourquoi les analyseurs statiques échouent souvent
Un analyseur peut voir l’assignation du la valeur NULL au champ « current->pi_blocked_on » et simplement conclure :
écriture valide dans une structure connue.
Il ne connaît pas forcément l’invariant logique :
current doit être identique au thread représenté par waiter.
13. Détection avec les outils kernel
Plusieurs outils auraient pu aider afin d’identifier la vulnérabilité.
13.1 KASAN
Kernel Address Sanitizer, qui permet de détecter :
- use-after-free
- buffer overflow
- accès mémoire invalide
Exemple de détection :
BUG: KASAN: use-after-free in remove_waiter()
13.2 KCSAN
Kernel Concurrency Sanitizer, qui lui est spécialisé dans :
- races conditions
- accès concurrents non protégés
13.3 Syzkaller
Très important dans l’écosystème Linux. Il s’agit d’un fuzzer kernel automatique.
Son principe de fonctionnement résumé :
Syscalls générés aléatoirement
|
v
Kernel Linux
|
v
Crash / anomalie
De nombreuses vulnérabilités futex ont par ailleurs déjà été découvertes grâce à lui.
14. Le correctif
Le correctif GhostLock est conceptuellement simple. Comme déjà vu précédemment, la libération des ressources liées à un thread qui n’attend plus le verrou amène au traitement :
current->pi_blocked_on = NULL;
Après correction, le traitement ressemble à ça :
waiter->task->pi_blocked_on = NULL;
Mais cette modification représente toutefois un changement fondamental, car avant on impactait le contexte CPU (current), alors que désormais il s’agit d’une mise à jour d’un objet logique (waiter).
15. Une leçon générale de développement kernel
GhostLock illustre une règle essentielle : dans un système concurrent, le contexte d’exécution n’est pas forcément l’objet manipulé.
Cette erreur bien évidemment existe dans beaucoup d’autres domaines comme :
- drivers
- systèmes distribués
- hyperviseurs
- noyaux temps réel
Un développeur doit toujours réaliser la distinction entre « Qui exécute ce code ? » et « Sur quel objet agit ce code ?« .
16. De la référence invalide au contrôle mémoire
Un pointeur invalide n’est pas automatiquement une vulnérabilité exploitable.
En effet, dans beaucoup de cas un pointeur invalide conduit à un crash du kernel et une indisponibilité de la machine. Ce scénario correspond à un simple kernel panic. Pour devenir une élévation de privilèges, plusieurs conditions doivent être réunies.
Le chemin conceptuel est plutôt :
Erreur logique
|
v
Objet kernel incohérent
|
v
Référence persistante
|
v
Réutilisation mémoire
|
v
Modification d'un état kernel critique
|
v
Gain de privilèges
17. Le modèle mémoire du noyau Linux
Pour comprendre l’impact d’un use-after-free kernel, il faut comprendre une différence fondamentale entre espace utilisateur (user space) et espace noyau (kernel space).
Le modèle des systèmes Gnu/Linux repose sur trois éléments distincts comme on peut le voir dans la figure suivante :

On distingue une partie matérielle qui ne nous intéresse pas dans ce contexte, puis une partie logicielle, scindée en 2 : l’espace utilisateur et le noyau.
17.1 Espace utilisateur
Dans une application classique, représentée sommairement ci-après :
Application
+---------------------+
| |
| malloc() |
| |
+---------------------+
|
|
v
Heap utilisateur
Chaque processus possède son propre espace mémoire, et donc une corruption affecte généralement le processus courant, ses données et ses fichiers ouverts.
17.2 Espace noyau
Le noyau est partagé :
Kernel Linux
+--------------------------------+
| Scheduler |
| Memory manager |
| Drivers |
| Filesystems |
| Network stack |
+--------------------------------+
^
|
|
Tous les processus
Une corruption kernel touche potentiellement :
- tous les processus
- les permissions
- la mémoire virtuelle
- les mécanismes de sécurité
L’impact est donc bien plus critique dans le cas d’une atteinte dans le kernel space !
18. Le concept de réutilisation mémoire
Un élément important des vulnérabilités UAF est le mécanisme d’allocation kernel.
Lorsqu’un objet disparaît, la mémoire n’est pas forcément détruite immédiatement, elle devient simplement disponible pour une utilisation ultérieure.

Ce qui est critique, c’est qu’ensuite le kernel réutilise très rapidement les zones mémoire, et on se retrouve avec de très nombreux cycles d’allocation, libération et réallocation de même zones mémoires de façon très rapide.

Le danger se trouve ici dans le cas ou un ancien pointeur pointe toujours vers cette zone récemment libérée puis réallouée, le noyau peut alors interpréter un objet d’un type comme un autre.
19. Confusion de type : un danger majeur
Les kernels modernes utilisent énormément de structures C.
Prenons l’exemple des 2 structures suivantes :
struct object_A
{
int state;
void *callback;
};
et :
struct object_B
{
int flags;
void *handler;
};
Ces 2 structures occupent la même taille mémoire :
+---------------+
| champ 1 |
+---------------+
| pointeur |
+---------------+
Une réutilisation incorrecte peut donc créer une confusion de type car en apparence le nouvel objet « ressemble » au premier créé et on peut accéder à ses paramètres et autres comme avec le précédent.
Il s’agit d’une forme de type confusion.
20. Pourquoi les structures kernel sont sensibles
Certaines structures du noyau contrôlent des mécanismes critiques.
20.1 Structures de processus
Par exemple la structure « task_struct » contient notamment :
- identité du processus
- informations scheduler
- namespaces
- credentials
Voici une vue simplifiée :
task_struct
+---------------+
| PID |
+---------------+
| UID |
+---------------+
| scheduler |
+---------------+
| mémoire |
+---------------+
20.2 Structures de credentials
Linux sépare les informations d’identité :
struct cred
{
uid_t uid;
uid_t euid;
gid_t gid;
};
Ces structures déterminent qui est l’utilisateur et quels droits il possède.
Une corruption de ce type de structure est donc extrêmement sensible.
21. Les protections modernes du noyau Linux
L’exploitation d’une vulnérabilité kernel moderne n’est plus comparable aux premiers exploits Linux.
En effet, depuis plusieurs années, de nombreuses protections rendent les attaques beaucoup plus difficiles.
21.1 KASLR – Kernel Address Space Layout Randomization
L’objectif de ce mécanisme est d’empêcher de connaître précisément l’emplacement mémoire du noyau.
Sans KASLR, le kernel space se trouvera toujours à une adresse connue, par exemple :
Kernel
0xffffffff81000000
Alors qu’avec KASLR, cette zone mémoire est différente à chaque démarrage :
Kernel
boot
0xffffffffa4300000
reboot
0xffffffff45600000
reboot
0xffffffffdea00000
21.2 SMEP – Supervisor Mode Execution Prevention
Le SMEP à pour rôle d’empêcher le noyau d’exécuter du code situé en mémoire utilisateur.
Sans activation de ce mécanisme, lorsque le kernel souhaite exécuter du code présent dans le userland space, il peut le faire sans problème, alors qu’en utilisant le SMEP cela devient impossible et bloque par la même une attaque souhaitant utiliser ce biais là.
21.3 SMAP – Supervisor Mode Access Prevention
Très proche du SMEP, le SMAP empêche le noyau d’accéder directement aux données utilisateur. Le but de cette protection n’est pas d’empêcher une exécution de code présent en mémoire utilisateur par le kernel, mais d’éviter qu’un pointeur kernel puisse lire ou écrire arbitrairement dans l’espace utilisateur.
21.4 KPTI – Kernel Page Table Isolation
Il s’agit d’un mécanisme de sécurité introduit pour atténuer la vulnérabilité Meltdown, qui touche principalement les processeurs Intel x86. En parallèle, il renforce également la protection contre les attaques visant à contourner KASLR que l’on a vu précédemment.
Avant KPTI, le noyau Linux conservait en permanence les pages mémoire du noyau mappées dans les tables de pages des processus utilisateurs. Bien que ces pages soient protégées par les mécanismes de privilèges du processeur, leur présence permettait à certaines attaques par canaux auxiliaires (side channels), comme Meltdown, d’inférer leur contenu ou leur emplacement.
KPTI résout ce problème en utilisant deux ensembles distincts de tables de pages :
- un pour l’espace utilisateur, ne contenant pratiquement aucune page du noyau
- un pour le noyau, activé uniquement lors des appels système, des interruptions ou des exceptions
Ainsi, lorsque le processeur exécute du code utilisateur, la mémoire du noyau est quasiment absente de son espace d’adressage, empêchant Meltdown de lire des données sensibles.

21.5 Stack protections
Les Linux « moderne » utilisent également :
- stack canaries (permettant d’éviter une altération de la stack)
- STACKLEAK (permet entre autre l’effacement de la stack kernel à la fin d’un syscall)
- randomisation du stack kernel
L’objectif là encore est de réduire l’impact des corruptions mémoire.
22. Des protections inefficaces pour GhostLock
Un point important est à noter : GhostLock n’est pas une corruption mémoire classique.
Elle ne commence pas par un buffer overflow, mais par un état logique incorrect (logic flaw introduit par les développeurs).
Souvenez-vous, les protections mémoire ne détectent pas forcément :
current != waiter->task
car la mémoire est parfaitement valide.
Le problème de fond est que le noyau manipule le mauvais objet.
23. Détection et posture défensive
Même si une vulnérabilité kernel existe, plusieurs mesures réduisent le risque.
23.1 Maintenir le noyau à jour
Il s’agit de LA mesure principale, à savoir :
- appliquer les mises à jour de sécurité
- utiliser les kernels maintenus par les distributions
23.2 Réduire les utilisateurs locaux
GhostLock est une vulnérabilité locale permettant une élévation de privilèges (LPE pour Local Privilege Escalation).
Le scénario classique nécessite un accès initial à un utilisateur local possédant un shell, puis la possibilité d’exécuter du code qui va ensuite déclencher l’exploitation de la vulnérabilité au travers des mécanismes détaillés précédemment jusqu’à l’obtention d’un shell root.
Donc, pour s’en prémunir et ainsi réduire la surface d’attaque il est nécessaire de :
- limiter les comptes
- éviter les accès shell inutiles
- contrôler les environnements multi-utilisateurs
23.3 Surveillance système
Même si il n’existe aucune signature fiable à analyser sur disque, il y a tout de même des signaux intéressants à monitorer tels que :
- crash kernel
- messages
BU Oops- anomalies scheduler
- comportements inhabituels d’un utilisateur non privilégié
24. Comparaison avec d’autres vulnérabilités Linux historiques
GhostLock rejoint une longue liste de vulnérabilités kernel célèbres.
24.1 Dirty COW (CVE-2016-5195)
Le mis en cause ici est une race condition au niveau de la copie mémoire.
Schéma :
Lecture mémoire
+
Modification concurrente
=
Etat incohérent
L’impact est également une élévation locale de privilèges (LPE).
24.2 Dirty Pipe (CVE-2022-0847)
On est ici confronté à une mauvaise gestion d’un flag interne des buffers pipe.
L’impact direct est l’écriture dans des fichiers normalement non modifiables (on peut imaginer alors l’ajout d’un nouvel utilisateur avec des droits root).
24.3 StackRot (CVE-2023-3269)
Il s’agit là d’une mauvaise gestion de structures mémoire liées aux VMA (Virtual Memory Area).
L’exploitation de cette vulnérabilité conduit à une corruption mémoire kernel.
24.4 GhostLock
Comme vu tout au long de cet article, la cause est une mauvaise association entre le thread courant et celui représenté par un objet waiter.
Cette vulnérabilité s’inscrit dans la catégorier des Kernel object lifetime bugs.
25. Pourquoi GhostLock est un excellent cas d’étude sécurité
Cette vulnérabilité illustre plusieurs concepts fondamentaux.
25.1 La complexité des systèmes concurrents
Plusieurs acteurs interviennent :
CPU 1
Thread A
CPU 2
Thread B
Kernel scheduler
Memory manager
Les bugs apparaissent souvent dans les interstices.
25.2 Le danger des hypothèses implicites
Un, raccourci, une hypothèse dangereuse :
La fonction agit toujours sur le thread courant.
Dans un système concurrent où l’objet exécutant n’est pas forcément celui qui est manipulé.
25.3 Le rôle crucial des invariants
Un invariant est une règle qui doit toujours rester vraie.
Par exemple : « waiter->task représente toujours le thread propriétaire de cette attente ».
Une vulnérabilité apparaît alors dès que cet invariant est « cassé ».
26. Synthèse technique
L’exploitation de GhostLock peut être résumé ainsi :

Conclusion générale
GhostLock n’est pas une vulnérabilité spectaculaire par la taille de son code ou par une erreur évidente. Elle est intéressante précisément parce qu’elle démontre une réalité fondamentale de la sécurité des systèmes modernes :
Les vulnérabilités les plus dangereuses ne sont pas toujours provoquées par une mauvaise ligne de code, mais parfois par une mauvaise compréhension de l’état que cette ligne manipule.
Après plus d’une décennie d’existence dans le noyau Linux, cette faille rappelle que les mécanismes de synchronisation restent parmi les composants les plus complexes et les plus sensibles d’un système d’exploitation.
Les développeurs kernel doivent raisonner en permanence sur :
- la concurrence
- les cycles de vie mémoire
- les relations entre objets
- les invariants internes
Pour les administrateurs systèmes, la leçon est plus simple :
Un noyau régulièrement mis à jour reste la première barrière de sécurité contre les élévations de privilèges locales.
Glossaire
| Terme | Définition |
|---|---|
| Futex | Primitive Linux permettant une synchronisation rapide entre espace utilisateur et noyau |
| PI Futex | Futex avec héritage de priorité |
| RT Mutex | Mutex kernel utilisé notamment pour les scénarios temps réel |
task_struct | Structure représentant un processus/thread Linux |
| UAF | Use-After-Free, utilisation d’une mémoire après sa libération |
| Dangling pointer | Pointeur conservant une référence vers une zone invalide |
| KASLR | Randomisation de l’espace mémoire kernel |
| SMEP | Protection empêchant l’exécution de code utilisateur en mode noyau |
| SMAP | Protection empêchant l’accès kernel aux données utilisateur |
| KPTI | Isolation des tables mémoire kernel/utilisateur |