Un panneau d’administration sur un serveur se heurte presque toujours au même mur : le processus web doit faire quelque chose pour quoi il n’a pas les droits. Bannir une adresse, ouvrir un port, lire le journal. La réponse qui vient à l’esprit est d’accorder une seule ligne étroite dans sudoers, et rien d’autre :

www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *

La ligne se lit comme « seulement bannir ». Elle signifie en réalité « n’importe quoi, en tant que root ». Voici pourquoi il en va ainsi, comment le vérifier sur votre propre serveur en cinq minutes, et par quoi de telles règles se remplacent.

Ce que fait réellement *

Le point décisif : sudo ne compare pas les arguments un à un mais la ligne de commande dans son ensemble, et * dans le motif franchit allègrement les espaces — c’est-à-dire les frontières entre arguments. Le littéral banip placé au milieu de la règle ne restreint donc rien : il suffit que le mot banip apparaisse quelque part dans la commande, et l’on peut passer n’importe quoi avant comme après.

On cherche ensuite un programme auquel on puisse confier une commande à exécuter. Dans notre cas, il figurait dans la règle elle-même. Chez fail2ban l’action de bannissement est définie par du texte, et ce texte peut être redéfini à chaud :

fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<commande>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11

Le premier appel remplace le texte d’action d’une action réelle, le second fait déclencher la prison — et fail2ban, qui tourne en root, exécute ce qui a été substitué. Nous l’avons fait le 4 juillet sur notre propre serveur : un fichier dans /root a été créé en root, autrement dit le processus web a obtenu les pleins droits sur la machine à partir d’une règle qui paraissait étroite.

Trois détails sont apparus en chemin, qui manquent d’ordinaire dans les exposés d’autrui :

  • l’option -c devant set est coupée par sudo : dans le motif, set est collé au nom du binaire et rien ne peut être inséré avant ;
  • addaction et action sans le mot banip ne passent pas non plus — mais tout le nécessaire tient dans une seule commande set … banip …, et le motif est donc respecté ;
  • le nom de l’action doit être réel, sinon il n’y a rien à remplacer : fail2ban-client get <jail> actions l’affiche.

Qui figure encore sur cette même liste

fail2ban n’est ici ni coupable ni unique. Ce qui est dangereux, c’est l’association de NOPASSWD et d’une étoile. Ce que nous avons trouvé à côté sur nos propres serveurs :

  • journalctl * — le journal s’ouvre via un pager, et depuis un pager on lance un interpréteur. La règle a l’air de porter sur la lecture des logs ; en pratique c’est un shell root. Le remède n’est pas un motif plus étroit mais le groupe systemd-journal : journalctl fonctionne alors sans sudo du tout ;
  • grep * /var/log/fail2ban.log — le premier argument de grep est le motif, mais l’étoile permet de fournir aussi un second chemin, et grep s’exécute en root. Lire /etc/shadow par cette règle tient en une commande. Le remplacement est le même : le groupe adm pour la lecture des logs ;
  • ufw --force * et les ufw allow/deny/delete * nus — le processus web peut désactiver le pare-feu en entier. Détail particulièrement désagréable : dans les deux cas le panneau ne se servait pas du tout de ces règles — des autorisations mortes héritées des premières versions de l’installateur ;
  • lynis-scan.sh * — un script d’encapsulation qui transmet "$@" à un processus root équivaut à une règle sans aucune restriction.

Comment voir ce que vous avez

Ce qu’il faut regarder, ce n’est pas le fichier mais les droits effectifs d’un utilisateur précis — celui sous lequel tourne réellement le pool PHP-FPM (pas toujours www-data ; sur l’une de nos machines le panneau tournait en admin) :

ps -o user= -C php-fpm | sort -u
sudo -l -U www-data
sudo -l -U admin

Ensuite la liste des fichiers elle-même, et là deux pièges nous ont coûté du temps :

sudo grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/
ls -la /etc/sudoers.d/

Le premier : les règles vivent à deux endroits en même temps. Chez nous la ligne dangereuse se trouvait à la fois dans /etc/sudoers.d/monitor et dans le /etc/sudoers principal — dans ce dernier en huit copies environ, accumulées par différentes versions de l’installateur. Nettoyer un seul endroit et se rassurer est la façon habituelle de laisser le trou ouvert.

Le second : sudo ignore les fichiers dont le nom contient un point. Un fichier www-data.bak3 dans /etc/sudoers.d/ a l’air d’une règle effective et se lit comme une règle effective, mais n’a aucun effet. Cela joue dans les deux sens : « la règle est là, les droits non », et le faux réconfort d’une copie de configuration qui « traîne juste à côté ».

Par quoi remplacer

Resserrer le motif ne sert à rien — une étoile n’importe où dans la ligne ramène le problème au début. Une seule approche tient : un script d’encapsulation root qui reçoit des arguments positionnels fixes, les valide lui-même et appelle le programme sans la moindre possibilité d’ajouter quoi que ce soit.

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>, et rien de plus
set -eu
action="$1"; jail="$2"; ip="$3"
case "$action" in ban|unban) ;; *) echo "invalid action"; exit 1 ;; esac
case "$jail" in *[!a-zA-Z0-9_-]*|'') echo "invalid jail"; exit 1 ;; esac
printf '%s' "$ip" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo "invalid ip"; exit 1; }

case "$action" in
  ban)   exec /usr/bin/fail2ban-client set "$jail" banip   "$ip" ;;
  unban) exec /usr/bin/fail2ban-client set "$jail" unbanip "$ip" ;;
esac

Le script appartient à root, droits 755, et — ce point n’est pas facultatif — il ne doit pas être inscriptible depuis le web, sans quoi toute la construction n’a plus de sens. Seul lui reste dans sudoers :

www-data ALL=(root) NOPASSWD: /usr/local/sbin/arciveo-f2b

Les arguments ne sont pas énumérés dans la règle : c’est le script qui les vérifie, et les positions y sont fixes, il n’y a donc nulle part où glisser un texte d’action. Les règles en lecture seule — états, ss, ipset list — passent sur des lignes distinctes, également sans étoile lorsque c’est possible.

À part, sur le remplacement de sudo par des groupes. La méthode est juste : systemd-journal pour le journal et adm pour les logs coûtent moins cher et sont plus sûrs que n’importe quelle règle sudo. Mais on ne retire pas l’utilisateur web de ses groupes à l’aveugle. Nous avons sorti www-data d’un groupe sur un hôte de panneau et obtenu aussitôt des 403 sur tous les sites : Apache y tourne en www-data, les fichiers des sites appartiennent à un autre utilisateur, et c’est précisément cette appartenance au groupe qui donnait l’accès en lecture. Le rétablissement a exigé un redémarrage complet — pas un rechargement — d’Apache et de PHP-FPM, car les anciens processus conservent leur ancien jeu de groupes et produisent des « ça marche, puis 403 ».

Vérification après la modification

Avant de remplacer un fichier sudoers, il faut en vérifier la syntaxe — sinon on peut se retrouver sans sudo du tout :

sudo visudo -cf /etc/sudoers.d/monitor

Ensuite, confirmer que l’ancien vecteur est mort, et le faire au nom de ce même utilisateur :

sudo -k
sudo -u www-data sudo -n fail2ban-client set portscan banip 192.0.2.10

La réponse doit être a password is required, tandis que l’appel du script avec n’importe quoi à la place d’une adresse doit donner invalid ip. Un piège propre à cette étape : sudo -v met la confirmation en cache pendant quinze minutes, et après cela toute vérification par sudo -n passe « avec succès ». C’est exactement ainsi que nous avons un jour confirmé une règle qui n’existait pas sur le serveur. D’où l’obligation de faire sudo -k avant la vérification.

L’intérêt de l’observation

Les règles sudo changent rarement, mais elles changent sans bruit : l’installateur y ajoute des lignes, le panneau d’hébergement complète les siennes, un paquet supprimé laisse les siennes derrière lui. Une révision ponctuelle ferme ce qui existe aujourd’hui et ne dit rien de ce qu’apportera la prochaine mise à jour. La conclusion pratique est simple : la liste de ceux qui peuvent devenir root a sa place en vue, à côté du reste de l’état du serveur, et non dans le souvenir qu’on en a après un incident. À quoi cela ressemble une fois assemblé : voir les pages de démonstration ci-dessous.