Élévation de privilèges dans le noyau : l’impact de l’isolation des conteneurs Kubernetes sur les attaques
Kamil Potrec
3 décembre 2020
0 minutes de lectureLe jour, j’analyse du code Terraform et des fichiers de configuration d’objets Kubernetes, et je repère les problèmes de sécurité courants. À la tombée de la nuit, j’enfile mon sweat à capuche, lance des machines virtuelles Linux et des débogueurs pour examiner de près les technologies qui composent l’écosystème cloud native.
Dans cet article, nous allons voir comment l’isolation des conteneurs Kubernetes influe sur les attaques par élévation de privilèges. Nous utiliserons des techniques courantes d’exploitation du noyau pour comprendre comment les couches d’abstraction des conteneurs peuvent nous empêcher d’obtenir ce précieux shell root.
Qu’est-ce que l’élévation de privilèges ?
L’élévation de privilèges désigne le processus qui consiste à obtenir davantage de permissions sur une ressource. L’élévation de privilèges dans le noyau consiste à obtenir ces permissions en exploitant une faille dans l’un des nombreux points d’entrée du noyau, également appelés vecteurs d’attaque. Un vecteur d’attaque est simplement un chemin qui donne accès au code vulnérable.
Nous interagissons avec le noyau de nombreuses façons : en lisant le système de fichiers, en ouvrant un fichier de périphérique, en effectuant des appels système ou en envoyant un paquet sur l’interface réseau. Toutes ces actions nécessitent qu’un traitement ait lieu dans l’espace noyau. Lorsque le noyau exécute une action pour le compte d’un processus utilisateur, on dit qu’il fonctionne dans un contexte de processus. Chaque processus est représenté dans le noyau par une structure struct task_struct. Ces structures sont stockées dans une liste circulaire doublement chaînée et accessibles depuis les variables PER_CPU sur l’architecture x86-64, lors du passage de l’espace utilisateur à l’espace noyau.
Une structure task_struct contient un membre struct creds qui stocke l’identifiant utilisateur et les capacités associés au processus. Le noyau s’appuie sur ces informations pour déterminer si le processus peut effectuer une action, par exemple s’il est autorisé à exécuter un appel système donné. L’objectif général d’une élévation de privilèges dans le noyau est de remplacer ou de mettre à jour la structure d’identifiants afin d’obtenir davantage de permissions.
Comment fonctionne l’élévation de privilèges ?
La technique la plus courante pour obtenir des permissions élevées dans l’espace noyau consiste à utiliser la combinaison des fonctions du noyau [commit_creds](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L437)([prepare_kernel_cred(0)](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L682)). Pour y parvenir, un exploit doit d’abord prendre le contrôle du pointeur d’instruction (RIP), puis contourner avec succès les mécanismes de contrôle de l’accès mémoire et de randomisation. [prepare_kernel_cred](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L682) peut générer un objet d’identifiants à partir d’un objet existant ou, plus généreusement, en créer un par défaut avec tous les privilèges root. [commit_creds](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L437) met simplement à jour le task_struct du processus actuel avec le nouvel objet d’identifiants.
L’exploitation du noyau est un domaine très vaste. Dans cet article, nous allons donc nous limiter à une version très simplifiée de l’élévation de privilèges dans le noyau. Celui-ci comporte de nombreux mécanismes de sécurité destinés à rendre l’exploitation plus difficile. SMEP, SMAP, KASLR et KPTI sont des mécanismes implémentés dans le matériel ou dans le noyau, qui sont activés ou désactivés par la distribution utilisée ou par un administrateur système. Kubernetes ne permet pas de contrôler directement ces paramètres ; ils ne seront donc pas abordés dans cet article.
Nous allons nous appuyer sur une ancienne faille dans l’implémentation de af_packet, référencée sous le numéro CVE-2017-7308. Cette vulnérabilité peut être exploitée avec la capacité CAP_NET_RAW, car elle nécessite un accès aux sockets brutes. Les détails de la vulnérabilité sont expliqués en long et en large ici ; nous n’y reviendrons donc pas. Nous pouvons obtenir toutes les capacités nécessaires dans un espace de noms utilisateur non privilégié. Dans la distribution Ubuntu, l’accès aux espaces de noms utilisateur n’est not restreint par défaut.
Entrons dans le vif du sujet
Commençons par examiner le processus de bout en bout dans un environnement sans conteneur.
Nous devons connecter GNU Debugger (gdb) au stub de la machine virtuelle. Une fois le débogueur attaché, nous pouvons définir un point d’arrêt à un emplacement pratique. Ici, nous utilisons un appel système mlock, que nous pouvons déclencher manuellement depuis l’exploit pour examiner l’état interne du processus en cours d’exécution. Notez que gdb ne s’arrêtera que si le processus en cours d’exécution porte le nom « exploit ». Cela réduit le risque que le point d’arrêt soit déclenché par un autre processus du système. Les tâches de configuration sont définies dans un fichier de commandes .gdb. Nous lançons GDB avec l’option -x pour effectuer la configuration de manière cohérente et reproductible.
Les points d’arrêt se déclencheront avant la création de l’espace de noms utilisateur non privilégié, juste avant l’exécution de la vulnérabilité et après l’obtention des identifiants root. Il suffit d’exécuter l’appel système approprié pour obtenir ce comportement.
Nous pouvons maintenant exécuter l’exploit :
Notre premier point d’arrêt se déclenche comme prévu. Nous pouvons examiner la structure cred en exécutant la fonction d’aide gdb $lx_current. L’UID effectif du processus actuel est 1000 et, comme prévu, il ne dispose d’aucune capacité effective dans l’espace de noms actuel.
Le deuxième point d’arrêt se déclenche après un appel à unshare, qui crée le nouvel espace de noms utilisateur du processus. Notez que l’UID reste inchangé, tandis que les attributs cap_effective et user_ns ont changé. Les capacités sont stockées sous forme de masque de bits, plus lisible en hexadécimal.
Notre dernier point d’arrêt se déclenche après l’exploitation de la vulnérabilité. Notez que l’UID est maintenant défini sur 0 et que l’espace de noms utilisateur est réinitialisé à init_user_ns, qui correspond à l’espace de noms utilisateur init de l’hôte.
Le shell réapparaît et nous disposons maintenant de tous les privilèges root sur l’hôte.
Exploitation du noyau dans un conteneur
Nous allons maintenant essayer d’exécuter le même exploit dans un pod. Nous avons créé une définition très simple d’objet pod et l’avons déployée dans le cluster.
Voyons ce qui se passe avec la configuration par défaut.
Root par défaut
L’image utilisée dans la démonstration ne précise pas d’utilisateur non privilégié et, par défaut, Kubernetes n’impose pas d’UID. Il semble donc que nous ayons accès à root sans avoir besoin d’exploiter le noyau. Nous exécutons à nouveau le même exploit et interrompons le noyau juste avant qu’il emprunte le chemin vulnérable. Les capacités effectives du processus montrent clairement qu’il en manque certaines. La valeur est définie sur 2818844155, ce qui correspond à l’ensemble de capacités par défaut accordé par le runtime Docker.
Une fois l’exploit terminé, l’ensemble effectif comprend de nouveau toutes les capacités.
Cette fois, nous allons imposer un UID non root au conteneur en définissant les attributs de contexte de sécurité runAs.
Cette fois, nous ne disposons pas des privilèges root d’emblée. L’exploit s’exécute toutefois de la même manière, à une différence majeure près dans le résultat final. Il semble que nous ayons toutes les permissions, mais nous ne voyons pas tout ce qui se trouve sur le système.
La cage des espaces de noms
Nous avons obtenu toutes les capacités et l’UID root, mais nous n’avons franchi que la barrière des capacités du conteneur : nous n’avons toujours pas accès au système de fichiers de l’hôte. Nous ne pouvons donc pas voir tous les processus ni communiquer via les interfaces réseau de l’hôte.
À ce stade, nous pouvons charger n’importe quel module du noyau, mais cela laisse des traces et déclenchera la plupart des systèmes de détection d’intrusion élémentaires (du moins, espérons-le). Pour le vérifier, nous allons supprimer un module inutilisé. Notez que votre image Docker doit inclure les paquets de modules. Dans le cas d’images Debian, vous devrez installer le paquet kmod.
Nous pouvons plutôt étendre notre exploit du noyau et définir l’objet [struct nsproxy](https://github.com/torvalds/linux/blob/master/include/linux/nsproxy.h#L31) du contexte actuel de façon à ce qu’il pointe vers les espaces de noms de notre choix. Les espaces de noms sont identifiés par des inodes, mais le noyau exporte l’adresse de [init_nsproxy](https://github.com/torvalds/linux/blob/master/kernel/nsproxy.c#L32), que nous pouvons utiliser pour copier les espaces de noms init de l’hôte dans notre conteneur.
L’appel système sys_setns permet de mettre à jour les espaces de noms du contexte du processus. Nous voulons principalement accéder à trois espaces de noms pour l’élévation de privilèges : PID, réseau et montage. Tout d’abord, nous devons obtenir une référence aux espaces de noms root. Pour cela, nous pouvons déplacer le PID 1 du conteneur vers les espaces de noms de l’hôte. Nous pouvons ensuite obtenir des références à n’importe quels espaces de noms depuis le système de fichiers /proc/ du PID 1. Enfin, nous déplaçons le processus actuel dans les espaces de noms requis.
Après l’exécution de notre exploit, nous pouvons accéder à toutes les ressources système intéressantes.
Utilité des capacités
Les capacités attribuées par défaut aux conteneurs Kubernetes (avec le runtime Docker) incluent CAP_NET_RAW. Cela signifie-t-il que nous pourrions exploiter la vulnérabilité même si les espaces de noms utilisateur non privilégiés sont désactivés ? Nous avons ajouté du code pour définir les capacités effectives nécessaires afin d’atteindre le code vulnérable.
Comme vous pouvez le constater, l’exploit échoue. Mais pourquoi ?
Cela s’explique par les capacités héritables et leur implémentation. Même si le runtime du conteneur a accordé ces capacités aux processus du conteneur, il faut les définir explicitement comme effectives via sys_capset. Pour le moment, seuls les processus avec l’UID 0 peuvent définir des capacités effectives. Ainsi, si vous souhaitez exécuter un processus en tant qu’utilisateur non root tout en conservant l’accès à certaines capacités, vous devez inclure un binaire suid dans votre conteneur pour définir les capacités effectives. Vous pouvez également définir les capacités requises sur l’exécutable et supprimer les capacités du conteneur. Les capacités de fichier sont limitées aux systèmes de fichiers qui prennent en charge les attributs étendus.
Seccomp à la rescousse
Parlons maintenant de l’accessibilité du vecteur d’attaque. Notre exploit fonctionne parce que les utilisateurs non privilégiés peuvent obtenir la capacité CAP_NET_RAW dans des espaces de noms utilisateur non privilégiés. Nous avons vu dans notre analyse des capacités comment cela influe sur l’exploit. Il existe une autre contre-mesure pour arrêter cette attaque — et oui, vous pouvez l’activer via Kubernetes.
Seccomp est un mécanisme qui permet de réduire la surface d’attaque du noyau en filtrant les appels système. Malheureusement, Kubernetes n’applique pas de profil seccomp à votre conteneur par défaut. Tous les appels système sont donc autorisés, sous réserve des contrôles de permissions déjà évoqués. Vous pouvez modifier ce comportement en ajoutant une annotation à la déclaration de l’objet (avant la version v1.19) ou en ajoutant l’attribut du profil seccomp au contexte de sécurité du pod.
Voyons comment le profil par défaut fourni par le runtime du conteneur (Docker, dans ce cas) influe sur notre exploit. Le message d’erreur « Opération non permise » s’affiche, car le profil seccomp par défaut n’autorise pas l’appel système unshare.
Seccomp est très utile pour limiter les points d’entrée du noyau superflus. Les appels système tels que unshare ou userfaultfd peuvent être désactivés sans risque dans la plupart des cas d’usage et permettent de contrer certaines techniques d’exploitation. Mais certains appels sont difficiles à bloquer, comme waitid. Découvrez ici d’autres techniques permettant d’exploiter des conteneurs.
Conclusion
Nous avons réussi à empêcher cette exploitation grâce à un profil seccomp par défaut. Comme vous pouvez le constater, même si notre système d’exploitation est vulnérable, le chemin d’exploitation est inaccessible depuis notre conteneur (dans ce cas précis). Cette technique pourrait vous donner suffisamment de répit pour planifier la mise à jour du système d’exploitation, dont vous avez tant besoin ! Considérez ces mesures comme des contrôles de défense en profondeur et des stratégies d’atténuation.
Appliquez toujours les correctifs à vos systèmes ! Snyk Infrastructure as Code peut vous aider à repérer ces options d’atténuation très tôt dans votre pipeline CI/CD, bien avant tout déploiement en production. Nous utilisons des techniques adversariales pour identifier les options de sécurité à fort impact dans Kubernetes et chez les fournisseurs de services cloud. Utilisez Snyk gratuitement en créant un compte gratuit.
Lancez-vous dans les compétitions Capture The Flag
Apprenez à résoudre des défis Capture The Flag en regardant à la demande notre atelier virtuel d’initiation.


