BitPage

PHP2Shell on PHP 8: docker build --pull succeeded and PHP is still 8.2.29

By  ·   · 15 min read

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

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:

TagPHP insideTag last pushedWhat it does to you
php:8.2-fpm-bullseye8.2.292025-08-05frozen; --pull fetches a vulnerable image and succeeds
php:8.2-fpm-bookworm8.2.352026-10-21fixed
php:8.2-fpm8.2.352026-10-24fixed, but same config digest as -trixie; the OS moved under you
php:8.4-fpm-bookworm8.4.272026-10-21fixed
php:8.0-fpm8.0.302023-11-22frozen; no fixed release exists for the branch
php:8.1-cli-alpine8.1.342025-12-22frozen; branch is EOL, no fixed release
wordpress:latest8.3.362026-10-22fixed, but the PHP minor version changed
wordpress:php8.2-apache8.2.352026-10-22fixed, branch pinned
dunglas/frankenphp:1.8-php8.4-bookworm8.4.102025-07-14frozen minor tag
dunglas/frankenphp:1.13.2-php8.4.27-bookworm8.4.272026-10-21fixed

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:

  1. Tags on older OS releases are frozen. -bullseye stopped 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 except PHP_VERSION looks healthy: the pull succeeds, the layers for the application are new, the build date on the final image is today.
  2. The suffix-less tag changed OS. php:8.2-fpm now resolves to the same image config digest as php:8.2-fpm-trixie (Debian 13), so the two names are the same image under different labels. Dockerfiles that call apt-key stop building, and OpenSSL, ICU and libpq all move at once.
  3. Minor tags of third-party images freeze the same way. FrankenPHP’s 1.8-php8.4-bookworm is a tag that looks floating and has not moved since July 2025.
  4. A latest tag can change the PHP branch. An image built when wordpress:latest carried 8.2 now gets 8.3.36 from the identical Dockerfile line.
  5. Distribution packages backport without a version bump. For a PPA or distro install, php -v cannot 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:

1
2
3
4
docker pull php:8.2-fpm-bullseye
docker image inspect php:8.2-fpm-bullseye \
  --format '{{range .Config.Env}}{{println .}}{{end}}' | grep PHP_VERSION
docker image inspect -f '{{.Created}}' php:8.2-fpm-bullseye

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.

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
FROM debian:bullseye-slim
# bullseye-security moved to archive.debian.org when Debian 11 left LTS
RUN sed -i 's#http://deb.debian.org/debian-security#http://archive.debian.org/debian-security#' /etc/apt/sources.list \
 && grep -q 'archive.debian.org/debian-security bullseye-security' /etc/apt/sources.list
# ... unchanged up to the PHP build ...
COPY php2shell-8.0.patch /usr/src/php2shell-8.0.patch   # git format-patch 82f8f31b16a0
RUN set -eux; \
    apt-get update; apt-get install -y --no-install-recommends patch <build-deps>; \
    docker-php-source extract; \
    cd /usr/src/php; \
    patch -p1 --forward < /usr/src/php2shell-8.0.patch; \
    grep -c array_init_singleton Zend/zend_API.h; \
# repack the patched tree: docker-php-ext-install re-extracts php.tar.xz later
    tar -cJf /usr/src/php.tar.xz -C /usr/src php; \
    ./configure ...   # as in the original

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:

1
2
3
4
T='register_shutdown_function(function(){echo "SHUTDOWN-CALLED\n";}); $a=str_repeat("x", 20*1024*1024);'
docker run --rm IMG php -d memory_limit=8M -r "$T"; echo "exit=$?"
# patched:   Fatal error ..., exit=1,   no SHUTDOWN-CALLED
# unpatched: Fatal error ..., exit=255, SHUTDOWN-CALLED

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:

BuildExitFatal error toShutdown function
php:8.2-cli-alpine3.21, 8.2.29 (vulnerable)255stdoutran
php:8.2-cli-alpine, 8.2.35 (fixed)255stdoutran
sury php8.0-cli 1:8.0.30-25 (EOL backport)1stderrdid 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”.

1
2
3
docker inspect -f '{{.Image}}' <container>     # must equal the new image ID
docker image inspect <image:tag> -f '{{.Id}}'
docker exec <container> php -r 'echo PHP_VERSION, " up=", ini_get("session.upload_progress.enabled"), PHP_EOL;'

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:

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:

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

  1. For every Dockerfile, print PHP_VERSION and Created from the base image under the tag you currently reference. Decide from that, not from the tag name.
  2. Replace suffix-less and OS-suffixed-but-frozen tags with a tag that is still being built, pinning the OS explicitly.
  3. For third-party images, pin the full version triple plus the digest.
  4. Build with --pull --platform linux/amd64.
  5. Before deploying a Debian 11 to 12 move, test TLS handshakes to every external API from both images.
  6. Verify, per branch: memory_hard_limit_percent on 8.2 and newer, the package changelog for distro installs, the OOM behaviour test for self-built EOL images.
  7. Recreate every PHP container explicitly. Then re-verify inside the running container, not in the registry.
  8. 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.

#php #docker #cve #frankenphp #security

<< Previous Post

|

Next Post >>