systemd-Sandboxing für den Homeserver — Ein kleinerer Schadensradius für jeden Dienst
Ein Homeserver bleibt selten lange einfach. Es beginnt mit einem Webserver, dann kommt eine Datenbank dazu, dann ein Tunnel, dann ein Monitoring-Agent, und plötzlich läuft auf einer einzigen Maschine rund um die Uhr ein Dutzend Dienste. In einem typischen Linux-Setup lebt jeder dieser Dienste in einer systemd-Unit-Datei — und eine unveränderte Standard-Unit gibt ihrem Prozess einen sehr großzügigen Blick auf den Host: das komplette Dateisystem, fast alle Capabilities, Zugriff auf die Prozessinformationen anderer Benutzer. Das wirft eine Frage auf, die man sich ehrlich stellen sollte: Wenn nur einer dieser Dienste eine Schwachstelle hat, wie weit kommt ein Angreifer von innerhalb dieses Prozesses?
systemd bringt seit Jahren einen großen Satz eingebauter Sandboxing-Optionen mit, und sie erfordern keine zusätzliche Software. Dieser Artikel geht ihnen nach: wie sie funktionieren, wie man den Ist-Zustand der eigenen Dienste misst und wo die ehrlichen Grenzen dieses Ansatzes liegen.
Was systemd-Sandboxing ist — und was nicht
Die Optionen leben in der Unit-Datei selbst und sind in systemd.exec(5) dokumentiert: Direktiven wie ProtectSystem=, NoNewPrivileges= oder SystemCallFilter=. Unter der Haube kombinieren sie Kernel-Primitiven — Mount-Namespaces, seccomp-Filter, Capability-Sets, procfs-Mount-Optionen — aus Sicht der Administration sind es aber einfach Zeilen in einer Konfigurationsdatei. Viele davon existieren schon lange; ProtectSystem= wurde laut Dokumentation bereits in Version 214 ergänzt.
Zwei Dinge ist es nicht: keine Container-Runtime und keine virtuelle Maschine. Das Ziel ist enger gefasst. Ein Container isoliert die gesamte Umgebung einer Anwendung; Sandbox-Direktiven nehmen einem einzelnen Dienst alles weg, was er nicht braucht, während er wie jede andere Unit weiterläuft. Am besten versteht man es als Defense in Depth — eine weitere Schicht zwischen einem kompromittierten Prozess und dem Rest der Maschine.
Zuerst messen statt raten
Der schnellste Realitätscheck ist systemd-analyze security. Ohne Argumente ausgeführt, prüft er jeden Dienst und weist jedem einen "Exposure Level" zwischen 0,0 und 10,0 zu. Die Dokumentation ist erfrischend offen, was diese Zahl bedeutet: Sie ist eine Schätzung, hohe Werte bedeuten nicht, dass ein Dienst tatsächlich verwundbar ist, und die Analyse erfasst nur die Sandboxing-Funktionen, die systemd selbst kennt.
Ich habe das auf dem Homeserver ausgeführt, der diese Website betreibt; er läuft mit Debian 13 und systemd 257 (der in den Debian-Manpages dokumentierten Version). Die meisten Dienste landeten zwischen 9,0 und 9,6, eingestuft als UNSAFE: die Cloudflare-Tunnel, code-server, cron, dnsmasq. Die nginx-Unit startet ihren Hauptprozess weiterhin als root, mit leerem NoNewPrivileges= und nahezu unangetasteter Capability-Menge.
Eine interessante Ausnahme gab es: MariaDB aus Debians Repository bringt User=mysql und ProtectSystem=full bereits vom Paketbetreuer mit — allerdings ohne NoNewPrivileges=yes. Einige Distributionen härten also teilweise vor, aber es variiert von Paket zu Paket, und nichts hindert einen daran, mit eigenen Overrides weiter zu verschärfen.
Die Optionsfamilien, die man kennen sollte
Dutzende Direktiven existieren, aber sie gruppieren sich in drei intuitive Familien.
Die Sicht aufs Dateisystem
ProtectSystem=true mountet /usr und die Boot-Verzeichnisse read-only; full fügt /etc hinzu; strict macht die gesamte Hierarchie read-only, außer den API-Dateisystemen. Die Dokumentation empfiehlt, dies "for all long-running services" zu aktivieren. ProtectHome=yes verbirgt /home, /root und /run/user vollständig vor dem Dienst. PrivateTmp=yes gibt dem Prozess sein eigenes privates /tmp und /var/tmp.
Eine Read-only-Welt wirft eine naheliegende Frage auf: Wo darf der Dienst schreiben? Darauf gibt es zwei saubere Antworten. ReadWritePaths=/path schafft in einem strikten Setup gezielt Ausnahmen, und die Verzeichnisoptionen — StateDirectory=notes, RuntimeDirectory=notes — legen eigene schreibbare Verzeichnisse unter /var/lib bzw. /run an, verwaltet von systemd und gegen UID-Wiederverwendungsprobleme geschützt.
Privilegien
User=/Group= führt den Prozess unter einem normalen Konto aus; DynamicUser=yes geht weiter und allokiert für die Lebensdauer des Dienstes eine Wegwerf-UID und zieht dabei implizit ProtectSystem=strict nach sich. Obendrauf sitzt NoNewPrivileges=yes, das die Dokumentation "the simplest and most effective way" nennt, um sicherzustellen, dass ein Prozess und seine Kinder nie wieder über setuid-Binaries Privilegien gewinnen können.
CapabilityBoundingSet= stutzt die Kernel-Capabilities eines Prozesses zusammen — ein leerer Wert entfernt alle. Wenn ein Dienst wirklich eine benötigt, dokumentiert das Arch Wiki das übliche Muster: exakt diese Capability gewähren, etwa AmbientCapabilities=CAP_NET_BIND_SERVICE, damit ein nicht-privilegierter Prozess Port 80 oder 443 binden darf, während CapabilityBoundingSet=CAP_NET_BIND_SERVICE garantiert, dass nichts darüber hinausgeht. RestrictSUIDSGID=yes verbietet das Anlegen neuer setuid-Dateien, und ProtectProc=invisible verbirgt die Prozesse anderer Benutzer vor /proc.
Kernel- und Syscall-Oberfläche
Diese Familie kappt den Weg von einem kompromittierten Prozess zum Kernel selbst: ProtectKernelModules=yes blockiert das Laden von Kernelmodulen, ProtectKernelLogs=yes schließt den Kernel-Log-Ringpuffer, ProtectClock=yes verweigert Uhrzeitänderungen, PrivateDevices=yes entfernt physische Geräte aus /dev, und RestrictNamespaces=true nimmt das Anlegen von Namespaces. RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 verbietet exotische Socketfamilien wie Packet Sockets, und SystemCallArchitectures=native blockiert Fremd-ABI-Aufrufe.
Das chirurgischste Werkzeug hier ist SystemCallFilter=, eine Allow-Liste von Syscalls oder vordefinierten Syscall-Gruppen. @system-service ist eine kuratierte Gruppe, die abdeckt, was typische Netzwerk-Daemons benötigen. Eine Direktive verdient ein Warnlabel: MemoryDenyWriteExecute=yes verbietet gleichzeitig schreibbaren und ausführbaren Speicher und härtet damit eine ganze Exploit-Klasse ab, doch die Dokumentation stellt ausdrücklich fest, dass sie mit Programmen inkompatibel ist, die zur Laufzeit Code erzeugen — JIT-Engines eingeschlossen. Node.js- und Java-Dienste werden damit wahrscheinlich nicht laufen.
Ein durchgerechnetes Beispiel, offline gemessen
Um zu sehen, was das konkret verändert, habe ich eine absichtlich schlichte Unit für einen kleinen Demo-Dienst geschrieben und dann eine gehärtete Variante:
[Service]
Type=simple
ExecStart=/usr/bin/python3 -m http.server 8080
Restart=on-failure
[Service]
Type=simple
DynamicUser=yes
StateDirectory=notes
RuntimeDirectory=notes
ExecStart=/usr/bin/python3 -m http.server 8080
Restart=on-failure
# Filesystem view
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
# Privileges
NoNewPrivileges=true
CapabilityBoundingSet=
PrivateDevices=true
ProtectProc=invisible
RestrictSUIDSGID=true
# Kernel surface
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectKernelLogs=true
ProtectClock=true
ProtectControlGroups=true
LockPersonality=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallArchitectures=native
systemd-analyze security hat einen Offline-Modus, der eine Unit-Datei direkt von der Platte analysiert, ohne sie zu installieren — Experimente wie dieses sind dadurch ungefährlich. Auf dieser Maschine bewertete systemd-analyze security --offline=true die schlichte Unit mit 9,4 (UNSAFE) und die gehärtete mit 1,6 (OK) — ohne Warnungen über unbekannte Direktiven, das heißt, jede Zeile wird von systemd 257 sauber geparst. Diese Zahl ist eine Schätzung statt ein Sicherheitsnachweis, und der Offline-Test kann natürlich nicht sagen, ob eine echte Anwendung diese Einschränkungen tolerieren würde; er zeigt, wie viel Angriffsfläche der Standard offenlässt.
Was typischerweise bricht
Sandboxing scheitert auf vorhersehbare Weise — eine gute Nachricht, denn Vorhersehbarkeit macht Debugging beherrschbar.
- JIT-Runtimes und
MemoryDenyWriteExecute=beißen sich grundlegend, wie oben beschrieben. Bei V8-basierten oder JVM-Diensten lieber ausschalten. - Geteilte Temp-Dateien: Sobald
PrivateTmp=yesgesetzt ist, können zwei Dienste keine Dateien mehr über/tmpaustauschen — die Dokumentation sagt das unverblümt. - Niedrige Ports: Wer von root auf einen dynamischen Benutzer umzieht, verliert die Fähigkeit, Ports unter 1024 zu binden — außer man reicht
CAP_NET_BIND_SERVICEperAmbientCapabilities=weiter. - Netzwerkdienste und Namespaces: Das Arch Wiki weist darauf hin, dass ein Webserver nicht
PrivateNetwork=yesverwenden sollte, da er normalerweise echten Netzwerkzugriff braucht. - Vage Fehlermeldungen: Wenn ein gehärteter Dienst mysteriös stirbt, ist
journalctl -u NAME.service -edie erste Station, und das Arch Wiki empfiehlt, bei unklaren Meldungen die Ausführlichkeit des Managers mitsystemctl log-level debugzu erhöhen.
Daraus ergibt sich ein praktischer Rhythmus: eine Optionsfamilie verschärfen, neu starten, das Journal beobachten, wiederholen. Nicht alle vierzig Direktiven auf einmal.
Grenzen, die im Kopf bleiben sollten
Das größte Risiko bei einem Bewertungswerkzeug ist, den Score als Versprechen zu behandeln. Die systemd-Dokumentation warnt, dass einzelne Einstellungen "can be circumvented — unless combined with others", und dass die Analyse ignoriert, was ein Dienst selbst zu seinem Schutz beiträgt, ebenso wie privilegierte Seitenkanäle: Ein Prozess, der über D-Bus sprechen darf, kann besser geschützte Dienste bitten, Dinge für ihn zu erledigen. Das Arch Wiki formuliert es noch deutlicher: Der Score ist "slightly misleading", denn nur ein Hello-World-Programm kommt an eine perfekte Wertung heran — echte Anwendungen brauchen immer Ausnahmen.
Ein faires Gegenargument kommt aus der Containerwelt: Läuft ohnehin jeder Dienst in Docker oder podman mit eigener Isolation, bringt Sandboxing auf Unit-Ebene weniger. Das trifft auf manche Setups zu — aber viele Workloads auf Homeservern, darunter einige auf der Maschine, auf der diese Website läuft, kommen weiterhin als simple System-Units daher, und genau die profitieren am meisten.
Und Sandboxing ersetzt nicht die Grundlagen. Patchen, Backups, schlanker Softwarebestand und solide Authentifizierung bleiben die tragenden Wände; Sandboxing begrenzt, wie weit sich ein Vorfall ausbreitet, nachdem etwas anderes bereits schiefgelaufen ist.
Wo ich damit stehe
Was an diesem Ansatz reizt, ist das Kosten-Nutzen-Verhältnis: Der Durchsetzungsmechanismus wartet seit Jahren in jeder modernen Distribution darauf, mit ein paar Konfigurationszeilen aktiviert zu werden. Meine eigene Messung zeigt, dass fast alles auf diesem Server noch weit offen läuft — das ehrliche Fazit ist also, dass hier Arbeit unerledigt bleibt, keine fertige Erfolgsgeschichte. Der sinnvolle nächste Schritt sind Drop-in-Overrides — systemctl edit nginx.service erzeugt ein Overlay, statt die Vendor-Datei anzufassen — angewendet Dienst für Dienst, beginnend mit denjenigen, die ins Internet exponiert sind. Ob ein Dienst seine Sandbox toleriert, ist letztlich eine empirische Frage, und die einzige verlässliche Antwort ist: ausprobieren, ins Journal schauen, nachjustieren.
References
- systemd project — systemd.exec(5), freedesktop.org (abgerufen 2026-08-22)
- systemd project — systemd-analyze(1), freedesktop.org (abgerufen 2026-08-22)
- Debian — systemd.exec(5), Debian trixie manpages (systemd 257) (abgerufen 2026-08-22)
- Arch Linux Wiki — Systemd/Sandboxing (abgerufen 2026-08-22)
