Sicherheit geht vor – auch bei Containern

Container, Docker & Kubernetes: Mehr Sicherheit mit SysCall-Filtern

Andreas Jaekel

© Shutterstock / cwales

Container und Plattformen wie Kubernetes sind mittlerweile ein beliebtes Angriffsziel von Hackern. In diesem Artikel zeigt Andreas Jaekel, Head of PaaS Development bei IONOS, wie man seine Container absichern kann, denn von Natur aus sicher sind sie nicht.

Kaum ein Entwickler kommt heutzutage ohne Container und Kubernetes aus. Kein Wunder, erleichtert die Container-Technologie doch das Arbeiten mit Microservices und in agilen Teams ungemein. Kubernetes hat in den vergangenen fünf Jahren eine Erfolgsgeschichte hingelegt und ist mittlerweile als Standardtool für die Container-Orchestrierung etabliert.

Beliebte Technologien bekommen jedoch auch mehr Aufmerksamkeit von Hackern, die ihre Malware verbreiten wollen. Das trifft natürlich auch Container. Sie sind nicht per se vertrauenswürdig – selbst solche, die wir selbst erstellt haben, können gehackt werden. Malware kann in Images enthalten sein oder in die Container heruntergeladen werden.

Zwar sollten generische Container die Eindringlinge in Schach halten. Dennoch können wir das Sicherheitsniveau weiter erhöhen. Ein Ansatz ist es, eine bekannte Linux-Funktion zu instrumentalisieren und Container vorzugeben, welche System Calls (SysCalls) sie ausführen dürfen.

SysCalls – was versteckt sich dahinter?

Jedem Linux-Prozess wird beim Starten ein kleines Stück Arbeitsspeicher zur Verfügung gestellt. Der Code kann dann völlig frei auf diesem Speicherabschnitt arbeiten, also beispielsweise Rechenoperationen durchführen. Für alles andere muss er den Kernel um Erlaubnis bitten. Hier eine kleine Auswahl:

  • Schreib- und Leseberechtigungen (write / read)
  • Erstellung eines Verzeichnisses (mkdir)
  • Start eines neuen Prozesses (fork)
  • Abrufen der Tageszeit (gettimeofday)

Der Code erhält die Erlaubnis, indem er die Anfrage per SysCall an den Kernel stellt, welcher daraufhin die Zugriffsrechte prüft bevor er der Anfrage nachkommt.

Es gibt ungefähr 330 SysCalls in Linux, welche hier zu finden sind – sie sind übrigens unabhängig von der Programmiersprache immer gleich. write bleibt write, egal ob in C oder GoLang.

Warum ergibt eine Filterung von SysCalls Sinn?

Man kann grob drei Einfallstore unterscheiden, über die Container attackieren werden:

  1. Backdoors in Docker Upstream Images
  2. Bugs in Anwendungen
  3. Schwachstellen in SysCalls im Linux Kernel

SysCalls sind genau das richtige Werkzeug, um Programme und Container davon abzuhalten, etwas zu tun, was sie nicht sollten. Ein Beispiel: Nutzen Sie Nginx, können Sie gleich alle SysCalls filtern, bei denen Sie sicher sind, dass Nginx sie nicht benötigt. Hier ein kleiner Ausschnitt davon:

Damit machen Sie die Nutzung von Nginx sicherer und schränken es nicht in seiner Funktionsfähigkeit ein.

Welche Filter sollten wir setzen?

Das vorige Beispiel sieht einfach aus. Doch: Wie findet man nun heraus, welche SysCalls eine Anwendung überhaupt nutzt? Filtert man zu viele davon, funktionieren die Anwendungen nicht mehr. Bei zu wenigen Filtern bleibt Angreifern zu viel Raum. Vorweg: Es ist schwierig oder fast unmöglich, eine perfekte Filterliste zu erstellen. Es gibt jedoch fünf unterschiedliche Vorgehensweisen, sich einer geeignetes Filterliste zumindest anzunähern:

  1. Quelltext lesen, und zwar alles – inklusive aller Librarys

    Das ist im Grunde die einzige Möglichkeit, wirklich sicher zu gehen, schadhaften Code auszuschließen. Aufgrund der schier unendlichen Abhängigkeiten ist dieser Weg in der Praxis jedoch kaum gangbar.

  2. Trial and Error

    Natürlich können Sie auch einfach drauflos probieren, Filter zu setzen. Bei rund 330 SysCalls und Unmengen an verschiedenen Kombinationen, ist auch dieses Vorgehen praktisch nicht umsetzbar.

  3. Educated Guessing

    Eine bessere Variante ist es da schon, gezielt zu raten. Das ergibt natürlich nur dann Sinn, wenn das Wissen rund um Softwaredesign und SysCalls ausgeprägt ist. Selbst dann ist es wahrscheinlich, dass entweder zu viele oder zu wenige Filter gesetzt werden. Das Educated Guessing ist besser als gar nichts, von einem gut funktionierenden Filter jedoch weit entfernt.

  4. Analyse der Binarys

    Auch aus den Binaries könnte man theoretisch herauslesen, welche SysCalls genutzt werden. Der Vorteil liegt hier darin, dass Binarys immer im Maschinensprache vorliegen. Jedoch erzeugen unterschiedliche Sprachen und Compiler auch unterschiedlichen Maschinencode. So lässt sich automatisiert kaum feststellen, welche SysCalls darin enthalten sind und welche nicht.

  5. Call Tracing mit strace

    SysCalls, die eine Anwendung ausführt, können mit einem Tracing-Tool nachverfolgt werden. Das lässt sich zum Beispiel während eines Testlaufs oder in einer CI-Pipeline durchführen. Linux bietet hierfür das Tool strace – ein sehr gutes Werkzeug zum Debuggen und zur Fehlerbehebung. Zusätzlich zeigt strace Call-Parameter an. Ein Beispiel im Counter Mode:

  6. > strace -c -S name ./helloworld
    
    Hello World!
    
    % time     seconds  usecs/call     calls    errors syscall
    
    ------ ----------- ----------- --------- --------- ----------------
    
    0.00    0.000000           0         1           arch_prctl
    
    0.00    0.000000           0         4           brk
    
    0.00    0.000000           0         1           execve
    
    0.00    0.000000           0         1           uname
    
    0.00    0.000000           0         1           write
    
    ------ ----------- ----------- --------- --------- ----------------
    
    100.00    0.000000                     8           total
    

    Zusammenfassend ist festzuhalten:

    • Es ist schwierig, eine gute Filterliste zu erstellen.
    • Zu enge Filter hindern die Anwendung daran, fehlerfrei zu funktionieren.
    • Zu lasche Filter öffnen Malware Tür und Tor.
    • Ich empfehle zunächst mit strace alle nötigen SysCalls nachzuverfolgen.
    • Im zweiten Schritt ergibt es Sinn, den Filter über Educated Guessing zu verfeinern.

    In der Praxis: Erstellung eines eBPF mit Seccomp

    Linux kann vor jedem SysCall kleine State Machines ausführen. Diese Programme müssen als so genannte „extended Berkeley Packet Filter“, kurz eBPF ausgeliefert und können per SysCall bpf() in den Kernel geladen werden.

    eBPF können für verschiedene Dinge verwendet werden, z.B. zur Messung der Performance, zum Debuggen oder Tracing. Jedoch ist es sehr komplex, ein eBPF zu erstellen. Und: Sie wollen ja nur SysCalls filtern und keine neue Programmiersprache lernen.

    Dabei hilft uns Seccomp BPF – eine Google-Erfindung von 2005. Seccomp verbirgt die Komplexität von eBPF und ist in der Lage, individuelle SysCalls zu filtern. Hinzu kommt, dass es noch mehr Möglichkeiten bietet und unter anderem

    • vorgeben kann, dass ein SysCall ausgeführt wurde, obwohl das nicht der Fall war,
    • gefälschte Ergebnisse und Fehlernummern zurücksenden kann,
    • oder Breakpoints erzeugen kann.

    Seccomp ist also ein gutes Tool, um zu testen, Fehler zu injizieren oder zum Debugging. Ein Beispiel, wie Seccomp in C genutzt wird:

    int
    main(int argc, char *argv[])
    {
      scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);
      seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(getpid), 0);
      seccomp_load(ctx);
      pid_t pid = getpid();
      /* never reached: process killed */
      return 0;
    }
    

    Seccomp kann auf Basis bestimmter Parameter filtern:

    unsigned char buf[BUF_SIZE];
    int fd = open(“data.raw", 0);
    int rc = seccomp_rule_add(
      ctx,
      SCMP_ACT_ALLOW,
      SCMP_SYS(read), 3,
       SCMP_A0(SCMP_CMP_EQ, fd),
      SCMP_A1(SCMP_CMP_EQ, (scmp_datum_t)buf),
      SCMP_A2(SCMP_CMP_LE, BUF_SIZE));
    

    Dies filtert read()-Aufrufe, die nicht alle der drei folgenden Bedingungen erfüllen:

    1. Die Daten werden aus genau dem File Descriptor gelesen, der zuvor mit open() erstellt wurde.
    2. Die gelesenen Daten werden im dafür vorgesehenen Speicherbereich buf abgelegt.
    3. Es werden nicht mehr Daten gelesen, als in buf hineinpassen.

    Nach Parametern zu filtern kann in vielen Fällen sinnvoll sein, zum Beispiel um

    • read-only SysCalls zu erzwingen,
    • die reads und writes auf Standardeingabe und -ausgabe zu beschränken,
    • setuid() auf eine bestimmte User-ID zu beschränken,
    • ausschließlich SIGHUP-Signale zu erlauben und
    • zu verhindern, dass zu großzügige Dateiberechtigungen gesetzt werden.

    Das Filtern auf Basis von Parametern hat jedoch auch seine Grenzen. So können nur Wertparameter (Pass by Value) ausgewertet werden. Sie können daher keinen Blick in Zeichenfolgen oder Strukturen werfen. So kann beispielsweise open() nicht auf bestimmte Verzeichnisnamen beschränkt werden.

    Anwendung auf Container und K8s

    Die gute Nachricht ist, dass Seccomp in Docker v1.10 hinzugefügt wurde. Standardmäßig werden bereits 44 SysCalls geblockt, inklusive reboot(). Diese unerwünschten SysCalls werden nicht ausgeführt, das Programm jedoch dabei nicht beendet.

    Für die Erstellung eines benutzerdefinierten Filters ist es zu empfehlen, mit den Standardeinstellungen zu beginnen und sie wie gewünscht anzupassen. Benutzerdefinierte Filter werden als JSON-Dateien ausgegeben. Wie sieht dies in Docker aus?

    {
    	"defaultAction": "SCMP_ACT_ERRNO",
    	"syscalls": [
    		{
    			"names": [
    				"accept",
    				"access",
    				…
    			],
    			"action": "SCMP_ACT_ALLOW",
    			"args": [],
    			"comment": "",
    			"includes": {},
    			"excludes": {}
    		}
    	]
    }
    
    

    Die oben aufgelisteten System Calls werden unabhängig ihrer Parameter erlaubt.

    {
    	"names": [
    		"Ptrace"
    	],
    	"action": "SCMP_ACT_ALLOW",
    	"args": null,
    	"comment": "",
    	"includes": {
    		"minKernel": "4.8"
    	},
    	"excludes": {}
    }
    

    Hier wird ptrace() erlaubt, jedoch nur auf Kernels, die mindestens Linux-4.8 entsprechen. Um das JSON-Filter Set in Docker zu laden (Docker nennt das übrigens seccomp profile) hilft diese Kommandozeile:

    # docker run -ti --rm --security-opt seccomp:custom_filter.json alpine /bin/sh
    

    Dabei sollte man beachten, dass jeder benutzerdefinierte Filter den standardmäßigen Filter ersetzt und nicht etwa ergänzt. Zudem gilt der Filter für den gesamten Container.

    SysCall Filter in Kubernetes

    Seit der Version 1.3 sind SysCall Filter über Seccomp auch in Kubernetes möglich und werden von den meisten Laufzeitumgebungen unterstützt, nicht nur von Docker. Seccomp-Profile beziehen sich immer auf den gesamten Pod, nicht nur auf einzelne Container.

    Um benutzerdefinierte Profile über Seccomp anzulegen, müssen Sie die Pod-Sicherheitseinstellungen (Pod Security Policies) im K8s Cluster aktivieren und danach eine Pod-Sicherheitseinstellung definieren, die es erlaubt, Seccomp-Profile zu nutzen. Über die Erstellung einer Rollenbindung (RoleBinding) erlauben Sie dies auch den Pods.

    Um die Sicherheitseinstellungen dann zu aktivieren, fügen Sie zumindest eine permissive Policy hinzu und erstellen außerdem mindestens eine passende Rolle und eine Rollenbindung für den Namespace kube-system. Andernfalls kann K8s keine Pods mehr starten.

    Fügen Sie dann die PodSecurityPolicy zur Liste der aktivierten Admission Controller hinzu:

    kube-apiserver \
    	--enable-admission-plugins= \
    		PodSecurityPolicy,LimitRanger ...
    

    Im nächsten Schritt können Sie Seccomp-Profile bereitstellen, indem Sie diese erstellen und den Worker Nodes in kubelet --seccomp-profile-root= verfügbar machen (Default: /var/lib/kubelet/seccomp).

    Um nun einen Seccomp-Filter auf einen Pod anzuwenden, müssen Sie Folgendes im Pod ergänzen:

    […]
    	metadata:
    		labels:
    			app: problemsolver
    	annotations:
    	kubernetes.io/psp: allowseccomp
    	seccomp.security.alpha.kubernetes.io/pod: localhost/custom-profile.json
    […]
    

    Zum Starten können Sie hier ein Beispiel herunterladen: https://github.com/ionos-enterprise/K8s-seccomp-demo

    Zusammenfassung

    Einen geeigneten Filter zu erstellen ist nicht einfach. Ist er zu großzügig, dient er nicht der Abwehr von Malware. Bei einem zu eng gestrickten Filter kann es vorkommen, dass die Applikation gar nicht erst ausführbar ist. Hinzu kommt, dass die Docker-Standardeinstellungen bereits recht ausgereift sind.

    Dennoch kann es in bestimmten Szenarien Sinn ergeben, seine Zeit in die Erstellung eines eigenen Filters zu investieren. Insbesondere, wenn Sie Ihre Anwendung gut kennen ist das ein Quick Win. Auch wenn Sie eine sehr sichere Docker-Umgebung benötigen (FinTech u.a.) lohnt sich der Aufwand. Gerade als Container-Hoster ist es wertvoll, selbst zu definieren, welche SysCalls ausgeführt werden dürfen und welche nicht.

Geschrieben von
Andreas Jaekel

Andreas Jaekel ist als Leiter der PaaS-Entwicklung zuständig für die Entwicklung des Managed Kubernetes Angebots von IONOS. Seit er 1995 mit Linux- und Unix-Systemen arbeitet, hat er Erfahrungen als System- und Anwendungsentwickler, DevOps-Ingenieur, Systemmanager und auch als Endanwender gesammelt. Nach ausgedehnten Abenteuern in den Bereichen Webhosting, E-Mail-Verarbeitung und Cloud-Storage, gehören zu seinen jüngsten Projekten die Lösungen Managed OpenStack und Kubernetes.

Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: