Aller au contenu
Sécurité medium

Durcir un service systemd avec le sandboxing

12 min de lecture

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.

  • 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)

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 : SAFEOKMEDIUMEXPOSEDUNSAFEDANGEROUS. Sans argument, la commande liste tous les services et leur score :

Fenêtre de terminal
systemd-analyze security
UNIT EXPOSURE PREDICATE HAPPY
dbus.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).

  • 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)

Avant de durcir, capturez la baseline. La commande affiche le score global et la checklist de chaque directive (active ou non, son poids) :

Fenêtre de terminal
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.

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 :

Fenêtre de terminal
sudo systemctl edit nginx.service

Cela 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 :

Fenêtre de terminal
sudo systemctl daemon-reload
sudo systemctl restart nginx.service

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 listes
ProtectSystem=strict
ReadWritePaths=/var/log/nginx /var/lib/nginx /run
# Isolation home, /tmp, noyau, cgroups
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
# Reduire familles de sockets, namespaces, suid
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
RestrictSUIDSGID=true
LockPersonality=true
# Seccomp : uniquement les appels d'un service raisonnable
SystemCallFilter=@system-service
SystemCallArchitectures=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_OVERRIDE
UMask=0077

Après daemon-reload et restart, vérifiez les deux choses qui comptent : le score a baissé et le service fonctionne toujours.

Fenêtre de terminal
systemctl is-active nginx # active
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/ # 200
systemd-analyze security nginx.service | grep "Overall exposure"
active
200
→ 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 :

DirectiveFerme quoiRisque de casse
NoNewPrivilegesGain de privilège via execveBinaires setuid appelés par le service (ping, sudo)
ProtectSystem=strictÉcriture sur tout le système de fichiersService qui écrit hors ReadWritePaths
ProtectHomeAccès à /home, /rootService qui lit /home
PrivateTmp/tmp partagé entre servicesÉchange de fichiers via /tmp
SystemCallFilter=@system-serviceAppels système dangereux (seccomp)Service qui a besoin d'un appel hors du groupe
CapabilityBoundingSetToutes les capabilities sauf la listeRetirer une capability nécessaire (ex. CAP_NET_BIND_SERVICE)
MemoryDenyWriteExecutePages mémoire W^XTous 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.

Le piège le plus fréquent. En lab, ProtectSystem=strict sans déclarer les chemins inscriptibles fait échouer nginx au démarrage :

Fenêtre de terminal
systemctl restart nginx
# Job for nginx.service failed because the control process exited with error code.
systemctl is-active nginx
# failed
journalctl -u nginx -n 3 --no-pager
nginx[294427]: nginx: configuration file /etc/nginx/nginx.conf test failed
systemd[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).

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.

Quand un service durci échoue, le code de sortie (systemctl status) pointe la directive fautive. Valeurs issues du code source systemd (exit-status.h) :

CodeConstanteCause
226EXIT_NAMESPACEProtectSystem / ReadWritePaths / ProtectHome en conflit, ou chemin inexistant
227EXIT_NO_NEW_PRIVILEGESLe noyau ne sait pas appliquer NoNewPrivileges
228EXIT_SECCOMPSystemCallFilter bloque un appel requis (ou noyau sans seccomp)
232EXIT_ADDRESS_FAMILIESRestrictAddressFamilies a bloqué une famille de sockets nécessaire

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 :

Fenêtre de terminal
lynis audit system
lynis show details BOOT-5264

Un 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é.

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 »).

SymptômeCause probableSolution
Service failed, code 226ProtectSystem=strict sans ReadWritePaths, ou chemin absentDéclarer les chemins inscriptibles réels
Service failed, code 228SystemCallFilter bloque un appel requisPartir de @system-service ; ajouter les groupes manquants (@clock…)
Service active mais ne répond pasDirective trop agressive (ProcSubset=pid, ~@privileged)Retirer les directives pointues, réintroduire une par une
Service JIT qui crash (SIGTRAP)MemoryDenyWriteExecute=trueRetirer la directive sur un service Node/JVM/.NET
Surcharge sans effetOubli du daemon-reload ou édition de l'unité du paquetsystemctl edit + daemon-reload ; vérifier avec systemctl cat
Données perdues au redémarrageProtectHome=tmpfs (écritures volatiles)Utiliser read-only + ReadWritePaths ciblé
  • 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, CapabilityBoundingSet ciblé
  • MemoryDenyWriteExecute casse les JIT (Node, JVM) ; ProcSubset=pid casse beaucoup de services
  • Les codes 226 / 227 / 228 pointent la directive fautive ; réintroduisez une par une
  • Lynis BOOT-5264 et ANSSI R64/R65 s'appuient sur ce durcissement

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn