Kapitel 4. Container-Isolierung
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
In diesem Kapitel erfährst du, wie Container wirklich funktionieren! Dies ist wichtig, um zu verstehen, inwieweit Container voneinander und vom Host isoliert sind. Du wirst in der Lage sein, selbst zu beurteilen, wie stark die Sicherheitsgrenze ist, die einen Container umgibt.
Wie du weißt, wenn du schon einmal docker exec <image> bash
ausgeführt hast, sieht ein Container von innen ähnlich aus wie eine virtuelle Maschine. Wenn du Shell-Zugriff auf einen Container hast und ps
aufrufst, kannst du nur die Prozesse sehen, die in ihm laufen. Der Container verfügt über einen eigenen Netzwerkstack und scheint ein eigenes Dateisystem mit einem Root-Verzeichnis zu haben, das in keiner Beziehung zu Root auf dem Host steht. Du kannst Container mit begrenzten Ressourcen betreiben, z. B. mit einer begrenzten Menge an Arbeitsspeicher oder einem Bruchteil der verfügbaren CPUs. Das alles geschieht mit Hilfe der Linux-Funktionen, die wir in diesem Kapitel näher erläutern werden.
So sehr sie sich oberflächlich betrachtet auch ähneln mögen, ist es wichtig zu erkennen, dass Container keine virtuellen Maschinen sind. In Kapitel 5 werden wir uns die Unterschiede zwischen diesen beiden Arten der Isolierung ansehen. Meiner Erfahrung nach ist es absolut wichtig, die beiden Arten von Isolation zu verstehen und voneinander abzugrenzen, um zu verstehen, inwieweit herkömmliche Sicherheitsmaßnahmen in Containern wirksam sein können, und um zu erkennen, wo containerspezifische Werkzeuge notwendig sind.
Du wirst sehen, wie Container aus Linux-Konstrukten wie Namensräumen und chroot
sowie cgroups, die in Kapitel 3 behandelt wurden, aufgebaut sind. Wenn du diese Konstrukte kennst, hast du ein Gefühl dafür, wie gut deine Anwendungen geschützt sind, wenn sie in Containern laufen.
Obwohl die allgemeinen Konzepte dieser Konstrukte recht einfach sind, kann die Art und Weise, wie sie mit anderen Funktionen des Linux-Kernels zusammenarbeiten, komplex sein. Container-Escape-Schwachstellen (z. B. CVE-2019-5736, eine schwerwiegende Schwachstelle, die sowohl in runc
als auch in LXC
entdeckt wurde) basieren auf Feinheiten in der Art und Weise, wie Namensräume, Fähigkeiten und Dateisysteme zusammenwirken.
Linux Namensräume
Während C-Gruppen die Ressourcen kontrollieren, die ein Prozess nutzen kann, bestimmen Namensräume, was er sehen kann. Indem du einen Prozess in einen Namensraum einordnest, kannst du die Ressourcen einschränken, die für diesen Prozess sichtbar sind.
Die Ursprünge der Namensräume gehen auf das Betriebssystem Plan 9 zurück. Zu dieser Zeit hatten die meisten Betriebssysteme einen einzigen "Namensraum" für Dateien. Unix-Systeme erlaubten das Einbinden von Dateisystemen, aber sie wurden alle in dieselbe systemweite Ansicht aller Dateinamen eingebunden. In Plan 9 war jeder Prozess Teil einer Prozessgruppe, die ihre eigene "Namensraum"-Abstraktion hatte, die Hierarchie von Dateien (und dateiähnlichen Objekten), die diese Gruppe von Prozessen sehen konnte. Jede Prozessgruppe konnte ihre eigenen Dateisysteme einhängen, ohne sich gegenseitig zu sehen.
Der erste Namensraum wurde 2002 mit der Version 2.4.19 in den Linux-Kernel eingeführt. Es handelte sich dabei um den Mount-Namensraum, der eine ähnliche Funktionalität wie der Plan 9 hatte. Heutzutage gibt es mehrere verschiedene Arten von Namensräumen, die von Linux unterstützt werden:
-
Unix Timesharing System (UTS) - das hört sich kompliziert an, aber im Grunde geht es in diesem Namensraum nur um die Hostnamen und Domänennamen für das System, die einem Prozess bekannt sind.
Es ist möglich, dass in zukünftigen Versionen des Linux-Kernels weitere Ressourcen mit Namensräumen versehen werden. Es wurde zum Beispiel darüber diskutiert, einen Namensraum für Zeit einzurichten.
Ein Prozess befindet sich immer in genau einem Namensraum jedes Typs. Wenn du ein Linux-System startest, verfügt es über einen einzigen Namensraum jedes Typs, aber wie du noch sehen wirst, kannst du zusätzliche Namensräume erstellen und ihnen Prozesse zuweisen. Du kannst die Namensräume auf deinem Rechner ganz einfach mit dem Befehl lsns
einsehen:
vagrant@myhost:~$ lsns NS TYPE NPROCS PID USER COMMAND 4026531835 cgroup 3 28459 vagrant /lib/systemd/systemd --user 4026531836 pid 3 28459 vagrant /lib/systemd/systemd --user 4026531837 user 3 28459 vagrant /lib/systemd/systemd --user 4026531838 uts 3 28459 vagrant /lib/systemd/systemd --user 4026531839 ipc 3 28459 vagrant /lib/systemd/systemd --user 4026531840 mnt 3 28459 vagrant /lib/systemd/systemd --user 4026531992 net 3 28459 vagrant /lib/systemd/systemd --user
Das sieht schön und ordentlich aus, und es gibt einen Namensraum für jeden der Typen, die ich zuvor erwähnt habe. Leider ist das ein unvollständiges Bild! In der Manpage von lsns
steht, dass es "Informationen direkt aus dem /proc-Dateisystem liest und für Nicht-Root-Benutzer möglicherweise unvollständige Informationen zurückgibt". Schauen wir mal, was du bekommst, wenn du als root startest:
vagrant@myhost:~$ sudo lsns NS TYPE NPROCS PID USER COMMAND 4026531835 cgroup 93 1 root /sbin/init 4026531836 pid 93 1 root /sbin/init 4026531837 user 93 1 root /sbin/init 4026531838 uts 93 1 root /sbin/init 4026531839 ipc 93 1 root /sbin/init 4026531840 mnt 89 1 root /sbin/init 4026531860 mnt 1 15 root kdevtmpfs 4026531992 net 93 1 root /sbin/init 4026532170 mnt 1 14040 root /lib/systemd/systemd-udevd 4026532171 mnt 1 451 systemd-network /lib/systemd/systemd-networkd 4026532190 mnt 1 617 systemd-resolve /lib/systemd/systemd-resolved
Der Root-Benutzer kann einige zusätzliche Namensräume sehen, und es gibt viel mehr Prozesse, die für Root sichtbar sind, als für den Nicht-Root-Benutzer. Der Grund, warum ich dir das zeige, ist, dass wir, wenn wir lsns
verwenden, als root laufen sollten (oder sudo
verwenden), um ein vollständiges Bild zu bekommen.
Lass uns herausfinden, wie du Namensräume nutzen kannst, um etwas zu schaffen, das sich wie ein "Container" verhält.
Hinweis
Die Beispiele in diesem Kapitel verwenden Linux-Shell-Befehle, um einen Container zu erstellen. Wenn du versuchen möchtest, einen Container mit der Programmiersprache Go zu erstellen, findest du eine Anleitung unter https://github.com/lizrice/containers-from-scratch.
Den Hostnamen isolieren
Beginnen wir mit dem Namensraum für das Unix Timesharing System (UTS). Wie bereits erwähnt, umfasst dieser den Hostnamen und die Domänennamen. Indem du einen Prozess in seinen eigenen UTS-Namensraum stellst, kannst du den Hostnamen für diesen Prozess unabhängig vom Hostnamen des Rechners oder der virtuellen Maschine, auf der er läuft, ändern.
Wenn du unter Linux ein Terminal öffnest, kannst du den Hostnamen sehen:
vagrant@myhost:~$ hostname myhost
Die meisten (vielleicht alle?) Containersysteme geben jedem Container eine zufällige ID. Standardmäßig wird diese ID als Hostname verwendet. Du kannst das sehen, indem du einen Container startest und Shell-Zugriff erhältst. In Docker könntest du zum Beispiel Folgendes tun:
vagrant@myhost:~$ docker run --rm -it --name hello ubuntu bash root@cdf75e7a6c50:/$ hostname cdf75e7a6c50
Übrigens kannst du in diesem Beispiel sehen, dass selbst wenn du dem Container in Docker einen Namen gibst (hier habe ich --name hello
angegeben), dieser Name nicht für den Hostnamen des Containers verwendet wird.
Der Container kann seinen eigenen Hostnamen haben, weil Docker ihn mit einem eigenen UTS-Namensraum erstellt hat. Du kannst dasselbe erforschen, indem du den Befehl unshare
verwendest, um einen Prozess zu erstellen, der einen eigenen UTS-Namensraum hat.
Wie auf der Manpage beschrieben (zu finden unter man unshare
), kannst du mit unshare
"ein Programm mit einigen Namensräumen ausführen, die nicht mit dem Elternprogramm geteilt werden". Schauen wir uns diese Beschreibung etwas genauer an. Wenn du "ein Programm ausführst", erstellt der Kernel einen neuen Prozess und führt das Programm darin aus. Dies geschieht aus dem Kontext eines laufenden Prozesses - dem Elternprozess - undder neue Prozess wird als Kind bezeichnet. Das Wort "unshare" bedeutet, dass das Kind nicht die Namensräume seines Elternteils teilt, sondern seine eigenen bekommt.
Lass es uns ausprobieren. Dazu brauchst du Root-Rechte, deshalb steht am Anfang der Zeile sudo
:
vagrant@myhost:~$ sudo unshare --uts sh $ hostname myhost $ hostname experiment $ hostname experiment $ exit vagrant@myhost:~$ hostname myhost
Dadurch wird die Shell sh
in einem neuen Prozess gestartet, der einen neuen UTS-Namensraum hat. Alle Programme, die du innerhalb der Shell ausführst, erben deren Namensräume. Wenn du den Befehl hostname
ausführst, wird er in dem neuen UTS-Namensraum ausgeführt, der von dem des Hostrechners isoliert wurde.
Wenn du vor dem exit
ein anderes Terminalfenster auf demselben Host öffnen würdest, könntest du bestätigen, dass sich der Hostname für die gesamte (virtuelle) Maschine nicht geändert hat. Du kannst den Hostnamen auf dem Host ändern, ohne dass sich das auf den Hostnamen auswirkt, der dem Prozess mit dem Namensraum bekannt ist, und andersherum.
Dies ist eine Schlüsselkomponente für die Funktionsweise von Containern. Namensräume geben ihnen eine Reihe von Ressourcen (in diesem Fall den Hostnamen), die unabhängig vom Hostrechner und von anderen Containern sind. Wir sprechen aber immer noch von einem Prozess, der von demselben Linux-Kernel ausgeführt wird. Das hat Auswirkungen auf die Sicherheit, auf die ich später im Kapitel eingehen werde. Schauen wir uns jetzt ein weiteres Beispiel für einen Namensraum an, indem wir sehen, wie du einem Container seine eigene Ansicht der laufenden Prozesse geben kannst.
Prozess-IDs isolieren
Wenn du den Befehl ps
innerhalb eines Docker-Containers ausführst, kannst du nur die Prozesse sehen, die innerhalb dieses Containers laufen, aber keine der Prozesse, die auf dem Host laufen:
vagrant@myhost:~$ docker run --rm -it --name hello ubuntu bash root@cdf75e7a6c50:/$ ps -eaf UID PID PPID C STIME TTY TIME CMD root 1 0 0 18:41 pts/0 00:00:00 bash root 10 1 0 18:42 pts/0 00:00:00 ps -eaf root@cdf75e7a6c50:/$ exit vagrant@myhost:~$
Dies wird durch den Prozess-ID-Namensraum erreicht, der die Menge der sichtbaren Prozess-IDs einschränkt. Führe unshare
erneut aus, aber gib diesmal mit dem Flag --pid
an, dass du einen neuen PID-Namensraum möchtest:
vagrant@myhost:~$ sudo unshare --pid sh $ whoami root $ whoami sh: 2: Cannot fork $ whoami sh: 3: Cannot fork $ ls sh: 4: Cannot fork $ exit vagrant@myhost:~$
Das scheint nicht sehr erfolgreich zu sein - es ist nicht möglich, nach dem ersten whoami
irgendwelche Befehle auszuführen! Aber es gibt einige interessante Artefakte in dieser Ausgabe.
Der erste Prozess unter sh
scheint in Ordnung zu sein, aber jeder weitere Befehl schlägt fehl, weil er sich nicht gabeln kann. Die Fehlermeldung wird in der Form <command>: <process ID>: <message>
ausgegeben, und du kannst sehen, dass die Prozess-IDs jedes Mal hochgezählt werden. Aufgrund der Reihenfolge kann man davon ausgehen, dass die erste whoami
als Prozess-ID 1 ausgeführt wurde. Das ist ein Hinweis darauf, dass der PID-Namensraum auf irgendeine Weise funktioniert, da die Nummerierung der Prozess-IDs wieder begonnen hat. Aber es ist ziemlich nutzlos, wenn du nicht mehr als einen Prozess ausführen kannst!
Es gibt Hinweise auf das Problem in der Beschreibung des Flags --fork
in der Manpage für unshare
: "Forke das angegebene Programm als Kindprozess von unshare, anstatt es direkt auszuführen. Das ist nützlich, wenn du einen neuen pid-Namensraum erstellst."
Du kannst dies untersuchen, indem du ps
ausführst, um die Prozesshierarchie in einem zweiten Terminalfenster zu sehen:
vagrant@myhost:~$ ps fa PID TTY STAT TIME COMMAND ... 30345 pts/0 Ss 0:00 -bash 30475 pts/0 S 0:00 \_ sudo unshare --pid sh 30476 pts/0 S 0:00 \_ sh
Der Prozess sh
ist nicht ein Kind von unshare
, sondern ein Kind des Prozesses sudo
.
Versuche nun das Gleiche mit dem Parameter --fork
:
vagrant@myhost:~$ sudo unshare --pid --fork sh $ whoami root $ whoami root
Das ist insofern ein Fortschritt, als dass du jetzt mehr als einen Befehl ausführen kannst, bevor der Fehler "Cannot fork" auftritt. Wenn du dir die Prozesshierarchie noch einmal von einem zweiten Terminal aus ansiehst, wirst du einen wichtigen Unterschied feststellen:
vagrant@myhost:~$ ps fa PID TTY STAT TIME COMMAND ... 30345 pts/0 Ss 0:00 -bash 30470 pts/0 S 0:00 \_ sudo unshare --pid --fork sh 30471 pts/0 S 0:00 \_ unshare --pid --fork sh 30472 pts/0 S 0:00 \_ sh ...
Mit dem Parameter --fork
wird die Shell sh
als Kind des Prozesses unshare
ausgeführt, und du kannst innerhalb dieser Shell so viele verschiedene untergeordnete Befehle ausführen, wie du willst.
Da sich die Shell in ihrem eigenen Prozess-ID-Namensraum befindet, könnten die Ergebnisse der Ausführung von ps
darin überraschend sein:
vagrant@myhost:~$ sudo unshare --pid --fork sh $ ps PID TTY TIME CMD 14511 pts/0 00:00:00 sudo 14512 pts/0 00:00:00 unshare 14513 pts/0 00:00:00 sh 14515 pts/0 00:00:00 ps $ ps -eaf UID PID PPID C STIME TTY TIME CMD root 1 0 0 Mar27 ? 00:00:02 /sbin/init root 2 0 0 Mar27 ? 00:00:00 [kthreadd] root 3 2 0 Mar27 ? 00:00:00 [ksoftirqd/0] root 5 2 0 Mar27 ? 00:00:00 [kworker/0:0H] ...many more lines of output about processes... $ exit vagrant@myhost:~$
Wie du sehen kannst, zeigt ps
immer noch alle Prozesse auf dem gesamten Host an, obwohl er in einem neuen Prozess-ID-Namensraum läuft. Wenn du das Verhalten von ps
haben willst, das du in einem Docker-Container sehen würdest, reicht es nicht aus, einen neuen Prozess-ID-Namensraum zu verwenden. Den Grund dafür findest du in der Manpage für ps
: "Dieser ps funktioniert durch das Lesen der virtuellen Dateien in /proc."
Werfen wir einen Blick auf das Verzeichnis /proc
, um zu sehen, auf welche virtuellen Dateien sich das bezieht. Dein System wird ähnlich aussehen, aber nicht genau so, da es eine andere Reihe von Prozessen ausführen wird:
vagrant@myhost:~$ ls /proc 1 14553 292 467 cmdline modules 10 14585 3 5 consoles mounts 1009 14586 30087 53 cpuinfo mpt 1010 14664 30108 538 crypto mtrr 1015 14725 30120 54 devices net 1016 14749 30221 55 diskstats pagetypeinfo 1017 15 30224 56 dma partitions 1030 156 30256 57 driver sched_debug 1034 157 30257 58 execdomains schedstat 1037 158 30283 59 fb scsi 1044 159 313 60 filesystems self 1053 16 314 61 fs slabinfo 1063 160 315 62 interrupts softirqs 1076 161 34 63 iomem stat 1082 17 35 64 ioports swaps 11 18 3509 65 irq sys 1104 19 3512 66 kallsyms sysrq-trigger 1111 2 36 7 kcore sysvipc 1175 20 37 72 keys thread-self 1194 21 378 8 key-users timer_list 12 22 385 85 kmsg timer_stats 1207 23 392 86 kpagecgroup tty 1211 24 399 894 kpagecount uptime 1215 25 401 9 kpageflags version 12426 26 403 966 loadavg version_signature 125 263 407 acpi locks vmallocinfo 13 27 409 buddyinfo mdstat vmstat 14046 28 412 bus meminfo zoneinfo 14087 29 427 cgroups misc
Jedes nummerierte Verzeichnis in /proc
entspricht einer Prozess-ID, und es gibt viele interessante Informationen über einen Prozess in seinem Verzeichnis. Zum Beispiel ist /proc/<pid>/exe
ein symbolischer Link zu der ausführbaren Datei, die in diesem Prozess ausgeführt wird, wie du im folgenden Beispiel sehen kannst:
vagrant@myhost:~$ ps PID TTY TIME CMD 28441 pts/1 00:00:00 bash 28558 pts/1 00:00:00 ps vagrant@myhost:~$ ls /proc/28441 attr fdinfo numa_maps smaps autogroup gid_map oom_adj smaps_rollup auxv io oom_score stack cgroup limits oom_score_adj stat clear_refs loginuid pagemap statm cmdline map_files patch_state status comm maps personality syscall coredump_filter mem projid_map task cpuset mountinfo root timers cwd mounts sched timerslack_ns environ mountstats schedstat uid_map exe net sessionid wchan fd ns setgroups vagrant@myhost:~$ ls -l /proc/28441/exe lrwxrwxrwx 1 vagrant vagrant 0 Oct 10 13:32 /proc/28441/exe -> /bin/bash
Unabhängig von der Prozess-ID des Namensraums, in dem er läuft, wird ps
in /proc
nach Informationen über laufende Prozesse suchen. Damit ps
nur die Informationen über die Prozesse innerhalb des neuen Namensraums zurückgibt, muss es eine separate Kopie des Verzeichnisses /proc
geben, in das der Kernel Informationen über die Prozesse mit Namensraum schreiben kann. Da /proc
ein Verzeichnis direkt unter root ist, bedeutet dies, dass das root-Verzeichnis geändert werden muss.
Ändern des Stammverzeichnisses
In einem Container siehst du nicht das gesamte Dateisystem des Hosts, sondern nur eine Teilmenge, da das Stammverzeichnis beim Erstellen des Containers geändert wird.
Du kannst das Stammverzeichnis in Linux mit dem Befehl chroot
ändern. Dadurch wird das Stammverzeichnis des aktuellen Prozesses an einen anderen Ort im Dateisystem verschoben. Sobald du den Befehl chroot
ausgeführt hast, verlierst du den Zugriff auf alles, was in der Dateihierarchie höher als dein aktuelles Root-Verzeichnis war, da es im Dateisystem keine Möglichkeit gibt, höher als Root zu gehen, wie in Abbildung 4-1 dargestellt.
Die Beschreibung in der Manpage von chroot
lautet wie folgt: "Führe COMMAND mit dem Stammverzeichnis NEWROOT aus. [...] Wenn kein Befehl angegeben wird, führe ${SHELL} -i aus (Standard: /bin/sh -i)."
Daran siehst du, dass chroot
nicht nur das Verzeichnis wechselt, sondern auch einen Befehl ausführt, der auf eine Shell zurückfällt, wenn du keinen anderen Befehl angibst.
Erstelle ein neues Verzeichnis und versuche, chroot
in dieses Verzeichnis zu kopieren:
vagrant@myhost:~$ mkdir new_root vagrant@myhost:~$ sudo chroot new_root chroot: failed to run command ‘/bin/bash’: No such file or directory vagrant@myhost:~$ sudo chroot new_root ls chroot: failed to run command ‘ls’: No such file or directory
Das funktioniert nicht! Das Problem ist, dass es innerhalb des neuen Stammverzeichnisses kein Verzeichnis bin
gibt, so dass es unmöglich ist, die Shell /bin/bash
auszuführen. Wenn du versuchst, den Befehl ls
auszuführen, ist er ebenfalls nicht vorhanden. Die Dateien für alle Befehle, die du ausführen willst, müssen im neuen Stammverzeichnis verfügbar sein. Das ist genau das, was in einem "echten" Container passiert: Der Container wird aus einem Container-Image instanziiert, das das Dateisystem kapselt, das der Container sieht. Wenn eine ausführbare Datei nicht in diesem Dateisystem vorhanden ist, kann der Container sie nicht finden und ausführen.
Warum versuchst du nicht, Alpine Linux in deinem Container laufen zu lassen? Alpine ist eine relativ minimale Linux-Distribution, die für Container entwickelt wurde. Du musst zunächst das Dateisystem herunterladen:
vagrant@myhost:~$ mkdir alpine vagrant@myhost:~$ cd alpine vagrant@myhost:~/alpine$ curl -o alpine.tar.gz http://dl-cdn.alpinelinux.org/ alpine/v3.10/releases/x86_64/alpine-minirootfs-3.10.0-x86_64.tar.gz % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 2647k 100 2647k 0 0 16.6M 0 --:--:-- --:--:-- --:--:-- 16.6M vagrant@myhost:~/alpine$ tar xvf alpine.tar.gz
Jetzt hast du eine Kopie des Alpine-Dateisystems in dem von dir erstellten Verzeichnis alpine
. Entferne die komprimierte Version und wechsle zurück in das übergeordnete Verzeichnis:
vagrant@myhost:~/alpine$ rm alpine.tar.gz vagrant@myhost:~/alpine$ cd ..
Du kannst den Inhalt des Dateisystems mit ls alpine
untersuchen, um zu sehen, dass es wie das Stammverzeichnis eines Linux-Dateisystems aussieht, mit Verzeichnissen wie bin
, lib
, var
, tmp
, und so weiter.
Nachdem du die Alpine-Distribution entpackt hast, kannst du mit chroot
in das Verzeichnis alpine
wechseln, sofern du einen Befehl angibst, der in der Hierarchie dieses Verzeichnisses existiert.
Es ist etwas komplizierter, denn die ausführbare Datei muss sich im Pfad des neuen Prozesses befinden. Dieser Prozess erbt die Umgebung des Elternprozesses, einschließlich der Umgebungsvariablen PATH
. Das Verzeichnis bin
innerhalb von alpine
ist für den neuen Prozess zu /bin
geworden. Wenn dein regulärer Pfad /bin
enthält, kannst du die ausführbare Datei ls
aus diesem Verzeichnis abrufen, ohne den Pfad explizit anzugeben:
vagrant@myhost:~$ sudo chroot alpine ls bin etc lib mnt proc run srv tmp var dev home media opt root sbin sys usr vagrant@myhost:~$
Beachte, dass nur der Kindprozess (in diesem Beispiel der Prozess, der ls
ausgeführt hat) das neue Stammverzeichnis erhält. Wenn dieser Prozess beendet ist, geht die Kontrolle an den Elternprozess zurück. Wenn du eine Shell als Kindprozess ausführst, wird sie nicht sofort beendet, so dass es einfacher ist, die Auswirkungen der Änderung des Stammverzeichnisses zu sehen:
vagrant@myhost:~$ sudo chroot alpine sh / $ ls bin etc lib mnt proc run srv tmp var dev home media opt root sbin sys usr / $ whoami root / $ exit vagrant@myhost:~$
Wenn du versuchst, die Shell bash
zu starten, wird sie nicht funktionieren. Das liegt daran, dass die Alpine-Distribution sie nicht enthält, also ist sie im neuen Stammverzeichnis nicht vorhanden. Wenn du das Gleiche mit dem Dateisystem einer Distribution wie Ubuntu versuchst, die bash
enthält, würde es funktionieren.
Zusammenfassend lässt sich sagen, dass chroot
buchstäblich "das Stammverzeichnis" eines Prozesses ändert. Nach der Änderung des Stammverzeichnisses können der Prozess (und seine Kinder) nur noch auf die Dateien und Verzeichnisse zugreifen, die in der Hierarchie niedriger sind als das neue Stammverzeichnis.
Hinweis
Zusätzlich zu chroot
gibt es einen Systemaufruf namens pivot_root
. Für die Zwecke dieses Kapitels ist es ein Detail der Implementierung, ob chroot
oder pivot_root
verwendet wird; der wichtigste Punkt ist, dass ein Container sein eigenes Stammverzeichnis haben muss. Ich habe in diesen Beispielen chroot
verwendet, weil es etwas einfacher und vielen Menschen vertrauter ist.
Es gibt Sicherheitsvorteile, wenn du pivot_root
gegenüber chroot
verwendest. In der Praxis solltest du daher Ersteres finden, wenn du dir den Quellcode einer Container-Laufzeitimplementierung ansiehst. Der Hauptunterschied besteht darin, dass pivot_root
den Mount-Namensraum ausnutzt; die alte Root wird nicht mehr gemountet und ist daher innerhalb dieses Mount-Namensraums nicht mehr zugänglich. Der Systemaufruf chroot
verfolgt diesen Ansatz nicht und lässt das alte Stammverzeichnis über Einhängepunkte zugänglich.
Du hast jetzt gesehen, wie ein Container sein eigenes Root-Dateisystem bekommen kann. Darauf werde ich in Kapitel 6 näher eingehen, aber jetzt wollen wir erst einmal sehen, wie ein eigenes Root-Dateisystem es dem Kernel ermöglicht, einem Container nur eine eingeschränkte Sicht auf die Ressourcen mit Namensräumen zu zeigen.
Kombiniere Namensräume und ändere die Wurzel
Bis jetzt hast du gesehen, dass Namespacing und das Ändern des Stammverzeichnisses zwei verschiedene Dinge sind, aber du kannst beides kombinieren, indem du chroot
in einem neuen Namensraum ausführst:
me@myhost:~$ sudo unshare --pid --fork chroot alpine sh / $ ls bin etc lib mnt proc run srv tmp var dev home media opt root sbin sys usr
Wenn du dich an den Abschnitt "Isolierung von Prozess-IDs" erinnerst, kannst du mit einem eigenen Root-Verzeichnis für den Container ein Verzeichnis /proc
erstellen, das unabhängig von /proc
auf dem Host ist. Damit dieses Verzeichnis mit Prozessinformationen gefüllt werden kann, musst du es als Pseudodateisystem vom Typ proc
mounten. Mit der Kombination aus einem Prozess-ID-Namensraum und einem unabhängigen /proc
Verzeichnis zeigt ps
jetzt nur noch die Prozesse an, die sich im Prozess-ID-Namensraum befinden:
/ $ mount -t proc proc proc / $ ps PID USER TIME COMMAND 1 root 0:00 sh 6 root 0:00 ps / $ exit vagrant@myhost:~$
Erfolg! Es war komplizierter, als den Hostnamen des Containers zu isolieren, aber durch die Kombination aus dem Erstellen eines Prozess-ID-Namensraums, dem Ändern des Root-Verzeichnisses und dem Einbinden eines Pseudodateisystems zur Verarbeitung von Prozessinformationen kannst du einen Container so einschränken, dass er nur seine eigenen Prozesse im Blick hat.
Es gibt noch mehr Namensräume, die du erforschen kannst. Als Nächstes sehen wir uns den Mount-Namensraum an.
Mount Namensraum
Normalerweise willst du nicht, dass ein Container die gleichen Dateisystem-Mounts hat wie sein Host. Indem du dem Container einen eigenen Mount-Namensraum gibst, erreichst du diese Trennung.
Hier ist ein Beispiel, das einen einfachen Bind-Mount für einen Prozess mit eigenem Mount-Namensraum erstellt:
vagrant@myhost:~$ sudo unshare --mount sh $ mkdir source $ touch source/HELLO $ ls source HELLO $ mkdir target $ ls target $ mount --bind source target $ ls target HELLO
Sobald das Bind-Mount eingerichtet ist, ist der Inhalt des Verzeichnisses source
auch unter target
verfügbar. Wenn du dir alle Mounts aus diesem Prozess heraus ansiehst, wird es wahrscheinlich eine Menge davon geben, aber der folgende Befehl findet das Ziel, das du erstellt hast, wenn du dem vorangegangenen Beispiel gefolgt bist:
$ findmnt target TARGET SOURCE FSTYPE OPTIONS /home/vagrant/target /dev/mapper/vagrant--vg-root[/home/vagrant/source] ext4 rw,relatime,errors=remount-ro,data=ordered
Aus der Perspektive des Hosts ist dies nicht sichtbar, was du beweisen kannst, indem du denselben Befehl in einem anderen Terminalfenster ausführst und bestätigst, dass er nichts zurückgibt.
Versuche erneut, findmnt
aus dem Mount-Namensraum heraus aufzurufen, aber diesmal ohne Parameter, und du wirst eine lange Liste erhalten. Du denkst vielleicht, dass es nicht richtig ist, dass ein Container alle Mounts auf dem Host sehen kann. Das ist eine ähnliche Situation wie beim Prozess-ID-Namensraum: Der Kernel verwendet das /proc/<PID>/mounts
Verzeichnis, um Informationen über Einhängepunkte für jeden Prozess zu übermitteln. Wenn du einen Prozess mit einem eigenen Mount-Namensraum anlegst, der aber das Verzeichnis /proc
des Hosts benutzt, wirst du feststellen, dass seine Datei /proc/<PID>/mounts alle bereits existierenden Host-Mounts enthält. (Du kannst diese Datei einfach unter cat
aufrufen, um eine Liste der Mounts zu erhalten).
Um einen vollständig isolierten Satz von Mounts für den containerisierten Prozess zu erhalten, musst du die Erstellung eines neuen Mount-Namensraums mit einem neuen Root-Dateisystem und einem neuen proc
Mount kombinieren, etwa so:
vagrant@myhost:~$ sudo unshare --mount chroot alpine sh / $ mount -t proc proc proc / $ mount proc on /proc type proc (rw,relatime) / $ mkdir source / $ touch source/HELLO / $ mkdir target / $ mount --bind source target / $ mount proc on /proc type proc (rw,relatime) /dev/sda1 on /target type ext4 (rw,relatime,data=ordered)
Da Alpine Linux nicht mit dem Befehl findmnt
ausgeliefert wird, wird in diesem Beispiel mount
ohne Parameter verwendet, um die Liste der Mounts zu erstellen. (Wenn du dieser Änderung skeptisch gegenüberstehst, probiere das frühere Beispiel mit mount
anstelle von findmnt
aus, um zu prüfen, ob du die gleichen Ergebnisse erhältst.)
Du kennst vielleicht das Konzept, Host-Verzeichnisse mit docker run -v <host directory>:<container directory> ...
in einen Container zu mounten. Dazu wird, nachdem das Root-Dateisystem für den Container eingerichtet wurde, das Zielverzeichnis des Containers erstellt und das Quellverzeichnis des Hosts in dieses Ziel eingehängt. Da jeder Container seinen eigenen Mount-Namensraum hat, sind auf diese Weise eingehängte Host-Verzeichnisse für andere Container nicht sichtbar.
Hinweis
Wenn du ein Mount erstellst, das für den Host sichtbar ist, wird es nicht automatisch aufgeräumt, wenn dein "Container"-Prozess beendet wird. Du musst sie mit umount
zerstören. Das gilt auch für die /proc
Pseudodateisysteme. Sie richten zwar keinen besonderen Schaden an, aber wenn du für Ordnung sorgen willst, kannst du sie mit umount proc
entfernen. Das System lässt es nicht zu, dass du das endgültige, vom Host verwendete /proc
aushängst.
Netzwerk-Namensraum
Der Netzwerk-Namensraum ermöglicht es einem Container, seine eigene Ansicht der Netzwerkschnittstellen und Routing-Tabellen zu haben. Wenn du einen Prozess mit einem eigenen Netzwerk-Namensraum erstellst, kannst du ihn mit lsns
einsehen:
vagrant@myhost:~$ sudo lsns -t net NS TYPE NPROCS PID USER NETNSID NSFS COMMAND 4026531992 net 93 1 root unassigned /sbin/init vagrant@myhost:~$ sudo unshare --net bash root@myhost:~$ lsns -t net NS TYPE NPROCS PID USER NETNSID NSFS COMMAND 4026531992 net 92 1 root unassigned /sbin/init 4026532192 net 2 28586 root unassigned bash
Hinweis
Du wirst vielleicht auf den Befehl ip netns
stoßen, aber der ist für uns hier nicht von großem Nutzen. Mit unshare --net
wird ein anonymer Netzwerk-Namensraum erstellt, und anonyme Namensräume erscheinen nicht in der Ausgabe von ip netns list
.
Wenn du einen Prozess in seinen eigenen Netzwerk-Namensraum stellst, beginnt er nur mit der Loopback-Schnittstelle:
vagrant@myhost:~$ sudo unshare --net bash root@myhost:~$ ip a 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
Mit nur einer Loopback-Schnittstelle kann dein Container nicht kommunizieren. Um ihn mit der Außenwelt zu verbinden, erstellst du eine virtuelle Ethernet-Schnittstelle - oder genauer gesagt, zwei virtuelle Ethernet-Schnittstellen. Diese sind sozusagen die beiden Enden eines Kabels, das deinen Container-Namensraum mit dem Standard-Netzwerk-Namensraum verbindet.
In einem zweiten Terminalfenster kannst du als root ein virtuelles Ethernet-Paar erstellen, indem du die anonymen Namensräume angibst, die mit ihren Prozess-IDs verknüpft sind, etwa so:
root@myhost:~$ ip link add ve1 netns 28586 type veth peer name ve2 netns 1
-
ip link add
zeigt an, dass du einen Link hinzufügen möchtest. -
ve1
ist der Name des einen "Endes" des virtuellen Ethernet-"Kabels". -
netns 28586
besagt, dass dieses Ende mit dem Netzwerk-Namensraum "verbunden" ist, der zur Prozess-ID 28586 gehört (die in der Ausgabe vonlsns -t net
im Beispiel am Anfang dieses Abschnitts gezeigt wird). -
type veth
zeigt, dass es sich um ein virtuelles Ethernet-Paar handelt. -
peer name ve2
gibt den Namen des anderen Endes des "Kabels" an. -
netns 1
gibt an, dass dieses zweite Ende mit dem Netzwerk-Namensraum verbunden ist, der zur Prozess-ID 1 gehört.
Die virtuelle Ethernet-Schnittstelle ve1
ist jetzt innerhalb des "Container"-Prozesses sichtbar:
root@myhost:~$ ip a 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: ve1@if3: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group ... link/ether 7a:8a:3f:ba:61:2c brd ff:ff:ff:ff:ff:ff link-netnsid 0
Die Verbindung ist im "DOWN"-Status und muss erst wiederhergestellt werden, bevor sie genutzt werden kann. Beide Enden der Verbindung müssen wiederhergestellt werden.
Rufe die Seite ve2
auf dem Host auf:
root@myhost:~$ ip link set ve2 up
Sobald du das ve1
Ende in den Container bringst, sollte der Link in den Zustand "UP" übergehen:
root@myhost:~$ ip link set ve1 up root@myhost:~$ ip a 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: ve1@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP ... link/ether 7a:8a:3f:ba:61:2c brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet6 fe80::788a:3fff:feba:612c/64 scope link valid_lft forever preferred_lft forever
Um IP-Verkehr zu senden, muss eine IP-Adresse mit der Schnittstelle verbunden sein. Im Container:
root@myhost:~$ ip addr add 192.168.1.100/24 dev ve1
root@myhost:~$ ip addr add 192.168.1.200/24 dev ve1
Dadurch wird auch eine IP-Route in die Routing-Tabelle des Containers aufgenommen:
root@myhost:~$ ip route 192.168.1.0/24 dev ve1 proto kernel scope link src 192.168.1.100
Wie bereits zu Beginn dieses Abschnitts erwähnt, isoliert der Netzwerk-Namensraum sowohl die Schnittstellen als auch die Routing-Tabelle, so dass diese Routing-Informationen unabhängig von der IP-Routing-Tabelle auf dem Host sind. Zu diesem Zeitpunkt kann der Container nur noch Datenverkehr an die Adresse 192.168.1.0/24
senden. Du kannst dies mit einem Ping aus dem Container heraus an die Gegenstelle testen:
root@myhost:~$ ping 192.168.1.100 PING 192.168.1.100 (192.168.1.100) 56(84) bytes of data. 64 bytes from 192.168.1.100: icmp_seq=1 ttl=64 time=0.355 ms 64 bytes from 192.168.1.100: icmp_seq=2 ttl=64 time=0.035 ms ^C
In Kapitel 10 werden wir uns näher mit Netzwerken und der Sicherheit von Containernetzwerken beschäftigen.
Benutzer-Namensraum
Der Benutzer-Namensraum ermöglicht es Prozessen, ihre eigene Ansicht von Benutzer- und Gruppen-IDs zu haben. Ähnlich wie die Prozess-IDs existieren die Benutzer und Gruppen auch auf dem Host, aber sie können unterschiedliche IDs haben. Der Hauptvorteil ist, dass du die Root-ID von 0 innerhalb eines Containers auf eine andere Nicht-Root-Identität auf dem Host abbilden kannst. Das ist aus Sicherheitssicht ein großer Vorteil, denn so kann die Software in einem Container als root laufen, aber ein Angreifer, der aus dem Container auf den Host flieht, hat eine nicht-root Identität, die nicht privilegiert ist. Wie du in Kapitel 9 sehen wirst, ist es nicht schwer, einen Container so zu konfigurieren, dass er leicht auf den Host übertragen werden kann. Mit Namensräumen für Benutzer bist du nicht nur einen falschen Schritt von der Übernahme des Hosts entfernt.
Hinweis
Zum Zeitpunkt der Erstellung dieses Artikels sind Namensräume für Benutzer noch nicht sehr verbreitet. Diese Funktion ist in Docker nicht standardmäßig aktiviert (siehe "Einschränkungen für Benutzernamensräume in Docker") und wird in Kubernetes überhaupt nicht unterstützt, obwohl darüber diskutiert wurde.
Normalerweise musst du root sein, um neue Namensräume zu erstellen. Deshalb läuft der Docker-Daemon auch als root, aber der Benutzer-Namensraum ist eine Ausnahme:
vagrant@myhost:~$ unshare --user bash nobody@myhost:~$ id uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup) nobody@myhost:~$ echo $$ 31196
Innerhalb des neuen Benutzer-Namensraums hat der Benutzer die nobody
ID. Du musst eine Zuordnung zwischen den Benutzer-IDs innerhalb und außerhalb des Namensraums einrichten, wie in Abbildung 4-2 dargestellt.
Diese Zuordnung existiert in /proc/<pid>/uid_map
, die du als root (auf dem Host) bearbeiten kannst. In dieser Datei gibt es drei Felder:
-
Die niedrigste zuzuordnende ID aus Sicht des Kindprozesses
-
Die niedrigste korrespondierende ID, die auf dem Host abgebildet werden soll
-
Die Anzahl der zuzuordnenden IDs
Ein Beispiel: Auf meinem Rechner hat der Benutzer vagrant
die ID 1000. Damit vagrant
im Kindprozess die Stamm-ID 0 zugewiesen bekommt, sind die ersten beiden Felder 0 und 1000. Das letzte Feld kann 1 sein, wenn du nur eine ID zuordnen willst (was durchaus der Fall sein kann, wenn du nur einen Benutzer im Container haben willst). Hier ist der Befehl, mit dem ich diese Zuordnung eingerichtet habe:
vagrant@myhost:~$ sudo echo '0 1000 1' > /proc/31196/uid_map
Innerhalb seines Benutzer-Namensraums hat der Prozess sofort die Root-Identität angenommen. Lass dich nicht von der Tatsache abschrecken, dass in der Eingabeaufforderung der Bash immer noch "nobody" steht; dies wird nur dann aktualisiert, wenn du die Skripte, die beim Starten einer neuen Shell ausgeführt werden, erneut ausführst (z. B. ~/.bash_profile
):
nobody@myhost:~$ id uid=0(root) gid=65534(nogroup) groups=65534(nogroup)
Ein ähnlicher Zuordnungsprozess wird verwendet, um die Gruppe(n) zuzuordnen, die innerhalb des Kindprozesses verwendet werden.
Dieser Prozess läuft jetzt mit einer großen Anzahl von Fähigkeiten:
nobody@myhost:~$ capsh --print | grep Current Current: = cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid, cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable, cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock, cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace, cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource, cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write, cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog, cap_wake_alarm,cap_block_suspend,cap_audit_read+ep
Wie du in Kapitel 2 gesehen hast, gewähren Fähigkeiten dem Prozess verschiedene Berechtigungen. Wenn du einen neuen Benutzer-Namensraum erstellst, gibt der Kernel dem Prozess all diese Fähigkeiten, so dass der Pseudo-Root-Benutzer innerhalb des Namensraums andere Namensräume erstellen, Netzwerke einrichten und so weiter darf und alles erfüllt, was nötig ist, um ihn zu einem echten Container zu machen.
Wenn du einen Prozess mit mehreren neuen Namensräumen gleichzeitig anlegst, wird der Benutzer-Namensraum zuerst angelegt, damit du über alle Fähigkeiten verfügst, die es dir ermöglichen, andere Namensräume zu erstellen:
vagrant@myhost:~$ unshare --uts bash unshare: unshare failed: Operation not permitted vagrant@myhost:~$ unshare --uts --user bash nobody@myhost:~$
Mit Hilfe von Namensräumen kann ein unprivilegierter Benutzer zum Root des Containerprozesses werden. Dadurch kann ein normaler Benutzer Container mit einem Konzept namens " Rootless Container" ausführen, das wir in Kapitel 9 behandeln werden.
Der allgemeine Konsens ist, dass Benutzer-Namensräume ein Sicherheitsvorteil sind, weil weniger Container als "echter" Root laufen müssen (d.h. als Root aus der Sicht des Benutzers). Es gab jedoch einige Schwachstellen (z. B. CVE-2018-18955), die direkt damit zusammenhängen, dass Privilegien beim Wechsel in oder aus einem Benutzernamensraum falsch umgewandelt wurden. Der Linux-Kernel ist ein komplexes Stück Software und du solltest damit rechnen, dass Menschen von Zeit zu Zeit Probleme darin finden werden.
Benutzer-Namensraum-Einschränkungen in Docker
Du kannst die Verwendung von Namensräumen in Docker aktivieren, aber sie ist nicht standardmäßig aktiviert, weil sie mit einigen Dingen, die Docker-Benutzer tun möchten, nicht kompatibel ist.
Das Folgende betrifft dich auch, wenn du Namensräume mit anderen Container-Laufzeiten verwendest:
-
Benutzer-Namensräume sind nicht mit der gemeinsamen Nutzung einer Prozess-ID oder eines Netzwerk-Namensraums mit dem Host kompatibel.
-
Auch wenn der Prozess innerhalb des Containers als root läuft, hat er nicht wirklich die vollen root-Rechte. Er hat z. B. nicht
CAP_NET_BIND_SERVICE
und kann sich daher nicht an einen Port mit niedriger Nummer binden. (Weitere Informationen zu den Linux-Fähigkeiten findest du in Kapitel 2 ). -
Wenn der containerisierte Prozess mit einer Datei interagiert, benötigt er die entsprechenden Berechtigungen (z. B. Schreibrechte, um die Datei zu ändern). Wenn die Datei vom Host gemountet wird, ist die effektive Benutzer-ID auf dem Host ausschlaggebend.
Das ist gut, um die Hostdateien vor unbefugtem Zugriff aus einem Container heraus zu schützen, aber es kann verwirrend sein, wenn z.B. der vermeintliche root innerhalb des Containers eine Datei nicht ändern darf.
Namensräume für prozessübergreifende Kommunikation
In Linux ist es möglich, zwischen verschiedenen Prozessen zu kommunizieren, indem man ihnen Zugriff auf einen gemeinsamen Speicherbereich gibt oder eine gemeinsame Nachrichtenwarteschlange verwendet. Die beiden Prozesse müssen Mitglieder desselben IPC-Namensraums sein, damit sie auf dieselben Bezeichner für diese Mechanismen zugreifen können.
In der Regel willst du nicht, dass deine Container gegenseitig auf den gemeinsamen Speicher zugreifen können, deshalb bekommen sie eigene IPC Namensräume.
Du kannst dies in Aktion sehen, indem du einen Shared-Memory-Block erstellst und dir dann den aktuellen IPC-Status mit ipcs
ansiehst:
$ ipcmk -M 1000 Shared memory id: 98307 $ ipcs ------ Message Queues -------- key msqid owner perms used-bytes messages ------ Shared Memory Segments -------- key shmid owner perms bytes nattch status 0x00000000 0 root 644 80 2 0x00000000 32769 root 644 16384 2 0x00000000 65538 root 644 280 2 0xad291bee 98307 ubuntu 644 1000 0 ------ Semaphore Arrays -------- key semid owner perms nsems 0x000000a7 0 root 600 1
In diesem Beispiel erscheint der neu erstellte gemeinsame Speicherblock (mit seiner ID in der Spalte shmid
) als letztes Element im Block "Gemeinsame Speichersegmente". Es gibt auch einige bereits existierende IPC-Objekte, die zuvor von root erstellt wurden.
Ein Prozess mit eigenem IPC-Namensraum sieht keines dieser IPC-Objekte:
$ sudo unshare --ipc sh $ ipcs ------ Message Queues -------- key msqid owner perms used-bytes messages ------ Shared Memory Segments -------- key shmid owner perms bytes nattch status ------ Semaphore Arrays -------- key semid owner perms nsems
Cgroup Namensraum
Der letzte der Namensräume (zumindest zum Zeitpunkt der Erstellung dieses Buches) ist der cgroup-Namensraum. Er ist so etwas wie ein Chroot für das cgroup-Dateisystem; er verhindert, dass ein Prozess die cgroup-Konfiguration sieht, die in der Hierarchie der cgroup-Verzeichnisse weiter oben liegt als seine eigene cgroup.
Hinweis
Die meisten Namensräume wurden mit der Linux-Kernel-Version 3.8 hinzugefügt, der cgroup-Namensraum jedoch erst mit Version 4.6. Wenn du eine relativ alte Linux-Distribution verwendest (z. B. Ubuntu 16.04), hast du keine Unterstützung für diese Funktion. Du kannst die Kernelversion auf deinem Linux-Host überprüfen, indem du uname -r
aufrufst.
Du kannst den cgroup-Namensraum in Aktion sehen, indem du den Inhalt von /proc/self/cgroup
außerhalb und dann innerhalb eines cgroup-Namensraumes vergleichst:
vagrant@myhost:~$ cat /proc/self/cgroup 12:cpu,cpuacct:/ 11:cpuset:/ 10:hugetlb:/ 9:blkio:/ 8:memory:/user.slice/user-1000.slice/session-51.scope 7:pids:/user.slice/user-1000.slice/session-51.scope 6:freezer:/ 5:devices:/user.slice 4:net_cls,net_prio:/ 3:rdma:/ 2:perf_event:/ 1:name=systemd:/user.slice/user-1000.slice/session-51.scope 0::/user.slice/user-1000.slice/session-51.scope vagrant@myhost:~$ vagrant@myhost:~$ sudo unshare --cgroup bash root@myhost:~# cat /proc/self/cgroup 12:cpu,cpuacct:/ 11:cpuset:/ 10:hugetlb:/ 9:blkio:/ 8:memory:/ 7:pids:/ 6:freezer:/ 5:devices:/ 4:net_cls,net_prio:/ 3:rdma:/ 2:perf_event:/ 1:name=systemd:/ 0::/
Du hast nun die verschiedenen Arten von Namensräumen kennengelernt und gesehen, wie sie zusammen mit chroot
verwendet werden, um die Sicht eines Prozesses auf seine Umgebung zu isolieren. Kombiniere dies mit dem, was du im vorherigen Kapitel über cgroups gelernt hast, und du solltest ein gutes Verständnis von allem haben, was man braucht, um einen sogenannten"Container" zu erstellen.
Bevor wir mit dem nächsten Kapitel weitermachen, lohnt es sich, einen Container aus der Perspektive des Hosts zu betrachten, auf dem er läuft.
Container-Prozesse aus der Host-Perspektive
Obwohl sie als Container bezeichnet werden, ist es vielleicht genauer, den Begriff "containerisierte Prozesse" zu verwenden. Ein Container ist immer noch ein Linux-Prozess, der auf dem Host-Rechner läuft, aber er hat nur eine eingeschränkte Sicht auf den Host-Rechner und kann nur auf einen Teilbaum des Dateisystems und vielleicht auf eine begrenzte Anzahl von Ressourcen zugreifen, die durch cgroups eingeschränkt sind. Da er eigentlich nur ein Prozess ist, existiert er im Kontext des Host-Betriebssystems und nutzt den Kernel des Hosts, wie in Abbildung 4-3 dargestellt.
Wie das im Vergleich zu virtuellen Maschinen aussieht, erfährst du im nächsten Kapitel. Vorher wollen wir aber noch genauer untersuchen, inwieweit ein containerisierter Prozess vom Host und von anderen containerisierten Prozessen auf diesem Host isoliert ist, indem wir einige Experimente mit einem Docker-Container durchführen. Starte einen Containerprozess auf Basis von Ubuntu (oder deiner Lieblings-Linux-Distribution) und führe darin eine Shell aus, in der du dann eine lange sleep
wie folgt ausführst:
$ docker run --rm -it ubuntu bash root@1551d24a $ sleep 1000
In diesem Beispiel wird der Befehl sleep
für 1.000 Sekunden ausgeführt. Beachte aber, dass der Befehl sleep
als Prozess innerhalb des Containers läuft. Wenn du am Ende des Befehls sleep
die Eingabetaste drückst, veranlasst dies Linux, einen neuen Prozess mit einer neuen Prozess-ID zu klonen und die ausführbare Datei sleep
innerhalb dieses Prozesses auszuführen.
Du kannst den Schlafprozess in den Hintergrund stellen (Ctrl-Z
, um den Prozess anzuhalten, und bg %1
, um ihn in den Hintergrund zu stellen). Führe nun ps
innerhalb des Containers aus, um denselben Prozess aus der Perspektive des Containers zu sehen:
me@myhost:~$ docker run --rm -it ubuntu bash root@ab6ea36fce8e:/$ sleep 1000 ^Z [1]+ Stopped sleep 1000 root@ab6ea36fce8e:/$ bg %1 [1]+ sleep 1000 & root@ab6ea36fce8e:/$ ps PID TTY TIME CMD 1 pts/0 00:00:00 bash 10 pts/0 00:00:00 sleep 11 pts/0 00:00:00 ps root@ab6ea36fce8e:/$
Während der Befehl sleep
noch läuft, öffne ein zweites Terminal auf demselben Host und betrachte denselben Schlafprozess aus der Perspektive des Hosts:
me@myhost:~$ ps -C sleep PID TTY TIME CMD 30591 pts/0 00:00:00 sleep
Der Parameter -C sleep
legt fest, dass wir nur an Prozessen interessiert sind, die die ausführbare Datei sleep
ausführen.
Der Container hat seinen eigenen Prozess-ID-Namensraum, daher ist es sinnvoll, dass seine Prozesse niedrige Nummern haben, und das sieht man auch, wenn man ps
im Container ausführt. Aus der Sicht des Hosts hat der Schlafprozess jedoch eine andere, höhere Prozess-ID. Im obigen Beispiel gibt es nur einen Prozess mit der ID 30591 auf dem Host und 10 im Container. (Die tatsächliche Zahl hängt davon ab, was sonst noch auf demselben Rechner läuft und gelaufen ist, aber wahrscheinlich ist es eine viel höhere Zahl).
Um Container und den Grad der Isolation, den sie bieten, richtig zu verstehen, ist es wichtig, die Tatsache zu begreifen, dass es zwar zwei verschiedene Prozess-IDs gibt, diese sich aber beide auf denselben Prozess beziehen. Aus der Sicht des Hosts hat er nur eine höhere Prozess-ID-Nummer.
Die Tatsache, dass Containerprozesse vom Host aus sichtbar sind, ist einer der grundlegenden Unterschiede zwischen Containern und virtuellen Maschinen. Ein Angreifer, der sich Zugang zum Host verschafft, kann alle Container, die auf diesem Host laufen, beobachten und beeinflussen, vor allem, wenn er Root-Zugriff hat. Und wie du in Kapitel 9 sehen wirst, gibt es einige bemerkenswert einfache Möglichkeiten, wie du es einem Angreifer versehentlich ermöglichen kannst, von einem kompromittierten Container auf den Host zu gelangen.
Container-Host-Maschinen
Wie du gesehen hast, teilen sich Container und ihr Host einen Kernel, und das hat einige Konsequenzen für die bewährten Methoden in Bezug auf die Host-Maschinen für Container. Wenn ein Host kompromittiert wird, sind alle Container auf diesem Host ein potenzielles Opfer, vor allem wenn der Angreifer Root-Rechte oder anderweitig erhöhte Privilegien erlangt (z. B. als Mitglied der Gruppe docker
, die Container verwalten kann, bei denen Docker als Laufzeitsystem verwendet wird).
Es wird dringend empfohlen, Container-Anwendungen auf dedizierten Host-Maschinen auszuführen (egal ob es sich um VMs oder Bare Metal handelt):
-
Der Einsatz eines Orchestrators für den Betrieb von Containern bedeutet, dass Menschen nur wenig oder gar keinen Zugang zu den Hosts benötigen. Wenn du keine anderen Anwendungen betreibst, brauchst du nur eine kleine Anzahl von Benutzeridentitäten auf den Host-Rechnern. Diese sind leichter zu verwalten, und Versuche, sich als unbefugter Benutzer anzumelden, sind leichter zu erkennen.
-
Du kannst jede Linux Distribution als Host-Betriebssystem für den Betrieb von Linux-Containern verwenden, aber es gibt mehrere "Thin OS"-Distributionen, die speziell für den Betrieb von Containern entwickelt wurden. Diese reduzieren die Angriffsfläche für den Host, indem sie nur die für den Betrieb von Containern erforderlichen Komponenten enthalten. Beispiele hierfür sind RancherOS, Fedora CoreOS von Red Hat und Photon OS von VMware. Je weniger Komponenten auf dem Host-Rechner enthalten sind, desto geringer ist die Wahrscheinlichkeit, dass diese Komponenten Schwachstellen aufweisen (siehe Kapitel 7).
-
Alle Hostcomputer in einem Cluster können dieselbe Konfiguration nutzen, ohne dass anwendungsspezifische Anforderungen bestehen. Das macht es einfach, die Bereitstellung von Host-Maschinen zu automatisieren, und bedeutet, dass du Host-Maschinen als unveränderlich behandeln kannst. Wenn ein Host-Rechner ein Upgrade benötigt, musst du ihn nicht patchen, sondern du entfernst ihn aus dem Cluster und ersetzt ihn durch einen frisch installierten Rechner. Wenn du Hosts als unveränderlich behandelst, lassen sich Einbrüche leichter erkennen.
Auf die Vorteile der Unveränderlichkeit werde ich in Kapitel 6 zurückkommen.
Die Verwendung eines Thin OS reduziert die Anzahl der Konfigurationsoptionen, schließt sie aber nicht vollständig aus. Du hast zum Beispiel eine Container-Laufzeitumgebung (z.B. Docker) und einen Orchestrator-Code (z.B. das Kubernetes-Kubelet), die auf jedem Host laufen. Diese Komponenten haben zahlreiche Einstellungen, von denen einige die Sicherheit beeinflussen. Das Center for Internet Security (CIS) veröffentlicht Benchmarks für bewährte Methoden zur Konfiguration und zum Betrieb verschiedener Softwarekomponenten, darunter Docker, Kubernetes und Linux.
In einer Unternehmensumgebung solltest du nach einer Container-Sicherheitslösung suchen, die auch die Hosts schützt, indem sie über Schwachstellen und bedenkliche Konfigurationseinstellungen berichtet. Du wirst auch Protokolle und Warnungen für Anmeldungen und Anmeldeversuche auf Host-Ebene benötigen.
Zusammenfassung
Herzlichen Glückwunsch! Wenn du das Ende dieses Kapitels erreicht hast, solltest du jetzt wissen, was ein Container wirklich ist. Du hast die drei wichtigsten Mechanismen des Linux-Kernels kennengelernt, mit denen der Zugriff eines Prozesses auf Host-Ressourcen eingeschränkt wird:
-
Namensräume schränken ein, was der Containerprozess sehen kann - zum Beispiel, indem sie dem Container einen isolierten Satz von Prozess-IDs geben.
-
Die Änderung des Stammverzeichnisses schränkt die Anzahl der Dateien und Verzeichnisse ein, die der Container sehen kann.
-
C-Gruppen kontrollieren die Ressourcen, auf die der Container zugreifen kann.
Wie du in Kapitel 1 gesehen hast, ist die Isolierung eines Workloads von einem anderen ein wichtiger Aspekt der Container-Sicherheit. Du solltest dir jetzt darüber im Klaren sein, dass alle Container auf einem bestimmten Host (egal ob es sich um eine virtuelle Maschine oder einen Bare-Metal-Server handelt) denselben Kernel nutzen. Dasselbe gilt natürlich auch für ein Mehrbenutzersystem, in dem sich verschiedene Benutzer/innen auf demselben Rechner anmelden und Anwendungen direkt ausführen können. In einem Mehrbenutzersystem werden die Administratoren jedoch wahrscheinlich die Rechte der einzelnen Benutzer/innen einschränken; sie werden ihnen sicher nicht alle Root-Rechte geben. Bei Containern - zumindest zum Zeitpunkt der Erstellung dieses Artikels - laufen alle standardmäßig als Root und verlassen sich auf die Abgrenzung durch Namensräume, geänderte Root-Verzeichnisse und cgroups, um zu verhindern, dass ein Container einen anderen beeinträchtigt.
Hinweis
Jetzt, wo du weißt, wie Container funktionieren, solltest du die Website contained.af von Jess Frazelle besuchen, um zu sehen, wie effektiv sie sind. Wirst du die Person sein, die das Containment durchbricht?
In Kapitel 8 werden wir uns mit den Möglichkeiten zur Verstärkung der Sicherheitsgrenze um jeden Container herum befassen, aber als Nächstes wollen wir uns ansehen, wie virtuelle Maschinen funktionieren. So kannst du die relativen Stärken der Isolation zwischen Containern und zwischen VMs betrachten, insbesondere unter dem Aspekt der Sicherheit.
Get Container Sicherheit now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.