Kernel-Privilege-Eskalation: Wie sich die Kubernetes-Container-Isolierung auf Privilegieneskalationsangriffe auswirkt
Kamil Potrec
3. Dezember 2020
0 Min. LesezeitTagsüber analysiere ich Terraform-Code und Kubernetes-Objektkonfigurationsdateien und identifiziere gängige Sicherheitsprobleme. Wenn die Sonne untergeht, ziehe ich meinen Hoodie an, starte Linux-VMs und Debugger und sehe mir die Technologien genauer an, aus denen das Cloud-native-Ökosystem besteht.
In diesem Beitrag untersuchen wir, wie sich die Kubernetes-Container-Isolierung auf Privilegieneskalationsangriffe auswirkt. Anhand gängiger Kernel-Exploitation-Techniken finden wir heraus, wie Container-Abstraktionsschichten uns den Weg zur begehrten Root-Shell erschweren können.
Was ist eine Privilegieneskalation?
Privilegieneskalation bezeichnet den Vorgang, sich weitergehende Berechtigungen für eine Ressource zu verschaffen. Eine Kernel-Privilegieneskalation ist ein Vorgang, bei dem diese Berechtigungen durch Ausnutzung einer Schwachstelle in einem von vielen Kernel-Einstiegspunkten erlangt werden, die auch als Angriffsvektoren bezeichnet werden. Ein Angriffsvektor ist schlicht ein Pfad, über den auf den anfälligen Code zugegriffen werden kann.
Wir interagieren auf vielfältige Weise mit dem Kernel: indem wir das Dateisystem lesen, eine Gerätedatei öffnen, Systemaufrufe ausführen oder ein Paket über die Netzwerkschnittstelle senden. All diese Aktionen erfordern, dass im Kernel-Space ein Vorgang ausgeführt wird. Führt der Kernel eine Aktion im Auftrag eines Benutzerprozesses aus, sagen wir, dass er sich in einem Prozesskontext befindet. Jeder Prozess wird im Kernel durch eine struct task_struct-Struktur dargestellt. Diese sind in einer zirkulären, doppelt verketteten Liste gespeichert und werden über PER_CPU-Variablen auf der x86-64-Architektur abgerufen, wenn ein Kontextwechsel vom User-Space in den Kernel-Space erfolgt.
Eine task_struct enthält ein Element vom Typ struct creds, das die Benutzerkennung und die dem Prozess zugeordneten Capabilities speichert. Anhand dieser Informationen entscheidet der Kernel, ob ein Prozess eine Aktion ausführen darf, zum Beispiel einen bestimmten Systemaufruf. Bei einer Kernel-Privilegieneskalation besteht das allgemeine Ziel darin, die Credentials-Struktur zu ersetzen oder zu aktualisieren, um weitergehende Berechtigungen zu erhalten.
Wie funktioniert eine Privilegieneskalation?
Die gängigste Technik, um erhöhte Berechtigungen im Kernel-Space zu erlangen, ist die Kombination der Kernel-Funktionen [commit_creds](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L437)([prepare_kernel_cred(0)](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L682)). Das ist erst möglich, wenn ein Exploit die Kontrolle über einen Instruction Pointer (RIP) erlangt und die Schutzmechanismen für Speicherzugriff und Randomisierung erfolgreich umgangen hat. Mit [prepare_kernel_cred](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L682) lässt sich ein Credentials-Objekt auf Grundlage eines bestehenden Objekts erzeugen oder, großzügiger gesagt, ein Standardobjekt mit vollständigen Root-Berechtigungen generieren. [commit_creds](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L437) aktualisiert einfach die task_struct des aktuellen Prozesses mit dem neuen Credentials-Objekt.
Kernel-Exploitation ist ein sehr umfangreiches Gebiet. Deshalb betrachten wir in diesem Blogbeitrag nur eine stark vereinfachte Form der Kernel-Privilegieneskalation. Der Kernel verfügt über zahlreiche Sicherheitsmechanismen, die Exploits erschweren sollen. SMEP, SMAP, KASLR und KPTI sind Mechanismen, die in Hardware oder im Kernel implementiert sind. Ob sie aktiviert oder deaktiviert sind, hängt von der verwendeten Distribution oder der Systemadministration ab. Diese Einstellungen lassen sich nicht direkt über Kubernetes steuern und sind daher nicht Gegenstand dieses Beitrags.
Wir verwenden eine ältere Schwachstelle in der Implementierung von af_packet, für die CVE-2017-7308 vergeben wurde. Die Schwachstelle lässt sich mit der Capability CAP_NET_RAW ausnutzen, da dafür Zugriff auf Raw-Sockets erforderlich ist. Die Schwachstelle wird hier ausführlich beschrieben, daher gehen wir nicht näher darauf ein. Alle erforderlichen Capabilities können wir in einem unprivilegierten User-Namespace erlangen. In der Ubuntu-Distribution ist der Zugriff auf User-Namespaces standardmäßig not eingeschränkt.
Legen wir los
Sehen wir uns zunächst den durchgängigen Ablauf in einer Umgebung ohne Container an.
Wir müssen den GNU Debugger (gdb) mit dem VM-Stub verbinden. Sobald der Debugger verbunden ist, können wir an einer geeigneten Stelle einen Breakpoint setzen. In diesem Fall verwenden wir einen mlock-Systemaufruf, den wir im Exploit manuell auslösen können, wann immer wir den internen Zustand des laufenden Prozesses untersuchen möchten. Beachten Sie, dass gdb nur dann anhält, wenn der Name des ausgeführten Prozesses „exploit“ lautet. So verringern wir das Risiko, dass ein anderer Prozess auf dem System den Breakpoint auslöst. Die Einrichtungsschritte sind praktischerweise in einer gdb-Befehlsdatei skriptgesteuert. Wir führen GDB mit dem Flag -x aus, um die Einrichtung konsistent und wiederholbar vorzunehmen.
Die Breakpoints werden ausgelöst, bevor der unprivilegierte User-Namespace erstellt wird, unmittelbar vor der Ausführung der Schwachstelle und nachdem wir Root-Credentials erlangt haben. Dieses Verhalten setzen wir um, indem wir einfach den entsprechenden Systemaufruf ausführen.
Jetzt können wir den Exploit ausführen:
Unser erster Breakpoint wird wie erwartet ausgelöst. Mit der gdb-Hilfsfunktion $lx_current können wir die Cred-Struktur untersuchen. Die effektive UID des aktuellen Prozesses ist 1000, und wie erwartet verfügt er im aktuellen Namespace über keine effektiven Capabilities.
Der zweite Breakpoint wird nach einem Aufruf von unshare ausgelöst, durch den ein neuer User-Namespace für den Prozess erstellt wird. Beachten Sie, dass die UID unverändert bleibt, sich aber die Attribute cap_effective und user_ns geändert haben. Capabilities werden als Bitmaske gespeichert, die im Hexadezimalformat besser lesbar ist.
Unser letzter Breakpoint wird ausgelöst, nachdem die Schwachstelle ausgenutzt wurde. Beachten Sie, dass die UID nun auf 0 gesetzt ist und der User-Namespace auf init_user_ns zurückgesetzt wurde, den Init-User-Namespace des Hosts.
Unsere Shell kehrt zurück, und wir verfügen nun über vollständige Root-Berechtigungen auf dem Host.
Kernel-Exploit in einem Container
Als Nächstes versuchen wir, denselben Exploit in einem Pod auszuführen. Dazu haben wir eine einfache Pod-Objektdefinition erstellt und im Cluster bereitgestellt.
Sehen wir uns an, was in der Standardkonfiguration passiert.
Standardmäßig Root
Das im Beispiel verwendete Image gibt keinen unprivilegierten Benutzer an, und Kubernetes erzwingt standardmäßig keine UID. Es sieht also so aus, als hätten wir Root-Zugriff, ohne den Kernel ausnutzen zu müssen. Wir führen denselben Exploit erneut aus und halten den Kernel direkt vor der Ausführung des anfälligen Codepfads an. Ein Blick auf die effektiven Capabilities des Prozesses zeigt, dass einige fehlen. Der Wert ist auf 2818844155 gesetzt und entspricht dem standardmäßigen Capability-Satz, den die Docker-Runtime gewährt.
Nach Abschluss des Exploits umfasst der effektive Satz wieder alle Capabilities.
Diesmal erzwingen wir für den Container eine Nicht-Root-UID, indem wir die Sicherheitskontextattribute runAs festlegen.
Diesmal erhalten wir nicht von Anfang an Root-Berechtigungen. Der Exploit funktioniert jedoch genauso, mit einem wesentlichen Unterschied beim Endergebnis. Es sieht so aus, als hätten wir alle Berechtigungen, aber wir sehen nicht alles auf dem System.
Im Namespace gefangen
Wir haben alle Capabilities und die Root-UID erlangt, aber lediglich die Capability-Schranke des Containers umgangen. Auf das Dateisystem des Hosts haben wir weiterhin keinen Zugriff. Deshalb können wir weder alle Prozesse sehen noch über die Netzwerkschnittstellen des Hosts kommunizieren.
An diesem Punkt können wir beliebige Kernel-Module laden. Das ist jedoch auffällig und löst die meisten grundlegenden Intrusion-Detection-Systeme aus – so sollte man zumindest hoffen. Um das zu testen, entfernen wir ein nicht verwendetes Modul. Beachten Sie, dass Ihr Docker-Image Modul-Pakete enthalten muss. Bei Debian-Images müssen Sie das Paket kmod installieren.
Stattdessen können wir unseren Kernel-Exploit erweitern und das Objekt [struct nsproxy](https://github.com/torvalds/linux/blob/master/include/linux/nsproxy.h#L31) im aktuellen Kontext so setzen, dass es auf die gewünschten Namespaces verweist. Namespaces werden über Inodes identifiziert, aber der Kernel stellt die Adresse von [init_nsproxy](https://github.com/torvalds/linux/blob/master/kernel/nsproxy.c#L32) bereit. Damit können wir die Init-Namespaces des Hosts in unseren Container kopieren.
Mit dem Systemaufruf sys_setns lassen sich die Namespaces des Prozesskontexts aktualisieren. In drei zentrale Namespaces wollen wir PrivEsc durchführen: PID, Netzwerk und Mount. Zunächst müssen wir eine Referenz auf die Root-Namespaces erhalten. Dazu verschieben wir PID 1 des Containers in die Namespaces des Hosts. Anschließend können wir über das /proc/-Dateisystem von PID 1 auf beliebige Namespaces zugreifen. Zum Schluss verschieben wir den aktuellen Prozess in die benötigten Namespaces.
Nach der Ausführung unseres Exploits können wir auf alle relevanten Systemressourcen zugreifen.
Einsatzmöglichkeiten von Capabilities
Die Standard-Capabilities, die Kubernetes-Containern (mit der Docker-Runtime) zugewiesen werden, umfassen CAP_NET_RAW. Bedeutet das, dass wir die Schwachstelle auch dann ausnutzen können, wenn unprivilegierte User-Namespaces deaktiviert sind? Wir haben Code hinzugefügt, um die effektiven Capabilities festzulegen, die für den Zugriff auf den anfälligen Code erforderlich sind.
Wie Sie sehen, schlägt der Exploit fehl. Aber warum?
Das liegt an vererbbaren Capabilities und ihrer Implementierung. Obwohl die Container-Runtime dem Prozess im Container diese Capabilities gewährt hat, müssen sie über sys_capset ausdrücklich als effektiv festgelegt werden. Derzeit können nur Prozesse mit UID 0 effektive Capabilities festlegen. Wenn Sie also als Nicht-Root-Benutzer arbeiten, aber dennoch Zugriff auf bestimmte Capabilities benötigen, müssen Sie eine suid-Binärdatei in Ihren Container aufnehmen, um die effektiven Capabilities festzulegen. Alternativ können Sie die erforderlichen Capabilities direkt für die ausführbare Datei festlegen und die Container-Capabilities entfernen. Datei-Capabilities sind auf Dateisysteme mit erweiterten Attributen beschränkt.
Seccomp als Rettung
Sprechen wir nun darüber, ob ein Angriffsvektor erreichbar ist. Unser Exploit funktioniert, weil unprivilegierte Benutzer in unprivilegierten User-Namespaces die Capability CAP_NET_RAW erlangen können. Im obigen Abschnitt zu Capabilities haben wir gesehen, welche Auswirkungen das auf unseren Exploit hat. Es gibt noch eine weitere Gegenmaßnahme, mit der sich dieser Angriff verhindern lässt – und ja, Sie können sie über Kubernetes aktivieren.
Seccomp ist ein Mechanismus, mit dem sich die Angriffsfläche des Kernels durch das Filtern von Systemaufrufen verkleinern lässt. Kubernetes wendet standardmäßig jedoch kein Seccomp-Profil auf Ihren Container an. Das bedeutet, dass alle Systemaufrufe erlaubt sind, vorbehaltlich der bereits besprochenen Berechtigungsprüfungen. Das lässt sich ändern, indem Sie der Objektdeklaration eine Annotation hinzufügen (vor Version 1.19) oder das Seccomp-Profilattribut im Pod-Sicherheitskontext festlegen.
Sehen wir uns an, wie sich das vom Container-Runtime bereitgestellte Standardprofil (in diesem Fall Docker) auf unseren Exploit auswirkt. Wir erhalten die Fehlermeldung „Operation not permitted“, weil das Standard-Seccomp-Profil den Systemaufruf unshare nicht zulässt.
Seccomp eignet sich hervorragend, um unnötige Kernel-Einstiegspunkte einzuschränken. Systemaufrufe wie unshare oder userfaultfd lassen sich für die meisten Anwendungsfälle ohne Weiteres deaktivieren und können bestimmte Exploitation-Techniken wirksam verhindern. Es gibt jedoch auch Aufrufe, deren Blockierung schwierig wäre, etwa waitid. Weitere Informationen zu diesen und anderen Techniken zum Ausnutzen von Containern finden Sie hier.
Fazit
Wir konnten diesen Exploit mit einem standardmäßigen seccomp-Profil verhindern. Wie Sie sehen, ist der Exploit-Pfad trotz des verwundbaren Betriebssystems aus unserem Container heraus nicht erreichbar (in diesem Fall). Mit dieser Technik gewinnen Sie möglicherweise genügend Zeit, um das längst überfällige Update des Betriebssystems zu planen! Betrachten Sie diese Maßnahmen als Defense-in-Depth-Kontrollen und Strategien zur Risikominderung.
Patchen Sie Ihre Systeme stets! Snyk Infrastructure as Code hilft Ihnen, solche Maßnahmen zur Risikominderung frühzeitig in Ihrer CI/CD-Pipeline zu erkennen – lange bevor etwas in der Produktionsumgebung bereitgestellt wird. Mit adversarischen Techniken identifizieren wir besonders wirksame Sicherheitsoptionen in Kubernetes und bei Cloud-Service-Providern. Nutzen Sie Snyk kostenlos und registrieren Sie sich für ein kostenloses Konto.
Starten Sie mit Capture the Flag
Erfahren Sie in unserem virtuellen On-Demand-Workshop für Einsteiger, wie Sie Capture-the-Flag-Herausforderungen lösen.


