PHP2Shell: how we found and fixed it across our server in one morning
A critical PHP engine bug was disclosed and scanners started knocking within hours. How we checked every PHP runtime we run, proved we weren't compromised, and patched or retired each one without breaking anything.
ByteCreators Engineering ·
The short version
- 6
- PHP runtimes found, including one hidden inside our mail server
- 0
- signs of compromise after checking logs, processes and files
- 5
- patched to PHP 8.2.35 with no visible change for users
- 1
- legacy app on PHP 7.1 switched off, since no patch exists
Mail kept flowing the whole time. The biggest lessons had nothing to do with PHP itself: floating Docker tags, unsafe Dockerfiles and vendor update scripts.
What PHP2Shell is
On October 21, 2026, Sansec published PHP2Shell: two use-after-free bugs in the PHP engine (CVE-2026-109618 and CVE-2026-109619, CVSS 9.2). Chained together they allow remote code execution without logging in.
Three details made this one serious:
- It is in the engine, not a framework. Laravel, WordPress, Symfony and Magento are all affected, and so is every unpatched PHP since 5.4.
- The trigger is a
multipart/form-datarequest. PHP parses that body before your application code runs, so your routes, middleware and login screens never get a say. - Docker users on older versions are stuck. The fixes shipped in PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12. The official
phpDocker image gets no fix for 8.1 or older.
- First attacks seen in the wild, reported by Cloudflare
- Patched PHP releases published
- Scanners spray large POST requests at our server
- We inventory, investigate, patch and verify everything
Are you affected? What to do
Fixed versions, all released on October 21, 2026:
| PHP branch | Vulnerable | Upgrade to |
|---|---|---|
| 8.5 | below 8.5.12 | 8.5.12 |
| 8.4 | below 8.4.27 | 8.4.27 |
| 8.3 | below 8.3.36 | 8.3.36 |
| 8.2 | below 8.2.35 | 8.2.35 |
| 8.1 and older | all versions | No fix End of life, so upgrade to 8.2 or newer |
| 8.6 (pre-release) | before RC4 | RC4 includes the fix, but don't run pre-releases in production |
- Check every runtime, not just your laptop. Run
php -vinside each container and on each server, including PHP that ships inside third-party products. - Upgrade to the fixed release for your branch. PHP says this is the only reliable fix.
- On Docker, pin the exact tag and rebuild, for example
FROM php:8.2.35-fpm-alpine. A floating tag like8.2-fpmonly picks up the fix when you rebuild, and an old image cached on your server keeps running the vulnerable version. - Turn off upload progress if you don't use it by setting
session.upload_progress.enabled = 0, then restart PHP workers. PHP recommends this even after patching, and it also blocks a rarer follow-up issue (CVE-2026-115560) that's due to be fixed in the next release. - Check whether you were already hit. Look for crashed PHP workers, unexpected new files in web or temp folders, and unfamiliar processes. A 404 or 405 in your access logs proves nothing, because the bug is triggered before your application code runs.
Full details are in PHP's official advisory: GHSA-6wmf-4rv5-p3rp.
Here is how we worked through that checklist on our own server, on the morning of October 23.
Step 1: inventory runtimes, not repositories
We know our stack well, so our first instinct was to list our PHP apps from memory. That would have been a guess. Repositories, compose files and memory don't tell you what is actually running, so we asked every running container directly:
for c in $(docker ps -q); do
name=$(docker inspect -f '{{.Name}}' "$c")
docker exec "$c" sh -c 'command -v php >/dev/null && php -r "echo PHP_VERSION;"' 2>/dev/null \
| sed "s|^|$name |"
done
It found six PHP runtimes. Five were our own apps: a marketing site, a product-authentication platform, a school management system, a canteen point-of-sale, and a legacy ERP demo. The sixth was inside our self-hosted mail server, which uses PHP for its admin panel and for the endpoints mail apps call to set up accounts automatically. Nobody thinks of a mail server as "a PHP app," but this one is.
Every one of them was vulnerable, including the two we assumed were current:
Our "modern" apps were built from
php:8.2-fpm-alpine. A floating tag like that only moves when you rebuild, and we hadn't rebuilt in weeks. Your real patch level is your rebuild date.
Step 2: find out whether we were already hit
Before changing anything we went through the reverse-proxy logs from the disclosure onward. Scanners had been sending bursts of large POST / requests, fake browser user agents and scripted Python clients, at every site on the server. They hit our static and Node sites too, so this was blind internet-wide spraying, not a targeted attack.
Our apps answered those requests with 405 Method Not Allowed, and it would be easy to stop there. For this bug that is a mistake: PHP parses the multipart body before the framework's router decides to return 405. So we checked for the after-effects of exploitation instead:
- No
5xxresponses on any PHP site since the disclosure. - No PHP worker crashes, segfaults or out-of-memory errors in any container log.
- No new
.php,.phtml,.pharor.htaccessfiles in any web root, and nothing new in/tmpor/dev/shm. - No unexpected processes in any PHP container.
One finding looked bad at first: dozens of PHP session files in the legacy app's /tmp. They turned out to be harmless. The app calls session_start() itself, the files were empty and months old, and none contained the upload-progress data this exploit abuses.
Step 3: patch each app, and prove nothing broke
| App | Before | After | Approach |
|---|---|---|---|
| Marketing site | 8.2.31 | 8.2.35 | Pin the exact base image, rebuild |
| Product-auth platform | 8.2.32 | 8.2.35 | Pin the exact base image, rebuild |
| Canteen POS | 8.1.34 | 8.2.35 | 8.1 → 8.2 upgrade, tested in a throwaway container first |
| School management | 8.1.34 | 8.2.35 | New runtime-only Dockerfile, pages compared byte for byte |
| Mail server | 8.2.29 | 8.2.35 | Vendor release, applied narrowly, mail never stopped |
| Legacy ERP demo | 7.1.33 | Offline | No patch exists, so it was switched off |
Pin exact versions
Every image now names the patched release explicitly, so the version is written down and reviewable instead of depending on when someone last ran a build:
# Pinned: PHP2Shell (CVE-2026-109618/109619) fixed in 8.2.35.
FROM php:8.2.35-fpm-alpine
# Defence in depth: upload progress is unused by our apps
RUN echo 'session.upload_progress.enabled = 0' \
> /usr/local/etc/php/conf.d/zz-security.ini
Upgrade 8.1 apps, but prove it first
With no fixed 8.1 image available, the two 8.1 apps had to move to 8.2. That is a real upgrade, not a patch bump, so before swapping anything live we ran the new image against the real code and database in a throwaway container with no public port. Then we checked:
- Extensions: we diffed
php -mbetween the old and new image. The only differences wereftp(no longer bundled in the 8.2 image, and unused) andrandom(new in 8.2). - Dependencies:
composer check-platform-reqspassed, and the framework booted. - Output: we fetched the same pages from the old and new runtime and compared hashes, after stripping CSRF tokens. They were byte-identical, which gave us real confidence to cut over.
- Logs: no new errors or deprecation warnings during the test.
Some Dockerfiles are not safe to rebuild
One app's Dockerfile ran an unpinned composer update (the repo had no lock file) and php artisan key:generate, and copied the whole source tree, .env included, into the image. Rebuilding it could have pulled in untested dependency versions. None of that work was even used, because the app's code is mounted over the image at runtime.
The fix was a separate, runtime-only Dockerfile with the same PHP extensions and web-server config and nothing else. The original file stays untouched so upstream pulls keep working.
"Unpatchable" is a valid answer
The legacy ERP demo runs Laravel 5.6 on PHP 7.1. No fix exists for 7.1 anywhere, and the framework can't run on PHP 8. It is a demo we bring up on request, so we switched it to a static landing page and stopped its PHP containers. The safest fix for software that can't be patched is to stop exposing it.
The mail server: read the vendor's updater before you run it
Our mail server software ships its own PHP image, so the fix had to come from the vendor. Their release with PHP 8.2.35 shipped the same day. The supported path is their update script, and we read it before running it.
Halfway through, the script asks whether it may change a Docker setting, and the default answer is yes. On our server, yes means restarting Docker itself, which briefly takes down every website and app we host, not just mail. Anyone pressing Enter to get through the update quickly would have caused a server-wide outage. So we didn't run the script. We did its job by hand.
Comparing the release against our installed version showed that only three image tags had changed: PHP, the web server and webmail. So we applied exactly that. We took a verified backup, pulled the three images, merged the release the same way the updater does, and recreated only those three containers. The SMTP and IMAP services never restarted, and mail delivery was not interrupted.
A web-tier bug in a mail server reaches further than the admin panel. In our mail server, the PHP container holds the mail database credentials and the admin API key, and it sits in the IMAP and SMTP login path. If it were compromised, the attacker would effectively have the whole mail system.
What we deliberately didn't do
- We didn't set
memory_limit = -1, one of the suggested temporary mitigations. On a shared server it lets a single request eat all the RAM, which trades a code-execution risk for an outage risk. - We didn't block all multipart requests. That breaks file uploads, and patching properly took less time than testing the workaround would have.
Lessons we're keeping
- Inventory what is running, not what you think you deploy. The most surprising runtime was inside a third-party product.
- Floating base-image tags feel current but aren't. Pin exact versions and rebuild on a schedule.
- End-of-life runtimes get no fixes in Docker. If you're on PHP 8.1 or older in an official image, you can't patch, only upgrade.
- A 405 or 404 proves nothing for parser-level bugs. Look for crashes, new files and odd processes instead.
- Prove an upgrade before you swap it in. A throwaway container plus a byte-for-byte page comparison is cheap and convincing.
- Read update scripts and mitigation advice before running them. Defaults are written for the typical install, which may not be yours.
- A critical bug is not a reason to leave a language. Java, .NET, Node.js and Python all have their own history of them, and PHP's security team shipped fixes the same day. What matters is how fast you can find and patch every runtime.
- Write everything down as you go. Every command, check and rollback step went into a log, and that log is where this post came from.