Pourquoi durcir le service systemd ?
Un service systemd qui démarre, écoute sur son port et répond aux requêtes n'est pas, pour autant, un service confiné. La différence n'apparaît qu'en cas d'incident : c'est à ce moment que l'étendue réelle des accès du processus détermine l'impact.
Même exécuté sous un utilisateur dédié, un processus ASP.NET Core conserve souvent plus d'accès que nécessaire au système hôte : portions du système de fichiers lisibles, chemins d'écriture implicites, interfaces noyau visibles, familles d'adresses réseau non restreintes, absence de filtrage explicite des appels système. En cas de compromission applicative ou d'exploitation d'une dépendance, cette surface d'exposition devient une surface de post-exploitation évitable.
Systemd expose un ensemble riche de directives de sandboxing qui s'appuient directement sur des primitives noyau : namespaces, cgroups, seccomp. Elles ne remplacent pas la sécurité applicative, mais elles réduisent concrètement la surface d'exposition du processus et peuvent contribuer à limiter l'impact d'une compromission. La documentation existe et couvre bien les mécanismes pris individuellement (voir la section Ressources). La difficulté, en pratique, est ailleurs : articuler ces briques entre elles, puis analyser leurs effets dans un service ASP.NET Core réel. Cet article se concentre sur cette couche de décision propre au runtime .NET, encore dispersée entre man pages, documentation Microsoft et retours d'implémentation.
Périmètre
Une application ASP.NET Core .NET 8+ (la configuration s'applique à l'identique sous .NET 10) hébergée derrière nginx sur une machine Debian 12+ ou Ubuntu 22.04+ (systemd ≥ 249).
Carte des arbitrages spécifiques à .NET
Les directives génériques de sandboxing systemd (protection des tunables noyau, des modules, des cgroups, de l'horloge, retrait des capabilities, etc.) sont documentées en détail ailleurs, notamment sur linux-audit.com/systemd/. Les arbitrages ci-dessous portent exclusivement sur les directives dont le comportement change dans un contexte .NET, parce que le runtime, le Generic Host ou le JIT entrent en interaction avec la directive.
| Directive | Décision à prendre |
|---|---|
Type= / ProtectProc= |
simple + invisible ou notify + default |
MemoryDenyWriteExecute= |
Désactivé avec JIT, activable en Native AOT |
KillSignal= |
KillSignal=SIGTERM pour rester cohérent avec UseSystemd() |
TimeoutStopSec= |
À tenir strictement supérieur à HostOptions.ShutdownTimeout |
ProtectSystem=strict + StateDirectory= |
Rouvrir les chemins d'écriture pour Data Protection, logs applicatifs, cache |
EnvironmentFile= vs LoadCredential= |
Gestion des identités et des secrets |
Un exemple complet de fichier unit reprenant ces arbitrages, accompagné d'un jeu de directives génériques de sandboxing, est disponible dans la page de ressource à adapter au contexte. Ce fichier est un support d'analyse, pas une configuration à copier en production : chaque directive doit être validée dans votre modèle de menace, vos dépendances et vos contraintes d'exploitation.
Les sections suivantes détaillent chaque ligne.
Type= et ProtectProc=
Par défaut, Type=simple considère le service opérationnel dès le fork,
sans attendre que l'application soit réellement prête.
Type=notify permet à l'application de signaler explicitement à systemd
qu'elle est prête, via l'envoi de READY=1 sur le socket de notification.
Ce mode est généralement préférable pour les applications ASP.NET Core, dont le temps
de démarrage varie selon la charge, les dépendances externes et la compilation JIT
initiale.
Le NOTIFY_SOCKET étant un socket Unix, si vous restreignez
RestrictAddressFamilies=, AF_UNIX doit y figurer.
La configuration idéale combinerait Type=notify et
ProtectProc=invisible (les processus des autres utilisateurs sont masqués
dans /proc). Ces deux directives sont incompatibles dans le cas le
plus courant : un service de niveau system (par opposition à un service user) tournant
sous un utilisateur dédié (User=myapp-user).
La cause est un bug dans la bibliothèque Microsoft.Extensions.Hosting.Systemd : sous
ProtectProc=invisible, UseSystemd() tente de lire /proc/{ppid}/comm
pour détecter si l'application est supervisée par systemd.
Le processus parent appartient à un autre utilisateur et se trouve donc masqué ; la lecture échoue silencieusement, READY=1 n'est jamais envoyé, et systemd tue le service après expiration du timeout de démarrage.
J'ai proposé un correctif (PR #125520). Il a été
mergé sur dotnet/runtime le 23/04/2026 (milestone 11.0.0). Il remplace la lecture de
/proc/{ppid}/comm par une détection fondée sur la variable
$SYSTEMD_EXEC_PID exportée par systemd ≥ 248, avec fallback
/proc pour les systèmes plus anciens. Le correctif sera disponible dans .NET 11.
Sur .NET 8, 9 et 10, les deux options ci-dessous restent d'actualité. Vérifiez la version .NET effectivement déployée avant de figer le choix d'exploitation.
Option A : priorité sécurité
Type=simple + ProtectProc=invisible. Isolation maximale,
au prix de la notification de démarrage et donc d'une fiabilité moindre
sur l'ordonnancement des dépendances After=.
Option B : priorité opérationnelle
Type=notify + ProtectProc=default. Ordonnancement de
démarrage fiable, isolation /proc perdue. AF_UNIX est
généralement requis (pour le NOTIFY_SOCKET et toute IPC locale en socket Unix).
Le choix dépend avant tout du contexte : sur une application isolée avec peu de dépendances, Type=simple peut être acceptable. Sur une architecture avec des dépendances strictes entre services, Type=notify reste souvent préférable tant que la correction n'est pas disponible dans la version déployée.
MemoryDenyWriteExecute= et le JIT
MemoryDenyWriteExecute=true active une protection noyau qui interdit
la création ou la transition de mappings mémoire à la fois inscriptibles et exécutables.
Le détail des appels système concernés et les contournements possibles sont documentés dans
systemd.exec(5),
qui précise explicitement l'incompatibilité avec les moteurs JIT et toute génération
de code à l'exécution.
Le runtime .NET en mode JIT repose précisément sur ce type de transitions mémoire pour produire
et exécuter du code natif à partir de l'IL. En conséquence, activer
MemoryDenyWriteExecute=true sur un service ASP.NET Core exécuté en JIT provoque
un échec au démarrage.
Alternative : Native AOT. Depuis .NET 7 (console) et .NET 8 (ASP.NET Core),
le déploiement en mode Native AOT
produit un binaire compilé entièrement à l'avance, sans JIT au runtime.
Un service déployé en Native AOT rend généralement MemoryDenyWriteExecute=true envisageable, sous réserve d’absence de dépendances/runtime paths nécessitant génération de code à l’exécution et au prix de contraintes de build et de compatibilité.
En .NET 10, MVC reste non supporté en Native AOT (Issue #53667), et:
seuls Minimal APIs et gRPC sont éligibles, et certaines API dynamiques restent exclues (chargement dynamique d'assembly, génération de code à l'exécution, usages de réflexion non compatibles trimming/AOT). La matrice de support évolue d'une version .NET à l'autre, à revérifier avant ce choix.
Les limitations détaillées sont documentées par Microsoft : Native AOT deployment.
Signal d'arrêt et cycle de vie du Generic Host
Le signal d'arrêt envoyé par systemd, contrôlé par
KillSignal=, doit être intercepté par le Generic Host pour
déclencher un arrêt gracieux : drain des requêtes, exécution de
IHostedService.StopAsync, libération des ressources.
Le composant responsable de cette interception est l'implémentation
de IHostLifetime active dans l'application.
Par défaut, c'est ConsoleLifetime qui gère ce rôle. Son
code source enregistre des handlers pour SIGINT,
SIGQUIT et SIGTERM, et déclenche un arrêt
gracieux pour les trois. C'est dans ce contexte que la
documentation Microsoft recommande KillSignal=SIGINT.
L'appel à UseSystemd() change cette mécanique : il substitue
SystemdLifetime à ConsoleLifetime comme
IHostLifetime. Or le
code source de SystemdLifetime n'enregistre un
handler que pour SIGTERM. Ceci est explicitement documenté
dans un commentaire du source. Sur SIGINT, aucun handler
applicatif n'est installé : l'OS applique le comportement par défaut et
termine le processus immédiatement, sans passer par le pipeline
de shutdown du Generic Host.
Le différentiel est mesurable sur un endpoint /LongRequest
simulant une opération de 10 secondes : on lance la requête, puis on
arrête le service quelques secondes plus tard.
Avec KillSignal=SIGINT, la connexion est coupée :
$ curl http://localhost:5000/LongRequest &
$ sudo systemctl stop WebApp-system-notify.service
curl: (52) Empty reply from server
# Journal
systemd[1]: Stopping WebApp-system-notify.service...
systemd[1]: WebApp-system-notify.service: Deactivated successfully.
Avec KillSignal=SIGTERM, Kestrel draine la requête en cours :
$ curl http://localhost:5000/LongRequest &
$ sudo systemctl stop WebApp-system-notify.service
Finished long request
# Journal
systemd[1]: Stopping WebApp-system-notify.service...
Microsoft.Hosting.Lifetime[0] Application is shutting down...
LongRequest[0] Finished long-running operation for /LongRequest.
Microsoft.AspNetCore.Hosting.Diagnostics[2] Request finished ... 200 ... 10060ms
systemd[1]: WebApp-system-notify.service: Deactivated successfully.
Dès que UseSystemd() est présent, que ce soit pour le
format de logs journald ou pour Type=notify,
KillSignal=SIGINT devient silencieusement incompatible avec
le graceful shutdown. La valeur cohérente est SIGTERM,
qui est aussi le défaut de systemd.kill(5) depuis la
version 187 : omettre KillSignal= suffit.
TimeoutStopSec= et ShutdownTimeout
TimeoutStopSec=45 donne au processus 45 secondes pour
s'arrêter proprement après réception du signal. Passé ce délai, systemd
envoie SIGKILL. Cette valeur doit rester strictement
supérieure au ShutdownTimeout configuré dans l'application,
qui définit le délai accordé au Generic Host pour exécuter l'arrêt des
IHostedService et drainer les requêtes en cours (30 secondes
par défaut).
Les deux compteurs ne démarrent pas au même instant. Côté systemd,
TimeoutStopSec= commence dès l'envoi du signal. Côté
Generic Host, ShutdownTimeout ne démarre qu'une fois le
signal traité par le runtime. Avec des valeurs égales, le
SIGKILL tombe avant la fin du shutdown applicatif dès que
celui-ci approche de sa limite (requête longue en vol,
IHostedService lent au démarrage de son arrêt). Résultat :
requêtes interrompues, IHostedService.StopAsync coupé,
ressources non libérées.
La marge nécessaire est techniquement faible (le délai entre l'envoi du
signal et le démarrage effectif de ShutdownTimeout est
généralement de l'ordre de quelques millisecondes). La valeur de 15
secondes retenue ici intègre une marge de sécurité face à la charge
système et aux éventuels délais de GC. Elle reste un choix conservateur,
pas un seuil calculé.
Système de fichiers en lecture seule
ProtectSystem=strict monte l'intégralité du système de fichiers
en lecture seule pour le service, à l'exception des sous-arbres d'API noyau
(/dev/, /proc/, /sys/) qui se gèrent
via leurs directives dédiées (PrivateDevices=,
ProtectKernelTunables=, ProtectControlGroups=).
Les chemins applicatifs traditionnellement utilisés en écriture (/var,
/run, etc.) tombent eux aussi sous la lecture seule, et doivent
être explicitement rouverts via StateDirectory=,
RuntimeDirectory=, LogsDirectory=, CacheDirectory=
ou ReadWritePaths=. Si PrivateTmp= est activé,
/tmp/ et /var/tmp/ redeviennent accessibles en écriture
dans un namespace privé au service. /home/ est traité séparément
par ProtectHome=.
PrivateMounts= agit sur la propagation des montages : les montages créés
par le service ne se propagent plus vers l'hôte, tandis que ceux créés sur l'hôte
restent visibles depuis le service. Ce n'est pas, en soi, un contrôle d'accès aux
fichiers. Dans ce périmètre, où ProtectSystem=strict est déjà activé,
un namespace de montage est de toute façon en place ; ajouter PrivateMounts=
n'apporte donc généralement pas d'effet supplémentaire mesurable. La directive reste
utile surtout lorsqu'on veut expliciter ce comportement sans autre option de file system
namespacing. Elle est disponible pour les services système ; en instance utilisateur,
elle suppose le support des user namespaces non privilégiés côté noyau.
ProtectSystem=strict est la directive qui a le plus de chance de
provoquer des erreurs lors du premier test de déploiements .NET.
Une application ASP.NET Core écrit à plusieurs endroits sans que
ce soit toujours explicite :
- clés Data Protection ; le chemin par défaut (
$HOME/.aspnet/DataProtection-Keys) est inaccessible sousProtectSystem=strictetProtectHome=true - fichiers de logs applicatifs (Serilog file sink, NLog)
- cache sur disque
- fichiers temporaires générés par des librairies tierces (export PDF, manipulation d'images)
- persistance des données
Toutes ces écritures
échouent avec une UnauthorizedAccessException ou
un EACCES parfois difficile à relier à systemd.
Pour identifier les chemins manquants après activation :
# Filtrer les erreurs d'accès dans les logs du service
journalctl -u myapp.service | grep -i "permission denied\|read-only\|EACCES"
Systemd propose plusieurs directives pour ouvrir des chemins en écriture. Chacune crée le répertoire avec les bons droits, expose son chemin via une variable d'environnement, et gère le nettoyage automatiquement :
| Directive | Chemin créé | Variable d'environnement | Usage |
|---|---|---|---|
StateDirectory=myapp |
/var/lib/myapp |
STATE_DIRECTORY |
Données persistantes (clés, fichiers applicatifs). Persiste après l'arrêt du service. |
RuntimeDirectory=myapp |
/run/myapp |
RUNTIME_DIRECTORY |
Artefacts d'exécution éphémères (sockets, PID files). Nettoyé à l'arrêt du service. |
LogsDirectory=myapp |
/var/log/myapp |
LOGS_DIRECTORY |
Fichiers de logs applicatifs, en complément de stdout/journald. |
CacheDirectory=myapp |
/var/cache/myapp |
CACHE_DIRECTORY |
Cache applicatif sur disque. |
Ces directives suivent le Filesystem Hierarchy Standard (FHS) et dispersent
les fichiers du service dans /var/lib, /var/log,
/var/cache et /run.
Cette approche est idiomatique Linux et bien gérée par
Systemd. En pratique cependant, les déploiements ASP.NET
Core hors packaging système (Debian/RPM officiel)
regroupent souvent l'application sous un chemin unique
type /opt/myapp/, parce que
WebApplication.CreateBuilder attend ses
fichiers de configuration à côté du binaire, et que
séparer le binaire (/usr/lib/myapp/) de la
config (/etc/myapp/) demande un code
applicatif de chargement spécifique. Le choix entre FHS
et /opt/ dépend donc du mode de déploiement
choisi. DynamicUser= n'impose pas un layout
FHS strict pour l'ensemble de l'application, mais
renforce l'intérêt d'utiliser ces répertoires systemd
pour les écritures, l'UID du service étant éphémère.
Exemple d'utilisation de StateDirectory=myapp pour le stockage des clés Data Protection
Le fichier unit de référence utilise StateDirectory=myapp pour
stocker les clés Data Protection dans /var/lib/myapp. Côté application,
le chemin est récupéré via la configuration ASP.NET Core ou, à défaut, via
la variable d'environnement exposée par systemd :
// Program.cs
var stateDir = builder.Configuration.GetValue<string>("DataProtection:KeysPath");
if(string.IsNullOrEmpty(stateDir)){
stateDir = Environment.GetEnvironmentVariable("STATE_DIRECTORY")
?? throw new InvalidOperationException("Aucun chemin de stockage des clés Data Protection configuré.");
stateDir = Path.Combine(stateDir, "keys");
}
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(stateDir));
Sans chemin accessible en écriture, Data Protection bascule à terme sur un stockage des clés éphémère en mémoire, avec des conséquences fonctionnelles (déconnexions utilisateurs) qui peuvent n'apparaître que plusieurs semaines après le déploiement, au moment de la rotation des clés.
Hors contexte systemd, STATE_DIRECTORY n'est pas définie.
La configuration
multi-environnement d'ASP.NET Core permet d'injecter un chemin
via une configuration spécifique pour les environnements concernés.
Identité du processus et secrets
Le service tourne sous un utilisateur dédié (User=myapp-user),
créé avec le minimum de droits nécessaires et sans shell de connexion.
DynamicUser= est une alternative à User=.
Les variables d'environnement sensibles (chaînes de connexion, clés API)
sont chargées via EnvironmentFile=.
Le fichier doit appartenir à root avec des permissions 600
et ne pas être versionné.
Limite à connaître : une fois injectées par systemd, sur une configuration
procfs standard, les variables d'environnement sont lisibles via
/proc/[pid]/environ par tout processus exécuté sous le même
utilisateur système :
$ ls -la /proc/15880/environ
-r-------- 1 www-data www-data 0 Apr 14 15:03 /proc/15880/environ
Le fichier est en lecture seule pour le propriétaire du processus, mais
cela suffit à poser un problème : tout processus s'exécutant sous le même
UID (www-data dans cet exemple) peut lire les variables
d'environnement de n'importe quel autre processus partageant cette identité.
C'est un argument supplémentaire pour isoler chaque service
sous un utilisateur dédié (User=myapp-user) plutôt que
sous un compte partagé.
Les directives de sandboxing comme ProcSubset=
ou PrivateUsers= limitent ce que le service voit du système,
mais elles ne protègent pas les données propres au processus,
/proc/[pid]/environ inclus, contre un attaquant
ayant déjà compromis un composant plus privilégié.
Pour les cas où il faut éviter spécifiquement /proc/[pid]/environ
(par exemple lorsque l'outillage applicatif sérialise les variables
d'environnement : pages d'erreur, APM, dumps mémoire), ou pour bénéficier
d'un chiffrement au repos sur disque, systemd propose
LoadCredential= et LoadCredentialEncrypted=.
Les secrets transitent alors par un tmpfs accessible uniquement au service
via une variable d'environnement $CREDENTIALS_DIRECTORY, et
n'apparaissent plus dans l'environnement du processus.
L'intégration .NET fait l'objet
d'un article dédié.
Mesurer l'effet des directives
La commande systemd-analyze verify myapp.service permet de valider
la syntaxe du fichier unit avant déploiement.
La commande systemd-analyze security myapp.service permet ensuite d'évaluer
le score de durcissement obtenu et d'identifier les axes d'amélioration restants
[systemd-analyze(1)].
La capture ci-dessous compare deux services issus d'un même binaire ASP.NET Core : WebApp-system-basic.service (configuration recommandée par la documentation Microsoft « Host on Linux with Nginx ») et WebApp-system-harden.service (configuration de cet article). Le score passe de 9.2 (« exposed ») à 1.7 (« OK »).
systemd-analyze security entre un service minimal (WebApp-system-basic.service) et la configuration étudiée ici (WebApp-system-harden.service).
Le score passe de 9.2 à 1.7 (-7.5 points), ce qui reflète une réduction de l'exposition selon les critères de l'outil.
Ce résultat ne constitue pas, à lui seul, une preuve de sécurité.
La comparaison n'est pertinente qu'à environnement constant (même hôte, même version de systemd, mêmes hypothèses d'exécution).
Lire le score sans se faire piéger
Le score s'agrège à partir d'une grille de directives auxquelles
systemd-analyze attribue chacune une exposition individuelle
(de 0.1 à 0.5). Trois biais à connaître avant de comparer :
-
une seule ligne dans le fichier unit peut faire basculer une dizaine
de sous-critères : poser
CapabilityBoundingSet=coche simultanément tous lesCAP_*, poserSystemCallFilter=coche tous les@*. La décroissance du score n'est pas proportionnelle à l'effort fourni. -
certaines expositions restantes sont des choix d'exploitation, pas des
faiblesses : un service web a besoin de
AF_INET,AF_UNIXreste requis pourNOTIFY_SOCKET,ProtectProc=invisibleest exclu tant que le bugUseSystemd()n'est pas corrigé en production. Le score pénalise ces lignes sans pouvoir savoir qu'elles sont voulues. -
le score ne mesure que ce qu'il sait mesurer. Une chaîne de connexion
en clair dans
EnvironmentFile=, un endpoint exposé sans authentification ou une dépendance vulnérable n'apparaissent nulle part dans la note.
Le score systemd-analyze security est un indicateur utile, mais il ne
constitue pas une preuve de sécurité globale. Un service peut obtenir un bon score
tout en restant fortement exposé si des secrets sont accessibles en mémoire,
si les flux réseau sortants sont trop ouverts, ou si l'application présente une
vulnérabilité exploitable.
Ce que cette approche ne couvre pas
Le durcissement systemd réduit l'impact d'une compromission, mais il ne transforme pas une application vulnérable en application sûre. Il ne remplace ni la sécurité applicative, ni le durcissement des dépendances, ni la gouvernance des secrets.
Concrètement, il ne protège pas contre :
- l'exfiltration de secrets déjà chargés en mémoire du processus ;
- l'usage malveillant d'accès réseau explicitement autorisés ;
- les actions dans les chemins ouverts en écriture ;
- les vulnérabilités noyau ou les échappements hors primitives de sandboxing ;
- les erreurs d'autorisation dans les systèmes externes (base de données, cache, API tierces).
Le hardening systemd reste donc une couche de confinement : essentielle en défense en profondeur, mais insuffisante à elle seule.
Validation avant production
Les décisions décrites dans cet article forment une base de travail. Avant déploiement, une validation en environnement de test est indispensable pour éviter les régressions silencieuses et les faux positifs de sécurité.
Un protocole simple et pragmatique consiste à :
- activer d'abord un profil de base, puis renforcer progressivement ;
- utiliser
systemd-analyze verifypour valider la syntaxe des fichiers unit avant déploiement ; - vérifier les erreurs d'accès disque et expliciter tous les chemins d'écriture nécessaires ;
- valider Data Protection, logs et cache sur charge représentative ;
- tester les arrêts gracieux et les redémarrages ;
- cartographier les flux réseau réellement nécessaires avant de restreindre ;
- utiliser
systemd-analyze securitycomme indicateur, sans le confondre avec une preuve de sécurité globale.
Ressources
- linux-audit.com/systemd/ : référence pratique sur le durcissement systemd, avec le détail de chaque directive et son implémentation noyau. Complément direct de cet article pour les directives génériques non détaillées ici.
- systemd.io : documentation officielle systemd, man pages et documents conceptuels, dont CREDENTIALS/ pour la gestion des secrets.
- man7.org : man pages Linux de référence, dont systemd.exec(5), systemd.resource-control(5) et capabilities(7).
- learn.microsoft.com / ASP.NET Core : documentation officielle Microsoft, dont Data Protection, Native AOT et Host on Linux with Nginx.