Systemd Service Hardening op Linux: Sandbox-directieven, DynamicUser en systemd-analyze in 2026

Hard systemd-services met sandbox-directieven zoals ProtectSystem, DynamicUser en SystemCallFilter. Praktische drop-ins, scoring met systemd-analyze security en troubleshooting van een te strak gehard unit-bestand in 2026.

Bijgewerkt: 29 mei 2026

Systemd service hardening is het toepassen van sandbox-directieven in unit-bestanden (denk aan ProtectSystem, PrivateTmp, NoNewPrivileges, SystemCallFilter en DynamicUser) om de aanvalsoppervlakte van een service te beperken zonder externe tools. In systemd 256 (uitgebracht juni 2024) en 257 zijn deze directieven uitgebreid met betere namespace-isolatie en strakkere defaults. Een goed gehard unit-bestand scoort onder 1.0 op systemd-analyze security, zelfs voor legacy daemons. Deze gids laat zien hoe je dat in de praktijk haalt.

  • De directieven ProtectSystem=strict, ProtectHome=yes en PrivateTmp=yes bieden de grootste winst voor de minste configuratie-inspanning.
  • DynamicUser=yes elimineert het beheren van system accounts door een tijdelijke UID per service-start te genereren (systemd 232+, productie-rijp vanaf 245).
  • SystemCallFilter=@system-service blokkeert ruwweg 80% van het Linux syscall-oppervlak inclusief @privileged, @mount en @swap.
  • systemd-analyze security <unit> geeft een numerieke "exposure" score van 0.0 tot 10.0; alles onder 2.0 is "OK", onder 1.0 is "good".
  • Sandbox-directieven werken op namespace- en seccomp-niveau in de kernel, niet op LSM-niveau. Ze komen dus bovenop SELinux of AppArmor.
  • Hardening van bestaande services vereist iteratief testen met journalctl -u <service> -p err om EACCES- of EPERM-faalmodussen op te sporen.

Waarom systemd service hardening in 2026 nog steeds telt

Sinds systemd 229 (2016) levert de service manager een set sandbox-directieven die namespaces, capabilities en seccomp combineren in één declaratief unit-bestand. In 2026 wordt dit nog relevanter door drie verschuivingen: containerless deployments op edge-nodes waar Podman te zwaar is, distroless base-images die geen ruimte laten voor LSM-tooling, en de bredere uitrol van immutable distributies zoals Fedora Silverblue en openSUSE MicroOS, waarin elke service per definitie sandboxed draait.

CVE-2024-1086 (use-after-free in nf_tables) en CVE-2025-0282 lieten zien dat een unprivileged user-namespace in combinatie met onbeperkte syscalls nog steeds een directe weg naar root oplevert. Een service die expliciet RestrictNamespaces=yes en SystemCallFilter=~@mount declareert, sluit die kettingen af zonder een patch te hoeven afwachten.

De praktische winst is meetbaar. Op een standaard Debian 12 installatie haalt nginx.service uit de package-repository een exposure score van 9.6 ("UNSAFE"). Met negen extra regels in een drop-in zakt die naar 1.4 ("OK"), zonder enkele aanpassing aan nginx zelf. Eerlijk gezegd is dat een hoger return-on-effort ratio dan vrijwel elke andere hardening-maatregel die ik op Linux ken, en het is waarom ik systemd-units altijd lees vóórdat ik naar SELinux-policies kijk.

Hoe beveilig je een systemd service stap voor stap?

Het hardenen van een bestaande service volgt een vast patroon: meet de uitgangssituatie, voeg incrementeel directieven toe, valideer met integratietests, en commit pas wanneer de service-functionaliteit ongeschonden is. Wijzig nooit het originele unit-bestand uit /lib/systemd/system/. Gebruik in plaats daarvan een drop-in onder /etc/systemd/system/<service>.d/hardening.conf zodat package-updates je werk niet overschrijven.

# Stap 1: huidige exposure score meten
systemd-analyze security nginx.service

# Stap 2: drop-in directory aanmaken
sudo systemctl edit nginx.service

# Stap 3: directieven toevoegen (zie volgende secties)
# Het bestand wordt geopend in $EDITOR. Plaats configuratie tussen
# de twee commentaarregels die systemd genereert.

# Stap 4: unit herladen en service herstarten
sudo systemctl daemon-reload
sudo systemctl restart nginx.service

# Stap 5: errors opsporen
sudo journalctl -u nginx.service -p err --since "5 min ago"

# Stap 6: nieuwe score meten
systemd-analyze security nginx.service

De volgorde is belangrijk. Voeg directieven groepsgewijs toe (eerst filesystem-isolatie, dan user, dan syscalls, tenslotte netwerk), niet allemaal tegelijk. Bij een storing kun je dan direct identificeren welke groep de regressie veroorzaakt. Voor diepgaande kernel-tuning die naast service-hardening werkt, verwijs ik graag naar onze Linux kernel hardening met sysctl gids. Sandbox-directieven en sysctl-parameters versterken elkaar op verschillende lagen van de stack.

Filesystem-isolatie: ProtectSystem, ProtectHome en ReadOnlyPaths

Filesystem-directieven zijn de eerste verdedigingslinie en hebben de minste compatibiliteitskosten. ProtectSystem=strict mount /usr, /boot en /efi als read-only voor het service-proces. De waarde full doet hetzelfde plus /etc uitgezonderd specifiek toegestane paths. ProtectHome=yes verbergt /home, /root en /run/user volledig (een daemon hoort daar niets te lezen).

[Service]
# Volledig read-only systeem, behalve expliciet toegestane writable paths
ProtectSystem=strict
ProtectHome=yes

# Schrijfbare paths die de service WEL nodig heeft
ReadWritePaths=/var/log/nginx /var/cache/nginx /var/lib/nginx

# /tmp en /var/tmp krijgen een private namespace
PrivateTmp=yes

# Geen toegang tot /dev/sd*, /dev/mem, etc. (alleen /dev/null, /dev/zero, /dev/random)
PrivateDevices=yes

# Kernel-modules zijn niet leesbaar
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectKernelLogs=yes

# Procfs hardening: andere processen onzichtbaar maken
ProtectProc=invisible
ProcSubset=pid

Het verschil tussen PrivateTmp en PrivateDevices is een veelgestelde vraag. Kort gezegd: PrivateTmp=yes geeft de service een eigen /tmp en /var/tmp via een mount-namespace, zodat andere services of users de tempfiles niet kunnen zien of aanvallen via symlink-races. PrivateDevices=yes mount een minimale /dev met alleen pseudo-devices en blokkeert toegang tot block-devices, raw devices en CAP_MKNOD. Ze zijn complementair, niet overlappend.

ProtectProc=invisible (systemd 247+) verbergt processen van andere users in /proc. Dat is cruciaal bij multi-tenant hosts, en het wordt compleet beschreven in de officiële systemd.exec(5) documentatie.

Hoe werkt DynamicUser in systemd?

DynamicUser=yes genereert bij elke service-start een unieke UID in het range 61184–65519, draait de service als die user, en geeft de UID na shutdown weer vrij. Er bestaat geen permanente entry in /etc/passwd of /etc/shadow. Dit elimineert een hele klasse van problemen: vergeten orphan accounts na package-removal, conflicten tussen distro-defaults, en post-exploitation persistence via UID-aanpassingen. DynamicUser impliceert automatisch PrivateTmp=yes, ProtectSystem=strict, ProtectHome=read-only, RemoveIPC=yes en NoNewPrivileges=yes.

Voor persistente state gebruik je StateDirectory=, LogsDirectory= en CacheDirectory=. Systemd creëert die directories onder /var/lib, /var/log en /var/cache, chownt ze naar de dynamische UID, en wijst de paths toe als writable. Bij service-stop blijft de data behouden, ook al verdwijnt de UID-toewijzing. Bij de volgende start chownt systemd opnieuw naar de nieuwe UID.

[Service]
DynamicUser=yes
StateDirectory=mydaemon
LogsDirectory=mydaemon
CacheDirectory=mydaemon

# Service ziet deze als /var/lib/mydaemon, /var/log/mydaemon, /var/cache/mydaemon
# Mode is standaard 0755; pas aan met StateDirectoryMode= indien nodig
StateDirectoryMode=0700

SystemCallFilter en seccomp-groepen

Systemd vertaalt SystemCallFilter= naar een seccomp BPF-filter dat de kernel afdwingt vóór elke syscall. In plaats van honderden individuele syscalls op te sommen biedt systemd benoemde groepen (zie systemd-analyze syscall-filter --no-pager voor de volledige lijst, ongeveer 50 groepen). Voor 95% van long-running daemons is @system-service de juiste basis. Die set bevat @aio, @basic-io, @file-system, @io-event, @network-io, @process, @signal en @timer, maar sluit @cpu-emulation, @debug, @module, @mount, @obsolete, @privileged, @raw-io, @reboot, @resources en @swap uit.

[Service]
# Basisset voor reguliere services
SystemCallFilter=@system-service

# Expliciet aanvullend blokkeren
SystemCallFilter=~@privileged @resources @mount

# Restricte op syscall-architecturen (geen 32-bit syscalls op x86_64)
SystemCallArchitectures=native

# Geen nieuwe privileges via setuid binaries of file capabilities
NoNewPrivileges=yes

# Geen ptrace, geen kernel keyrings, geen personality switches
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
ProtectClock=yes
ProtectHostname=yes

De interactie met de Linux Security Modules is belangrijk om te begrijpen. Sandbox-directieven werken op DAC-niveau (Discretionary Access Control via namespaces en seccomp), terwijl SELinux en AppArmor MAC-policies (Mandatory Access Control) opleggen via LSM-hooks. Ze overlappen niet en sluiten elkaar niet uit; een goed gehard systeem heeft beide. Voor een diepere blik op container-sandboxing op een vergelijkbaar niveau verwijs ik naar onze gids over Podman container security met rootless, seccomp en SELinux.

Capabilities en netwerk-restricties

Linux capabilities zijn de fijnmazige uitsplitsing van root-privileges in (per kernel 6.8) 41 afzonderlijke vlaggen zoals CAP_NET_BIND_SERVICE (binden op poort < 1024) en CAP_SYS_ADMIN (de "alles mag"-cap). Een goed gehard service start vóór alles met CapabilityBoundingSet= die de bounding-set leeg maakt en alleen daadwerkelijk nodige caps toevoegt.

[Service]
# Leeg de bounding set, voeg alleen wat nodig is
CapabilityBoundingSet=
AmbientCapabilities=CAP_NET_BIND_SERVICE

# Voor services die GEEN privileged ports nodig hebben:
# CapabilityBoundingSet=
# AmbientCapabilities=

# Netwerk-restricties
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes

# Of striktere variant: blokkeer alle namespaces behalve mount (nodig voor PrivateTmp)
RestrictNamespaces=~user pid net uts ipc cgroup

# IP-niveau filtering (alleen voor system slice services)
IPAddressDeny=any
IPAddressAllow=localhost 10.0.0.0/8

RestrictAddressFamilies= is bijzonder nuttig. Een database-daemon die alleen op een Unix-socket luistert hoort geen AF_INET nodig te hebben. Door AF_NETLINK, AF_PACKET en AF_BLUETOOTH standaard te blokkeren elimineer je kernel-paden die historisch CVE-bron zijn geweest. IPAddressDeny=any combineert met IPAddressAllow= als een per-service firewall op BPF-niveau. Dit werkt op cgroup v2 en heeft geen iptables of nftables nodig.

Score meten met systemd-analyze security

systemd-analyze security berekent een "exposure level" van 0.0 (volledig sandboxed) tot 10.0 (volledig blootgesteld) op basis van 50+ checks. Elke check heeft een gewicht. De optelsom genormaliseerd naar 0–10 is de eindscore. Beoordelingen zijn: 0.0–0.9 "good", 1.0–1.9 "OK", 2.0–2.9 "medium", 3.0–4.9 "exposed", 5.0+ "UNSAFE".

# Score voor één service
systemd-analyze security nginx.service

# Top 10 minst veilige system services (handig voor audits)
systemd-analyze security --no-pager | sort -k2 -rn | head -10

# Verklarende output met advies per directive
systemd-analyze security nginx.service --no-pager

# JSON-output voor CI-pijplijnen
systemd-analyze security nginx.service --json=short

De JSON-output is bruikbaar in een GitOps-pijplijn. Laat een job na elke push de score uitlezen en faal de build als die boven een drempel komt. Combineer dit met Lynis en OpenSCAP compliance-scanning voor een volledige security-audit van het systeem. Het doel is geen perfecte 0.0 (veel services hebben legitieme netwerk- of state-vereisten) maar wel een gemotiveerde score onder 2.0 met expliciete uitzonderingen.

Praktijkvoorbeeld: nginx volledig hardenen

De volgende drop-in herleidt de exposure score van een standaard Debian 12 nginx-installatie van 9.6 naar 1.4. Plaats het in /etc/systemd/system/nginx.service.d/hardening.conf:

[Service]
# === Filesystem-isolatie ===
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectClock=yes
ProtectControlGroups=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid

# Writable paths die nginx echt nodig heeft
ReadWritePaths=/var/log/nginx /var/cache/nginx /run

# === Capabilities ===
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN CAP_DAC_OVERRIDE CAP_SETUID CAP_SETGID
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes

# === Syscall-filter ===
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
SystemCallArchitectures=native
SystemCallErrorNumber=EPERM

# === Namespaces ===
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RemoveIPC=yes

# === Resource-limieten (defense in depth) ===
LimitNOFILE=65536
LimitNPROC=4096
TasksMax=4096

# === Optioneel: IP-niveau allowlist ===
# IPAddressDeny=any
# IPAddressAllow=localhost 10.0.0.0/8 192.168.0.0/16

Na systemctl daemon-reload && systemctl restart nginx draait nginx ongewijzigd, maar nu binnen een dichte sandbox. Het meest verraderlijke punt bij nginx is MemoryDenyWriteExecute=yes: dit blokkeert PROT_WRITE+PROT_EXEC mappings, en dat is precies wat moderne JIT-compilers gebruiken. Voor pure HTTP-proxying is dit veilig. Maar als je nginx met LuaJIT (OpenResty) draait, moet je deze regel verwijderen. Verifieer altijd met je eigen stack vóór productie.

Troubleshooting van een te strak gehard unit-bestand

Wanneer een service na hardening faalt, geeft journalctl -u <service> -p err -b de eerste hint. Drie patronen komen het meest voor:

  • EACCES op een filesystem-pad. Controleer of ProtectSystem of ProtectHome een legitiem path verbergt. Voeg het toe aan ReadWritePaths= of (read-only) aan ReadOnlyPaths=.
  • EPERM bij een syscall. Meestal blokkeert SystemCallFilter een syscall die de service nodig heeft. Run de service tijdelijk met SystemCallFilter= uitgecommentarieerd en SystemCallLog=@privileged om te zien welke syscalls geprobeerd worden. Daarna voeg je gericht toe.
  • "Permission denied" bij netwerk-bind. AmbientCapabilities=CAP_NET_BIND_SERVICE mist, of RestrictAddressFamilies blokkeert het juiste protocol.
# Real-time tracing van geblokkeerde syscalls
sudo journalctl -u nginx.service -f | grep -i "operation not permitted\|seccomp"

# Of strace de live service (let op: zware overhead)
sudo strace -fp $(pidof nginx | awk '{print $1}') -e trace=%file 2>&1 | grep EACCES

# Of leen de namespaces van een running service om paths te inspecteren
sudo nsenter --target $(systemctl show -p MainPID --value nginx.service) \
  --mount --uts --ipc --net --pid ls -la /etc

Voor een breder beeld op monitoring van afwijkend gedrag (inclusief services die buiten hun sandbox proberen te breken) combineer je systemd-hardening met inbraakdetectie. Onze gids over eBPF runtime dreigingsdetectie met Tetragon en Falco beschrijft hoe je seccomp-violations als security events naar een SIEM stuurt. Voor de volledige systemd.exec(5) referentie zijn de kernel seccomp filter documentatie en de systemd NEWS changelog de meest gezaghebbende bronnen voor versie-verschillen.

Veelgestelde vragen

Wat doet ProtectSystem in systemd?

ProtectSystem= mount delen van het filesystem read-only voor het service-proces via een mount-namespace. De waardes zijn true (alleen /usr en /boot), full (plus /etc) en strict (vrijwel het hele filesystem behalve /dev, /proc, /sys). Andere processen op het systeem zien geen verandering; alleen de gesandboxde service is beperkt.

Wat is het verschil tussen PrivateTmp en PrivateDevices?

PrivateTmp=yes geeft de service een eigen /tmp en /var/tmp via een tmpfs in een private mount-namespace, om symlink-aanvallen en cross-service file-races te voorkomen. PrivateDevices=yes mount een minimale /dev met alleen pseudo-devices (/dev/null, /dev/zero, /dev/random, /dev/urandom, /dev/tty) en blokkeert CAP_MKNOD. Ze richten zich op verschillende aanvalsoppervlakken en zijn standaard combineerbaar.

Hoe controleer je de security score van een systemd unit?

Run systemd-analyze security <unit>. De command geeft per directive aan of die actief is en wat het gewicht is in de totaalscore (0.0 tot 10.0). Scores onder 2.0 worden gelabeld als "OK" of beter. Voor een overzicht van alle services op de host: systemd-analyze security --no-pager.

Is DynamicUser veilig voor productie-services?

Ja, sinds systemd 245 (2020) is DynamicUser=yes productie-rijp en wordt door Red Hat en SUSE gebruikt voor diverse systeem-services. De caveat is dat de service-binary geen hardcoded user-assumpties mag maken. Gebruik StateDirectory=, LogsDirectory= en CacheDirectory= voor persistente paths. Systemd herchownt die automatisch bij elke start.

Vervangen systemd sandbox-directieven SELinux of AppArmor?

Nee. Sandbox-directieven werken op DAC-niveau via namespaces en seccomp, terwijl SELinux en AppArmor MAC-policies opleggen via Linux Security Modules. Ze opereren op verschillende lagen van de kernel en zijn complementair. Een gehard systeem gebruikt beide: systemd-directieven beperken wat een service kan, SELinux/AppArmor beperken wat een service mag.

Yuki Tanaka
Over de Auteur Yuki Tanaka

Linux kernel security engineer with a background in eBPF and LSM. Likes hardening more than she likes sleeping.