Pourquoi durcir le service systemd ?
Lorsqu'on déploie une application ASP.NET Core sur Linux, l'approche la plus courante est de créer un service systemd, de vérifier que Kestrel écoute correctement derrière nginx, puis de passer à autre chose.
Le problème est qu'un service fonctionnel n'est pas nécessairement un service correctement confiné. 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 marge 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 limitent 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 : relier ces briques entre elles, puis arbitrer leurs effets dans un service ASP.NET Core réel. Cet article se concentre sur cette couche d'arbitrage 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 ≥ 247).
Configuration de travail (et non recette universelle)
Cette configuration est basée sur la documentation officielle de Microsoft Host ASP.NET Core on Linux with Nginx, qui propose une configuration de service fonctionnelle minimale. La version ci-dessous y ajoute l'isolation du système de fichiers, le filtrage des appels système, la restriction réseau et les limites de ressources.
Cette configuration est fournie comme support d'analyse. Elle n'est pas destinée à être copiée-collée telle quelle en production. Chaque directive doit être validée dans votre contexte (runtime, dépendances, contraintes d'exploitation, modèle de menace). Elle doit être testée avant déploiement : certaines directives peuvent bloquer silencieusement des fonctionnalités selon les dépendances et l'architecture de votre application. L'auteur ne saurait être tenu responsable d'un dysfonctionnement résultant d'une application directe sans validation préalable.
Les sections suivantes détaillent les arbitrages qui ont un impact spécifique sous .NET. Avant d'appliquer cette configuration, tranchez au minimum les points suivants :
- Votre service exige-t-il un signal de readiness strict (
Type=notify), ouType=simplesuffit-il ? - Quels chemins doivent rester accessibles en écriture avec
ProtectSystem=strict? - Les secrets peuvent-ils migrer de
EnvironmentFileversLoadCredential? - Quelles communications sortantes sont réellement nécessaires ?
- Quelles valeurs
MemoryMaxetTasksMaxrestent soutenables en charge réelle ?
# EXEMPLE DE TRAVAIL
# NE PAS COPIER-COLLER TEL QUEL EN PRODUCTION
# Toutes les valeurs ci-dessous sont à adapter et valider.
[Unit]
Description=MyApp ASP.NET Core
[Service]
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
WorkingDirectory=/opt/myapp/current
ExecStart=/usr/bin/dotnet /opt/myapp/current/MyApp.dll
Environment=DOTNET_NOLOGO=true
EnvironmentFile=/opt/myapp/app.env
# Choix à trancher :
# - Type=simple + ProtectProc=invisible
# - Type=notify + ProtectProc=default
# Voir « Type=simple vs Type=notify et ProtectProc »
Type=simple
# ---------------------------------------------------------------------------
# Process identity
# ---------------------------------------------------------------------------
User=myapp-user
Group=myapp-group
# ---------------------------------------------------------------------------
# Restart policy & arrêt
# ---------------------------------------------------------------------------
Restart=always
RestartSec=10
KillSignal=SIGINT
TimeoutStopSec=45
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
SyslogIdentifier=myapp
# ---------------------------------------------------------------------------
# Filesystem isolation
# ---------------------------------------------------------------------------
PrivateTmp=true
ProtectSystem=strict
# Voir « Système de fichiers en lecture seule »
StateDirectory=myapp
ProtectHome=true
# ---------------------------------------------------------------------------
# Kernel & system protection
# ---------------------------------------------------------------------------
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
# ---------------------------------------------------------------------------
# Process isolation
# ---------------------------------------------------------------------------
# À adapter selon l'arbitrage Type/ProtectProc
ProtectProc=invisible
ProcSubset=pid
PrivateDevices=true
NoNewPrivileges=true
CapabilityBoundingSet=
LockPersonality=true
RestrictRealtime=true
RestrictNamespaces=true
RestrictSUIDSGID=true
KeyringMode=private
UMask=0077
RemoveIPC=true
# Voir « PrivateUsers »
#PrivateUsers=true
# ---------------------------------------------------------------------------
# Network
# ---------------------------------------------------------------------------
# AF_UNIX absent : cohérent avec Type=simple (pas de NOTIFY_SOCKET)
# Si Type=notify : ajouter AF_UNIX
RestrictAddressFamilies=AF_INET AF_INET6
# Filtrage IP de destination : à définir selon les flux réels
#IPAddressDeny=any
#IPAddressAllow=localhost
# ---------------------------------------------------------------------------
# Syscall filtering
# ---------------------------------------------------------------------------
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
# Incompatible avec le JIT .NET, voir « MemoryDenyWriteExecute et le JIT »
#MemoryDenyWriteExecute=true
# ---------------------------------------------------------------------------
# Resource limits : à dimensionner sur mesure
# ---------------------------------------------------------------------------
#MemoryHigh=400M
#MemoryMax=512M
#TasksMax=256
[Install]
WantedBy=multi-user.target
Ce que fait cette configuration
Le fichier unit active une trentaine de directives de sandboxing. En résumé :
le système de fichiers est monté en lecture seule (ProtectSystem=strict)
sauf les chemins explicitement ouverts, /tmp est privé, /home
est inaccessible. Les interfaces noyau (tunables, modules, logs, cgroups, horloge, hostname)
sont protégées en lecture seule ou bloquées. Les processus des autres utilisateurs sont
masqués dans /proc. Toutes les capabilities Linux sont retirées,
les appels système sont limités au groupe @system-service sur l'architecture
native uniquement, et le réseau est restreint à IPv4/IPv6.
Ces directives sont toutes documentées dans
systemd.exec(5) et
systemd.resource-control(5).
Pour une référence pratique directive par directive,
linux-audit.com/systemd/
est une excellente ressource.
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)].
systemd-analyze security entre un service minimal (demo-ms.service) et la configuration étudiée ici (demo-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).
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 score élevé
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.
La suite de cet article se concentre sur les points qui nécessitent un arbitrage ou une adaptation spécifique au runtime .NET.
Les arbitrages spécifiques à .NET
Type=simple vs Type=notify et ProtectProc
La configuration idéale combinerait Type=notify (systemd attend que
l'application signale qu'elle est prête via READY=1) 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.
Cette lecture échoue silencieusement (le processus parent appartient à un autre utilisateur, donc masqué),
READY=1 n'est jamais envoyé, et systemd tue le service après expiration du timeout de démarrage.
Une correction est proposée dans dotnet/runtime (PR #125520, soumise par l'auteur de cet article).
Elle a déjà fait l'objet d'une revue approfondie et a reçu l'approbation d'un maintainer, mais le correctif
n'est pas encore intégré dans une version stable.
État à la date de publication de cet article : correctif non encore intégré dans un runtime stable. Ce statut pouvant évoluer rapidement, vérifiez l'état réel de la PR et la version .NET effectivement déployée avant de figer l'arbitrage d'exploitation.
Option A : priorité sécurité (retenue dans cette configuration)
Type=simple + ProtectProc=invisible. Isolation maximale,
au prix de la notification de démarrage : systemd considère le service opérationnel
dès le fork, sans attendre que l'application soit réellement prête.
AF_UNIX peut être retiré de RestrictAddressFamilies.
Option B : priorité opérationnelle
Type=notify + ProtectProc=default. Ordonnancement de
démarrage fiable, isolation /proc perdue. AF_UNIX doit
être ajouté à RestrictAddressFamilies (pour le NOTIFY_SOCKET).
À reconsidérer après le merge de la PR #125520.
Le choix entre Type=simple et Type=notify ne dépend pas
uniquement de la sécurité locale. ProtectProc=invisible améliore
l'isolation post-exploitation, tandis que Type=notify améliore
la fiabilité du démarrage et l'ordonnancement des dépendances. 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 côté runtime
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 de mappings mémoire PROT_WRITE|PROT_EXEC via mmap(2),
le passage d'un mapping à l'état exécutable via mprotect(2)/pkey_mprotect(2),
et les segments mémoire partagée exécutables via shmat(2) avec SHM_EXEC.
Cette sémantique est documentée dans
systemd.exec(5),
qui précise explicitement l'incompatibilité avec les moteurs JIT et, plus généralement,
avec 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. Sur la plateforme de test utilisée pour cet article
(Debian 13, systemd, .NET 10 en mode JIT), le service a échoué immédiatement avec
status=11/SEGV, ce qui est conforme au comportement attendu.
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 est compatible avec MemoryDenyWriteExecute=true,
au prix de contraintes de build et de compatibilité.
Au moment de la publication de cet article, MVC n'est pas supporté
(Minimal APIs et gRPC uniquement à partir de .NET 8), et certaines API dynamiques sont exclues
(chargement dynamique d'assembly, génération de code à l'exécution, usages de réflexion
non compatibles trimming/AOT).
Ces points de compatibilité évoluent d'une version .NET à l'autre : vérifiez systématiquement
la matrice de support la plus récente avant arbitrage.
Les limitations détaillées sont documentées par Microsoft : Native AOT deployment.
SystemCallFilter
SystemCallFilter=@system-service constitue ici une base pragmatique :
suffisamment restrictive pour éliminer une partie de la surface inutile, sans entrer d'emblée
dans un profil sur mesure fragile à maintenir. Ce n'est pas un profil optimal
pour .NET ; c'est un compromis de départ, à resserrer uniquement sur la base d'un audit réel des
appels système (strace, auditd).
Pour aller plus loin sur le filtrage des syscalls et la gestion des capabilities :
linux-audit.com / Restricting capabilities and syscalls.
Restart policy et arrêt gracieux
Le runtime ASP.NET Core (via IHostApplicationLifetime) gère à la fois
SIGINT et SIGTERM (le défaut systemd) pour déclencher un
arrêt gracieux : drain des requêtes en cours, libération des ressources, puis sortie
propre. Les deux signaux sont donc utilisables ; la configuration retient
KillSignal=SIGINT, conformément à la
documentation Microsoft.
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.
En pratique, cette valeur doit rester supérieure au ShutdownTimeout
configuré dans l'application, qui définit le délai accordé au Generic Host .NET pour
exécuter l'arrêt des IHostedService et drainer les requêtes en cours
(30 secondes par défaut).
Si TimeoutStopSec est inférieur ou égal à ShutdownTimeout,
systemd peut forcer l'arrêt avant la fin de l'arrêt applicatif : requêtes interrompues,
IHostedService.StopAsync coupé, ressources non libérées. La valeur retenue
ici (45 secondes) laisse une marge de 15 secondes sur la valeur par défaut. Si
ShutdownTimeout est augmenté côté application, TimeoutStopSec doit être ajusté en conséquence.
Points d'attention pour le déploiement .NET
Système de fichiers en lecture seule
ProtectSystem=strict monte la quasi-totalité du système de fichiers en
lecture seule. C'est la directive qui casse le plus de déploiements .NET au premier
essai, parce qu'une application ASP.NET Core écrit à plusieurs endroits sans que
ce soit toujours explicite : clés
Data Protection
(dont le chemin par défaut est sous le profil utilisateur, rendu inaccessible par
ProtectHome=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). 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. C'est l'approche idiomatique Linux,
et c'est celle que systemd gère le mieux. L'alternative de tout regrouper sous un
chemin unique (par exemple /opt/myapp) est courante.
Elle reste possible via ReadWritePaths, mais au prix d'une gestion manuelle
des permissions et du nettoyage.
La configuration de cet article 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 variable d'environnement :
// Program.cs
var stateDir = Environment.GetEnvironmentVariable("STATE_DIRECTORY")
?? "/var/lib/myapp";
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(stateDir, "keys")));
Hors contexte systemd, ces variables d'environnement ne sont pas définies.
Un mécanisme de fallback (par exemple, ici ??) est nécessaire
pour garantir le fonctionnement de l'application lors d'exécutions ponctuelles
ou de tests locaux.
En production, le système de
configuration
multi-environnement d'ASP.NET Core offre une approche plus robuste.
Dimensionnement MemoryHigh, MemoryMax et TasksMax
Ces directives sont laissées commentées car les valeurs dépendent directement de
l'application. Pour une application .NET 8, MemoryMax doit tenir compte
de la heap gérée, du JIT et des buffers natifs. Un point de départ raisonnable est
d'observer le pic de consommation en charge représentative et d'ajouter une marge de 30 à 50 %.
Lorsque la limite est atteinte, l'OOM killer du cgroup termine le processus.
Pour une dégradation progressive, MemoryHigh permet de définir un seuil
à partir duquel le noyau ralentit les allocations avant d'atteindre la limite dure.
TasksMax limite le nombre total de tâches (threads + processus).
Une application ASP.NET Core en charge standard crée un nombre de threads borné par
le thread pool, typiquement quelques dizaines.
TasksMax=256 constitue un point de départ conservateur dans de nombreux déploiements
courants, mais cette valeur doit être validée en charge représentative, notamment en présence de
bibliothèques qui multiplient les threads auxiliaires.
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.
Les variables d'environnement sensibles (chaînes de connexion, clés API)
sont chargées via EnvironmentFile=, conformément à la
documentation Microsoft.
Le fichier doit appartenir à root avec des permissions 600
et ne pas être versionné.
Limite à connaître : une fois injectées par systemd, 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 c'est suffisant : tout processus partageant la même identité
(www-data dans cet exemple) peut lire les secrets des
autres. 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é. Il faut noter que les directives de sandboxing
systemd (ProcSubset, PrivateUsers) limitent
ce que le service voit du système, mais ne protègent pas les données
du service (dont /proc/[pid]/environ) contre un
attaquant ayant déjà compromis le système à un niveau supérieur.
Pour les environnements où cette exposition n'est pas acceptable,
systemd propose LoadCredential= (systemd ≥ 247) et
LoadCredentialEncrypted= (systemd ≥ 250) qui placent
les secrets dans un répertoire en mémoire avec des permissions
restrictives, non exposé via /proc. Ces mécanismes
sont documentés dans
System and Service
Credentials / systemd.io.
PrivateUsers (à valider selon le contexte)
PrivateUsers=true crée un namespace utilisateur séparé pour le service.
L'utilisateur du service apparaît comme root (UID 0) à l'intérieur du
namespace, mais ce UID est mappé sur l'UID non privilégié réel à l'extérieur.
Dans le cas décrit ici, cette directive peut être utilisable sans difficulté majeure.
Elle ne doit toutefois être activée qu'après validation explicite des accès fichiers,
sockets Unix et intégrations qui dépendent de l'identité Unix visible hors namespace.
Ce que cette configuration ne couvre pas
Cette configuration réduit l'impact d'une compromission, mais elle ne transforme pas une application vulnérable en application sûre. Elle ne remplace ni la sécurité applicative, ni le durcissement des dépendances, ni la gouvernance des secrets.
Concrètement, elle 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
Cette configuration est conçue comme 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.