Backend 6 min read 1221 words

PHP2Shell: the patch is out, and this is how I updated my PHP Docker image

ES
PHP2Shell: the patch is out, and this is how I updated my PHP Docker image

This morning I wrote about what to do with your PHP servers until the patch arrives for PHP2Shell. At 14:00 UTC, as planned, PHP published the fixed versions and the details of both bugs. Time to update, and in my case that started with a Docker image I maintain and that all my PHP services use.

The fixed versions

BranchFixed version
PHP 8.58.5.12
PHP 8.48.4.27
PHP 8.38.3.36
PHP 8.28.2.35

PHP 8.6.0 RC4, still in testing, also includes the fixes. There are no official releases for PHP 8.1 and older, but given the severity, the security team has published minimal patches for 7.4, 8.0 and 8.1 for anyone building from source. And they remind everyone that PHP 8.2 reaches end of life on 31 December 2026.

According to the php.net announcement, every PHP installation serving web requests is at risk: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. The risk is highest for mod_php, where they’ve shown that an unauthenticated request can trigger the attack before a single line of the application runs.

The two bugs

Now that the advisories are public, what was going on is clearer. Both are use-after-free bugs, rated 9.2 on CVSS v4.

CVE-2026-109618, RCE via OOM corruption (GHSA-w8hw-w9fx-j79w). When PHP runs out of memory, it abandons the request with a longjmp(). If that happens while some internal request data is only partly initialised, the cleanup afterwards may treat it as valid. An attacker can trigger the out-of-memory condition while staying within PHP’s apparent limits, for example with multipart requests lacking a usable content length, with an unlimited number of headers per part, or with empty file fields and nested names. On mod_php it was validated with the default limits. On PHP-FPM they consider it vulnerable from source analysis, although no complete exploit was demonstrated. The fix removes the longjmp() and replaces it with an orderly exit.

CVE-2026-109619, RCE via stale global state (GHSA-6wmf-4rv5-p3rp). A PHP worker keeps global state (errors, session, objects, extensions) that may point to memory owned by a specific request. If a fatal error skips resetting that state, the next request handled by the same worker may use it after it has been freed. Two pieces are needed: one request that leaves the state dangling and a later one that uses it before initialising. The confirmed paths are session upload progress, validated on mod_php and FPM, and PHP 8.5’s fatal error backtraces.

A correction to what I wrote this morning

This morning, going by what was known, I said that disabling session.upload_progress covered one of the two paths and that the other had no known fix inside PHP. With the official advisories, the split is clearer:

BugInterim measures proposed by the advisories
CVE-2026-109618 (out of memory)memory_limit=-1 enforced by the administrator, low post_max_size (8M without uploads, 32M with uploads), size, field and part limits at the proxy, block multipart if unused, container or cgroup memory limit
CVE-2026-109619 (stale state)session.upload_progress.enabled=0, fatal_error_backtraces=0 on PHP 8.5, avoid fatal errors and execution timeouts an attacker can trigger

Two things from this morning’s guide need correcting. First: disabling upload progress only affects the second bug, and doesn’t even fully close it. Second: a low post_max_size does reduce exposure to the first bug, according to the official advisory. And there’s an important detail for anyone using containers: limiting memory with RLIMIT_AS or RLIMIT_DATA is counterproductive, because it causes exactly the vulnerable allocation failure, while a cgroup limit like Docker’s works as a boundary.

Even so, both advisories insist on the same thing: the only complete mitigation is updating. Upload progress can stay disabled after patching if it’s unused, as the advisory itself recommends.

How I updated my Docker image

I maintain drzippie/php-nginx, an image with PHP-FPM 8.4 and Nginx on Alpine Linux. It’s a fork of TrafeX/docker-php-nginx where I’ve been adding what my projects needed: Swoole, ImageMagick, APCu, Redis, Composer, Node.js, and support for WordPress and Laravel queues. All my PHP services on Docker are built on it, so it was the first thing to update.

The last published version shipped PHP 8.4.14 on Alpine 3.21, which is vulnerable. The jump was bigger than changing a version number:

  • Alpine 3.24 as the base, with PHP 8.4.27, Nginx 1.30 and Node.js 24.
  • A way to build PHP when Alpine doesn’t have the package yet. In an urgent update, distributions can take hours or days to publish the package, so I added an optional stage to the Dockerfile that builds the given PHP version with Alpine’s own APKBUILD.
  • AMD64 and ARM64 only. Alpine 3.24 has no Swoole package for PHP 8.4 on 32-bit ARM, so I stopped publishing ARMv6 and ARMv7.

The build stage downloads the php84 recipe from aports, changes the version and checks the source’s SHA-256 against the one php.net publishes before building:

# pkgrel=100 makes our build win over the same version in the official repo
RUN sed -i -e "s/^pkgver=.*/pkgver=${PHP_VERSION}/" -e 's/^pkgrel=.*/pkgrel=100/' \
      -e '/^pkgrel=/a options="!check"' APKBUILD && \
    export SRCDEST=/distfiles REPODEST=/packages && \
    abuild -F checksum && \
    echo "${PHP_SHA256}  /distfiles/php-${PHP_VERSION}.tar.xz" | sha256sum -c - && \
    apk update && abuild -F -r

By default the official Alpine packages are used, and the build is enabled with build arguments:

docker buildx build --build-arg PHP_SOURCE=build \
  --build-arg PHP_VERSION=8.4.27 --build-arg PHP_SHA256=<sha256 from php.net> .

The pkgrel=100 makes the built package win over the official one with the same version, and checking the SHA-256 ties in with what I wrote a few days ago about code integrity in package managers: if you’re going to build PHP in a hurry, at least make sure it’s the PHP that php.net published.

I also updated the Docker Hub documentation with a security notice at the top and a table with the PHP version of each tag, making it clear that the earlier ones are vulnerable:

TagPHPAlpineNotes
latest, 2026-10-218.4.273.24Fixes CVE-2026-109618 and CVE-2026-109619
2026-07-188.4.143.21Vulnerable, do not use

Updating the services

With the image published, I updated all my services that run on Docker. With Compose, the process for each one is to pull the new image and recreate the containers:

docker compose pull
docker compose up -d

And check the version, both in the image and in each service’s container:

docker run --rm drzippie/php-nginx php -v
docker compose exec app php -v

The hard part in these cases isn’t the command, but not missing anything: the service pinned to a fixed tag instead of latest, the container that wasn’t recreated because nobody ran up on it, or the image built months ago from a base that no longer gets updates. That’s why the tag table on Docker Hub seemed important to me: anyone using the image can check at a glance whether theirs is vulnerable.

My takeaway

It’s been an intense day, but with an orderly ending: advance notice, patches at the announced time, detailed technical advisories, and even minimal fixes for unsupported versions. Maintaining your own base image has its advantages, because updating it updates everything that depends on it, but it also comes with the responsibility of doing it fast. And on a day like today, the difference between waiting for Alpine to package the version and being able to build it yourself can be measured in hours of exposure.

Sources: PHP security announcement, GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp.