Skip to content
Go back

PHP2Shell: Is Your Homelab Running PHP?

By KingPin 14 min read
PHP2Shell: Is Your Homelab Running PHP?
Contents

You don’t run PHP. Sure. You run Nextcloud, WordPress for your cousin’s bakery, a Matomo instance from 2023, and a wiki you forgot about. All of them are PHP, and as of October 21, 2026 there is an unauthenticated remote code execution bug in PHP itself with exploitation already happening in the wild.

The short version: patch today, and treat Cloudflare’s WAF as a seatbelt, not the brakes. The seatbelt is good and you should leave it on. It will not stop the car.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/tree/main/security/php2shell-homelab-triage

What PHP2Shell actually is

PHP2Shell is two flaws disclosed on October 21, 2026. Both score CVSS v4.0 9.2, and both are unauthenticated.

Calif.io, Wiz, OpenAI, and Quarkslab disclosed them. Details leaked a day early: per Wikipedia’s account, a Hacker News thread, a watchTowr post on X, and a proof-of-concept video Calif released by mistake all surfaced on October 20, so PHP shipped the release on October 21 instead of the planned October 22. watchTowr reported exploitation in the wild through its honeypots as of October 21, and Cloudflare says it saw attack traffic aimed at its customers.

The affected list is “all PHP installations serving web requests”: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP, and LiteSpeed. php.net rates mod_php as the highest risk, with a demonstrated exploit where an unauthenticated request triggers the attack before any application code runs. Cloudflare’s write-up says PHP 5.4 or later is affected. That is a lot of years of containers.

I’m not going to describe exploit mechanics beyond that. Imperva’s public analysis says the session decoder bug builds a large session through the upload-progress mechanism and then forces memory exhaustion, and an OPcache bug leaves a stale class-cache slot that, in their demo, led to arbitrary file reads within the worker’s permissions. Think config files, database credentials, API keys. You don’t need more detail than that to decide to patch.

The fix: four version numbers

BranchFixed in
8.58.5.12
8.48.4.27
8.38.3.36
8.28.2.35

PHP 8.6.0 RC4 also contains the fixes, but it is a release candidate and does not belong in production.

PHP 8.1 and older no longer receive updates from the PHP project. The PHP security team published minimal source backport branches for 7.4, 8.0, and 8.1, and distros and LTS vendors received patches in advance, so some of them backport. If your image is on 8.1 or older, you are checking with your vendor or moving to a supported branch. Those are the two options.

The official line is blunt: “There are no complete mitigations, updating is the only safe choice.”

Docker made this easy and hard at the same time

On October 21 the official php images rebuilt the same day. The 8.2.35 tags landed around 17:23 UTC, 8.3.36 at 17:28, 8.4.27 at 17:31, and 8.5.12 at 19:00. The wordpress:php8.4-fpm image rebuilt at 18:42 UTC and nextcloud:33.0.10-fpm at 18:56 UTC (all checked on Docker Hub on October 21, 2026).

So for anything built on an official image, the fix is two commands:

Terminal window
docker compose pull
docker compose up -d

The gotcha is everything that pins its own PHP. linuxserver.io images, vendor images, and any custom Dockerfile with a pinned FROM php:8.4.20-fpm will not update on their own. I won’t tell you whether any specific third-party image is patched, because I haven’t checked yours. Look at the image’s tags and changelog, or just ask the container.

Step 1: Inventory, because you forgot some of them

docker ps shows images, not PHP versions. A tag like latest tells you nothing, and an app image can bundle PHP under a name you would never guess. So ask each container directly.

The companion script does that. For every running container it hunts for a PHP binary: php on PATH, Alpine-style names like php84, images that ship only php-fpm or php-fpm83, and FrankenPHP (where frankenphp version prints the bundled PHP version). It prints the version, a SAPI hint, a verdict, and the ini values that matter for the mitigations below. Containers with no shell get skipped with a note instead of a crash.

The core of the verdict logic is a version comparison with sort -V:

php2shell-check.sh (abridged)
FIXED="8.5.12 8.4.27 8.3.36 8.2.35"
status() {
local v=$1 mm=${1%.*} f
case $v in
8.6.0RC*) [ "${v#8.6.0RC}" -ge 4 ] 2>/dev/null && echo PATCHED || echo VULNERABLE; return ;;
esac
for f in $FIXED; do
[ "${f%.*}" = "$mm" ] || continue
[ "$(printf '%s\n%s\n' "$f" "$v" | sort -V | head -1)" = "$f" ] && echo PATCHED || echo VULNERABLE
return
done
# no fixed release for this branch: older than 8.2 is EOL, newer than 8.5 is post-fix
[ "$(printf '%s\n%s\n' 8.2.0 "$v" | sort -V | head -1)" = "8.2.0" ] && echo PATCHED || echo EOL-NO-FIX
}

Run it:

Terminal window
./php2shell-check.sh # every running container
./php2shell-check.sh '^cloud' # only names matching a regex

I tested it against a pile of throwaway containers on Docker 29.8.2. This is the real output, trimmed to the test containers (the p2s-test- prefix is mine):

CONTAINER IMAGE PHP SAPI STATUS MEMLIM POSTMAX UPLPR OPC
p2s-test-alpine84 php:8.4.27-fpm-alpine 8.4.27 cli/php84 PATCHED 128M 8M On On
p2s-test-fpmonly php:8.4.26-fpm 8.4.26 fpm-fcgi/php-fpm VULNERABLE 128M 8M On On
p2s-test-franken dunglas/frankenphp 8.5.12 cli/php PATCHED 128M 8M On On
# p2s-test-traefik: no PHP found, skipped
# p2s-test-redis: no PHP found, skipped
p2s-test-wp wordpress:php8.4-fpm 8.4.27 cli/php PATCHED 128M 8M On On
p2s-test-8.4.27-fpm php:8.4.27-fpm 8.4.27 cli/php PATCHED 128M 8M On On
p2s-test-8.1-fpm php:8.1-fpm 8.1.34 cli/php EOL-NO-FIX 128M 8M On On
# p2s-test-8.1-fpm: PHP project ships no fix for this branch. Check your image vendor or distro for a backport, or upgrade.
p2s-test-8.4.26-fpm php:8.4.26-fpm 8.4.26 cli/php VULNERABLE 128M 8M On On
# p2s-test-scratch: no shell or exec failed (rc=127), skipped

A few notes on those rows. The 8.4.26 container is the vulnerable one and 8.4.27 is the fixed one, one patch release apart. The fpmonly and alpine84 rows are the same official images with the php binary renamed inside the container, which exercises the php-fpm and php84 detection paths. The script exits with code 1 here because it found VULNERABLE and EOL-NO-FIX rows, so you can drop it in cron or CI and alert on the exit code. The scratch-based container has no shell, so the script says so and moves on.

Look at the last four columns too. Every default container above has memory_limit at 128M and session.upload_progress.enabled at On. Those are the defaults, and they are exactly the settings the interim mitigations touch.

One caveat is printed in the script header and worth repeating. The ini values are the CLI view. A PHP-FPM pool can override them with php_admin_value, so the web-facing value can differ. Check your pool configs.

The verdict also reads the version string only. A distro-packaged PHP (Debian’s php8.2 from apt, say) can carry a backported fix under an older version number, so a VULNERABLE row on a distro build means “go read your distro’s advisory”, not “definitely exploitable”.

Step 2: Patch (the brakes)

Official-image apps: docker compose pull && docker compose up -d, then run the script again and confirm the status flipped. If your Dockerfile pins a patch version, bump the FROM line to the fixed tag and rebuild with docker compose build --pull.

Apps that pin their own PHP: check the vendor’s tags and changelog for a rebuild on or after October 21. If there is none yet, you are in the interim-mitigation section below until there is.

PHP 8.1 or older: no official fix. Ask your vendor, or move the app to a supported branch. A pinned PHP 8.1 container facing the internet is the container I would take offline first.

Step 3: The Cloudflare WAF (the seatbelt)

Here is the good news. Cloudflare built WAF protections starting in early October, and deployed them about nine hours before public disclosure. “Starting on October 21 3:02 am UTC, Cloudflare customers using our free or paid WAF were protected by default against php2shell.”

On the Free plan the new rules live in the Cloudflare Managed Free Ruleset, and you don’t need to do anything. Cloudflare also added a “full transaction detection” capability that inspects both the request and the response, plus a “Default block for High confidence Signatures” rule in the new Cloudflare Managed Response Firewall Default, which applies to all plans including Free.

If you publish your homelab through a Cloudflare Tunnel, you most likely get this too, but the reason is worth getting right. The Cloudflare PHP2Shell post does not mention Tunnel. The coverage comes from the hostname: Cloudflare’s Tunnel DNS docs say “Published applications inherit the Cloudflare settings for their hostname, including cache rules, WAF rules, and other Rules configurations.” A public hostname routed through a tunnel (a CNAME to <UUID>.cfargotunnel.com) gets the same WAF rules as any proxied site.

What does not pass through those rules:

  1. LAN access.
  2. Tailscale and WireGuard traffic.
  3. Direct port forwards. If the tunnel hostname and a port forward both point at the same app, anyone who finds your IP skips Cloudflare entirely.
  4. DNS-only (grey-cloud) records.
  5. Any WAF skip or custom rule you added for that hostname. Nextcloud and WebDAV sync clients often get one, because the WAF breaks sync.

That last one is the sneaky one. You added a skip rule in 2024 to stop a 403 on your phone’s photo upload, and you forgot it exists. Go look.

Also remember what a WAF is. It matches signatures, and Cloudflare itself says attackers were already mutating payloads. The WAF buys you time. The patch closes the hole. Cloudflare’s own advice is to apply the PHP update and keep the WAF on as an additional protection, and it says restricting uploads and limits does “not replace the PHP fix.”

Step 4: Interim mitigations for what you can’t patch yet

If a container is stuck on an unpatched or EOL image for a day or two, the PHP project lists interim mitigations. Read the framing first: they “reduce exposure to specific attack paths but do not make an installation safe.”

For CVE-2026-109618 (the OOM bug):

For CVE-2026-109619 (the stale state bug):

Imperva says disabling session upload-progress removes the trigger for three of the five exploitation techniques it reported. It also lists opcache.enable = 0 as an optional temporary measure, with a performance cost.

The tension: memory_limit=-1 plus a runaway request

Read the first bullet again. An unlimited memory_limit means one bad request can eat the whole box. That is why the container memory limit comes paired with it. Unlimited inside, a hard wall outside.

Mount an ini override into the FPM container. For official images, conf.d is the right place:

zz-php2shell.ini
memory_limit = -1
post_max_size = 8M
upload_max_filesize = 2M
session.upload_progress.enabled = 0
; PHP 8.5 only
fatal_error_backtraces = 0
compose.yaml
services:
app:
image: php:8.4-fpm
mem_limit: 512m
volumes:
- ./zz-php2shell.ini:/usr/local/etc/php/conf.d/zz-php2shell.ini:ro

docker compose honors both mem_limit and deploy.resources.limits.memory. Swarm reads only the deploy form. Size it for your app, not for my example.

An ini file can be overridden by an app that calls ini_set(), so for the settings that matter, pin them in the pool config with php_admin_value:

; www.conf (FPM pool)
php_admin_value[memory_limit] = -1
php_admin_value[post_max_size] = 8M
php_admin_value[session.upload_progress.enabled] = 0

Then restart the container and re-run the script. The MEMLIM, POSTMAX, and UPLPR columns should show -1, 8M, and 0. Remember that the script reads the CLI view, so the pool file is your source of truth for what the web sees. A clean row shows the ini file loaded. The pool may still disagree.

Do all of that and you have reduced exposure, nothing more. Put a reminder in your calendar to remove the workaround and patch.

The order I’d do it in

  1. Run the script. Know what you have.
  2. compose pull && up -d for everything on an official image. Re-run the script.
  3. For pinned images, find the vendor’s patched tag today. If it doesn’t exist, apply the interim ini and a container memory limit.
  4. Kill or isolate anything on PHP 8.1 or older that faces the internet.
  5. Check your Cloudflare skip rules for the hostnames you publish. Leave the WAF on.
  6. Ask yourself why the wiki you forgot about is still running.

Your 2 AM self will thank you for doing steps 1 and 2 now, while you are still having a normal evening and not reading an incident channel.

All dates and version numbers in this post are as of October 21, 2026. Check php.net and your image vendor for anything newer.

Common Questions

Do I need to patch PHP if my app is only reachable over Tailscale or my LAN?

Yes, though the urgency is lower. PHP2Shell is unauthenticated, so anyone who can reach the web server can send the request. A private network shrinks that group to your devices and anything compromised on them. Cloudflare’s WAF also does not see LAN or Tailscale traffic, so the patch is your only protection there.

Does Cloudflare Tunnel protect me from PHP2Shell?

Partly. A public hostname routed through a tunnel inherits the WAF rules of its Cloudflare zone, and Cloudflare turned on PHP2Shell rules by default for free and paid WAF customers on October 21. Direct port forwards, LAN access, and any WAF skip rule you added for that hostname bypass those rules. Patch regardless.

How do I check the PHP version inside a Docker container?

Run docker exec <container> php -v. Images with only PHP-FPM use php-fpm -v, and FrankenPHP uses frankenphp version. Alpine images sometimes name the binary php84 or php-fpm83. The companion script tries all of these across every running container and compares the result to the fixed versions.

Is PHP 8.1 safe if my distro backports security fixes?

It can be, but verify it. The PHP project ships no official binaries for 8.1, though it published minimal source backport branches, and some distros and LTS vendors received patches early. A distro build can report an old version string and still contain the fix. Check your vendor’s advisory for PHP2Shell instead of trusting the version number.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
LittleLink vs LinkStack: Ditch Linktree
Next Post
Multi-GPU LLM Inference on Consumer Cards

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts