At 14:00 UTC on 21 October 2026, the PHP team shipped an unscheduled emergency release for two remote code execution vulnerabilities — CVE-2026-109618 and CVE-2026-109619, both rated CVSSv4.0 9.2. By then, watchTowr had already reproduced one of them, Cloudflare's CTO had confirmed early attacks in the wild, and the nickname php2shell was circulating through every ops channel on the internet. For operators of self-hosted infrastructure, the twenty-four hours between disclosure and patch day were a live fire drill: identify every PHP surface you expose, realise which of them will never receive a fix, and put a reverse proxy in front of the exploit path before someone else finds it first.
This is how we did that for a four-host web fleet running Traefik — including one mail server still running PHP 7.4, three years past end of life.
Details were embargoed until the patch release, but the operational picture solidified quickly:
| Fact | Detail |
|---|---|
| CVEs | CVE-2026-109618, CVE-2026-109619 |
| Severity | CVSSv4.0 9.2 each (PHP security team) |
| Impact | Unauthenticated remote code execution |
| Affected | All PHP versions from 5.x onwards, per CERT-EU |
| Fixed | 8.5.12, 8.4.27, 8.3.36, 8.2.35 (21 Oct 2026, 14:00 UTC) |
| EOL branches | 5.x–8.1.x: no fix, ever |
| Exploitation | Early attacks confirmed by Cloudflare before patch day |
Early reporting converged on the PHP session upload progress mechanism reached through multipart request handling as the vulnerable code path. That matters for mitigation, because it means the exploit arrives as a multipart/form-data POST — something a reverse proxy can identify from the Content-Type header alone, without inspecting the body.
Two quotes from PHP core developers set the tone. On whether disabling session.upload_progress in php.ini is sufficient: "ini settings won't save you." On what actually helps: "the only mitigation is patching or upgrading PHP." CERT-EU's advice for anything that cannot patch immediately was blunter still: isolate it from the public internet.
Before deploying anything, inventory what you are defending. A sweep across a small multi-host fleet — checking system PHP packages, running php-fpm processes, Docker image inventories, and actual service/listener state — produced a more sobering list than expected, and it is representative of what most self-hosters will find:
| Surface class | Example | Version | Exposure | Verdict |
|---|---|---|---|---|
EOL system PHP via Apache mod_php | legacy ActiveSync gateway | 7.4.3 | formerly public | 🔴 No patch will ever exist |
| Vendor container lagging upstream | mailcow phpfpm image | 8.2.29 | Public (webmail) | 🟡 Patchable once the image rebuilds |
| Self-built app container | Invoice Ninja (PHP bundled) | 8.2.28 | Public | 🟡 Patch pending upstream rebuild |
| Internal-only container | Nextcloud (nextcloud:apache) | 8.5.10 | Docker network only, no published ports | 🟢 Not internet-facing |
| Hosts without PHP | — | — | — | 🟢 Clean |
Two findings generalise. First, package inventories overstate exposure: one supposedly web-facing mod_php stack turned out to be a masked, non-running Apache unit — dead weight, not attack surface. Verify systemctl is-active/is-enabled/is-masked and ss -tlnp before trusting a package audit; the questions that matter are what listens, what starts at boot, and what the reverse proxy actually routes. Second, EOL branches change the calculus: PHP 7.4 receives no emergency release, so for that surface a reverse-proxy block is not a stopgap — it is the permanent control until the stack is upgraded or retired. In our case: retired. The entire Apache + mod_php + z-push stack was purged from the host the same day — when the application is gone, the fastest fix is to remove the interpreter, not to shim it.
All four public-facing hosts front their services with Traefik v2.11, which gives a single point of control. The trick is a file-provider router with a higher priority than everything else, matching multipart POSTs and routing them into Traefik's built-in noop@internal service — which answers with HTTP 418 and never touches a backend:
http:
routers:
php2shell-block-multipart:
rule: >-
Method(`POST`) && HeadersRegexp(`Content-Type`, `(?i)^multipart/`)
entryPoints: ["websecure", "web"]
priority: 10000
service: noop@internal
tls: {}
Drop this in the dynamic config directory (/etc/traefik/dynamic/routers-php2shell.yml on a standard mount) and recreate Traefik. Three details decide whether this works, and all three bit us during deployment:
Entrypoint names. Our first attempt used the documented examples' http/https entrypoints. Traefik rejected every router with entryPoint "https" doesn't exist — this fleet uses Traefik's other common convention, web and websecure. Check traefik.yml before deploying; a router with a nonexistent entrypoint fails silently from a routing perspective (the errors only appear in Traefik's logs).
HeadersRegexp, not HeaderRegexp. The singular form does not exist in Traefik OSS and produces unsupported function: HeaderRegexp — again only in the logs, again with the router silently missing from the routing table. This fleet runs v2.11 and the plural form works there.
Verify with the logs, not the playbook exit code. Both failures above left the Ansible run green. The only reliable signal is docker logs traefik | grep php2shell coming back empty, followed by a synthetic request.
With the router live, verification takes one request per class:
# Blocked: multipart POST (the php2shell delivery vehicle)
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://mail.example.org/ \
-H "Content-Type: multipart/form-data; boundary=----"
# → 418
# Unaffected: regular form POST
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://mail.example.org/ \
-H "Content-Type: application/x-www-form-urlencoded"
# → 200
# Unaffected: GET
curl -s -o /dev/null -w "%{http_code}\n" https://mail.example.org/
# → 200
All four hosts returned 418 for multipart POSTs and passed everything else through unchanged. Notably, ActiveSync traffic on the mail host — plain XML/encoded POSTs from phone mail clients — continued to work, because it does not use multipart encoding.
For the mailcow phpfpm container (8.2.29 at the time of writing), we also disabled session upload progress inside the container. Not because it mitigates the vulnerability — it does not, per the PHP core developers — but because it removes a related code path at zero cost while waiting for the updated image:
PHP_FPM=$(docker ps --format '{{.Names}}' | grep -m1 php-fpm)
docker exec -u 0 "$PHP_FPM" sh -c \
'echo "session.upload_progress.enabled = Off" > /usr/local/etc/php/conf.d/zzz-php2shell.ini'
docker exec "$PHP_FPM" kill -USR2 1 # graceful php-fpm reload
docker exec "$PHP_FPM" php -i | grep upload_progress.enabled
# session.upload_progress.enabled => Off => Off
The zzz- prefix ensures the file loads last among the conf.d snippets. kill -USR2 1 reloads php-fpm workers gracefully without dropping the container.
Blocking multipart breaks legitimate file uploads through every proxied application that uses multipart/form-data — webmail attachments, for instance. Interestingly, some applications barely notice: Nextcloud, a common casualty candidate, uploads through its own chunked endpoint rather than multipart forms and reportedly keeps working behind such blocks.
This is a deliberate, temporary degradation for anything patchable. For the EOL PHP 7.4 surface, however, the block is the control — and that forces the real question: how long can a stack that will never receive another security patch stay on the public internet at all?
One operational note worth its weight: --force-recreate-ing Traefik on four hosts in quick succession triggered a Let's Encrypt revalidation storm, and several domains hit the five-failed-authorisations-per-hour rate limit from challenge traffic that no longer arrived correctly. The certificates themselves were fine — all issued months earlier — but any new issuance froze for up to an hour. When recreating a shared reverse proxy during an incident, do it once, do it last, and expect ACME turbulence if your routers reference resolvers with pending challenges.
Update, same day: the PHP 7.4 exit below has been executed — see the checked item at the end. The mailcow, Nextcloud and Invoice Ninja images remain pending upstream rebuilds.
The emergency release is out, but the work is not done the moment distributions publish packages:
phpfpm:8.2.35-x, then re-evaluate removing the multipart block for that hostapache2 (masked, not even startable), libapache2-mod-php7.4, all php7.4-* packages, and the z-push* packages — was purged from the mail host. The vulnerable interpreter no longer exists on disk; there is nothing left to accidentally start. The Traefik block now guards containerised PHP only (mailcow's phpfpm image, pending upstream rebuild)The meta-lesson fits in a sentence: when the mitigation lives at the reverse proxy, four hosts of PHP exposure collapse into one YAML file — if you know your entrypoint names, your fleet's PHP inventory, and your Traefik log tail. None of that is improvisable during an active exploitation window. Draw the inventory now.