Un correctif comme celui de PHP2Shell bouche une faille précise. Il ne change rien à ce qui se passe le jour où la prochaine sort — et il y en aura une prochaine. La vraie question opérationnelle n'est pas « comment éviter toute RCE » (personne n'y arrive), c'est « qu'est-ce qu'un attaquant peut atteindre une fois dedans ». Ce tutoriel configure un pool PHP-FPM isolé pour qu'une exécution de code dans une appli ne donne pas accès au reste du serveur — la même logique de défense en profondeur qu'OpenAI décrit pour ses environnements d'entraînement, appliquée à un serveur web PHP ordinaire. C'est particulièrement pertinent pour un endpoint qui appelle un LLM ou expose un agent : une clé d'API ou des identifiants de service y traînent presque toujours en mémoire ou en variable d'environnement.
Étape 1 — un pool par application
Par défaut, beaucoup d'installations PHP-FPM font tourner toutes les applications d'un serveur dans le même pool www, sous le même utilisateur système. Une RCE dans l'une donne un accès en lecture/écriture à toutes les autres. Première étape : un pool dédié par application, dans son propre fichier sous /etc/php/8.4/fpm/pool.d/.
; /etc/php/8.4/fpm/pool.d/monapp.conf
[monapp]
user = monapp-fpm
group = monapp-fpm
listen = /run/php/monapp.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
Étape 2 — un utilisateur système dédié, sans shell
Le user/group du pool doit être propre à cette appli, pas un compte partagé ni www-data. Un attaquant qui obtient l'exécution de code hérite des droits de ce compte : autant qu'ils ne mènent nulle part.
sudo useradd --system --no-create-home --shell /usr/sbin/nologin monapp-fpm
Étape 3 — limiter les ressources du pool
Un pool sans limites explicites peut saturer la mémoire ou les descripteurs de fichiers du serveur entier — utile aussi contre une boucle qui part en vrille, pas seulement contre une attaque.
pm = dynamic
pm.max_children = 8
pm.max_requests = 500 ; recycle le worker périodiquement
request_terminate_timeout = 30s
rlimit_files = 1024
rlimit_core = 0 ; pas de core dump (peut contenir des secrets)
Étape 4 — restreindre le système de fichiers
open_basedir confine les fonctions fichier de PHP à une liste de chemins — un processus compromis ne peut pas lire /etc/passwd ni le code d'une autre appli sur le même serveur, même s'il peut exécuter du PHP arbitraire. disable_functions retire l'accès direct au shell.
php_admin_value[open_basedir] = /var/www/monapp:/tmp
php_admin_value[disable_functions] = exec,shell_exec,proc_open,popen,system,passthru
Étape 5 — filtrer l'egress réseau du pool
Une RCE qui ne peut pas téléphoner dehors perd l'essentiel de son intérêt pour l'attaquant : pas d'exfiltration, pas de canal de command and control. Comme le pool tourne sous un utilisateur système dédié, une règle iptables peut cibler ce seul egress sans toucher au reste du trafic du serveur :
# Autoriser uniquement l'API LLM utilisée par monapp, tout bloquer d'autre en sortie
iptables -A OUTPUT -m owner --uid-owner monapp-fpm -d api.anthropic.com -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner monapp-fpm -j REJECT
Vérifier que l'isolation tient
# Le worker tourne bien sous le compte dédié
ps -eo user,cmd | grep php-fpm
# open_basedir bloque la lecture hors périmètre
php -d open_basedir=/var/www/monapp -r "readfile('/etc/passwd');"
# -> Warning: open_basedir restriction in effect
# L'egress est bien coupé pour autre chose que l'API autorisée
sudo -u monapp-fpm curl -m 3 https://example.com
# -> curl: (7) Failed to connect (REJECT)
| Ce qu'un attaquant atteint | Pool partagé | Pool isolé |
|---|---|---|
| Fichiers des autres applis | oui | non (open_basedir) |
| Shell système | oui | non (disable_functions) |
| Sortie réseau arbitraire | oui | non (egress filtré) |
| Ressources du serveur entier | oui | limitées au pool |
Générateur de pool
Un petit outil pour générer le squelette de configuration à partir de quelques paramètres :
Règle
Un pool par application, un utilisateur système par pool, open_basedir et disable_functions systématiques, egress filtré sur l'utilisateur du pool. Rien de ça n'empêche une RCE — seul le patch le fait. Mais le jour où une faille sort avant le correctif, c'est la différence entre un incident contenu à une appli et un serveur entier compromis.
FAQ
Est-ce que ça remplace les mises à jour de sécurité ?
Non. L'isolation de pool limite les dégâts d'une compromission, elle ne l'empêche pas. Les deux sont nécessaires : patcher reste la priorité, l'isolation est le filet en dessous.
Pourquoi un utilisateur système dédié plutôt que www-data ?
Parce que www-data est souvent partagé entre plusieurs applications sur le même serveur. Si une RCE obtient les droits de www-data, elle obtient potentiellement accès à tout ce qui tourne sous ce compte, pas seulement l'appli visée.
open_basedir suffit-il à bloquer une sortie réseau ?
Non, open_basedir ne restreint que les fonctions fichier de PHP. Bloquer l'egress réseau est une mesure séparée, au niveau du pare-feu, ciblée sur l'utilisateur système du pool.