BlogWissenwertesDocker: Starten von Programmen unter Linux – Teil 1

Docker: Starten von Programmen unter Linux – Teil 1

Ein Beitrag von Benjamin Breitenstein

Linux-Kernel und Betriebssysteme

Im Gegensatz zu Windows, den BSD-Varianten und/oder MacOS ist Linux kein Betriebssystem, sondern nur der Kernel, also die zentrale Komponente eines Betriebssystems. Neben dem Kernel gehören zu einem Betriebssystem aber noch andere wichtige Komponenten, die das Gesamtsystem erst lauffähig machen, wie beispielsweise die Gerätetreiber. Aufgrund des monolithischen Designs von Linux befinden sich viele Treiber aber bereits im Kernel.

Andere Betriebssysteme haben hingegen eine klarere Trennung von Treibern und Kernel, da die Treiber eigentlich von den Geräteherstellern (Hardware Vendors) zur Verfügung gestellt werden müssten – denn nur die Hersteller wissen ja, wie ihre Hardware funktioniert, welche Funktionen sie bereitstellt und wie sie im Detail “angesprochen“ werden muss.

Was genau ist also der Unterschied zu einem kompletten Betriebssystem?

Ein komplettes Betriebssystem, im Falle von Linux auch Distribution genannt, ergänzt den Kernel um das sogenannte User Land, also alle Funktionalitäten, die man aus Sicht des Benutzers braucht. Dazu gehören unter anderem:

  • System-Daemons (systemd) und Services wie die Uhrzeit-Synchronisation (chrony), crontab, uvm.
  • die Shell, also die Kommandozeile, in der man das System bedienen kann (bash, ash, etc.)
  • optional die grafische Benutzeroberfläche (graphical user interface – GUI) inklusive der Wayland/X-Komponenten. Beispiele dafür sind u. a. Gnome, KDE, Mate, Cinnamon
  • ein Package-Manager für das Updaten des Systems (yum, apt, rpm, apk, etc.)
  • eine SSL-Library zum Verschlüsseln der Netzwerkkommunikation (OpenSSL, BorinSSL, LibreSSL, etc.) inklusive der benötigten Root-Zertifikate
  • eine C-Library, damit in C geschriebene und dynamisch gelinkte Programme lauffähig sind (GLIBC, musl, Bionic, etc.)
  • alle anderen Tools und Bedienhilfen (cat, netstat, vi, awk, etc.) oder eine entsprechende Tool-Sammlung wie BusyBox oder ToyBox

Aus den Komponenten kann jede Distribution frei wählen, sprich die Linux-basierten Betriebssysteme sind alle verschieden. Das bedeutet, dass eine Distribution eine Zusammenstellung aus vielerlei Programmen ist: Anwendungen, Bibliotheken, Extras für Entwickler und manchmal sogar Spielen.

Es wird daher auch von GNU/Linux gesprochen, da das User Land oft (aber nicht immer) auf den GNU-Komponenten basiert oder zumindest solche enthält.

Da hier also ein Sammelsurium entsteht und es unklar bleibt, was denn nun zu einem Betriebssystem gehört, wurde versucht, Standards für die Kategorisierung zu erstellen. Dies sind zum Beispiel POSIX oder die Linux Standard Base (LSB), an die sich die Distributions-Hersteller halten – oder eben auch nicht.

Nebenbei:

Unter Windows ist relativ klar definiert, was Teil des Betriebssystems ist und was nicht (wobei sich das zwischen Windows-Versionen natürlich unterscheiden kann). Entwickler können sich somit einigermaßen darauf verlassen, dass bestimmte Funktionalitäten und Libraries immer verfügbar sind.

Laden eines Programms – das ELF-File-Format

Was genau passiert beim Starten eines Programms?

Unter Linux wird zum Speichern des Programms das ELF-Format verwendet. Es besteht aus mehreren Segmenten, wovon die bedeutendsten für diesen Artikel folgende sind:

  • Dynamic Segment: zeigt Libraries, die dynamisch nachgeladen werden
  • Interpreter Segment: enthält den Pfad zum Loader
  • Exported Functions: Funktionen, die von anderen Libraries oder Programmen aufgerufen werden können
  • Relocation Table: Informationen zum Laden in den Speicher. Tabelle mit Adressen, die relativ zum Programm-Anfang abgespeichert sind

Eine detaillierte Übersicht findet sich in der Definition des ELF-Formats (https://www.man7.org/linux/man-pages/man5/elf.5.html) oder auch auf Wikipedia (https://en.wikipedia.org/wiki/Executable_and_Linkable_Format).

Um beispielhaft eine solche Datei anzuschauen, kann das Programm „readelf“ verwendet werden, wozu ggf. die „binutils“ nachinstalliert werden müssen:

Grafik 1 1
Grafik 3 1
Grafik 4 1

Alternativ zeigt das Programm ldd alle Abhängigkeiten rekursiv an.

(!) Achtung: Dies sollte nur auf Dateien ausgeführt werden, denen man vertraut – eine speziell angefertigte Datei/Malware könnte sonst ggf. beliebigen Code ausführen.

Grafik x 2

Im ersten Schritt wird das Programm vom Betriebssystem in den Speicher geladen. Wie das genau funktioniert, hängt von der Datei ab. In diesem Szenario wird davon ausgegangen, dass es sich um ein normales Programm im ELF-Format handelt.

Mehr Details dazu und wie man Linux dazu bringen kann, eigene Dateiformate zu unterstützen, kann in folgendem Blog von Cloudflare nachgelesen werden: https://blog.cloudflare.com/using-go-as-a-scripting-language-in-linux/

Wenn das Programm so kompiliert wurde, dass dazu ein Loader (bzw. Interpreter) benötigt wird, so ist dieser im INTERP Segment angegeben und wird vom Betriebssystem gestartet. Er bekommt die aktuelle Datei als Argument und übernimmt die weiteren Schritte. Wichtig dabei ist, dass verschiedene Distributionen auch verschiedene Loader mitbringen. Das heißt konkret: Ist das INTERP Segment vorhanden und gefüllt, ist das Programm plattformabhängig, bzw. abhängig von einer bestimmten Distribution. Siehe weitere Informationen hier: https://www.man7.org/linux/man-pages/man8/ld.so.8.html.

Nach dem Laden der Datei in den Speicher werden die Adressen dort gepatched. Das ist notwendig, weil das Programm aus Sicherheitsgründen bei jedem Start in einen anderen zufälligen Speicherbereich geladen wird. Dadurch kann ein Angreifer nicht direkt wissen, wo sich welche Funktion im Speicher befindet und ein Angriff wird erschwert. Dies wird Address Space Layout Randomization (ASLR) genannt und ist mittlerweile für fas alle Programme der Standard, wie z. B. auch bei anderen Betriebssystemen wie Windows.

Woher weiß das Programm jetzt aber, wo beispielsweise eine Funktion im Speicher liegt?

In der ELF-Datei werden dazu relative virtuelle Adressen (RVA) benutzt, d.h. alle Adressen zu Konstanten und Funktionen werden als Offset zum Start der Datei angegeben. In der Relocation Table ist vermerkt, bei welchen Teilen der ELF-Datei es sich um solche handelt.

Nach dem Laden der Datei in den Speicher werden genau diese vom Loader korrigiert. Der Loader lädt dann die Libraries nach, die im Dynamic Segment angegeben sind. Diese beinhalten normalerweise Versionsnummern und machen das Programm dann ebenfalls plattform- bzw. distributionsabhängig. Dieser Ablauf wird dann rekursiv wiederholt, bis das Programm und alle Abhängigkeiten geladen sind.

Interessanterweise können so die Abhängigkeiten auch zwischen verschiedenen Programmen geteilt werden – ist ein Shared Object bereits geladen, wird es einfach wiederverwendet. Damit benötigen die Programme, die sich Abhängigkeiten teilen, weniger RAM (Random-Access Memory).

Alle Programme können allerdings auch statisch gelinkt werden. Dann beinhalten sie kein Dynamic Segment und auch keinen Interpreter, sodass sie distributions-unabhängig sind, keinerlei Abhängigkeiten haben und sie einfach zwischen verschiedenen Linux-Varianten hin- und herkopiert werden können.

Grafik 5 2

Statisches Linken hat aber Vor- und Nachteile, wie nachfolgend beschrieben wird.

Vorteil ist die Unabhängigkeit bezüglich der externen Libraries und des Loaders. Weiterhin werden aus den statischen Abhängigkeiten mittels Link Time Optimization (LTO) nur die Teile der abhängigen Libraries in das Programm eingebunden, die wirklich verwendet werden. Das minimiert die Angriffsfläche der Applikation.

Nachteil ist jedoch, dass bei vielen Abhängigkeiten auch entsprechend viele Sicherheitslücken entdeckt werden können. Bei externen Libraries können diese unabhängig vom Programm gepatched werden. Bei statisch gelinkten Programmen muss der Hersteller jeweils einen Patch liefern, sobald eine abhängige Library eine Sicherheitslücke aufweist – das kann vergleichsweise lange dauern. Eigene Programme müssen dann jeweils neu gebaut und deployed werden. 

Zusammenfassung – Und was hat das alles mit Containern zu tun?

Ein Programm wird unter Linux meist im ELF Format gespeichert. Es kann verschiedene Abhängigkeiten beinhalten, meistens sind dies folgende:

  • der Loader / dynamischen Linker (z. B. /lib64/ld-linux-x86-64.so.2 oder /usr/lib/ld-musl-x86_64.so.1)
  • verschiedene Libraries / Shared Objects (z. B. /lib64/libc.so.6)

Diese machen das Programm abhängig von der jeweiligen Distribution bzw. dem jeweiligen User Land.

Ein leerer (Scratch/Distroless) Container kann lediglich mit dem Kernel kommunizieren und enthält keinerlei User Land. Wenn nun ein Programm im Container gestartet werden soll, muss sichergegangen werden, dass alle Abhängigkeiten aufgelöst werden können, da sonst das Programm nicht lauffähig ist. Hier gibt es nun zwei Möglichkeiten: Erstes, es wird ein statisch gelinktes Programm genommen, welches ggf. dann selbst statisch kompiliert werden muss. Oder zweites, es werden alle Abhängigkeiten mit in den Container gepackt.

Dabei ist zu beachten, dass der Container und auch die Angriffsfläche größer wird, je mehr vom User Land dort hineinpackt wird. Besonders klein ist beispielsweise ein Alpine Image, da hier das User Land absichtlich reduziert wurde. Auch viele andere Distributionen bieten spezielle Images an, bei denen das User Land minimiert wurde.

Alternativ kann das Programm statisch kompiliert werden, sodass das Image den wenigsten Speicherbedarf hat. Dies beschleunigt außerdem z. B. das Laden des Images in einer Kubernetes-Umgebung und bietet darüber hinaus die geringste Angriffsfläche. Ein Nachteil könnte hier jedoch sein, dass dann wiederum voraussichtlich öfter gepatched bzw. das Image neu aufgebaut und deployed werden muss.



Schreiben Sie einen Kommentar

Ihre E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert