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-data request. 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 php Docker image gets no fix for 8.1 or older.
  1. First attacks seen in the wild, reported by Cloudflare
  2. Patched PHP releases published
  3. Scanners spray large POST requests at our server
  4. We inventory, investigate, patch and verify everything

Are you affected? What to do

Fixed versions, all released on October 21, 2026:

PHP branchVulnerableUpgrade to
8.5below 8.5.128.5.12
8.4below 8.4.278.4.27
8.3below 8.3.368.3.36
8.2below 8.2.358.2.35
8.1 and olderall versionsNo fix End of life, so upgrade to 8.2 or newer
8.6 (pre-release)before RC4RC4 includes the fix, but don't run pre-releases in production
  • Check every runtime, not just your laptop. Run php -v inside 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 like 8.2-fpm only 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 5xx responses 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, .phar or .htaccess files in any web root, and nothing new in /tmp or /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

AppBeforeAfterApproach
Marketing site8.2.318.2.35Pin the exact base image, rebuild
Product-auth platform8.2.328.2.35Pin the exact base image, rebuild
Canteen POS8.1.348.2.358.1 → 8.2 upgrade, tested in a throwaway container first
School management8.1.348.2.35New runtime-only Dockerfile, pages compared byte for byte
Mail server8.2.298.2.35Vendor release, applied narrowly, mail never stopped
Legacy ERP demo7.1.33OfflineNo 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 -m between the old and new image. The only differences were ftp (no longer bundled in the 8.2 image, and unused) and random (new in 8.2).
  • Dependencies: composer check-platform-reqs passed, 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.

All posts