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.

  • Prozess-IDs

  • Punkte montieren

  • Netzwerk

  • Benutzer- und Gruppen-IDs

  • Kommunikation zwischen Prozessen (IPC)

  • Kontrollgruppen (cGruppen)

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 chrootlautet wie folgt: "Führe COMMAND mit dem Stammverzeichnis NEWROOT aus. [...] Wenn kein Befehl angegeben wird, führe ${SHELL} -i aus (Standard: /bin/sh -i)."

Changing root so a process sees only a subset of the filesystem
Abbildung 4-1. Root ändern, damit ein Prozess nur eine Teilmenge des Dateisystems sieht

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 von lsns -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

Und über den Gastgeber:

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.

Mapping a non-root user on the host to root in a container
Abbildung 4-2. Zuordnung eines Nicht-Root-Benutzers auf dem Host zu Root in einem Container

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.

Containers share the host's kernel
Abbildung 4-3. Container teilen sich den Kernel des Hosts

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.