PHP2Shell : comment j'ai patché mon serveur Symfony

Arnaud Pointet · · 5 min de lecture

Read this article in English

Le 20 octobre au soir, deux failles critiques ont été annoncées dans PHP : CVE-2026-109618 et CVE-2026-109619, vite surnommées « PHP2Shell ». Elles permettent d'exécuter du code sur le serveur, à distance et sans être connecté, et sont notées 9.2 sur 10. Le lendemain matin, des attaques réelles étaient déjà signalées.

Ces failles ne sont pas dans Symfony ni dans Laravel, mais dans l'interpréteur PHP. Aucun composer update ne les corrige : il faut changer de PHP. Voici comment je l'ai fait pour apprendre.forelse.fr, qui tourne sur Symfony et FrankenPHP dans Docker.

1. Savoir quel PHP tourne vraiment

Première question : quelle version de PHP sert les requêtes ? Pas celle de mon poste, ni celle écrite dans composer.json, mais celle du conteneur de production.

docker compose exec app php -v
PHP 8.4.26 (cli) (built: Oct  6 2026 01:25:42) (ZTS)

PHP 8.4.26 : la dernière version publiée à ce moment-là, et pourtant vulnérable. Le correctif n'existait pas encore.

Mon Dockerfile part de dunglas/frankenphp:1-php8.4. Ce tag suit les nouvelles versions de PHP 8.4, ce qui donne l'impression d'être toujours à jour. C'est faux : l'image est figée au moment où elle est construite. La mienne datait du 16 octobre. Tant que je ne la reconstruis pas, elle garde le PHP de ce jour-là, même si FrankenPHP publie une image corrigée.

Un tag flottant (php8.4, latest) ne vous donne la dernière version qu'au moment où vous reconstruisez l'image.

2. Une mesure d'appoint en attendant le correctif

Le correctif officiel était annoncé pour le 21 octobre à 16h (heure de Paris). En attendant, j'ai réduit la surface d'attaque. Les deux failles touchent la mémoire. La première (CVE-2026-109618) est une corruption de mémoire : une requête multipart conçue pour épuiser la mémoire interrompt brutalement le traitement. La seconde (CVE-2026-109619) est un use-after-free entre deux requêtes servies par le même worker. D'après l'analyse de Rapid7, son premier déclencheur connu est le suivi de progression des envois, session.upload_progress. Il est activé par défaut, et utilisable sans même appeler session_start().

Ce suivi, mon application ne s'en sert pas. Je l'ai donc coupé dans php.ini :

session.upload_progress.enabled = Off

Ce fichier est embarqué dans l'image : le changement n'arrive en prod qu'après un rebuild et un redéploiement, comme n'importe quelle autre config. On vérifie ensuite qu'il est bien pris en compte (dans l'image FrankenPHP, la CLI et le serveur lisent le même php.ini) :

docker compose exec app php -i | grep upload_progress.enabled

Ce n'est pas un correctif. Ça ferme le premier déclencheur connu de la seconde faille, mais pas la corruption de mémoire de la première. Une option inutile en moins, c'est une porte en moins, le temps d'appliquer la vraie mise à jour.

3. Reconstruire l'image avec le PHP corrigé

Le correctif est sorti le 21 octobre 2026 : PHP 8.4.27 pour ma branche (et 8.2.35, 8.3.36, 8.5.12 pour les autres). FrankenPHP a suivi le soir même avec sa version 1.13.2, dont les images embarquent déjà ces PHP corrigés.

Mon Dockerfile partait de dunglas/frankenphp:1-php8.4, un tag flottant. J'en ai profité pour épingler une version précise : dunglas/frankenphp:1.13.2-php8.5, qui embarque PHP 8.5.12, corrigé lui aussi. Pour rester sur PHP 8.4 sans changer de branche, 1.13.2-php8.4 donne la 8.4.27.

Reste à reconstruire l'image. Comme les runners de CI hébergés sont neufs à chaque fois, le FROM retélécharge l'image de base. Mais sur une machine qui a déjà une image en cache, Docker la réutiliserait : pour être sûr, on force le retéléchargement de la base.

docker build --pull -t mon-image .

En CI avec docker/build-push-action, l'équivalent est l'option pull: true. Le cache de couches (cache-from) ne pose pas de problème ici : sa clé dépend de l'empreinte de l'image de base, qui a changé, donc toutes les couches qui en découlent sont reconstruites.

Puis on redéploie, et on revérifie :

docker compose exec app php -v

La version affichée doit maintenant être 8.5.12. Tant qu'elle indique 8.4.26, c'est que l'ancienne image tourne encore.

4. Un garde-fou pour la prochaine fois

Ce qui m'a le plus gêné dans cet épisode, c'est de ne pas avoir su tout de suite que ma prod traînait un PHP vulnérable. composer audit ne regarde que les dépendances PHP, pas l'interpréteur. J'ai donc ajouté un garde-fou à ma CI : au moment où je crée une version (un tag), un job lit le PHP réellement embarqué dans l'image et le compare à la dernière version publiée de sa branche. S'il est en retard, le job pose un avertissement bien visible sur la version.

# PHP embarqué dans l'image qu'on s'apprête à publier
installe=$(docker run --rm --entrypoint php "$IMAGE" -r 'echo PHP_VERSION;')
branche=${installe%.*}   # 8.5.12 -> 8.5

# Dernière version publiée de cette branche, d'après php.net
dernier=$(curl -fsS "https://www.php.net/releases/?json&version=${branche}&max=1" \
  | jq -r 'keys_unsorted[0]')

if dpkg --compare-versions "$installe" lt "$dernier"; then
  echo "::warning::PHP ${installe} en retard sur ${dernier}, pensez à monter l'image de base."
fi

Je l'ai câblé pour qu'il ne tourne qu'à la création d'une version, pas sur les autres builds. J'ai choisi un avertissement plutôt qu'un échec : un retard d'un correctif ne doit pas m'empêcher de livrer, mais il doit se voir. Ce n'est pas une protection contre les failles elles-mêmes : c'est un rappel, au bon moment, que l'image de base a pris du retard. Couplé à un outil qui surveille l'image de base (Renovate, Dependabot), la fenêtre entre « un PHP corrigé existe » et « ma prod le fait tourner » se referme toute seule.

5. Ce que je retiens

  • Surveiller PHP, pas seulement Composer. composer audit ne voit pas les failles de l'interpréteur. D'où le garde-fou de CI de la section 4, qui compare le PHP de l'image à la dernière version publiée.
  • Reconstruire régulièrement, et suivre l'image de base. Une image qu'on ne reconstruit pas vieillit en silence. Et si on épingle une version précise de la base, plus sûr pour la reproductibilité, il faut un outil comme Renovate ou Dependabot pour être prévenu des nouvelles versions.
  • Couper ce qui ne sert pas. session.upload_progress est activé par défaut, alors que peu d'applications l'utilisent.
  • Vérifier dans le conteneur. La seule version qui compte est celle que renvoie php -v en production.

Sources

← Tous les articles