PHP2Shell on PHP 8: docker build --pull succeeded and PHP is still 8.2.29
Short answer: docker build --pull honestly downloads the newest image under your tag, which tells you nothing, because the tag may not have been rebuilt in over a year. php:8.2-fpm-bullseye has carried PHP 8.2.29 since 5 August 2025 and never received the PHP2Shell fix; the fixed 8.2.35 exists only under -bookworm and -trixie. Check PHP_VERSION in the base image config before you build, not the build log afterwards.
When PHP2Shell landed I expected the 8.x half of the work to be the easy half. Bump the tag, recreate the container, move on to the EOL branches where the actual problem lives. In several places the rebuild went green and the PHP inside was still vulnerable, and the check everybody runs (reading the build output) did not show it.
The background on the two CVEs and the fleet-wide audit is in the overview piece. This one is only about PHP 8 and Docker.
TL;DR
- Fix: pin the OS suffix explicitly (
php:8.2-fpm-bookworm), build with--pull --platform linux/amd64, and verifyPHP_VERSIONin the base image before the application build. - Fix, supported branches:
php -r 'var_dump(ini_get("memory_hard_limit_percent") !== false);'returns true only on 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 and later. It is the one static check that survives a distro backport. The!== falseis load-bearing: the directive defaults toNULL, soini_get()returns an empty string and a truthiness test calls a patched build unpatched. - Trap: minor tags of third-party images freeze too.
dunglas/frankenphp:1.8-php8.4-bookwormhas held PHP 8.4.10 since 14 July 2025; 8.4.27 ships only in FrankenPHP 1.13.x. - Trap:
wordpress:latesthas moved to PHP 8.3.36. A blindpullchanges your PHP minor version without saying so. - Trap: EOL 8.0 has no fixed release at all. The only path is 8.0.30 plus the official backport commit, which means 23 patch versions of forward movement before you even start.
- Trap: the OOM behaviour test does not prove patching on 8.2. I measured 8.2.29 and 8.2.35 behaving identically.
Why the same tag with a fresh pull is not a fix
An official image is PHP plus an OS plus the docker-library tag policy, and the fixed PHP is not under every tag where you would expect it. I went through the registry API for every tag I had in a Dockerfile. Five distinct failure modes, all measured on 26 October 2026:
| Tag | PHP inside | Tag last pushed | What it does to you |
|---|---|---|---|
php:8.2-fpm-bullseye | 8.2.29 | 2025-08-05 | frozen; --pull fetches a vulnerable image and succeeds |
php:8.2-fpm-bookworm | 8.2.35 | 2026-10-21 | fixed |
php:8.2-fpm | 8.2.35 | 2026-10-24 | fixed, but same config digest as -trixie; the OS moved under you |
php:8.4-fpm-bookworm | 8.4.27 | 2026-10-21 | fixed |
php:8.0-fpm | 8.0.30 | 2023-11-22 | frozen; no fixed release exists for the branch |
php:8.1-cli-alpine | 8.1.34 | 2025-12-22 | frozen; branch is EOL, no fixed release |
wordpress:latest | 8.3.36 | 2026-10-22 | fixed, but the PHP minor version changed |
wordpress:php8.2-apache | 8.2.35 | 2026-10-22 | fixed, branch pinned |
dunglas/frankenphp:1.8-php8.4-bookworm | 8.4.10 | 2025-07-14 | frozen minor tag |
dunglas/frankenphp:1.13.2-php8.4.27-bookworm | 8.4.27 | 2026-10-21 | fixed |
One caveat on that third column, because I got it wrong myself at first and the two numbers are a few days apart. An image has two timestamps: created in its config blob, which is when it was built, and the tag’s own last push, which is when that name last moved. For php:8.2-fpm-bullseye they are 2025-07-31 and 2025-08-05. The column above is the push date, because the question being asked is “when did this tag last change”, not “when was this layer compiled”. If you script this, pick one field and label it; mixing them produces a table that nobody including you can reconcile later.
Reading down that list, the modes are:
- Tags on older OS releases are frozen.
-bullseyestopped at 8.2.29. The branch is still supported, the tag is not still built. This is the one that cost me time, because every signal exceptPHP_VERSIONlooks healthy: the pull succeeds, the layers for the application are new, the build date on the final image is today. - The suffix-less tag changed OS.
php:8.2-fpmnow resolves to the same image config digest asphp:8.2-fpm-trixie(Debian 13), so the two names are the same image under different labels. Dockerfiles that callapt-keystop building, and OpenSSL, ICU and libpq all move at once. - Minor tags of third-party images freeze the same way. FrankenPHP’s
1.8-php8.4-bookwormis a tag that looks floating and has not moved since July 2025. - A
latesttag can change the PHP branch. An image built whenwordpress:latestcarried 8.2 now gets 8.3.36 from the identical Dockerfile line. - Distribution packages backport without a version bump. For a PPA or distro install,
php -vcannot answer the question at all; the package changelog can.
Alpine is not an escape hatch. Alpine minor tags freeze exactly like Debian ones: php:8.2-cli-alpine3.20 carries 8.2.28, alpine3.21 carries 8.2.29, alpine3.22 carries 8.2.31, and only the bare -alpine suffix carries 8.2.35.
The check that would have caught all of it takes one command and belongs before the application build, not after:
| |
That .Created is the build date from the config blob, which is the only one of the two timestamps a local Docker can show you; the push date needs the registry API. For this purpose the build date is enough. If it is more than a few weeks old on a supported branch, the tag is not being rebuilt and no amount of --pull will change it.
Supported branches: what to actually pin
Take a setup with a Laravel app on php:8.2-fpm-bookworm, a couple of small services still on -bullseye, a WordPress site on wordpress:latest, an Octane service on the FrankenPHP 1.8 minor tag, and a CLI install from the ondrej PPA. Each needs a different change and only one of them is a version bump.
- Move
-bullseyeto-bookworm. That is Debian 11 to 12, which moves more than PHP: OpenSSL 1.1.1w to 3.0.22, ICU 67 to 72, libpq 13 to 15, curl 7.74 to 7.88. OpenSSL 3 is the one that bites, because it drops legacy ciphers. Run a TLS handshake against every external API you talk to from both the old and the new image before deploying, not after. - Never use the suffix-less tag.
php:8.2-fpmis a moving OS target by design. - WordPress:
wordpress:php8.2-apache, notlatest. Then only the PHP patch version and the OS move, and going to 8.3 becomes a decision rather than a side effect. Worth knowing separately: changing the WordPress image does not update the WordPress core in the volume, because the entrypoint only copies core into an empty directory. - FrankenPHP: pin both versions and the digest, as in
dunglas/frankenphp:1.13.2-php8.4.27-bookworm@sha256:…. Going from 1.8 to 1.13 is a real upgrade of the server, not just of PHP: the header limit becomes 16 KiB,putenv()no longer writes to the process environment, andSCRIPT_NAMEandPATH_INFOare set differently. Read the FrankenPHP release notes before the bump, not during the incident. - PPA or distro package:
apt install --only-upgrade 'php8.2-*', then confirm from the changelog rather than the version. First rundpkg -l 'php8.2*'and check whether an fpm, cgi or mod_php package is installed that you had forgotten about. A CLI-only box that quietly haslibapache2-mod-php8.2on it is a web-facing install. - Add
session.upload_progress.enabled = 0to the image’s ini directory regardless of branch. It closes the confirmed FPM path for CVE-2026-109619 and breaks nothing except upload progress bars. It has to be inphp.inior the pool config:ini_set()and.user.iniare applied after the body is parsed, which is too late by construction.
Always build with --platform linux/amd64 if you build on an Apple Silicon laptop. An arm64 image that cannot start on the target is at least an obvious failure; the subtler version is a successful multi-arch build where you test the wrong architecture locally.
I rejected memory_limit = -1 as a standalone mitigation here. The advisory lists it for 109618, but on a pool with a few hundred children and no container memory cap it trades a high-complexity RCE for a reliable OOM of the whole VM. RLIMIT_AS and ulimit -v are worse: they trigger the vulnerable allocation failure themselves.
EOL 8.0: build your own base image
There is no fixed 8.0 release, no tarball and no Docker image. What exists is a branch in php-src: PHP-8.0-CVE-2026-109618-109619, which is the php-8.0.30 tag plus a single commit, 82f8f31b16a0, titled “Avoid state corruption via bailout” and described in its own message as a minimal mitigation for an EOL version. It touches 17 files for +229/−59: Zend/*, main/rfc1867.c, main/SAPI.c, main/main.c, main/php_variables.c and ext/session/session.c. The fpm and apache2handler SAPI directories are untouched.
The patch applies cleanly to 8.0.30 and conflicts on 8.0.7 in zend_vm_*. That settles the route: there is exactly one, and it is 8.0.30 plus the patch. For an application pinned to 8.0.7 from a 2021 image that is 23 patch versions of forward movement, which is work you have to scope honestly rather than wish away. The 8.0 NEWS entries between those two versions that actually change behaviour: 8.0.28 introduced max_multipart_body_parts and fixed password_verify; 8.0.24 and 8.0.27 stopped url-decoding cookie names; 8.0.30 stopped libxml loading external entities; 8.0.8 made FILTER_VALIDATE_URL stricter.
The base image comes from the last docker-library Dockerfile that still had the branch. For 8.0 that is commit 41c9ad0b43a5, variant 8.0/bullseye/fpm, the last commit to touch that file before 4d3ed4a9bcb0 “Remove PHP 8.0 (EOL 26 November 2023)”. It carries ENV PHP_VERSION 8.0.30 on line 63, which is the version the official patch wants anyway. Using the official Dockerfile rather than writing your own matters more than it looks: the layout stays identical, so /usr/local/etc/php, docker-php-ext-install and pecl all keep working, and the application Dockerfile changes one FROM line.
| |
The grep -q after patch is an assert, and choosing the right symbol matters. My first attempt used zend_objects_store_mark_destructed, which exists in unpatched source as well, so the assert passed unconditionally and proved nothing. array_init_singleton in Zend/zend_API.h occurs zero times in vanilla 8.0.30 and is added by the patch, which is what an assert needs.
I rejected the sury php8.0 packages on debian:bookworm-slim for this one. The fix is there, in 0137-Avoid-state-corruption-via-bailout.patch, and the package also carries dozens of post-EOL CVE fixes the official backport does not, but the layout is different (/etc/php/8.0, php-fpm8.0, extensions via apt), so the application Dockerfile would have to be rewritten. For a fleet-wide emergency, one changed FROM line beats a better-maintained package.
Gotchas
The patch is in and the version says otherwise
php -v reports 8.0.30 with and without the patch. Worse, the obvious marker does not work on this branch: max_multipart_body_parts exists in 8.0.28 and later regardless of the patch — I counted it in vanilla main/main.c for php-8.0.28 and php-8.0.30, one occurrence each. And php -i still prints session.upload_progress.enabled => On even though the patch disables the feature in code with if (!PS(rfc1867_enabled) || true).
On 8.0, then, a version-and-ini scanner will call your patched image vulnerable, and an ini showing Off does not prove a patch is present. The proof is behavioural:
| |
Then tag the result so nobody has to rerun the test to identify the build: an image tag like 8.0.30-p2s-… plus a LABEL, and the behaviour test kept in the build checklist.
That same test proves nothing on 8.2
This is the correction I had to make to my own notes. I assumed the exit-code-1 signature was what “patched” looked like everywhere. It is not. I ran the identical script in a 512 MB container with memory_limit=64M against three builds:
| Build | Exit | Fatal error to | Shutdown function |
|---|---|---|---|
php:8.2-cli-alpine3.21, 8.2.29 (vulnerable) | 255 | stdout | ran |
php:8.2-cli-alpine, 8.2.35 (fixed) | 255 | stdout | ran |
sury php8.0-cli 1:8.0.30-25 (EOL backport) | 1 | stderr | did not run |
The upstream fix for supported branches deliberately leaves ordinary memory_limit handling alone — the soft OOM is deferred to a safe point rather than turned into a process exit. Only the EOL backports convert it into _exit(1). Use the behaviour test on EOL branches and the new memory_hard_limit_percent directive on supported ones; swapping them gives you a confident wrong answer in both directions.
The image is updated and the running PHP is not
This one is delayed, which makes it worse. A new image in the registry is not a new PHP in production.
The stage deployment role in one place ran docker compose up -d --no-recreate with no pull, so the stages would never have picked the new image up on their own. I found that by reading the role before the rollout rather than after, which was luck as much as process. CI runners are the same shape of problem: a runner pulls php-cli:8.2 when its next job starts, so until then it is still running the vulnerable image, and your report needs a line that says “fixed later” rather than “fixed”.
| |
docker-php-ext-install rebuilds from unpatched sources
The official Dockerfile ends with docker-php-source delete, leaving /usr/src/php.tar.xz in place. Later, an application image running docker-php-ext-install re-extracts that tarball (the pristine one, without your patch) and compiles extensions against it. Repacking the patched tree into the same php.tar.xz right after patch costs one line and removes the question.
I have not seen this cause a real failure, so treat it as a precaution rather than a reported bug. It is cheap enough that the distinction does not matter.
A 2021 Dockerfile no longer builds, and without a build there is no patch
Two separate breakages, both unrelated to PHP:
- Debian 11 left LTS in August 2026.
bullseye-securitymoved toarchive.debian.organd returns 404 ondeb.debian.org. Rewritesources.listand put agrep -qafter it so the build fails loudly if the file format changes under you rather than silently falling back. - The pecl.php.net REST channel no longer serves old releases for PHP 8.0, so
pecl install redisfails. Download the exact version you were already running (redis-5.3.4.tgzin my case) and verify the sha256 instead.
Neither is interesting. Both are the reason an emergency rebuild of a five-year-old image takes a day instead of an hour, and both are worth discovering before the day you need the patch.
What changes for the application
On the EOL backport, three behavioural changes, all visible from the application side:
- OOM terminates through
_exit(1). Shutdown functions do not run, the fatal error goes to stderr, and FPM behind nginx answers 502 where it answered 500. An application that regularly brushesmemory_limitwill start serving 502s, and anything you do in a shutdown handler (flushing an error reporter, releasing a queue job) stops happening. - Session upload progress is off in code, whatever
php.inisays. max_multipart_body_partscaps form size. Default-1falls back tomax_input_vars + max_file_uploads; on stock defaults that is 1020 parts, after which PHP emits “Multipart body parts limit exceeded %d. To increase the limit change max_multipart_body_parts in php.ini.” Forms with thousands of fields will hit it.
On the supported branches the picture is different, and I checked this in the source because the advisory does not spell it out. Upstream keeps session upload progress and adds a guard instead of amputating the feature: if (PG(modules_activated)) { /* request_parse_body(). Do not run here as it corrupts the session. */ return retval; }. That guard is new in the fixed release: PG(modules_activated) appears twice in ext/session/session.c on both 8.2.29 and 8.2.34, and three times on 8.2.35. An 8.2 app with upload progress bars keeps them; an EOL app loses them the moment it is patched.
Step by step
- For every Dockerfile, print
PHP_VERSIONandCreatedfrom the base image under the tag you currently reference. Decide from that, not from the tag name. - Replace suffix-less and OS-suffixed-but-frozen tags with a tag that is still being built, pinning the OS explicitly.
- For third-party images, pin the full version triple plus the digest.
- Build with
--pull --platform linux/amd64. - Before deploying a Debian 11 to 12 move, test TLS handshakes to every external API from both images.
- Verify, per branch:
memory_hard_limit_percenton 8.2 and newer, the package changelog for distro installs, the OOM behaviour test for self-built EOL images. - Recreate every PHP container explicitly. Then re-verify inside the running container, not in the registry.
- List the places where the fix will land later (CI runners, stages behind
--no-recreate) as outstanding rather than done.
Bottom line
A Docker tag is not a promise of updates. --pull can leave PHP entirely unchanged, as php:8.2-fpm-bullseye does at 8.2.29, or change far more than PHP, as php:8.2-fpm does by moving to Debian 13. The only reliable inputs are PHP_VERSION in the base image config before the build and the running container’s own php -v after it.
How you prove a build is fixed depends on where it came from: official image by version, distro package by changelog, self-built backport by behaviour, and supported branch by the new memory_hard_limit_percent directive. For EOL 8.0 the official minimal backport on top of 8.0.30, built with the last docker-library Dockerfile for the branch, gives a fixed PHP without changing branch — and the application changes one line.
Branch-by-branch companions: the fleet-wide audit and the verification matrix, PHP 7.1 to 7.4, and PHP 5.6.
Primary sources: GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp, php.net ChangeLog-8, docker-library/php, FrankenPHP releases. Tag data read from the Docker Hub and registry v2 APIs on 26 October 2026.