HardStacked
Partager :

systemd · permissions

Un service systemd tourne en root et reçoit « Permission denied » (Debian 13)

Publié le · HardStacked · 8 min

Ton unité tourne en root. Tu as vérifié les permissions de la cible dix fois, elles sont bonnes. Le service meurt quand même avec Permission denied, ou pire, il termine proprement en ayant fait, sans le dire, moins de travail que ce que tu lui demandais.

Le root d'une unité systemd durcie n'est plus le root appris sur une installation nue. Les directives de durcissement retirent des pouvoirs à l'UID 0 sans jamais l'annoncer nulle part, ni dans le fichier d'unité ni dans le code de sortie. Pire : l'une d'elles ne produit aucune erreur du tout.

Tout ce qui suit a été mesuré le 2026-09-20, sur Debian 13.7 (trixie) avec systemd 257 (257.13-1~deb13u1) et coreutils 9.7, noyau 6.18.39, sur un Raspberry Pi 5 Model B. Les directives ont été appliquées une à la fois avec les surcharges de propriétés de systemd-run (-p), qui ne changent rien à aucun fichier d'unité sur le disque.

Une question restée sans réponse acceptée sur Super User résume bien où ça laisse les gens :

“I would like to understand what the difference between systemd root and ‘regular’ root is, but can’t find anything online.”

(Super User, question 1939025, 1270 vues, toujours ouverte.) La question est légitime. La réponse honnête est éparpillée dans une dizaine de fils, et chacun ne règle que sa propre moitié du problème.

01. Le verdict en deux lignes

Le durcissement systemd retire des pouvoirs à root sans jamais le dire, nulle part où tu penserais regarder. Ce que tu vois ensuite dépend de ce que le bac à sable fait subir au programme. S'il lui interdit une opération, le programme reçoit un Permission denied qui accuse le mauvais coupable. S'il lui présente plutôt une vue valide mais vidée du système de fichiers, il n'y a aucune erreur à détecter : le programme parcourt ce qu'on lui donne, ne trouve rien, et sort en 0 après avoir fait moins de travail que prévu.

Deux défauts indépendants produisent exactement le même Permission denied, et un troisième défaut ne produit aucune plainte du tout. C'est tout l'article. Le reste sert à savoir d'où vient chacun et comment les distinguer.

Ce n'est pas un guide de durcissement systemd. Ce terrain est déjà bien couvert ailleurs, directive par directive. Cette page traite du symptôme qui apparaît ensuite, chez celui qui hérite de l'unité durcie sans savoir quelle ligne du drop-in a changé quelque chose.

02. Deux causes indépendantes, un refus identique

Le test : un dossier appartenant à l'UID 1000, contenant des fichiers réels, supprimé par /bin/rm -rf lancé en root via systemd-run.

Ces commandes sont fournies telles quelles, sans garantie. Teste d'abord sur une machine non critique ou sur un instantané.

rm -rf en root, une directive à la fois
cas sur le disque systemd rapporte témoin : root nu, sans durcissement supprimé Result=success, status 0 contrôle négatif : /bin/false à la place de rm encore là Result=exit-code, status 1 PrivateUsers=yes, seul encore là Result=exit-code, status 1 CapabilityBoundingSet=/AmbientCapabilities= les deux vides, seuls encore là Result=exit-code, status 1 les deux précédents, plus ProtectSystem=strict et un ReadWritePaths correspondant encore là Result=exit-code, status 1

Le contrôle négatif compte autant que le témoin. Il prouve que les lignes en échec ne veulent pas simplement dire « root sous systemd est cassé en général » : un /bin/false échoue exprès, et tout ce qui suit échoue pour de vraies raisons, différentes les unes des autres.

Voici le message lui-même, capturé dans le journal avec LC_ALL=C, identique pour les deux causes :

journalctl, LC_ALL=C
rm: cannot remove '/tmp/...': Permission denied

L'errno sous-jacent est EACCES, pas EPERM. Ça vaut la peine de le préciser : une capability manquante, on s'attendrait à la voir remonter en Operation not permitted, et ce n'est pas le cas.

Rien dans le rapport ne distingue les trois dernières lignes. Même Result=exit-code, même status 1, que root ait perdu ses capacités ou perdu sa vue sur l'UID propriétaire des fichiers. C'est exactement pour ça qu'un fil de forum résout son cas en retirant PrivateUsers=yes et déclare le problème réglé, pendant qu'un autre fil, avec le même message en apparence, règle un cas identique en vidant CapabilityBoundingSet=. Les deux ont raison, sur leur propre machine, et aucun des deux ne mentionne l'autre cause parce qu'il n'en a jamais eu besoin.

Un détail enfoui dans ce rapport Super User mérite d'être extrait : le même job en échec pouvait supprimer un dossier vide, mais pas un dossier contenant des fichiers. Supprimer un dossier vide ne demande qu'un droit d'écriture sur son parent. Supprimer un dossier plein demande aussi d'écrire dedans, contre des entrées dont l'UID propriétaire peut ne même plus exister dans la vue actuelle de root sur le système. Cette différence, c'est le mécanisme en miniature. La section 04 explique pourquoi.

03. La moitié dangereuse : un succès qui a fait moins de travail

Deuxième service, même machine : find /home /root -xdev -type f | wc -l.

find /home /root -xdev -type f | wc -l
config systemd rapporte fichiers réellement comptés témoin : root nu Result=success, status 0 110332 ProtectHome=yes Result=success, status 0 0 ProtectHome=read-only Result=success, status 0 110332 ProtectSystem=strict Result=exit-code, status 1 (le service plante franchement)

La dernière ligne demande une note, parce qu'elle ne mesure pas la même chose que les autres : ProtectSystem=strict monte tout le système de fichiers en lecture seule sauf /dev, /proc et /sys, donc ce service a échoué en écrivant son propre résultat sur le disque, pas en comptant. C'est le premier type d'échec, pas le silencieux.

ProtectHome=read-only laisse le vrai /home monté, simplement non inscriptible, donc le compte reste juste. ProtectHome=yes remplace /home par un dossier vide et valide avant même que le processus ne démarre. find le parcourt jusqu'au bout et ne trouve rien, parce qu'il n'y a rien à trouver, et parcourir un dossier vide jusqu'au bout n'est pas une erreur. Une seule valeur changée dans le fichier d'unité, un écart de 110332 fichiers, et zéro écart dans ce que systemd te rapporte.

Le seul moyen de l'attraper est de comparer avec un comptage pris hors systemd, sur les mêmes données, à peu près au même moment. systemctl status ne le montre pas. Rien ne change dans le code de sortie. C'est celle qui devrait t'inquiéter le plus, parce qu'elle ne réclame jamais ton attention : aucune ligne rouge dans le journal, aucune unité en échec dans systemctl --failed, juste un chiffre discrètement faux dans ce qui l'a consommé ensuite, un job de sauvegarde, un script d'inventaire, un rapport de conformité.

04. Pourquoi le noyau ment sur sa propre raison

L'UID 0 et « les pouvoirs de root » sont deux choses séparées. Ce qui permet à un processus d'ignorer les permissions de fichiers, ce sont des capacités, principalement CAP_DAC_OVERRIDE et CAP_FOWNER. CapabilityBoundingSet= vide veut dire que le processus reste UID 0 tout en ne détenant aucune de ces capacités. PrivateUsers=yes place le processus dans son propre user namespace, où les UID non explicitement mappés apparaissent comme nobody, et root n'a aucun pouvoir sur des fichiers appartenant à nobody.

Les deux chemins aboutissent à la même réponse du noyau : EACCES. Exactement le code qu'une permission de fichier réellement mal réglée produit aussi. Le noyau ne dit pas « capacité manquante » ni « UID non mappé », parce que de son point de vue, une vérification de permission a simplement échoué, et c'est le seul fait qu'il a à rapporter. Le message d'erreur ment sur sa propre cause parce que le noyau n'a jamais gardé trace de cette cause.

Une mesure rend ça concret. Lance le même rm en simple utilisateur sans privilège, sans aucun systemd dans l'histoire, contre le même dossier :

même rm, utilisateur ordinaire, sans systemd
rm: cannot remove '/tmp/...': Permission denied

La même phrase. Root sous une unité durcie et un utilisateur qui n'a aucun privilège sont indiscernables à partir du message seul. Si le texte ne sépare pas ces deux situations, il n'allait jamais te pointer une directive dans un drop-in.

05. Isoler plutôt que deviner

Il n'y a pas de correctif unique ici, parce que le même symptôme a plus d'une cause indépendante, et la section 02 a mesuré deux directives qui suffisent chacune à elles seules. Promettre « la » solution à ce problème donnerait un article court et faux.

Deux gestes permettent vraiment de resserrer le champ. D'abord, lancer la même commande hors systemd, en simple ligne de commande avec sudo, pour avoir une base de ce que fait le programme quand rien n'est durci. Ensuite, retirer les directives de durcissement de l'unité une à la fois, en redémarrant entre chaque essai, jusqu'à ce que le comportement change. Comme au moins deux d'entre elles suffisent chacune indépendamment à causer l'échec, les retirer toutes d'un coup et déclarer la victoire te dit seulement que l'une d'elles était le problème, pas laquelle, et le drop-in reste à moitié durci sans que personne s'en rende compte.

systemd-analyze security <unité> vaut la peine d'être lancé, et il liste fidèlement le durcissement appliqué. Ce qu'il ne te dira pas, c'est ce que ton programme a cessé de voir à cause de ça. Ce manque n'est pas un bug de l'outil, c'est la description d'une autre question que celle que tu poses.

La même forme traverse le reste de cette série, où le système déclare une chose et en fait une autre : un pare-feu que l'unité systemd déclare activé alors qu'il est éteint, un pare-feu qui se déclare actif sans qu'aucune règle ne soit chargée, et un service WireGuard qui se déclare actif alors qu'aucune interface n'existe.

L'habitude qui vaut la peine d'être gardée est petite : quand une unité durcie rapporte un succès, vérifie la sortie réelle du job, pas le code de sortie de l'enveloppe qui l'a lancé. Un Result=success propre te dit que le processus s'est terminé. Il ne dit rien sur le fait qu'il ait terminé le travail que tu avais en tête.

Partager :