On the evening of 20 October, two critical flaws were disclosed in PHP: CVE-2026-109618 and CVE-2026-109619, quickly nicknamed "PHP2Shell". They allow unauthenticated remote code execution on the server, and are rated 9.2 out of 10. By the next morning, real-world attacks were already being reported.
These flaws are not in Symfony or Laravel, but in the PHP interpreter. No composer update fixes them: you have to change PHP itself. Here is how I did it for apprendre.forelse.fr, which runs on Symfony and FrankenPHP in Docker.
1. Know which PHP actually runs
First question: which version of PHP is serving requests? Not the one on my laptop, nor the one written in composer.json, but the one in the production container.
docker compose exec app php -v
PHP 8.4.26 (cli) (built: Oct 6 2026 01:25:42) (ZTS)
PHP 8.4.26: the latest version published at that point, and yet vulnerable. The fix did not exist yet.
My Dockerfile starts from dunglas/frankenphp:1-php8.4. That tag follows new releases of PHP 8.4, which gives the impression of always being up to date. It is not: the image is frozen at the moment it is built. Mine dated from 16 October. Until I rebuild it, it keeps the PHP from that day, even after FrankenPHP publishes a fixed image.
A floating tag (
php8.4,latest) only gives you the latest version at the moment you rebuild the image.
2. A stopgap while waiting for the fix
The official fix was announced for 21 October at 14:00 UTC. In the meantime, I reduced the attack surface. Both flaws are memory issues. The first (CVE-2026-109618) is a memory corruption: a multipart request crafted to exhaust memory abruptly aborts request processing. The second (CVE-2026-109619) is a use-after-free between two requests served by the same worker. According to Rapid7's analysis, its first known trigger is upload progress tracking, session.upload_progress. It is enabled by default, and usable without even calling session_start().
My application does not use that tracking. So I turned it off in php.ini:
session.upload\_progress.enabled = Off
This file ships inside the image: the change only reaches production after a rebuild and a redeploy, like any other config. Then you check it was applied (in the FrankenPHP image, the CLI and the server read the same php.ini):
docker compose exec app php -i | grep upload_progress.enabled
This is not a fix. It closes the first known trigger of the second flaw, but not the memory corruption of the first. One unused option switched off is one less open door until the real fix is in.
3. Rebuild the image with the fixed PHP
The fix shipped on 21 October 2026: PHP 8.4.27 for my branch (and 8.2.35, 8.3.36, 8.5.12 for the others). FrankenPHP followed the same evening with its 1.13.2 release, whose images already bundle these fixed PHP versions.
My Dockerfile started from dunglas/frankenphp:1-php8.4, a floating tag. I took the opportunity to pin an exact version: dunglas/frankenphp:1.13.2-php8.5, which bundles PHP 8.5.12, fixed as well. To stay on PHP 8.4 without switching branches, 1.13.2-php8.4 gives you 8.4.27.
That leaves rebuilding the image. Since hosted CI runners are fresh every time, the FROM re-pulls the base image. But on a machine that already has an image cached, Docker would reuse it: to be safe, force a re-pull of the base image.
docker build --pull -t my-image .
In CI with docker/build-push-action, the equivalent is the pull: true option. The layer cache (cache-from) is not a problem here: its key depends on the base image's digest, which has changed, so every layer that builds on it is rebuilt.
Then redeploy, and check again:
docker compose exec app php -v
The version shown should now be 8.5.12. As long as it says 8.4.26, the old image is still running.
4. A safety net for next time
What bothered me most in this episode was not knowing right away that my production server was still running a vulnerable PHP. composer audit only looks at PHP dependencies, not the interpreter. So I added a safety net to my CI: when I cut a release (a tag), a job reads the PHP actually bundled in the image and compares it to the latest published version of its branch. If it is behind, the job raises a clearly visible warning on the release.
# PHP bundled in the image we are about to publish
installed=$(docker run --rm --entrypoint php "$IMAGE" -r 'echo PHP\_VERSION;')
branch=${installed%.\*} # 8.5.12 -> 8.5
# Latest published version of that branch, per php.net
latest=$(curl -fsS "https://www.php.net/releases/?json&version=${branch}&max=1" \
| jq -r 'keys\_unsorted[0]')
if dpkg --compare-versions "$installed" lt "$latest"; then
echo "::warning::PHP ${installed} is behind ${latest}, consider bumping the base image."
fi
I wired it so that it only runs when cutting a release, not on other builds. I chose a warning rather than a failure: being one patch behind should not stop me from shipping, but it must be visible. This is not protection against the flaws themselves: it is a reminder, at the right moment, that the base image has fallen behind. Paired with a tool that watches the base image (Renovate, Dependabot), the window between "a fixed PHP exists" and "my production runs it" closes on its own.
5. What I take away
-
Watch PHP, not just Composer.
composer auditdoes not see flaws in the interpreter. Hence the CI safety net from section 4, which compares the image's PHP to the latest published version. - Rebuild regularly, and track the base image. An image you never rebuild quietly goes stale. And if you pin an exact base version, which is safer for reproducibility, you need a tool like Renovate or Dependabot to get notified of new versions.
-
Turn off what you don't use.
session.upload_progressis enabled by default, while few applications use it. -
Check inside the container. The only version that matters is the one
php -vreturns in production.
Top comments (0)