Docker-Sicherheit härten: Rootless-Modus, Seccomp, AppArmor auf einem VPS
Sieben Härtungsschichten für Docker auf einem VPS. Jeder Abschnitt erklärt die Bedrohung, zeigt die Lösung mit CLI- und Compose-Syntax und überprüft das Ergebnis.
Die Standardkonfiguration von Docker tauscht Sicherheit gegen Bequemlichkeit. Container laufen als root auf dem Host. Alle 14 Linux Capabilities bleiben aktiv. Seccomp blockiert nur 44 von über 300 Syscalls. Der Netzwerkverkehr zwischen Containern fließt ungehindert.
Auf einem VPS ist das relevanter als auf einer lokalen Entwicklungsmaschine. Sie teilen sich einen physischen Host mit anderen Mietern. Ein Container-Ausbruch bedeutet, dass ein Angreifer als root auf dem Kernel landet, der dem Hypervisor zugewandt ist. Jede Härtungsschicht, die Sie hinzufügen, verkleinert den Schadensradius.
Dieses Tutorial behandelt sieben Härtungsmaßnahmen. Jeder Abschnitt erklärt die Bedrohung, die er verhindert, zeigt die Implementierung (sowohl docker run-Flags als auch Compose-Syntax) und enthält einen Verifikationsschritt. Wir haben jeden Befehl auf Ubuntu 24.04 mit Docker Engine 29.x getestet.
Voraussetzungen: Ein VPS mit Debian 12 oder Ubuntu 24.04 und installierter Docker Engine. SSH-Zugang als Nicht-root-Benutzer mit sudo. Falls Sie den Host selbst noch nicht abgesichert haben, beginnen Sie zuerst mit unserem Linux VPS Security: Bedrohungen, Schutzschichten und Hardening-Guide. Bei Docker-Firewall-Problemen siehe Docker umgeht UFW: 4 getestete Lösungen für Ihren VPS.
Dieser Artikel ist Teil der Docker in Produktion auf einem VPS: Was schiefgeht und wie Sie es beheben Serie.
Wie richte ich Rootless Docker auf Ubuntu 24.04 oder Debian 12 ein?
Rootless Docker führt den Daemon und alle Container unter einem regulären Benutzerkonto aus, nicht als root. Wenn ein Angreifer aus einem Container ausbricht, landet er als unprivilegierter Benutzer auf dem Host. Kein root-Zugang. Von allen Maßnahmen in diesem Guide hat diese die größte Wirkung.
Rootless Docker installieren
Installieren Sie die benötigten Pakete. Das Paket uidmap stellt newuidmap und newgidmap bereit, die das Mapping von Subordinate-UIDs/-GIDs übernehmen:
sudo apt-get update && sudo apt-get install -y uidmap docker-ce-rootless-extras
Prüfen Sie, ob Ihr Benutzer mindestens 65.536 Subordinate-UIDs und -GIDs besitzt:
grep "^$(whoami):" /etc/subuid
grep "^$(whoami):" /etc/subgid
Die Ausgabe sollte etwa deploy:100000:65536 lauten. Falls die Einträge fehlen, fügen Sie sie hinzu:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $(whoami)
Stoppen Sie den systemweiten Docker-Daemon. Im Rootless-Modus wird er nicht benötigt:
sudo systemctl disable --now docker.service docker.socket
Führen Sie das Rootless-Setup-Tool als regulärer Benutzer aus (nicht als root):
dockerd-rootless-setuptool.sh install
Das Skript gibt Umgebungsvariablen aus, die Sie setzen müssen. Fügen Sie diese zu Ihrem Shell-Profil hinzu:
echo 'export PATH=/usr/bin:$PATH' >> ~/.bashrc
echo 'export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock' >> ~/.bashrc
source ~/.bashrc
Aktivieren Sie Linger, damit der Rootless-Daemon beim Systemstart startet, nicht erst bei der Anmeldung:
sudo loginctl enable-linger $(whoami)
Rootless-Modus verifizieren
docker context use rootless
docker run --rm hello-world
Prüfen Sie, dass der Daemon-Prozess unter Ihrem Benutzer läuft, nicht als root:
ps aux | grep dockerd
Die Ausgabe sollte Ihren Benutzernamen zeigen, nicht root. Bestätigen Sie außerdem, dass Docker Info rootless meldet:
docker info --format '{{.SecurityOptions}}'
Sie sollten rootless in der Liste sehen.
Wenn Rootless Docker Probleme verursacht
Rootless-Modus hat echte Einschränkungen. Wenn Sie diese kennen, sparen Sie Stunden beim Debugging.
| Einschränkung | Ursache | Workaround |
|---|---|---|
| Ports unter 1024 nicht bindbar | Nicht-root-Benutzer können keine privilegierten Ports binden | sysctl net.ipv4.ip_unprivileged_port_start=0 setzen oder einen Reverse Proxy auf dem Host nutzen |
| Berechtigungsfehler bei Bind Mounts | Host-Dateien im Besitz von root sind für die remappte UID nicht zugänglich | Eigentümer auf Ihren Benutzer ändern oder Named Volumes verwenden |
| Langsameres Overlay-Dateisystem | Rootless verwendet fuse-overlayfs statt nativem overlay2 |
Den Overhead akzeptieren (5-15 % bei I/O-intensiven Workloads) oder natives overlay2 mit --privileged nutzen (macht den Zweck zunichte) |
Kein --net=host |
Rootless-Networking nutzt slirp4netns oder pasta, nicht den Host-Netzwerkstack | Port-Mapping (-p) verwenden. Für bessere Performance pasta als Netzwerktreiber installieren |
ping schlägt in Containern fehl |
CAP_NET_RAW ist eingeschränkt |
slirp4netns >= 0.4.0 installieren oder pasta verwenden |
Hinweis zur Netzwerk-Performance: Standardmäßig nutzt Rootless Docker slirp4netns für das Netzwerk, was NAT-Overhead verursacht. Der pasta-Treiber kopiert die Host-Netzwerkkonfiguration in den Container-Namespace ohne NAT und bietet besseren Durchsatz. Auf Debian 12 und Ubuntu 24.04 installieren Sie ihn mit:
sudo apt-get install -y passt
Docker erkennt pasta automatisch, wenn es installiert ist.
Wann sollte ich User Namespace Remapping statt Rootless Docker verwenden?
User Namespace Remapping (userns-remap) bildet UID 0 in Containern auf eine unprivilegierte UID auf dem Host ab. Anders als bei Rootless Docker läuft der Daemon selbst weiterhin als root. Das bedeutet: Sie behalten die volle Docker-Funktionalität (privilegierte Ports, Host-Networking, natives overlay2) und verhindern trotzdem, dass Container-root gleich Host-root ist.
Wählen Sie userns-remap, wenn der Rootless-Modus Ihren Workload beeinträchtigt, Sie aber dennoch UID-Isolation wünschen. Wählen Sie Rootless, wenn Sie mit dessen Einschränkungen leben können.
| Feature | Rootless Docker | userns-remap | Standard Docker |
|---|---|---|---|
| Daemon läuft als | Benutzer | Root | Root |
| Container-root = Host-root | Nein | Nein | Ja |
| Privilegierte Ports | Workaround nötig | Funktioniert | Funktioniert |
--net=host |
Nein | Ja | Ja |
| Storage-Treiber | fuse-overlayfs | overlay2 | overlay2 |
| Setup-Aufwand | Mittel | Gering | Keiner |
userns-remap konfigurieren
Erstellen Sie den Benutzer dockremap oder verwenden Sie die Abkürzung default, die ihn automatisch erstellt:
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"userns-remap": "default"
}
EOF
sudo systemctl restart docker
Docker erstellt den Benutzer dockremap und fügt Subordinate-UID/GID-Bereiche zu /etc/subuid und /etc/subgid hinzu.
Verifizieren Sie die Funktion:
sudo ls -ld /var/lib/docker/
Sie sollten ein neues Unterverzeichnis sehen, das nach dem remappten UID-Bereich benannt ist, z. B. /var/lib/docker/100000.100000/. Die genaue Zahl hängt vom Subordinate-UID-Bereich ab, der dem Benutzer dockremap in /etc/subuid zugewiesen ist.
Starten Sie einen Container und prüfen Sie die Prozess-UID auf dem Host:
docker run -d --name test-userns nginx:alpine
ps aux | grep nginx
Der nginx-Prozess sollte eine hohe UID anzeigen (passend zur ersten Zahl aus /etc/subuid für dockremap), nicht 0.
Aufräumen:
docker rm -f test-userns
Hinweis zu Volume-Eigentümerschaft: Bind-gemountete Dateien im Besitz von Host-root (UID 0) erscheinen im Container als nobody, weil UID 0 auf einen anderen Bereich abgebildet wird. Verwenden Sie Named Volumes oder ändern Sie die Eigentümerschaft der Dateien auf die remappte UID mit chown.
Wie entferne ich Linux Capabilities aus einem Docker-Container?
Docker gewährt Containern standardmäßig 14 Linux Capabilities. Jede Capability ist eine Kernel-Berechtigung, die ein Angreifer nach einem Container-Ausbruch oder innerhalb eines kompromittierten Containers ausnutzen kann. Alle Capabilities zu entfernen und nur die benötigten zurückzufügen, verkleinert die Angriffsfläche.
Standard-Docker-Capabilities
| Capability | Erlaubte Aktion | Behalten oder entfernen? |
|---|---|---|
CAP_CHOWN |
Datei-Eigentümerschaft ändern | Entfernen, wenn nicht benötigt |
CAP_DAC_OVERRIDE |
Lese-/Schreibberechtigungen umgehen | Entfernen, wenn nicht benötigt |
CAP_FOWNER |
Berechtigungsprüfung auf Datei-Eigentümer umgehen | Entfernen, wenn nicht benötigt |
CAP_FSETID |
setuid/setgid-Bits setzen | Entfernen |
CAP_KILL |
Signale an beliebige Prozesse senden | Entfernen, wenn nicht benötigt |
CAP_SETGID |
Prozess-GID ändern | Für die meisten Anwendungen behalten |
CAP_SETUID |
Prozess-UID ändern | Für die meisten Anwendungen behalten |
CAP_SETPCAP |
Prozess-Capabilities ändern | Entfernen |
CAP_NET_BIND_SERVICE |
Ports unter 1024 binden | Behalten, wenn Port 80/443 gebunden wird |
CAP_NET_RAW |
Raw Sockets nutzen (Pakete erzeugen) | Entfernen, es sei denn Sie brauchen ping/traceroute |
CAP_SYS_CHROOT |
chroot verwenden | Entfernen |
CAP_MKNOD |
Gerätedateien erstellen | Entfernen |
CAP_AUDIT_WRITE |
In Kernel-Audit-Log schreiben | Entfernen, wenn nicht benötigt |
CAP_SETFCAP |
Datei-Capabilities setzen | Entfernen |
Alle entfernen, benötigte zurückfügen
CLI-Syntax:
docker run -d \
--cap-drop ALL \
--cap-add CHOWN \
--cap-add NET_BIND_SERVICE \
--cap-add SETUID \
--cap-add SETGID \
--name hardened-nginx \
nginx:alpine
Nginx benötigt CHOWN, weil sein Entrypoint-Skript beim Start die Eigentümerschaft von Cache-Verzeichnissen ändert. Ohne diese Capability beendet sich der Container sofort mit dem Fehler chown: Operation not permitted.
Compose-Syntax:
services:
web:
image: nginx:alpine
cap_drop:
- ALL
cap_add:
- CHOWN
- NET_BIND_SERVICE
- SETUID
- SETGID
Verifizieren, dass Capabilities entfernt wurden
docker exec hardened-nginx sh -c 'cat /proc/1/status | grep Cap'
Vergleichen Sie die CapEff (Effective Capabilities) Bitmaske. Mit allen entfernten und vier zurückgefügten ist der Wert deutlich niedriger als der Standardwert 00000000a80425fb.
Für eine lesbare Ausgabe installieren Sie capsh auf dem Host und dekodieren den Hex-Wert:
docker exec hardened-nginx sh -c 'cat /proc/1/status | grep CapEff' | awk '{print $2}' | xargs -I{} capsh --decode=0x{}
In der Ausgabe sollten nur cap_chown,cap_setgid,cap_setuid,cap_net_bind_service erscheinen.
Aufräumen:
docker rm -f hardened-nginx
Was verhindert das no-new-privileges-Flag?
Das no-new-privileges-Flag verhindert, dass Prozesse innerhalb eines Containers durch setuid- oder setgid-Binaries zusätzliche Berechtigungen erlangen. Ohne dieses Flag kann ein kompromittierter Prozess ein setuid-Binary (wie su oder sudo) ausführen und zu root eskalieren. Mit gesetztem Flag verweigert der Kernel die Rechteeskalation.
Pro Container anwenden
CLI:
docker run -d --security-opt no-new-privileges:true --name no-priv-test nginx:alpine
Compose:
services:
web:
image: nginx:alpine
security_opt:
- no-new-privileges:true
Als Daemon-Standard setzen
Fügen Sie es zur daemon.json hinzu, damit jeder Container dieses Flag automatisch erhält:
{
"no-new-privileges": true
}
Docker nach der Bearbeitung neu starten:
sudo systemctl restart docker
Verifizieren
docker exec no-priv-test grep NoNewPrivs /proc/1/status
Erwartete Ausgabe:
NoNewPrivs: 1
Der Wert 1 bedeutet, dass keine neuen Berechtigungen erlangt werden können. Der Wert 0 bedeutet, dass das Flag nicht gesetzt ist.
Aufräumen:
docker rm -f no-priv-test
Wie erstelle ich ein benutzerdefiniertes Seccomp-Profil für Docker?
Das Standard-Seccomp-Profil von Docker blockiert etwa 44 von über 300 Syscalls. Ein benutzerdefiniertes Profil ermöglicht es, Container auf die Syscalls einzuschränken, die Ihre Anwendung tatsächlich nutzt. Wenn ein Angreifer den Container kompromittiert, kann er keine Kernel-Schwachstellen über blockierte Syscalls ausnutzen.
Herausfinden, welche Syscalls Ihre Anwendung benötigt
Verwenden Sie strace auf einem laufenden Container, um die Syscalls während des normalen Betriebs zu erfassen:
docker run -d --security-opt seccomp=unconfined --name trace-target nginx:alpine
# Install strace on the host
sudo apt-get install -y strace
# Get the container's PID
PID=$(docker inspect --format '{{.State.Pid}}' trace-target)
# Trace syscalls for 30 seconds during normal operation
sudo strace -f -p "$PID" -o /tmp/nginx-syscalls.log -e trace=all &
STRACE_PID=$!
sleep 30
# Send some test traffic to exercise the application
curl -s http://localhost:80 > /dev/null 2>&1 || true
kill "$STRACE_PID" 2>/dev/null
wait "$STRACE_PID" 2>/dev/null
Die eindeutigen Syscall-Namen extrahieren:
grep -oP '^\[pid \d+\] \K\w+|^\w+' /tmp/nginx-syscalls.log | sort -u
Das liefert Ihnen die Mindestmenge an Syscalls, die Ihre Anwendung benötigt.
Das benutzerdefinierte Profil erstellen
Erstellen Sie eine JSON-Datei. Die defaultAction ist SCMP_ACT_ERRNO (alles ablehnen, was nicht explizit erlaubt ist). Die Syscall-Liste muss sowohl die Anforderungen Ihrer Anwendung als auch die der Container-Runtime (runc) bei der Initialisierung enthalten. Das folgende Profil wurde mit nginx:alpine auf Docker Engine 29.x getestet:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": [
"SCMP_ARCH_X86_64",
"SCMP_ARCH_X86",
"SCMP_ARCH_AARCH64"
],
"syscalls": [
{
"names": [
"accept4",
"access",
"arch_prctl",
"bind",
"brk",
"capget",
"capset",
"chdir",
"chown",
"clone",
"clone3",
"close",
"close_range",
"connect",
"copy_file_range",
"dup",
"dup2",
"dup3",
"epoll_create1",
"epoll_ctl",
"epoll_pwait",
"epoll_pwait2",
"epoll_wait",
"eventfd2",
"execve",
"exit",
"exit_group",
"faccessat",
"faccessat2",
"fchmod",
"fchmodat",
"fchown",
"fchownat",
"fcntl",
"fork",
"fstat",
"fstatfs",
"futex",
"getcwd",
"getdents",
"getdents64",
"getegid",
"geteuid",
"getgid",
"getpgrp",
"getpid",
"getppid",
"getrandom",
"getrlimit",
"getsockname",
"getsockopt",
"gettid",
"getuid",
"io_destroy",
"io_getevents",
"io_setup",
"io_submit",
"ioctl",
"kill",
"listen",
"lseek",
"lstat",
"madvise",
"memfd_create",
"mkdir",
"mkdirat",
"mmap",
"mount",
"mprotect",
"mremap",
"munmap",
"nanosleep",
"newfstatat",
"open",
"openat",
"pipe",
"pipe2",
"pivot_root",
"poll",
"ppoll",
"prctl",
"pread64",
"prlimit64",
"pwrite64",
"read",
"readlink",
"readlinkat",
"recvfrom",
"recvmsg",
"rename",
"renameat",
"rseq",
"rt_sigaction",
"rt_sigprocmask",
"rt_sigreturn",
"sched_getaffinity",
"sched_yield",
"seccomp",
"sendfile",
"sendmsg",
"sendto",
"set_robust_list",
"set_tid_address",
"setgid",
"setgroups",
"sethostname",
"setitimer",
"setsockopt",
"setuid",
"sigaltstack",
"socket",
"socketpair",
"stat",
"statfs",
"statx",
"symlink",
"symlinkat",
"sysinfo",
"tgkill",
"umask",
"umount2",
"uname",
"unlink",
"unlinkat",
"unshare",
"utimensat",
"wait4",
"write",
"writev"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
Warum so viele Syscalls? Die Liste enthält Syscalls, die von drei Schichten benötigt werden: der Container-Runtime (runc) für Namespace-Setup (clone3, mount, pivot_root, unshare, seccomp, statx), der Alpine-Shell und Entrypoint-Skripten (fork, open, pipe, wait4) und nginx selbst (accept4, bind, listen, io_setup). Ein glibc-basiertes Image würde weniger Shell-bezogene Syscalls benötigen, dafür aber mehr libc-interne.
Speichern Sie dies als /etc/docker/seccomp-nginx.json.
Das benutzerdefinierte Profil anwenden
CLI:
docker run -d \
--security-opt seccomp=/etc/docker/seccomp-nginx.json \
--name seccomp-test \
nginx:alpine
Compose:
services:
web:
image: nginx:alpine
security_opt:
- seccomp=/etc/docker/seccomp-nginx.json
Verifizieren, dass das Profil aktiv ist
docker inspect --format '{{.HostConfig.SecurityOpt}}' seccomp-test
Die Ausgabe zeigt das vollständige Seccomp-Profil-JSON, das auf den Container angewendet wurde. Docker liest die Datei bei der Container-Erstellung und bettet den Profilinhalt ein.
Testen, dass eine eingeschränkte Operation fehlschlägt:
docker exec seccomp-test unshare --mount /bin/sh -c 'echo escaped'
Dies sollte mit "Operation not permitted" fehlschlagen. Der Container besitzt kein CAP_SYS_ADMIN (wird standardmäßig nicht gewährt), und das Seccomp-Profil bietet eine zweite Verteidigungsschicht, indem es nur die Syscalls auf die Allowlist setzt, die für den normalen Betrieb nötig sind.
Aufräumen:
docker rm -f seccomp-test
rm -f /tmp/nginx-syscalls.log
Produktionstipp: Starten Sie mit dem Standardprofil von Docker und entfernen Sie Syscalls, anstatt von Grund auf zu bauen. Der strace-Ansatz liefert das strikteste Profil, erfordert aber gründliche Tests. Lassen Sie während der strace-Erfassung jeden Codepfad Ihrer Anwendung durchlaufen: Start, normale Anfragen, Fehlerbehandlung, ordnungsgemäßes Herunterfahren (docker stop) und Log-Rotation.
Iterativer Ansatz: Falls das Erstellen per strace zu riskant erscheint, nutzen Sie diesen sichereren Workflow:
- Kopieren Sie das Standard-Seccomp-Profil von Docker als Ausgangspunkt.
- Starten Sie Ihren Container mit diesem kopierten Profil. Es verhält sich identisch zum Standard.
- Entfernen Sie Syscalls gruppenweise (z. B. alle
key*-Syscalls, alleswap*-Syscalls). - Testen Sie den Container nach jeder Entfernung. Falls er nicht mehr funktioniert, fügen Sie den zuletzt entfernten Syscall zurück.
- Wiederholen Sie, bis Sie alles entfernt haben, was Ihre Anwendung nicht benötigt.
Das ist langsamer als die strace-Methode, aber sicherer für Produktionscontainer, bei denen ein fehlender Syscall intermittierende Fehler verursachen könnte.
Wie schreibe ich ein AppArmor-Profil für einen Docker-Container?
AppArmor beschränkt, auf welche Dateien, Netzwerkressourcen und Capabilities ein Container-Prozess zugreifen darf. Docker wendet automatisch ein Standard-Profil docker-default an. Ein benutzerdefiniertes Profil erlaubt es, Container weiter einzuschränken auf nur die Dateisystempfade und Netzwerkoperationen, die sie benötigen.
Prüfen, ob AppArmor aktiv ist
sudo aa-status
Sie sollten docker-default in der Liste der geladenen Profile sehen. Falls AppArmor nicht installiert ist:
sudo apt-get install -y apparmor apparmor-utils
Ein benutzerdefiniertes Profil schreiben
Erstellen Sie /etc/apparmor.d/containers/docker-nginx:
#include <tunables/global>
profile docker-nginx flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
#include <abstractions/nameservice>
# Capabilities needed by nginx
capability chown,
capability setuid,
capability setgid,
capability net_bind_service,
capability dac_override,
# Network access
network inet tcp,
network inet udp,
network inet6 tcp,
network inet6 udp,
# Shell and entrypoint scripts
/bin/** rix,
/usr/bin/** rix,
/usr/sbin/** rix,
/lib/** mr,
/usr/lib/** mr,
/docker-entrypoint.sh rix,
/docker-entrypoint.d/ r,
/docker-entrypoint.d/** rix,
/dev/null rw,
/dev/stdout rw,
/dev/stderr rw,
# Nginx binary
/usr/sbin/nginx ix,
# Config files (read only)
/etc/ r,
/etc/nginx/ r,
/etc/nginx/** r,
# Web root (read only)
/usr/share/nginx/html/** r,
# Temp and cache directories
/var/cache/nginx/ rw,
/var/cache/nginx/** rw,
/var/log/nginx/ rw,
/var/log/nginx/** rw,
/run/ rw,
/run/** rw,
/tmp/ rw,
/tmp/** rw,
# Proc filesystem (needed for nginx worker management)
/proc/** r,
# Deny sensitive files
deny /etc/shadow r,
deny /etc/passwd w,
deny /proc/*/mem r,
deny /sys/** w,
}
Das Profil benötigt capability-Deklarationen, weil AppArmor die Capability-Nutzung unabhängig von Dockers --cap-add-Flags steuert. Die Entrypoint-Skript-Pfade (/docker-entrypoint.sh, /docker-entrypoint.d/) und Shell-Binaries (/bin/**) müssen explizit erlaubt sein, sonst startet der Container nicht. Die rix-Berechtigung bedeutet: lesen, Ausführungskontext erben und Ausführung erlauben.
Profil laden und anwenden
sudo apparmor_parser -r -W /etc/apparmor.d/containers/docker-nginx
Prüfen, ob es geladen wurde:
sudo aa-status | grep docker-nginx
Container mit dem benutzerdefinierten Profil starten:
CLI:
docker run -d \
--security-opt apparmor=docker-nginx \
--name apparmor-test \
nginx:alpine
Compose:
services:
web:
image: nginx:alpine
security_opt:
- apparmor=docker-nginx
AppArmor-Enforcement verifizieren
docker exec apparmor-test cat /etc/shadow
Dies sollte "Permission denied" zurückgeben, weil das Profil das Lesen von /etc/shadow explizit verweigert.
Den AppArmor-Status des Containers prüfen:
docker inspect --format '{{.AppArmorProfile}}' apparmor-test
Erwartete Ausgabe: docker-nginx.
Aufräumen:
docker rm -f apparmor-test
Wie starte ich einen Docker-Container mit einem schreibgeschützten Dateisystem?
Ein schreibgeschütztes Root-Dateisystem verhindert, dass Angreifer Malware, Backdoors oder veränderte Binaries in einem kompromittierten Container ablegen. Der Container kann weiterhin in explizit gemountete tmpfs-Volumes für temporäre Dateien und Laufzeitdaten schreiben.
CLI:
docker run -d \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /run:rw,noexec,nosuid,size=16m \
--tmpfs /var/cache/nginx:rw,noexec,nosuid,size=128m \
-p 8080:80 \
--name readonly-nginx \
nginx:alpine
Compose:
services:
web:
image: nginx:alpine
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
- /run:rw,noexec,nosuid,size=16m
- /var/cache/nginx:rw,noexec,nosuid,size=128m
Beachten Sie das noexec-Flag bei den tmpfs-Mounts. Dies verhindert das Ausführen von Binaries aus temporären Verzeichnissen, eine gängige Technik, die Angreifer nach Erlangung von Schreibzugriff nutzen.
Verifizieren, dass das Dateisystem schreibgeschützt ist
docker exec readonly-nginx touch /testfile
Erwartete Ausgabe:
touch: /testfile: Read-only file system
Bestätigen, dass tmpfs-Mounts funktionieren:
docker exec readonly-nginx touch /tmp/testfile && echo "tmpfs works"
Prüfen, dass der Container Traffic ausliefert:
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080
Erwartet: 200.
Aufräumen:
docker rm -f readonly-nginx
Wie sollte eine gehärtete Docker daemon.json aussehen?
Eine gehärtete daemon.json wendet Sicherheitsstandards auf jeden Container auf dem Host an. Einzelne Container können einige Einstellungen überschreiben, aber die Daemon-Konfiguration setzt die Basislinie.
Erstellen oder bearbeiten Sie /etc/docker/daemon.json:
{
"no-new-privileges": true,
"icc": false,
"live-restore": true,
"userland-proxy": false,
"log-driver": "journald",
"log-opts": {
"tag": "{{.Name}}"
},
"default-ulimits": {
"nproc": {
"Name": "nproc",
"Hard": 512,
"Soft": 256
},
"nofile": {
"Name": "nofile",
"Hard": 65536,
"Soft": 32768
}
},
"storage-driver": "overlay2"
}
Was jede Einstellung bewirkt:
no-new-privileges: Blockiert setuid/setgid-Eskalation in allen Containern standardmäßig.icc: false: Deaktiviert die Inter-Container-Kommunikation im Standard-Bridge-Netzwerk. Container können einander nur über explizit veröffentlichte Ports oder benutzerdefinierte Netzwerke erreichen. Das begrenzt laterale Bewegung, wenn ein Container kompromittiert wird.live-restore: Hält Container während Daemon-Neustarts am Laufen. Verhindert Ausfallzeit bei Docker-Upgrades.userland-proxy: false: Nutzt iptables für Port-Mapping statt eines Userland-Proxy-Prozesses. Bessere Performance, weniger offene Dateideskriptoren.log-driver: journald: Sendet Container-Logs an das Systemjournal, wo sie zentral verwaltet und rotiert werden.default-ulimits: Begrenzt Prozesse und offene Dateien pro Container. Verhindert Fork Bombs und Dateideskriptor-Erschöpfung.
Änderungen anwenden:
sudo systemctl restart docker
Verifizieren, dass der Daemon die Konfiguration übernommen hat:
docker info --format '{{.SecurityOptions}}'
Sie sollten no-new-privileges in der Liste der Sicherheitsoptionen sehen.
Prüfen, dass ICC deaktiviert ist:
docker network inspect bridge --format '{{index .Options "com.docker.network.bridge.enable_icc"}}'
Erwartete Ausgabe: false.
Hinweis zu userns-remap: Wenn Sie User Namespace Remapping statt Rootless-Modus gewählt haben, fügen Sie "userns-remap": "default" zu dieser Konfiguration hinzu. Kombinieren Sie userns-remap nicht mit Rootless Docker.
Versionsinformationen verbergen: Während Sie die daemon.json bearbeiten, sollten Sie auch überlegen, Docker-API-Versionsinformationen zu verbergen. Docker gibt standardmäßig keine Versions-Header preis wie Nginx, aber wenn Sie die Docker-API auf einem TCP-Socket betreiben (tun Sie das nicht, es sei denn es ist unbedingt nötig), schützen Sie sie mit TLS-Client-Zertifikaten. Eine exponierte Docker-API ist gleichbedeutend mit Root-Shell-Zugang.
Setup auditieren: Führen Sie Docker Bench for Security aus, um Ihre Konfiguration gegen den CIS Docker Benchmark zu prüfen. Klonen Sie das Repository und führen Sie das Skript direkt aus, da das Container-Image einen veralteten Docker-Client enthält, der mit Docker Engine 29.x inkompatibel ist:
git clone https://github.com/docker/docker-bench-security.git /tmp/docker-bench
cd /tmp/docker-bench && sudo bash docker-bench-security.sh
Prüfen Sie die Ausgabe auf WARN-Einträge. Die oben gehärtete daemon.json adressiert die meisten davon. Als INFO markierte Einträge sind Empfehlungen, keine Fehler.
Mehrere Härtungsschichten in Docker Compose kombinieren
Eine Produktions-Compose-Datei sollte mehrere Härtungsmaßnahmen zusammenfassen. Hier ein Beispiel für einen Nginx-Container mit allen sieben Schichten:
services:
web:
image: nginx:alpine
read_only: true
cap_drop:
- ALL
cap_add:
- CHOWN
- NET_BIND_SERVICE
- SETUID
- SETGID
security_opt:
- no-new-privileges:true
- seccomp=/etc/docker/seccomp-nginx.json
- apparmor=docker-nginx
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
- /run:rw,noexec,nosuid,size=16m
- /var/cache/nginx:rw,noexec,nosuid,size=128m
ports:
- "80:80"
pids_limit: 100
deploy:
resources:
limits:
memory: 256M
cpus: '1.0'
Beachten Sie, dass pids_limit Fork Bombs verhindert und deploy.resources.limits Speicher und CPU begrenzt. Das sind keine security-opt-Flags, aber sie verhindern Denial-of-Service aus dem Container heraus. Mehr zu Ressourcenlimits unter Docker Compose Ressourcenlimits, Healthchecks und Neustartrichtlinien.
Verifizieren, dass alle Sicherheitsoptionen auf dem laufenden Container aktiv sind:
docker compose up -d
docker inspect web --format '{{json .HostConfig.SecurityOpt}}' | python3 -m json.tool
Sie sollten no-new-privileges, Ihren Seccomp-Profilpfad und den AppArmor-Profilnamen in der Liste sehen.
Produktions-Härtungs-Checkliste
| Maßnahme | CLI-Flag | Compose-Schlüssel | daemon.json | Verhinderte Bedrohung |
|---|---|---|---|---|
| Rootless Docker | N/A (Daemon-Ebene) | N/A | N/A (separater Daemon) | Container-Ausbruch gibt Host-root |
| userns-remap | N/A | N/A | "userns-remap": "default" |
Container-root = Host-root |
| Capabilities entfernen | --cap-drop ALL --cap-add X |
cap_drop: [ALL] |
N/A | Kernel-Angriffsfläche |
| no-new-privileges | --security-opt no-new-privileges:true |
security_opt: [no-new-privileges:true] |
"no-new-privileges": true |
Setuid/setgid-Eskalation |
| Benutzerdefiniertes Seccomp | --security-opt seccomp=profile.json |
security_opt: [seccomp:path] |
N/A | Kernel-Exploit über blockierte Syscalls |
| AppArmor-Profil | --security-opt apparmor=name |
security_opt: [apparmor:name] |
N/A | Datei-/Netzwerkzugriff über App-Bedarf hinaus |
| Schreibgeschütztes rootfs | --read-only |
read_only: true |
N/A | Persistente Malware, Binary-Manipulation |
| ICC deaktivieren | N/A | N/A | "icc": false |
Laterale Bewegung zwischen Containern |
| Prozesse begrenzen | --pids-limit 100 |
pids_limit: 100 |
"default-pids-limit": 100 |
Fork Bombs |
| Userland-Proxy deaktivieren | N/A | N/A | "userland-proxy": false |
Ressourcenverschwendung, verkleinert Angriffsfläche |
Fehlerbehebung
Container startet nach dem Hinzufügen eines Seccomp-Profils nicht:
Ihrem Profil fehlt ein Syscall, den die Anwendung benötigt. Starten Sie vorübergehend mit --security-opt seccomp=unconfined, tracen Sie den Prozess mit strace und fügen Sie die fehlenden Syscalls Ihrem Profil hinzu.
Kernel-Audit-Logs auf blockierte Syscalls prüfen:
sudo journalctl -k | grep -i seccomp
AppArmor blockiert legitime Operationen: Setzen Sie das Profil in den Complain-Modus, um Ablehnungen zu protokollieren ohne sie durchzusetzen:
sudo aa-complain /etc/apparmor.d/containers/docker-nginx
Logs beobachten:
sudo journalctl -k | grep apparmor | tail -20
Nach dem Hinzufügen der benötigten Berechtigungen zurück in den Enforce-Modus wechseln:
sudo aa-enforce /etc/apparmor.d/containers/docker-nginx
Rootless Docker kann keine Images pullen:
Prüfen Sie, ob DOCKER_HOST korrekt gesetzt ist:
echo $DOCKER_HOST
Der Wert sollte auf /run/user/<UID>/docker.sock zeigen. Prüfen Sie außerdem, ob der Rootless-Daemon läuft:
systemctl --user status docker
Bind Mount verweigert Zugriff bei userns-remap: Dateien auf dem Host im Besitz von root (UID 0) sind nicht zugänglich, weil die Container-UID 0 auf eine hohe Host-UID abgebildet wird. Beheben Sie dies durch Änderung der Eigentümerschaft:
# Find the remapped UID
grep dockremap /etc/subuid
# Then chown to that UID
sudo chown -R <remapped-uid>:<remapped-uid> /path/to/bind/mount
Ersetzen Sie <remapped-uid> durch die erste Zahl aus /etc/subuid für den Benutzer dockremap (üblicherweise 100000).
Container-Logs: Bei jedem Docker-Problem beginnen Sie mit den Container-Logs und dem Docker-Daemon-Journal:
docker logs <container-name>
sudo journalctl -u docker -f
Für Rootless Docker prüfen Sie das Journal auf Benutzerebene:
journalctl --user -u docker -f
Bereit, es selbst auszuprobieren?
Stellen Sie Ihren eigenen Server in Sekunden bereit. Linux, Windows oder FreeBSD. →