Aller au contenu

gVisor, le chaînon manquant de la sécurité des conteneurs

Romain Boulanger
Auteur
Romain Boulanger
Architecte Infra/Cloud avec une touche de DevSecOps
Sommaire

Le conteneur, au-delà des apparences
#

Si vous travaillez dans un monde conteneurisé, notamment avec Kubernetes, vous savez que la conteneurisation repose sur deux piliers :

  • Les namespaces : pour cloisonner ce que le conteneur voit ;
  • Les cgroups : pour limiter ce que le conteneur consomme notamment le CPU et la mémoire.

Cependant, il existe une limite structurelle à ce modèle : le partage du noyau au sein du système d’exploitation lors d’appels système.

Dans un environnement standard, le plus souvent avec runc comme couche d’exécution pour vos conteneurs. Ces derniers, aussi isolés soient-ils, effectuent des appels système (syscalls) sur le même noyau : celui de l’hôte.

C’est notamment pour ça que par définition, la machine virtuelle permet une isolation beaucoup plus forte que la conteneurisation, car l’hyperviseur permet d’allouer des ressources de manière indépendante et chaque machine dispose de son propre système d’exploitation.

Si une application malveillante parvient à exploiter une faille du noyau, elle peut aller jusqu’à compromettre l’ensemble du nœud d’un cluster Kubernetes.

C’est ici qu’entre en scène gVisor. Ce projet open source sous licence Apache 2.0 et né chez Google, propose une approche radicalement différente : fournir à chaque conteneur son propre noyau virtuel.

À noter que si je vous parle de ce produit, outre son efficacité à renforcer la sécurité d’un environnement conteneurisé, c’est aussi parce qu’il est grandement utilisé sur Google Cloud au sein de plusieurs services comme App Engine, Cloud Run, Cloud Functions, etc. et que certaines solutions de sécurité estampillées CNCF l’utilisent comme Falco !

Les appels système : le talon d’Achille de la conteneurisation
#

Pour comprendre pourquoi l’isolation par défaut est insuffisante, il faut descendre d’un étage : au niveau du noyau Linux aussi appelé Kernel.

Une application conteneurisée n’est, au final, qu’un processus. Lorsqu’elle a besoin de faire quelque chose de concret (écrire un fichier, ouvrir une socket, allouer de la mémoire), elle ne peut pas le faire seul. Elle doit demander la permission au noyau de l’hôte via une interface définie : les appels système (syscalls).

Des appels système, il en existe des centaines ! Notamment côté Linux pour gérer des fichiers (open, write, close), gérer des processus (fork, exit) ou encore pour le réseau (socket, bind, connect). Vous l’aurez compris, la surface d’attaque est donc assez grande.

Pour contrer cela, plusieurs mécanismes de filtrage existent. C’est notamment ce que propose Seccomp et AppArmor que les amateurs de la CKS connaissent bien car ils sont au programme de cette certification.

Seccomp, le pare-feu des fonctions
#

Seccomp pour Secure Computing Mode est un mécanisme de sécurité du noyau Linux qui permet de restreindre les appels système qu’un processus a le droit d’effectuer. C’est une sorte de liste blanche de fonctions.

Voici un exemple minimaliste qui ne laisse rien passer ("defaultAction": "SCMP_ACT_ERRNO") sauf les opérations de read, write et close :

{
    "defaultAction": "SCMP_ACT_ERRNO",
    "architectures": [
        "SCMP_ARCH_X86_64",
    ],
    "syscalls": [
        {
            "names": [
                "read",
                "write",
                "close",
            ],
            "action": "SCMP_ACT_ALLOW"
        }
    ]
}

AppArmor : Le contrôle d’accès aux ressources
#

AppArmor est un module de sécurité qui protège les ressources (fichiers, chemins). Il définit ce que le processus a le droit de manipuler.

Voici un profil simplifié qui interdit toutes les opérations d’écriture de fichiers :

#include <tunables/global>

profile deny-write flags=(attach_disconnected) {
  #include <abstractions/base>

  file,

  deny /** w,
}

Ces deux outils constituent la première ligne de défense. Ils réduisent le risque, mais ils ne l’éliminent pas totalement. De plus, la configuration de ces deux outils peut être extrêmement complexe à réaliser notamment dans le cas où l’on souhaite que chaque application dispose de son propre profil, ce qui rend la tâche chronophage.

La solution : Un noyau dans l’espace utilisateur
#

gVisor change la donne en introduisant une couche d’interception. Au lieu de laisser l’application parler au noyau hôte, gVisor place une sorte d’intermédiaire entre les deux.

L’architecture repose sur deux composants clés :

  • Sentry : C’est un noyau émulé compatible Linux, il est écrit en Go, et fonctionne dans l’espace utilisateur (user-space). L’application croit parler au vrai noyau, mais elle parle en réalité au Sentry ;

  • Gofer : C’est le composant qui gère les accès aux fichiers, pour éviter que le Sentry n’accède directement au disque de l’hôte.

Ces deux composants dialoguent avec le protocole réseau 9P (Plan 9 Filesystem Protocol). Il a été à l’origine créé pour le système d’exploitation Plan 9 from Bell Labs qui peut être vu comme le successeur spirituel d’Unix. Cela permet au Gofer d’être piloté comme un agent et interagir avec le système de fichier de manière sécurisée.

Architecture avec gVisor

Si un attaquant compromet votre application et tente une évasion, il se retrouve piégé dans le Sentry. Comme ce dernier tourne dans le user-space (et non en kernel-space) et qu’il est écrit en Go (qui gère la mémoire de façon sûre), la surface d’attaque est drastiquement réduite.

Les mains dans le cambouis
#

C’est le moment d’utiliser gVisor au sein d’un cluster Kubernetes. J’ai pris un exemple très basique, une image nginx assez (voire très) ancienne, qui pourrait poser problème via ces nombreuses vulnérabilités.

Une des principales qualités de gVisor réside dans le fait qu’il n’est pas nécessaire de changer vos images de conteneur voire de réécrire vos applications.

gVisor s’installe comme un runtime alternatif (via le binaire runsc), compatible avec le standard OCI (Open Container Initiative) qu’il faudra mettre sur chaque nœud du cluster.

Dans Kubernetes, on utilise l’objet RuntimeClass pour configurer ce dernier :

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc

Une fois la RuntimeClass configurée, la configuration d’un Pod se fait via le champ runtimeClassName :

apiVersion: apps/v1
kind: Deployment
metadata:
  name: risky-app
spec:
  template:
    spec:
      runtimeClassName: gvisor 
      containers:
      - name: nginx
        image: nginx:1.11-alpine

Quelles sont les différences visibles ?
#

En effet, tout fonctionne correctement, le serveur web nginx ne remonte pas de logs problématiques.

kubectl logs risky-app-656887b7b7-kcxlr
kubectl get po risky-app-656887b7b7-kcxlr
NAME                         READY   STATUS    RESTARTS   AGE
risky-app-656887b7b7-kcxlr   1/1     Running   0          63m

La première différence va se trouver dans la commande dmesg qui permet d’afficher les messages du noyau :

kubectl exec -it risky-app-656887b7b7-kcxlr -- /bin/sh -c dmesg
[   0.000000] Starting gVisor...
[   0.208482] Moving files to filing cabinet...
[   0.302747] Checking naughty and nice process list...
[   0.617373] Checking naughty and nice process list...
[   0.935288] Segmenting fault lines...
[   0.966793] Singleplexing /dev/ptmx...
[   1.411706] Digging up root...
[   1.816345] Adversarially training Redcode AI...
[   1.896328] Accelerating teletypewriter to 9600 baud...
[   2.015801] Letting the watchdogs out...
[   2.234311] Searching for socket adapter...
[   2.689240] Ready!

Clairement, on voit que c’est gVisor qui opère le conteneur, notamment grâce à la première ligne : Starting gVisor....

Tout à l’heure, je vous ai parlé du noyau de l’espace utilisateur provenant de la Sentry, c’est donc le moment de faire la commande uname :

Avec gVisor :

kubectl exec -it risky-app-656887b7b7-kcxlr -- /bin/sh -c 'uname -a'
Linux risky-app-656887b7b7-kcxlr 4.4.0 #1 SMP Sun Jan 10 15:06:54 PST 2016 x86_64 Linux

Sans gVisor :

kubectl exec -it standard-app-67875cd8f-f22l2 -- /bin/sh -c 'uname -a'
Linux standard-app-67875cd8f-f22l2 6.18.2-talos #1 SMP Fri Jan  2 15:04:30 UTC 2026 x86_64 Linux

On remarque que dans la version sans gVisor, les informations du noyau du système hôte sont clairement visibles, ce qui peut indiquer à un attaquant des informations sur de potentielles vulnérabilités.

Pas de magie, des compromis, comme toujours…
#

Plus de sécurité ne peut pas venir sans compromis ! C’est notamment le cas de l’overhead.

Puisque chaque appel système doit être intercepté et traité par la Sentry (qui lui-même doit parfois appeler le noyau hôte), cela prend plus de temps qu’un appel direct.

De plus, gVisor n’implémente pas tous les appels systèmes, vous pouvez retrouver la liste des limitations à cette adresse.

De manière générale, tout ce qui a un lien fort avec le hardware (carte réseau, GPU, TPU, etc.) sera sujet à quelques dysfonctionnements ou adaptations à réaliser.

Néanmoins dans la plupart des cas, dans un environnement microservice, plusieurs langages de programmation sont régulièrement testés pour assurer une compatibilité avec gVisor.

La documentation officielle mentionne Python, Java, Node.js, PHP et Go mais en réalité, dès lors que vos langages ne font pas d’appels système hors du commun, cela devrait être compatible sans exception.

Si vous avez un doute, vous pouvez vous référer à la liste de compatibilité en fonction de votre architecture : amd64 ou arm64.

gVisor est donc un excellent candidat pour vos applications conteneurisées notamment celles où vous ne pouvez pas auditer le code ou qui fonctionnent avec de vieilles versions d’image (comme mon exemple du dessus), des binaires dépréciés ou qui ont de nombreuses vulnérabilités.

L’heure du récap !
#

Pour récapituler les différentes informations du dessus, je vous ai fait un tableau de synthèse entre runc que l’on retrouve dans les moteurs de conteneurisation classique comme Docker ou containerd, et runsc qui est le binaire de gVisor.

Critèrerunc (Standard)runsc (gVisor)
PhilosophieIsolation Logique : “Tout est permis sauf ce qui est interdit”Isolation Forte : “Tout est intercepté et émulé”
Architecture NoyauPartagé : L’application utilise directement le noyau de l’hôteDédié (user-space) : L’application utilise un noyau virtuel (Sentry) écrit en Go
Mécanisme de DéfenseFiltrage : Pas par défaut mais on peut utiliser Seccomp/AppArmor afin de bloquer les appels connus comme dangereuxInterception : Traite les appels en interne. Le noyau hôte est invisible pour l’application
Surface d’attaqueLarge : Vulnérable aux failles du noyauMinime : Une évasion compromet le Sentry, pas l’hôte physique
Performance CPUNative : Zéro overheadOverhead : Coût du changement de contexte lors des appels système
Système de FichiersDirect : Accès natif aux points de montageProxyfié (Gofer) : Accès via le protocole 9P, ajout d’une pointe de latence
Empreinte MémoireNulle : Consommation stricte de l’applicationFixe : ~15-20 Mo supplémentaires par Pod (pour le Sentry et le Gofer)
Visibilité (Hôte)Transparente : L’hôte voit les processus du conteneurOpaque : L’hôte ne voit que des processus runsc, pas l’intérieur de la sandbox
Compatibilité~100% : Supporte l’ensemble des charges de travailHaute : Supporte la majorité des apps, mais certains appels système ne sont pas possibles

Conclusion
#

Vous l’aurez compris, gVisor est le candidat idéal pour réduire la surface d’attaque et mettre en place une sorte de sandbox pour exécuter vos conteneurs.

Même s’il ne peut pas répondre à tous les cas d’utilisation côté conteneur, la majorité de vos applications sera compatible et permettra d’augmenter drastiquement la sécurité au sein de vos clusters Kubernetes.

Sa mise en place rapide et sa facilité d’utilisation font de gVisor une bonne alternative sécurisée à runc.

Articles connexes