Suche
Suche Menü

Klein aber sicher

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

Schreibe einen Kommentar

Pflichtfelder sind mit * markiert.


Agile Softwareentwicklung

Durch die weitere Nutzung der Seite stimmst du der Verwendung von Cookies zu. Weitere Informationen

Die Cookie-Einstellungen auf dieser Website sind auf "Cookies zulassen" eingestellt, um das beste Surferlebnis zu ermöglichen. Wenn du diese Website ohne Änderung der Cookie-Einstellungen verwendest oder auf "Akzeptieren" klickst, erklärst du sich damit einverstanden.

Schließen