Hardening dei Servizi Systemd 2026: Guida da Pentester al Sandboxing con Direttive Native

Impara a fare hardening dei servizi systemd con le direttive native di sandboxing: systemd-analyze security, drop-in per nginx da 9.6 UNSAFE a 2.9 OK e workflow di applicazione testato in campo.

Hardening Systemd 2026: Sandbox Servizi

Aggiornato: 7 Agosto 2026

L'hardening dei servizi systemd consiste nell'applicare le direttive di sandboxing native di systemd (ProtectSystem, NoNewPrivileges, SystemCallFilter, CapabilityBoundingSet e altre trenta) alle unit file dei daemon Linux, riducendo il raggio d'azione di un servizio compromesso senza toccare una riga del codice applicativo. Nei miei ingaggi OSCP-style l'80% dei privilege escalation parte da un servizio scritto per funzionare, non per resistere all'attacco. Un drop-in di venti righe, onestamente, chiude quasi sempre la porta.

  • Systemd espone oltre 30 direttive di sicurezza per unit file, ma la maggior parte dei pacchetti upstream (nginx, redis, php-fpm) non ne usa nessuna. Lo score di systemd-analyze security è tipicamente 9.6 UNSAFE sui default.
  • Un drop-in di ~20 righe con NoNewPrivileges, ProtectSystem=strict, PrivateTmp, SystemCallFilter=@system-service porta nginx da 9.6 UNSAFE a ~2.9 OK, senza toccare il config applicativo.
  • Le direttive vanno inserite in un drop-in (systemctl edit), MAI editando /lib/systemd/system/*.service. Gli upgrade sovrascrivono i file vendor.
  • L'ordine di applicazione conta. Prima le direttive "safe" (NoNewPrivileges, PrivateTmp), poi filesystem, poi capability, poi seccomp, testando dopo ogni step con journalctl -u <servizio>.
  • Lo score di systemd-analyze security è un'euristica, non una prova. Non conosce SELinux, AppArmor né i bug applicativi. Va usato come indicatore di superficie, non come certificato.

Che cos'è il sandboxing dei servizi systemd

Il sandboxing di systemd è un insieme di direttive dichiarative, scritte nella sezione [Service] di una unit file, che istruiscono il service manager a configurare namespace, filtri seccomp-BPF, bounding set delle capability e restrizioni di filesystem prima che il processo del daemon parta. Non è un container. Non c'è overlay, non c'è overhead di runtime, non c'è un secondo binario. È il kernel Linux, invocato tramite gli stessi meccanismi che usano Podman, systemd-nspawn e i pod di Kubernetes.

Come pentester, i servizi con unit file "vanilla" sono la mia zona di caccia preferita. Un webhook receiver in Python che ho compromesso a marzo aveva accesso in scrittura all'intero filesystem, poteva leggere /proc/*/environ di ogni processo dell'host, non aveva syscall filtering e girava con la capability CAP_SYS_ADMIN per un motivo che nessuno ricordava più. Non c'era nemmeno un bug in Python: bastava eseguire un comando di shell attraverso la deserializzazione di un JSON e da lì saltare in /etc/cron.d. Un solo NoNewPrivileges=true avrebbe già dimezzato le mie opzioni, e un ProtectSystem=strict mi avrebbe chiuso lo shell in faccia.

La documentazione ufficiale su systemd.exec(5) elenca l'intero catalogo di direttive. Quello che i pacchetti Debian, Ubuntu e Fedora spediscono, invece, è quasi sempre il minimo indispensabile per funzionare. Un nginx.service di default su Ubuntu 24.04 non contiene nessuna direttiva di hardening. Su Fedora 40, dove l'attenzione alla sicurezza è mediamente maggiore, se ne trovano tre o quattro. Su distribuzioni conservative come Debian stable, l'unit file di redis-server è quasi identico a quello del 2016.

Come usare systemd-analyze security per trovare i servizi esposti

Il primo comando che eseguo quando entro in un host è systemd-analyze security. Senza argomenti stampa una tabella di ogni service unit attivo con un exposure score che va da 0.0 (blindato) a 10.0 (spalancato). È l'equivalente sistemistico di un port scan: mi dice dove buttare l'occhio prima.

# Panoramica generale, ogni servizio con il suo score
systemd-analyze security

# Output tipico su Ubuntu 24.04 fresh install:
# UNIT                          EXPOSURE PREDICATE HAPPY
# atd.service                        9.6 UNSAFE    😨
# cron.service                       9.6 UNSAFE    😨
# nginx.service                      9.6 UNSAFE    😨
# ssh.service                        9.6 UNSAFE    😨
# systemd-timesyncd.service          2.1 OK        🙂
# systemd-resolved.service           2.4 OK        🙂

Il fatto che systemd-timesyncd stia sotto 2.5 mentre cron arrivi a 9.6 non è casuale. Le unit shippate dal team upstream di systemd sono quasi sempre già hardened, mentre quelle dei package di terze parti no. Il secondo passaggio è chiedere il dettaglio di un singolo servizio.

# Analisi dettagliata di un servizio specifico
systemd-analyze security nginx.service

# Output (estratto):
#   NAME                                        DESCRIPTION                                              EXPOSURE
# ✗ PrivateNetwork=                             Service has access to the host's network                     0.5
# ✗ User=/DynamicUser=                          Service runs as root user                                    0.4
# ✗ CapabilityBoundingSet=~CAP_SYS_ADMIN        Service has administrator privileges                         0.3
# ✗ CapabilityBoundingSet=~CAP_SYS_PTRACE       Service has ptrace() debugging abilities                     0.3
# ✗ RestrictAddressFamilies=~AF_(INET|INET6)    Service may allocate Internet sockets                        0.3
# ✓ NoNewPrivileges=                            Service processes cannot acquire new privileges              0.0
# ...
# → Overall exposure level for nginx.service: 9.6 UNSAFE 😨

Ogni riga con la è una direttiva che stai non applicando, con il peso che contribuisce al totale. Ordinandole per peso decrescente hai la tua checklist di priorità. In un ingaggio reale, questa è la lista che consegno al cliente dopo la prima ora di enumeration.

Le direttive di hardening che uso in ogni report

Le trenta e passa direttive del catalogo systemd.exec non hanno lo stesso peso. Dopo qualche centinaio di unit hardened, ho isolato quattro gruppi che coprono l'80% del gap: filesystem, privilegi, syscall e kernel/IPC. Le applico esattamente in quest'ordine, perché ogni gruppo rende il successivo più facile da debuggare.

Isolamento del filesystem

ProtectSystem=strict monta l'intero filesystem in read-only per il processo, con l'eccezione di /dev, /proc e /sys. ProtectHome=true nasconde /home, /root e /run/user (li fa apparire vuoti). PrivateTmp=true monta un /tmp e un /var/tmp privati, invisibili a ogni altro processo. Chiude tutta la classe di attacchi da race sulle temp file, che è stata la fonte principale di CVE su servizi come X.Org fino al 2020.

Il problema è che quasi tutti i daemon devono scrivere da qualche parte. La direttiva ReadWritePaths= ripristina la scrittura sulle directory che ti servono davvero, senza aprire il resto del disco. Il pattern che uso:

[Service]
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/log/nginx /var/cache/nginx /run/nginx

Riduzione dei privilegi

NoNewPrivileges=true è la singola direttiva con il miglior rapporto sicurezza/rischio dell'intero catalogo. Impone al processo (e a tutti i suoi figli) di non poter mai guadagnare nuovi privilegi tramite execve(): setuid, setgid, capability di filesystem, tutto disattivato. Se un attacker riesce a eseguire codice nel contesto del servizio, non può escalare montando un binario setuid come ping o sudo. Il breakage rate nella mia esperienza è vicino allo zero.

CapabilityBoundingSet= definisce l'insieme massimo di capability Linux che il processo può possedere. Un daemon che ascolta sulla porta 80 ne ha bisogno di una sola (CAP_NET_BIND_SERVICE). Tutte le altre, tipo CAP_SYS_ADMIN, CAP_NET_ADMIN, CAP_DAC_OVERRIDE, vanno rimosse:

[Service]
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

Syscall filtering con seccomp-BPF

SystemCallFilter= installa un filtro seccomp-BPF che blocca chiamate di sistema fuori dall'allowlist. Non devi curare la lista a mano. Systemd espone gruppi pre-curati come @system-service (l'insieme ragionevole per un daemon di rete), @basic-io, @network-io. Bloccare le syscall esotiche taglia via metà dei kernel exploit e la maggior parte delle backdoor che ho visto in-the-wild:

[Service]
SystemCallFilter=@system-service
SystemCallFilter=~@mount @debug @cpu-emulation @obsolete @privileged @raw-io @reboot @swap
SystemCallArchitectures=native
SystemCallErrorNumber=EPERM

Il tilde (~) crea una denylist all'interno dell'allowlist. SystemCallErrorNumber=EPERM restituisce un errore anziché killare il processo, il che rende il debug enormemente più semplice: vedi la syscall che manca nel journalctl invece di trovare il servizio morto.

Restrizioni kernel e IPC

L'ultimo gruppo chiude la porta a interazioni con kernel e altri processi che quasi nessun daemon userspace ha davvero bisogno di fare:

[Service]
ProtectKernelTunables=true       # /proc/sys, /sys read-only
ProtectKernelModules=true        # niente insmod/rmmod
ProtectKernelLogs=true           # niente /dev/kmsg
ProtectControlGroups=true        # /sys/fs/cgroup read-only
ProtectClock=true                # niente settimeofday()
RestrictNamespaces=true          # niente unshare/setns()
RestrictRealtime=true            # niente scheduler real-time
RestrictSUIDSGID=true            # niente creazione file setuid
LockPersonality=true             # niente personality() switch
MemoryDenyWriteExecute=true      # W^X: no pagine RWX (blocca JIT-based exploit)
RemoveIPC=true                   # cleanup IPC allo stop
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

Delle undici direttive qui sopra, l'unica che ha una vera probabilità di rompere qualcosa è MemoryDenyWriteExecute. Blocca la scrittura in pagine di memoria eseguibili, che è quello che fanno tutti i runtime JIT (V8, LuaJIT, .NET, Java HotSpot in alcune modalità). Un webserver in Go, C, Rust o Python interpretato non se ne accorge; un servizio Node.js sì.

Esempio pratico: nginx da 9.6 UNSAFE a 2.9 OK

Metti insieme i quattro gruppi e ottieni un drop-in di circa venticinque righe che sposta nginx da 9.6 UNSAFE al di sotto di 3.0. La procedura per applicarlo correttamente (senza sovrascrivere il file vendor) è sempre systemctl edit:

# Crea il drop-in override (apre l'editor definito in $EDITOR)
sudo systemctl edit nginx.service

# Il file viene creato in:
# /etc/systemd/system/nginx.service.d/override.conf

Contenuto del drop-in:

[Service]
# --- Filesystem ---
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ReadWritePaths=/var/log/nginx /var/cache/nginx /run/nginx

# --- Privilegi ---
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN CAP_SETUID CAP_SETGID CAP_DAC_OVERRIDE
AmbientCapabilities=CAP_NET_BIND_SERVICE

# --- Syscall ---
SystemCallFilter=@system-service
SystemCallFilter=~@mount @debug @cpu-emulation @obsolete @privileged @raw-io @reboot @swap
SystemCallArchitectures=native
SystemCallErrorNumber=EPERM

# --- Kernel e IPC ---
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
RemoveIPC=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

# --- Resource limits (bonus anti-DoS) ---
MemoryMax=1G
TasksMax=512

Ricarica e verifica:

sudo systemctl daemon-reload
sudo systemctl restart nginx
sudo systemd-analyze security nginx.service | tail -5

# → Overall exposure level for nginx.service: 2.9 OK 🙂

Tabella comparativa: profili minimo, consigliato, paranoico

Non tutti i servizi meritano lo stesso livello di paranoia. Un daemon interno che ascolta su un socket Unix in una rete privata ha un profilo di rischio diverso da un reverse proxy esposto su internet. Uso tre profili di riferimento:

DirettivaMinimo (safe)ConsigliatoParanoico
NoNewPrivilegestruetruetrue
PrivateTmptruetruetrue
ProtectSystemfullstrictstrict
ProtectHome-truetrue
PrivateDevices-truetrue
CapabilityBoundingSet-minimo necessario~ (vuoto)
SystemCallFilter-@system-serviceallowlist chirurgica
MemoryDenyWriteExecute-true (no JIT)true
PrivateNetwork--true (se locale)
Exposure score tipico~6.5 MEDIUM~3.0 OK~1.5 SAFE
Rischio di rotturatrascurabilebasso, testabilealto, va tunato

Il profilo "minimo" è quello che consiglio di applicare senza pensarci a qualunque servizio custom: costa cinque righe, non rompe niente, e da solo taglia lo score di due o tre punti. Il "consigliato" è il target per i daemon esposti su internet. Il "paranoico" lo riservo a servizi che gestiscono chiavi crittografiche o dati regolamentati, dove il costo di tuning vale la superficie ridotta.

Come applicare l'hardening senza rompere i servizi

L'errore più comune che vedo è applicare tutte le direttive in un colpo solo, riavviare, vedere il servizio morire e disabilitare tutto. Il workflow che funziona è iterativo:

  1. Snapshot dello stato attuale. systemd-analyze security <servizio> > /tmp/before.txt: salva lo score e la tabella di partenza.
  2. Applica il profilo minimo (NoNewPrivileges, PrivateTmp, ProtectSystem=full) e riavvia. Se il servizio parte, sei già a metà strada.
  3. Aggiungi il profilo consigliato una direttiva alla volta, riavviando e controllando journalctl -u <servizio> -n 100 --no-pager dopo ognuna. Le failure più comuni sono EACCES su path che il servizio deve scrivere (aggiungi a ReadWritePaths) e EPERM su syscall. L'errore in journalctl ti dice quale, così puoi aggiungerla all'allowlist o rimuovere il gruppo che la blocca.
  4. Testa la superficie applicativa reale. Se stai indurendo un web server, non basta che parta: devi vedere che serve traffico. Sanity check con curl, poi con un test suite se ne hai una.
  5. Documenta il drop-in in git (io li tengo in un repo Ansible/Salt separato) e distribuiscilo con la stessa pipeline del resto della config.

Per debuggare i failure di seccomp, l'opzione SystemCallLog=@system-service stampa nel journal ogni syscall consentita. Utilissimo per profilare quali syscall il servizio davvero usa prima di stringere il filtro. Da rimuovere in produzione: è rumoroso.

Per un approccio complementare che va oltre systemd (sandboxing unprivileged a livello LSM) vedi la guida su Landlock e sandboxing con LSM nativo del kernel. Landlock lavora dentro il processo (application-side), systemd sandbox lavora fuori (init-side): si combinano bene su servizi critici.

Cosa NON puoi fare con systemd

Lo score di systemd-analyze security è un'euristica di superficie, non un certificato di sicurezza. Ci sono tre categorie di rischi che rimangono aperti anche con score 1.5 SAFE:

Bug applicativi in-process. Se il tuo daemon ha una SSRF, una path traversal o una deserializzazione insicura, l'attacker gira dentro il perimetro di trust del servizio. Le direttive systemd limitano quello che può fare uscendo, non quello che può fare dentro. Un WAF, il rate limiting e la review del codice non li sostituisce nessuna direttiva di sandboxing.

Servizi user-mode. Le unit sotto ~/.config/systemd/user/ non possono essere hardened come le system unit. Il rationale ufficiale è che permetterlo aprirebbe problemi di privilege escalation su quel modello. Per servizi utente valuta bwrap, Flatpak sandbox o Landlock direttamente.

Interazioni con altri LSM. Score OK di systemd non implica policy AppArmor o SELinux corrette, anzi le due cose sono ortogonali. Sul mio Debian di test un servizio con score 2.9 può avere una policy AppArmor completamente permissiva, e viceversa un servizio con score 8.0 può essere strettamente confinato da SELinux. La difesa in profondità richiede entrambi, e va misurata con audit dedicati come OpenSCAP e i benchmark CIS.

Per un'introduzione più formale al modello di threat che le direttive coprono e a quello che lasciano scoperto, il paper originale di Lennart Poettering sui dynamic users resta la migliore lettura di fondo.

Domande frequenti

Cos'è il sandboxing dei servizi systemd?

È l'insieme delle direttive dichiarative (ProtectSystem, NoNewPrivileges, SystemCallFilter, CapabilityBoundingSet, ecc.) che si aggiungono a una unit file .service per far configurare a systemd namespace, filtri seccomp-BPF e restrizioni di capability prima che il processo del daemon parta. Non richiede modifiche al codice del servizio e non aggiunge overhead percepibile.

Come faccio a misurare la sicurezza di un servizio systemd?

Il comando systemd-analyze security <servizio> stampa una tabella di ogni direttiva di sandboxing rilevante con il suo contributo allo score totale, e un exposure score complessivo tra 0.0 (blindato) e 10.0 (spalancato). Senza argomenti mostra il punteggio di tutti i servizi attivi: è il punto di partenza per prioritizzare l'hardening.

Quale score dovrei puntare a ottenere?

Sotto 3.0 (OK) per servizi esposti su internet, sotto 5.0 (MEDIUM) per servizi interni. Score 0 è quasi irraggiungibile e non è un obiettivo utile: alcune direttive romperebbero funzionalità legittime. Considera lo score un indicatore di superficie, non una prova di correttezza. Un servizio con score 1.5 può comunque avere una SQL injection.

Le direttive di hardening rompono i miei servizi?

Le direttive "safe" (NoNewPrivileges, PrivateTmp, ProtectSystem=full) sono quasi sempre applicabili senza rischi. MemoryDenyWriteExecute rompe i runtime JIT (Node.js, .NET, alcuni Java). ProtectSystem=strict richiede di enumerare i path scrivibili con ReadWritePaths=. SystemCallFilter può bloccare syscall usate dal daemon: usa SystemCallErrorNumber=EPERM per debuggare senza kill.

Devo modificare direttamente il file .service del pacchetto?

Mai. I file in /lib/systemd/system/ o /usr/lib/systemd/system/ vengono sovrascritti dagli aggiornamenti del pacchetto. Usa sempre systemctl edit <servizio>, che crea un drop-in in /etc/systemd/system/<servizio>.service.d/override.conf. Il drop-in viene mergiato sopra il file vendor e sopravvive agli upgrade.

Systemd sandboxing sostituisce SELinux o AppArmor?

No, sono complementari. Le direttive systemd applicano restrizioni tramite namespace, seccomp e capability; SELinux e AppArmor applicano policy Mandatory Access Control su file, socket e IPC. Un servizio ben protetto ha tutti e tre: sandbox systemd per la superficie kernel, un LSM per il MAC, e (opzionalmente) un ulteriore livello con Landlock lato applicazione.

Felix Lindqvist
Sull'Autore Felix Lindqvist

Penetration tester and OSCP holder. Reverse engineers misconfigured servers for a living and writes about what he finds.