
🌐 Aussi en: English · Deutsch · Español
Deep Dive de la série autour de la visite Ansible. Niveau : administration Linux avancée. La visite explique les rôles ; les deep dives expliquent la technique en dessous – comment elle fonctionne, pourquoi elle est là, ce que cette installation en fait exactement, comment l’exploiter au quotidien et ce qu’il y aurait eu d’autre.
SELinux est la couche que la plupart des administrateurs désactivent en premier et comprennent en dernier. C’est dommage, car c’est la seule couche de ces serveurs qui tient encore lorsqu’un processus a déjà été pris – même un processus qui tourne en root. Cet article explique d’abord comment SELinux décide, avec les détails qui comptent en pratique : types, transitions, catégories, et comment lire un refus. Il s’intéresse ensuite au blog et aux deux serveurs Nextcloud : ce qui est confiné, comment, et les deux occasions où SELinux était activé, en enforcing, et ne protégeait pourtant rien – parce qu’un seul détail ailleurs empêchait les bonnes règles de s’appliquer.
🧭 Comment SELinux décide
Discrétionnaire et obligatoire
Les droits Unix classiques sont discrétionnaires : le propriétaire d’un fichier décide qui peut le lire, et root peut tout. SELinux ajoute par-dessus une couche obligatoire (mandatory). À chaque opération importante – ouvrir un fichier, connecter un socket, envoyer un signal, exécuter un programme – le noyau interroge une politique centrale, et la réponse ne dépend ni du propriétaire du fichier, ni du fait que le processus soit root. Les deux vérifications doivent dire oui. Un serveur web qui tourne en root ne peut toujours pas lire /etc/shadow si la politique n’autorise pas son type à lire le type de ce fichier.
Étiquettes : quatre champs, dont un fait presque tout le travail
Chaque processus et chaque objet – fichier, répertoire, socket, port, jusqu’aux structures du noyau – porte un contexte de sécurité de la forme user:role:type:level. Sur un serveur avec la politique targeted fournie par AlmaLinux, trois des quatre champs comptent à peine : l’utilisateur est presque toujours system_u ou unconfined_u, le rôle system_r ou object_r. C’est le type qui décide. Pour un processus, le type s’appelle son domaine.
$ ps -eZ | grep -E ' (mariadbd|redis-server|php-fpm)$'
system_u:system_r:mysqld_t:s0 ... mariadbd
system_u:system_r:redis_t:s0 ... redis-server
system_u:system_r:container_t:s0:c628,c864 ... php-fpm
$ ls -Zd /var/lib/mysql /var/lib/redis
unconfined_u:object_r:mysqld_db_t:s0 /var/lib/mysql
system_u:object_r:redis_var_lib_t:s0 /var/lib/redisLes étiquettes des fichiers sont stockées comme attributs étendus (security.selinux) dans le système de fichiers. L’origine de l’étiquette d’un fichier est la source de la plupart des ennuis avec SELinux, il vaut donc la peine de connaître les trois chemins : un nouveau fichier hérite du type de son répertoire ; une règle de type transition peut dire « les fichiers que le domaine X crée dans un répertoire de type Y reçoivent le type Z » ; et restorecon pose les étiquettes d’après la base des file contexts – des règles comme /var/lib/mysql(/.*)? → mysqld_db_t, fournies par la politique et étendues par semanage fcontext. Déplacer un fichier garde son étiquette, le copier lui en donne une nouvelle. Le classique : un fichier de configuration déplacé d’un répertoire personnel vers /etc et devenu illisible pour son service.
Type Enforcement
La politique est pour l’essentiel un grand ensemble de règles d’autorisation entre types : allow mysqld_t mysqld_db_t:file { read write open … }; – un processus du domaine mysqld_t peut lire et écrire les fichiers de type mysqld_db_t. Ce qui n’est pas autorisé est refusé. La politique targeted de ces hôtes compte bien plus de cent mille règles de ce genre, compilées en un binaire que le noyau charge ; 439 modules forment l’ensemble, d’apache à zabbix. Le sens du nom « targeted » : seuls les services dotés de leur propre module sont confinés. Tout le reste tourne dans un domaine non confiné auquel presque tout est permis – et c’est exactement là, comme le montre le deuxième cas, que quelque chose peut passer inaperçu.
Transitions de domaine – et pourquoi NoNewPrivileges peut les bloquer
Un processus reçoit son domaine lors de execve(). La règle type_transition init_t mysqld_exec_t:process mysqld_t dit : quand quelque chose en init_t (systemd) exécute un fichier de type mysqld_exec_t, le nouveau processus tourne en mysqld_t. C’est pourquoi l’étiquette d’un exécutable compte autant que celle des données : un binaire mariadbd étiqueté bin_t n’entrerait jamais dans son domaine.
Il existe une interaction subtile avec une option de systemd qui est par ailleurs un pur gain de durcissement. NoNewPrivileges=yes active le drapeau du noyau PR_SET_NO_NEW_PRIVS : dès lors, execve() ne peut plus jamais gagner de privilèges – ni setuid, ni capacités de fichier. SELinux considère lui aussi une transition de domaine comme un possible changement de privilèges. Sous NNP, il n’autorise une transition que si le domaine cible est un sous-ensemble de la source (bounded) ou si la politique accorde explicitement nnp_transition pour cette paire. Sinon, la transition est refusée – et le programme continue de tourner dans le domaine de son parent. Plusieurs autres options de bac à sable de systemd impliquent NNP sans le dire. À retenir : c’est le premier cas ci-dessous.
MCS : le champ de niveau qui sépare les conteneurs
Le quatrième champ, le niveau, vaut normalement s0. Avec Multi-Category Security, il peut porter des catégories : s0:c628,c864. Un processus ne peut accéder à un objet que si ses catégories dominent celles de l’objet : un processus en c628,c864 peut lire un fichier portant exactement ces catégories, ou un fichier sans catégorie (s0), mais pas un fichier en c53,c213. Les runtimes de conteneurs s’en servent pour séparer des conteneurs qui partagent tous un même type : chaque pod reçoit sa propre paire aléatoire. Les 46 processus de conteneurs d’un hôte Nextcloud tournent tous en container_t, et pourtant Collabora (c53,c213) ne peut pas toucher aux fichiers du conteneur PHP (c628,c864).
Une conséquence passe facilement inaperçue : un répertoire de l’hôte monté dans des pods et étiqueté container_file_t:s0 – donc sans catégorie – est accessible par tous les conteneurs. MCS isole les systèmes de fichiers propres aux conteneurs, pas les volumes partagés. Sur ces serveurs, chaque répertoire HostPath appartient à une seule application, ce n’est donc pas un problème ; sur un nœud multi-locataire, ce le serait.
Lire un refus
Quand la politique dit non, le noyau écrit un message AVC (access vector cache) dans le journal d’audit. Celui-ci vient de la vérification de l’outil d’intégrité des fichiers et contient tout ce qu’il faut pour le comprendre :
type=AVC msg=audit(...): avc: denied { getattr } for pid=... comm="aide"
name="/" dev="vda2" scontext=system_u:system_r:aide_t:s0
tcontext=system_u:object_r:dosfs_t:s0 tclass=filesystem permissive=0À lire comme une phrase : le processus aide, dans le domaine aide_t, voulait faire getattr sur un objet de classe filesystem de type dosfs_t – la partition EFI en vfat – et a été refusé (permissive=0). ausearch -m AVC -ts today trouve ces messages, audit2why explique quelle règle manque ou quel booléen l’autoriserait. audit2allow -M écrirait un module autorisant exactement ce qui a été refusé – et c’est la commande la plus dangereuse de l’administration SELinux, car elle transforme chaque symptôme en permission sans se demander si l’accès était légitime.
🛡️ Pourquoi il est activé partout
Les trois serveurs tournent avec la politique targeted en mode enforcing, et chaque rôle le suppose. La raison est un modèle de menace, pas une case à cocher. L’attaque réaliste contre un blog est une extension WordPress vulnérable ; contre Nextcloud, un document piégé qui exploite la suite bureautique. Dans les deux cas, l’attaquant finit par exécuter du code dans un processus PHP ou Collabora. Tout ce qui suit – lire directement les fichiers de la base, voler des clés SSH, déposer un binaire sur l’hôte, attaquer l’autre application – dépend de ce que ce processus a le droit de faire. Les droits Unix n’aident guère ; dans un conteneur, le processus est souvent root de toute façon. SELinux est la couche qui répond « non » indépendamment des identifiants d’utilisateur.
- Un worker PHP-FPM en
container_tne peut écrire que ducontainer_file_t– son propre volume. La politique ne contient aucune règle permettant àcontainer_tde lireshadow_t;/etc/shadowreste donc hors de portée même pour un processus root sorti de son namespace de montage. - La MariaDB de l’hôte en
mysqld_tpeut lire et écrire son propremysqld_db_t, ouvrir son port et pas grand-chose d’autre. Un serveur de base de données compromis ne peut pas lire les données des utilisateurs de Nextcloud dans/data, qui sont encontainer_file_t. - Redis en
redis_tde même : son propreredis_var_lib_t, son port, son socket. - Modules du noyau, périphériques bruts, ptrace sur d’autres domaines : refusés par défaut à tous.
Le coût est faible une fois les étiquettes correctes. Presque tous les problèmes SELinux rencontrés jusqu’ici sur ces hôtes étaient une étiquette manquante ou fausse, pas une règle manquante – et la politique pour les bases de données, Redis et les conteneurs est fournie toute prête par la distribution et container-selinux.
🔧 Ce que fait ce dépôt
common_selinux : enforcing et les bonnes étiquettes
Le rôle passe en mode enforcing avec la politique targeted et étiquette les répertoires de l’hôte que montent les pods. Lesquels dépend de la stack : le playbook définit la variable stack, et un dictionnaire choisit le profil – racine web et répertoire de la base pour le blog, racine web et données utilisateurs pour Nextcloud. Pour chaque répertoire, il ajoute une règle de file context persistante et l’applique :
semanage fcontext -a -t container_file_t "/srv/wordpress-www(/.*)?"
restorecon -Rv /srv/wordpress-wwwLa règle vit dans le magasin de la politique, elle survit donc à un réétiquetage complet ; restorecon l’applique à ce qui existe déjà. Le rôle lit d’abord les règles locales (semanage fcontext -l -C) et n’ajoute que ce qui manque – les versions récentes de semanage n’échouent plus sur une règle existante mais écrivent un message sur stdout et sortent avec 0, ce qui a un temps fait rapporter « changed » à la tâche à chaque exécution.
Cas 1 : une base de données en enforcing – et non confinée
En septembre, un drop-in systemd a durci la MariaDB des serveurs Nextcloud : aucune capacité, système de fichiers en lecture seule sauf son répertoire de données, /tmp privé, un filtre d’appels système – et, explicitement comme implicitement, NoNewPrivileges. MariaDB démarrait, Nextcloud fonctionnait, le drop-in avait tout d’une nette amélioration. Cinq semaines plus tard, un coup d’œil aux processus en cours montrait ceci :
$ ps -eZ | grep mariadbd
system_u:system_r:initrc_t:s0 ... mariadbdinitrc_t au lieu de mysqld_t. L’unité de la distribution ne démarre pas mariadbd directement mais via /bin/sh -c, pour lancer d’abord un utilitaire de récupération Galera. Le shell tourne en initrc_t ; quand il exécute mariadbd, la transition vers mysqld_t devrait avoir lieu – et sous NNP, la politique ne l’autorise pas depuis initrc_t. Le journal d’audit le disait, à chaque démarrage, dans une ligne que personne n’avait lue : avc: denied { nnp_transition } … scontext=…initrc_t… tcontext=…mysqld_t… tclass=process2. La base de données continuait de tourner dans le domaine du shell, bien moins restreint. Du 11 septembre au 16 octobre, la MariaDB de l’hôte n’était pratiquement pas confinée par SELinux.
La correction tient en une ligne dans le drop-in : démarrer le binaire directement, sans le shell (Galera n’est pas utilisé ici, l’utilitaire n’a donc rien à faire) :
ExecStart=
ExecStart=/usr/sbin/mariadbd $MYSQLD_OPTS $_WSREP_NEW_CLUSTERExécutée directement par systemd, la transition depuis init_t est autorisée, et ps -eZ montre de nouveau mysqld_t. Cela a d’abord été testé avec systemd-run et les mêmes propriétés, si bien que le changement n’a jamais dû être essayé sur la base de production.
Cas 2 : des pods qui n’ont jamais tourné en container_t
Le deuxième cas est plus grave. SELinux était en enforcing sur les trois serveurs, les répertoires de l’hôte portaient container_file_t, container-selinux était installé – et le 19 octobre, pendant la rédaction de cette série, ps -eZ a montré qu’aucun pod ne tournait en container_t. Sur le blog, ils étaient en spc_t (« super privileged container », pratiquement non confiné), sur les serveurs Nextcloud en unconfined_service_t.
Deux choses manquaient. K3s ne démarre son containerd intégré avec la prise en charge de SELinux que si sa configuration contient selinux: true ; sinon, enable_selinux vaut false chez containerd, qui ne demande jamais d’étiquette de conteneur à la politique. Et K3s a besoin de son propre paquet de politique, k3s-selinux, qui étiquette le binaire container_runtime_exec_t pour qu’il tourne en container_runtime_t. Sur le blog, le script d’installation avait ajouté ce paquet une fois ; sur les serveurs Nextcloud, le binaire était simplement en bin_t, systemd le démarrait donc en unconfined_service_t, et chaque enfant en héritait. Tout le soin mis à étiqueter les volumes préparait un contrôle qui n’avait jamais lieu.
common_k3s fait désormais quatre choses :
- il ajoute le dépôt de paquets signé de Rancher et installe
k3s-selinux, - il définit
selinux: truedans/etc/rancher/k3s/config.yaml, - il lance
restorecon -v /usr/local/bin/k3saprès chaque téléchargement du binaire –get_urlremplace le fichier via un fichier temporaire dans le même répertoire, ce qui donne au nouveau binaire lebin_tdu répertoire et annulerait discrètement la politique à la prochaine mise à jour, - et recrée une fois toutes les charges de travail tant qu’aucun conteneur pause ne tourne en
container_t. Les pods gardent le domaine avec lequel ils ont démarré ; un changement de configuration seul ne confine rien de ce qui tourne déjà.
Ensuite, le tableau sur un serveur Nextcloud : 46 processus en container_t, chaque pod avec sa propre paire de catégories, seuls les pods privilégiés en spc_t, et pas un seul AVC – ni de Nextcloud, ni de Collabora avec ses namespaces de montage, ni de notify_push. Collabora était le composant le plus susceptible de poser problème, car il construit pour chaque document une prison faite de montages bind ; container_t autorise les opérations de namespace de montage dont il a besoin.
Cas 3, petit : une règle pour AIDE
L’outil d’intégrité AIDE tourne en aide_t et parcourt tout le système de fichiers. La partition EFI est en vfat (dosfs_t), et la politique ne laisse pas aide_t y regarder. Exclure /boot/efi de la vérification empêchait AIDE de lire les fichiers, mais pas d’appeler statfs() sur le point de montage – 13 refus par exécution, chaque nuit. La solution la plus propre est un module local d’une seule règle, construit sur l’hôte et chargé seulement quand sa source change :
module aide_dosfs 1.0;
require { type aide_t; type dosfs_t; class filesystem getattr; }
allow aide_t dosfs_t:filesystem getattr;
checkmodule -M -m -o aide_dosfs.mod aide_dosfs.te
semodule_package -o aide_dosfs.pp -m aide_dosfs.mod
semodule -i aide_dosfs.ppC’est le contre-exemple d’audit2allow : la règle a été écrite à la main, après avoir décidé que lire les métadonnées du système de fichiers est légitime et que rien d’autre n’est nécessaire. Depuis, les trois serveurs ne signalent plus aucun AVC – ce qui est aussi la condition pour remarquer le prochain vrai refus.
La stack de supervision : ses propres domaines, encore en apprentissage
Mise à jour du 20/10/2026 : la phase d’apprentissage sur le blog s’est terminée après une journée, redémarrage compris. 82 703 refus journalisés se ramenaient à 203 accès distincts – les données propres des services, /proc et /sys, leurs ports, le DNS, le mail, les binaires de plugins de Grafana. Ils sont devenus des règles d’autorisation relues, surtout des macros de la politique de référence ; audit2allow -R n’a fourni que le brouillon. Une surprise en chemin : un contexte de fichier pour /run/grafana ne correspondait jamais, car la politique recherche les chemins /run sous /var/run. Après une vérification par audit2why contre tous les refus collectés et le redémarrage des trois services – zéro nouvel AVC –, le blog applique la politique depuis le 20 octobre. Les serveurs Nextcloud continuent d’apprendre quelques jours de plus, car mysqld_exporter ne tourne que là. Un effet secondaire à connaître : le flot de refus permissifs faisait tourner audit.log toutes les deux heures.
Jusqu’au 19 octobre, dix-huit processus d’un serveur Nextcloud tournaient encore en unconfined_service_t : Prometheus, les exportateurs, Grafana et ses plugins, Monit. Ils sont installés comme binaires isolés dans /usr/local/bin (bin_t) ou depuis le dépôt d’un éditeur, et la politique targeted n’a pas de module pour eux. Depuis ce jour, ils ont leurs propres domaines – de petits modules locaux homelab_prometheus, homelab_node_exporter, homelab_mysqld_exporter et homelab_grafana, construits avec les macros standard de la politique issues de selinux-policy-devel, avec des types pour leurs données et leurs journaux. Un détail a failli répéter le cas 1 : les unités tournent avec NoNewPrivileges, chaque module a donc aussi besoin de init_nnp_daemon_domain() ; sans cela, la transition de systemd était refusée, Grafana restait en init_t, ne pouvait pas lire sa propre base et a été indisponible cinq minutes sur le blog. Les nouveaux domaines démarrent en mode permissif : ce sont de vrais domaines, mais les refus sont seulement journalisés (permissive=1). Après quelques jours de fonctionnement normal, les accès journalisés deviennent des règles d’autorisation vérifiées, écrites à la main, et les domaines passent en enforcing. Monit ne reçoit volontairement pas de domaine – il exécute en root des vérifications arbitraires et redémarre des services –, mais un bac à sable systemd, qui a fait passer son score d’exposition de 9.6 à 7.1.
🧰 Administration au quotidien
| Question | Commande |
|---|---|
| Mode et politique | sestatus, getenforce |
| Dans quel domaine tourne vraiment un service ? | ps -eZ | grep <nom> – la vérification qui a trouvé les deux cas |
| Étiquette d’un fichier | ls -Z ; celle attendue : matchpathcon <chemin> |
| Refus depuis le démarrage / aujourd’hui | ausearch -m AVC -ts boot (ou today) |
| Pourquoi refusé ? | ausearch -m AVC -ts recent | audit2why |
| Règles de file context locales | semanage fcontext -l -C |
| Corriger les étiquettes | restorecon -Rv <chemin> – jamais chcon, qu’un réétiquetage annule |
| Booléens | getsebool -a | grep container, setsebool -P |
| Modules locaux | semodule -l, semodule -r <nom> |
| Interroger la politique elle-même | sesearch -A -s container_t -t container_file_t (paquet setools-console) |
Quelques habitudes qui ont fait leurs preuves :
- Après toute modification d’une unité de service, vérifier son domaine, pas seulement son état. Un service « active (running) » dans le mauvais domaine a l’air parfaitement sain.
- Traiter chaque AVC comme une découverte. Sur un système calme, ils sont assez rares pour qu’on les lise tous ; dès qu’on accepte un bruit de fond de refus connus, l’important s’y perd.
- Nouveau répertoire de l’hôte pour un pod ? L’ajouter au profil de la stack dans
common_selinux, ne jamais l’étiqueter à la main. Le répertoire de sauvegarde de la tâche de mise à jour du blog manquait au profil jusqu’au 19 octobre ; cela fonctionnait uniquement parce que les pods n’étaient pas encore confinés. - Le mode permissif pour un seul domaine (
semanage permissive -a <domaine>) est un outil de diagnostic de quelques minutes, pas une solution.setenforce 0sur un hôte de production n’est pas une option du tout. - Après une mise à jour de la politique,
semodule -lliste toujours les modules locaux ; si un module ne compile plus contre une nouvelle politique,semodule -iéchoue bruyamment pendant l’exécution du playbook, pas en silence à l’usage.
🔭 La suite
- Des alertes sur les nouveaux AVC via Loki, pour que le prochain refus silencieux – comme le
nnp_transitionjournalisé à chaque démarrage de MariaDB pendant cinq semaines – déclenche un mail dès sa première apparition. - Faire passer les domaines de supervision de permissive à enforcing une fois les accès journalisés vérifiés – prévu pour la fin de la première semaine.
- Des politiques par charge de travail avec
udica, qui génère une politique à partir des montages et des ports d’un conteneur – plus serrée que lecontainer_tgénérique. - Une vérification, dans le contrôle final du playbook, que chaque pod applicatif tourne en
container_t. Le cas 2 a duré des mois parce que personne ne l’a jamais demandé.
⚖️ Alternatives – et pourquoi SELinux
| Option | Points forts | Pourquoi pas ici |
|---|---|---|
| AppArmor | Profils basés sur les chemins, plus faciles à lire et à écrire ; par défaut sous Debian et Ubuntu | Pas le choix natif sous AlmaLinux ; les règles basées sur les chemins sont plus faibles face aux liens physiques et aux fichiers renommés ; pas de séparation de type MCS entre conteneurs identiques |
| seccomp seul | Filtre les appels système, peu coûteux, déjà utilisé par le runtime de conteneurs et par les unités systemd | Ne dit rien sur quels fichiers ou sockets – complément, pas remplacement |
| Landlock | Bac à sable sans privilèges depuis l’application | L’application doit l’utiliser ; aucune de celles-ci ne le fait |
| gVisor / Kata Containers | Un second noyau ou une frontière de VM par pod | Bien plus lourd, et excessif pour un seul opérateur de confiance sur un petit serveur |
| Aucun MAC | Rien à apprendre | Le premier processus compromis a tout ce que root a |
SELinux convient parce qu’il est déjà là : AlmaLinux fournit une politique targeted mûre, container-selinux s’intègre à containerd, K3s apporte son propre paquet de politique, et l’on attend des administrateurs de la famille RHEL qu’ils le connaissent. Son vrai coût n’est pas la complexité mais le silence – comme le montrent les deux cas, il peut être activé et ne rien faire sans que personne ne s’en aperçoive. La réponse n’est pas de le désactiver, mais de vérifier ce qu’il fait réellement.


