Aller au contenu principal

Durcir un service systemd pour .NET 8+ : les arbitrages spécifiques au runtime

JIT et MemoryDenyWriteExecute, Type=notify face à ProtectProc=invisible, chemins d'écriture sous ProtectSystem=strict : les arbitrages ASP.NET Core pour un service systemd durci.

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 sous ProtectSystem=strict et 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)
  • 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 »).

résultat de systemd-analyze security pour comparer WebApp-system-basic.service et WebApp-system-harden.service
Comparaison indicative du score 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 les CAP_*, poser SystemCallFilter= 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_UNIX reste requis pour NOTIFY_SOCKET, ProtectProc=invisible est exclu tant que le bug UseSystemd() 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 verify pour 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 security comme indicateur, sans le confondre avec une preuve de sécurité globale.

Ressources

Vous souhaitez évaluer la sécurité de votre infrastructure applicative ?

Je propose des missions de conseil en architecture sécurisée et des tests d'intrusion adaptés à vos contraintes techniques.

Discuter de votre besoin

Articles connexes

Secrets ASP.NET Core sous systemd : EnvironmentFile, credentials et AddKeyPerFile

Modèle de menace, limites de /proc/[pid]/environ, directives LoadCredential/SetCredential (et variantes chiffrées), puis intégration ASP.NET Core via AddKeyPerFile pour gérer les secrets sous systemd.