Docker ist ein absolut praktisches Tool, und ich möchte es beim Managen von Deployments nicht missen. Auch im Buildprozess für Software setzen wir es extensiv ein. Alle Dependencies sind schön in einen Container verpackt, voneinander und vom Hostsystem isoliert. Diese Isolation ist jedoch trügerisch: Leicht fällt man dem Trugschluss zum Opfer, dass ein Angreifer, der die Applikation übernimmt, im Container “gefangen” sei. Und in der Tat ist es zumindest eine gewisse Hürde, aus einem Docker-Container auszubrechen und somit seinen Einfluss in einem kompromittierten System auszubauen. Dennoch sollte man es einem hypothetischen Angreifer nicht einfacher machen als nötig, sich in seiner Infrastruktur breit zu machen.
Zwei einfache Schritte, um seine Docker-Images sicherer zu bauen sind:
- Prozesse in Containern nicht als
root
ausführen - Unnötige Dependencies entfernen
Im Folgenden stelle ich ein paar grundlegende Tipps vor, wie man das leicht erreichen kann.
Container ohne root
Per default werden Prozesse in Docker-Containern mit der User-ID (UID) und Group-ID (GID) 0 ausgeführt, also dem User root
:
$ docker run --rm --entrypoint id busybox -u
0
Das erlaubt es zum einen, innerhalb des Containers (fast)
beliebige Befehle auszuführen. Zum anderen maskiert der Docker-Daemon
per default nicht die User-IDs, sondern verwendet jene des Hostsystems.
Dies bedeutet, dass ein Prozess, der in einem Container läuft, auch auf
der Hostmaschine root
gehört:
$ docker run -d --rm --entrypoint sleep busybox
6014e4507028316129ab56874e11cd502fd08591d97d2ce3a275d7aeab6bd0a348
$ ps aux | grep sleep
root 14110 4.1 0.0 1288 4 ? Ss 14:39 0:00 sleep 60
Habe ich beispielsweise ein Host-Directory mit bind-mount
im Container verfügbar gemacht, erhalte ich auch gleich Vollzugriff auf
die Dateien darin. Und ist der Kernel verwundbar, mache ich es einem
Angreifer auch leichter, wenn er bereits root
ist. Best
Practice ist es daher, Containerprozesse mit einem dedizierten User
laufen zu lassen. Leider tun dies die meisten populären Base-Images aus
dem Docker-Hub nicht:
# openjdk: root
$ docker run --rm --entrypoint id openjdk -u
0
# node: root
$ docker run --rm --entrypoint id node -u
0
Vermutlich tun sie das nicht, weil sie sonst ständig Meldungen von Usern bekommen würden, dass sie in ihren Images z.B. nicht einfach ein Paket nachinstallieren können (s. “Einschränkungen”).
Möchte ich dennoch auf diesen Images aufsetzen, kann ich die User-ID aber sehr einfach auf einen dedizierten User “fallen” lassen. Ein entsprechendes Dockerfile wäre:
FROM openjdk:8-jre
# Add non-root user 'java'
RUN groupadd java && useradd --no-create-home --gid java java
# Run all following stages, including the ENTRYPOINT as user 'java'
USER java
Baut man dieses und führt es aus, folgt dies:
$ docker build . -t myimage
Sending build context to Docker daemon 15.81MB
Step 1/3 : FROM openjdk:8-jre
---> 2df355aab4eb
Step 2/3 : RUN groupadd java && useradd --no-create-home --gid java java
---> Using cache
---> 7d5d8da885d1
Step 3/3 : USER java
---> Using cache
---> 0112fb824b5a
Successfully built 0112fb824b5a
Successfully tagged foo:latest
$ docker run --rm --entrypoint id myimage -u
1000
Der Prozess läuft damit mit der UID 1000.
Die node
-Images bringen bereits den user node
mit der UID 1000
mit. Hier genügt also die Verwendung eines USER
-Statements. Man muss den User nicht noch selbst anlegen.
Einschränkungen
Ports
Als ein regulärer User darf ich keine Ports mit Portnummer kleiner als 1024 öffnen. Wenn meine Applikation also z.B. den Port 443 öffnen möchte, wird sie im Container scheitern und nicht hochfahren. Ich muss sie also anweisen, beispielsweise den Port 8443 zu verwenden, und diesen mit Docker (oder Kubernetes) auf den Port 443 binden:
$ docker run -p 443:8443 myimage
Dies funktioniert, weil die Containerlaufzeit auf dem Host meist das Recht hat, Prozesse an beliebige Ports zu binden.
Pakete installieren
Normalerweise kann ich die Layer mit dem USER
-Befehl als letzten Befehl angeben, und alle vorherigen Schritte mit root
durchführen. Dann wird nur der später ausgeführte Befehl mit dem
dedizierten User laufen und ich kann vorher z.B. Pakete installieren.
Nehmen wir aber an, ich möchte auf einem Image aufbauen, das die USER
-Anweisung enthält, und dort noch etwas nachinstallieren? Kein Problem, ich kann jederzeit wieder temporär zu root
zurückkehren:
FROM myimage
USER root
# Do something as root
RUN echo "i am root: $(whoami)"
# assuming the non-root user from myimage was 'java':
USER java
Bind-Mounts
Manchmal möchte ich Verzeichnisse vom Host-System in den Container mounten, beispielsweise um ein persistentes Datenverzeichnis herzustellen:
$ docker run -v /some/host/directory:/my/container/directory:rw myimage
Führe ich meinen Prozess nicht als root
aus,
wird mein Prozess unter Umständen nicht in das Verzeichnis schreiben
können. Ich habe hierfür bisher leider nur Hacks gesehen. Einer davon
ist es, das Verzeichnis vorher auf dem Host mit den korrekten
UID/GID-Werten zu erzeugen und dann zu mounten:
$ mkdir mydir
# Assuming UID and GID of the process in the container will be 1000
$ chown -R 1000:1000 mydir
$ docker run -v "$(pwd)/mydir:/my/container/directory:rw" myimage
Hier bin ich für andere, bessere Lösungen sehr offen!
Weniger Dependencies im Container
Einfache
Gleichung: je weniger Kram in meinem Container-Filesystem herumliegt,
desto weniger Angriffsfläche biete ich auch. Beispielsweise kann ich mir
bei meinen Applikationen meist sehr, sehr sicher sein, dass ich zur
Zeit der Ausführung nicht Build-Dependencies wie gcc
, git
oder make
benötige. Dennoch bringen auch hier die offiziellen Base-Images von openjdk
und insbesondere node
einiges mit. Node installiert in ihren Default-Images eine eindrucksvolle Liste an Build-Dependencies (Beispiel: node:12
):
autoconf, automake, bzip2, dpkg-dev, file, g++, gcc, imagemagick, libbz2-dev, libc6-dev, libcurl4-openssl-dev, libdb-dev, libevent-dev, libffi-dev, libgdbm-dev, libglib2.0-dev, libgmp-dev, libjpeg-dev, libkrb5-dev, liblzma-dev, libmagickcore-dev, libmagickwand-dev, libmaxminddb-dev, libncurses5-dev, libncursesw5-dev, libpng-dev, libpq-dev, libreadline-dev, libsqlite3-dev, libssl-dev, libtool, libwebp-dev, libxml2-dev, libxslt-dev, libyaml-dev, make, patch, unzip, xz-utils, zlib1g-dev, bzr, git, mercurial, openssh-client, subversion, procps, gnupg, dirmngr, ca-certificates, curl, netbase, wget
Was kann ich dagegen tun? Im Falle von node
hilft erst einmal, den Tag -slim
zu verwenden, z.B. node:12-slim
, da dieses auf die meisten der Dependencies verzichtet. Brauche ich in
meinem Build aus irgendeinem Grund dennoch die Dependencies, kann ich
das Problem mit einem Docker-Multistage-Build lösen:
FROM node:12.16 as builder
COPY buildfiles .
RUN my_build_script_that_uses_gcc_or_whatever.sh
# We will copy the resulting built files to the slim image, discarding the bloated build image.
FROM node:12.16-slim
# This is where we pull files from the file system of the builder container. We also adjust the ownership (which might or might not be necessary here). Everything else from the build container is discarded.
# Note that in this case, the node baseimage already has an existing 'node' user with UID 1000, so we can just use it
COPY --from=builder --chown=node:node /myartifact.bin
USER node
ENTRYPOINT ["/myartifact.bin"]
Bei openjdk
sieht es analog aus. Ich führe beispielsweise den Build in einem Image aus, das auf openjdk:11
basiert, und führe das Ganze dann in einem Image aus, das auf openjdk:11-jre-slim
basiert. Achtung hier: bei openjdk
bringt der Tag ohne Suffix jre
das gesamte JDK mit. Also in deinen Applikationen stets sowas wie 8-jre
verwenden.
Schöner Nebeneffekt der Verwendung der schlankeren Base-Images: sie setzen auf einem ohnehin schon verschlankten Debian auf und reduzieren (zusammen mit dem geringeren Bloat der Pakete) somit deutlich die Imagegröße:
$ docker images | grep node
node [...] 12.16 [...] 916MB
node [...] 12.16-slim [...] 140MB
$ docker images | grep openjdk
openjdk [...] 8 [...] 510MB
openjdk [...] 8-jdk [...] 510MB
openjdk [...] 8-jre [...] 264MB
openjdk [...] 8-jre-slim [...] 184MB