Un service exposé qui tourne en root sans confinement, c'est une élévation de
privilèges offerte au premier bug exploité. systemd sait pourtant confiner
chaque unité : système de fichiers en lecture seule, appels système filtrés,
capabilities retirées, le tout sans toucher au code du service. Ce guide montre
comment mesurer l'exposition d'un service avec systemd-analyze security,
le durcir par un drop-in propre, et surtout diagnostiquer quand une
directive casse le service. Toutes les sorties viennent d'un lab réel sur Debian 12
(systemd 252) où nginx passe de 9.6 UNSAFE à 3.5 OK tout en continuant de
servir. C'est exactement ce que Lynis remonte et ce que couvrent les
recommandations ANSSI-BP-028 R64 et R65.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Lire le score d'exposition d'un service avec
systemd-analyze security - Appliquer un jeu de durcissement par drop-in (sans éditer l'unité du paquet)
- Choisir les bonnes directives selon ce que fait le service
- Diagnostiquer une directive trop stricte (codes 226, 227, 228)
Le score d'exposition systemd
Section intitulée « Le score d'exposition systemd »Depuis systemd 240, systemd-analyze security <unité> calcule un niveau
d'exposition noté de 0 à 10. Attention au sens : ce n'est pas une note sur 10,
c'est une mesure de risque où plus le chiffre est bas, mieux c'est. À 10, le
service n'a aucune protection (exposition maximale) ; à 0, il est entièrement
confiné. Chaque directive de sandboxing active fait baisser ce chiffre. systemd
y associe un libellé, du meilleur au pire : SAFE → OK → MEDIUM → EXPOSED
→ UNSAFE → DANGEROUS. Sans argument, la commande liste tous les services
et leur score :
systemd-analyze securityUNIT EXPOSURE PREDICATE HAPPYdbus.service 9.6 UNSAFE :-{systemd-journald.service 4.3 OK :-)systemd-resolved.service 2.1 OK :-)On voit tout de suite la logique : les services systemd modernes (journald,
resolved) sont déjà durcis, alors qu'un service classique reste autour de
9 (UNSAFE).
Prérequis
Section intitulée « Prérequis »- Un serveur Linux avec systemd 240+ (Debian 11+, Ubuntu 20.04+, RHEL 8+)
- Un accès root (ou
sudo) - Une VM de test : une directive trop stricte peut empêcher un service de démarrer
- Un service à durcir (ici nginx, mais la méthode vaut pour tout service)
Mesurer l'exposition de départ
Section intitulée « Mesurer l'exposition de départ »Avant de durcir, capturez la baseline. La commande affiche le score global et la checklist de chaque directive (active ou non, son poids) :
systemd-analyze security nginx.service→ Overall exposure level for nginx.service: 9.6 UNSAFE :-{9.6 sur 10, libellé UNSAFE : c'est presque l'exposition maximale, nginx tourne sans aucun confinement. C'est le point de départ typique d'un service packagé. La liste détaillée au-dessus de cette ligne montre, ligne par ligne, ce qui pourrait être activé pour faire baisser ce chiffre.
Appliquer un drop-in (la bonne méthode)
Section intitulée « Appliquer un drop-in (la bonne méthode) »N'éditez jamais l'unité fournie par le paquet (/lib/systemd/system/nginx.service) :
elle est écrasée à chaque mise à jour. La bonne méthode est un drop-in
override, un fichier de surcharge dans /etc/systemd/system/<unité>.service.d/.
systemctl edit le crée pour vous :
sudo systemctl edit nginx.serviceCela ouvre un éditeur sur /etc/systemd/system/nginx.service.d/override.conf.
Renseignez-y le bloc [Service] avec le jeu de durcissement validé ci-dessous,
puis :
sudo systemctl daemon-reloadsudo systemctl restart nginx.serviceLe jeu de durcissement
Section intitulée « Le jeu de durcissement »Voici une base validée en lab qui fait passer nginx de 9.6 à 3.5 OK sans casser le service. Chaque directive ferme une surface d'attaque précise :
[Service]# Pas de gain de privilege (setuid, file caps)NoNewPrivileges=true# Tout le FS en lecture seule, sauf les chemins listesProtectSystem=strictReadWritePaths=/var/log/nginx /var/lib/nginx /run# Isolation home, /tmp, noyau, cgroupsProtectHome=truePrivateTmp=trueProtectKernelTunables=trueProtectKernelModules=trueProtectControlGroups=true# Reduire familles de sockets, namespaces, suidRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6RestrictNamespaces=trueRestrictSUIDSGID=trueLockPersonality=true# Seccomp : uniquement les appels d'un service raisonnableSystemCallFilter=@system-serviceSystemCallArchitectures=native# Liste blanche de capabilities (nginx : bind port 80 + drop vers www-data)CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_CHOWN CAP_DAC_OVERRIDEUMask=0077Après daemon-reload et restart, vérifiez les deux choses qui comptent : le
score a baissé et le service fonctionne toujours.
systemctl is-active nginx # activecurl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/ # 200systemd-analyze security nginx.service | grep "Overall exposure"active200→ Overall exposure level for nginx.service: 3.5 OK :-)Le service sert toujours en HTTP 200, et son exposition tombe de 9.6 à 3.5. Rappel : c'est une bonne nouvelle, le chiffre baisse parce que nginx est désormais bien mieux confiné, ce qui le fait passer du libellé UNSAFE à OK. Les principales directives et ce qu'elles protègent :
| Directive | Ferme quoi | Risque de casse |
|---|---|---|
NoNewPrivileges | Gain de privilège via execve | Binaires setuid appelés par le service (ping, sudo) |
ProtectSystem=strict | Écriture sur tout le système de fichiers | Service qui écrit hors ReadWritePaths |
ProtectHome | Accès à /home, /root | Service qui lit /home |
PrivateTmp | /tmp partagé entre services | Échange de fichiers via /tmp |
SystemCallFilter=@system-service | Appels système dangereux (seccomp) | Service qui a besoin d'un appel hors du groupe |
CapabilityBoundingSet | Toutes les capabilities sauf la liste | Retirer une capability nécessaire (ex. CAP_NET_BIND_SERVICE) |
MemoryDenyWriteExecute | Pages mémoire W^X | Tous les JIT (Node.js, JVM, .NET) |
Les pièges : quand le durcissement casse le service
Section intitulée « Les pièges : quand le durcissement casse le service »C'est le cœur du sujet. Une directive trop stricte empêche le service de démarrer, ou pire, le laisse actif mais incapable de servir. systemd renvoie alors un code de sortie parlant.
ProtectSystem=strict sans ReadWritePaths
Section intitulée « ProtectSystem=strict sans ReadWritePaths »Le piège le plus fréquent. En lab, ProtectSystem=strict sans déclarer les
chemins inscriptibles fait échouer nginx au démarrage :
systemctl restart nginx# Job for nginx.service failed because the control process exited with error code.systemctl is-active nginx# failedjournalctl -u nginx -n 3 --no-pagernginx[294427]: nginx: configuration file /etc/nginx/nginx.conf test failedsystemd[1]: nginx.service: Failed with result 'exit-code'.nginx ne peut plus écrire ses logs ni son état : le test de configuration échoue.
Correctif : déclarer les chemins légitimes avec ReadWritePaths=/var/log/nginx /var/lib/nginx /run. Un échec de mise en place du confinement renvoie le code
226 (EXIT_NAMESPACE).
Une directive trop agressive : actif mais muet
Section intitulée « Une directive trop agressive : actif mais muet »Plus sournois : un service peut être active et écouter son port, tout en
étant incapable de répondre. En lab, en ajoutant ProcSubset=pid et un filtre
SystemCallFilter=~@privileged @resources trop restrictif, nginx restait active
avec ses workers en écoute sur le port 80, mais curl renvoyait HTTP 000 :
les workers étaient tués à la première requête. Le manuel le dit clairement,
ProcSubset=pid est « inadapté à la plupart des programmes non triviaux ».
La leçon : n'empilez pas les directives à l'aveugle. Partez de
SystemCallFilter=@system-service (le groupe sûr), testez une vraie requête, et
n'ajoutez les directives pointues qu'une par une.
Les codes de sortie à connaître
Section intitulée « Les codes de sortie à connaître »Quand un service durci échoue, le code de sortie (systemctl status) pointe la
directive fautive. Valeurs issues du code source systemd (exit-status.h) :
| Code | Constante | Cause |
|---|---|---|
| 226 | EXIT_NAMESPACE | ProtectSystem / ReadWritePaths / ProtectHome en conflit, ou chemin inexistant |
| 227 | EXIT_NO_NEW_PRIVILEGES | Le noyau ne sait pas appliquer NoNewPrivileges |
| 228 | EXIT_SECCOMP | SystemCallFilter bloque un appel requis (ou noyau sans seccomp) |
| 232 | EXIT_ADDRESS_FAMILIES | RestrictAddressFamilies a bloqué une famille de sockets nécessaire |
Vérifier avec Lynis
Section intitulée « Vérifier avec Lynis »Lynis s'appuie directement sur ce score. Son
test BOOT-5264 exécute systemd-analyze security, classe chaque service par
son libellé (OK, EXPOSED, UNSAFE), et émet la suggestion « Consider
hardening system services ». Durcir vos unités fait donc disparaître cette
remontée :
lynis audit systemlynis show details BOOT-5264Un service passé en OK ne ressort plus comme exposé dans le rapport Lynis, ce qui
relie concrètement votre travail de sandboxing à l'audit de conformité.
Sécurité
Section intitulée « Sécurité »Le durcissement systemd applique le moindre privilège au niveau de chaque service, une défense en profondeur complémentaire des autres couches :
- Le sandboxing réduit l'impact d'une compromission, il ne l'empêche pas. Combinez-le avec un contrôle d'accès obligatoire (SELinux ou AppArmor).
- Le score n'est pas une preuve : un service à 2.0 mal configuré reste vulnérable. La valeur est dans les directives pertinentes, pas dans le chiffre.
- Tracez les changements d'unités avec auditd et complétez par le durcissement noyau via sysctl.
Ces pratiques correspondent aux recommandations ANSSI-BP-028 R64 (« Configurer les privilèges des services ») et R65 (« Cloisonner les services »).
Dépannage
Section intitulée « Dépannage »| Symptôme | Cause probable | Solution |
|---|---|---|
Service failed, code 226 | ProtectSystem=strict sans ReadWritePaths, ou chemin absent | Déclarer les chemins inscriptibles réels |
Service failed, code 228 | SystemCallFilter bloque un appel requis | Partir de @system-service ; ajouter les groupes manquants (@clock…) |
Service active mais ne répond pas | Directive trop agressive (ProcSubset=pid, ~@privileged) | Retirer les directives pointues, réintroduire une par une |
Service JIT qui crash (SIGTRAP) | MemoryDenyWriteExecute=true | Retirer la directive sur un service Node/JVM/.NET |
| Surcharge sans effet | Oubli du daemon-reload ou édition de l'unité du paquet | systemctl edit + daemon-reload ; vérifier avec systemctl cat |
| Données perdues au redémarrage | ProtectHome=tmpfs (écritures volatiles) | Utiliser read-only + ReadWritePaths ciblé |
À retenir
Section intitulée « À retenir »systemd-analyze security <unité>donne un score d'exposition (0-10) ; sans argument, il liste tous les services- Le score est une heuristique : visez des directives pertinentes, pas un chiffre, et testez le service
- Durcir se fait par drop-in (
systemctl edit), jamais en éditant l'unité du paquet - Base efficace :
NoNewPrivileges,ProtectSystem=strict+ReadWritePaths,PrivateTmp,SystemCallFilter=@system-service,CapabilityBoundingSetciblé MemoryDenyWriteExecutecasse les JIT (Node, JVM) ;ProcSubset=pidcasse beaucoup de services- Les codes 226 / 227 / 228 pointent la directive fautive ; réintroduisez une par une
- Lynis
BOOT-5264et ANSSI R64/R65 s'appuient sur ce durcissement