👻 GhostLock (CVE-2026-43499) : autopsie d’une vulnérabilité Linux Kernel vieille de 15 ans menant à une élévation de privilèges

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 :

Architecture simplifiée futex (source)

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, &param);

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, &param);

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, &param);

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, &param);
 // 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 » :

Réveil de tous les threads d’une liste de waiters sur une condition (pas de 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 :

Réveil d’un seul thread d’une liste de waiters sur une condition (requeue)

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 :

  1. Le waiter disparaît de l’arbre d’attente.
  2. Le lien avec le thread est supprimé.
  3. Le mécanisme d’héritage de priorité est recalculé.
  4. 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 :

  1. Une zone mémoire est libérée.
  2. Un pointeur continue de la référencer.
  3. 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 :

Architecture système linux

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.

Libération de la mémoire avec free() après allocation avec malloc()

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.

Réallocation d’une zone mémoire libérée par un free()

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.

Principe du KPTI

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

TermeDéfinition
FutexPrimitive Linux permettant une synchronisation rapide entre espace utilisateur et noyau
PI FutexFutex avec héritage de priorité
RT MutexMutex kernel utilisé notamment pour les scénarios temps réel
task_structStructure représentant un processus/thread Linux
UAFUse-After-Free, utilisation d’une mémoire après sa libération
Dangling pointerPointeur conservant une référence vers une zone invalide
KASLRRandomisation de l’espace mémoire kernel
SMEPProtection empêchant l’exécution de code utilisateur en mode noyau
SMAPProtection empêchant l’accès kernel aux données utilisateur
KPTIIsolation des tables mémoire kernel/utilisateur

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *