Back to Articles

PHP2Shell: What To Do When You Can't Just Upgrade

By Tony Perez (@perezbox) • Posted in: server-security, psa •

On October 21, 2026 the PHP project shipped fixes for two unauthenticated remote code execution bugs in PHP itself, now being called PHP2Shell: CVE-2026-109618 and CVE-2026-109619, both rated CVSS 9.2. The embargo leaked the day before. The fixed releases are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Everything older is end of life and will never get an upstream fix. If one of your servers is still exposed when the exploits start working reliably, you're looking at a compromised box, a cleanup, and every credential on it rotated.

The official guidance is one line: update, there's no complete mitigation. If you can update today, stop reading and do it. We've spent the day working through this with our customers: more than 200 servers, with PHP going all the way back to 5.5. Yes, 5.5. In 2026. We know. For a good share of them, "just update" isn't an answer. Below is what broke, what we did when updating wasn't possible, and the free tools we built along the way.

Why this one is different

Most PHP vulnerabilities live in an application or a plugin. These live in the PHP engine and fire during request startup and shutdown, before your code runs. The attack is a crafted multipart/form-data POST with no usable Content-Length, built to push PHP into an out-of-memory condition and then reuse state it left behind. It doesn't matter whether your application has an upload form. If PHP is answering requests on the internet, it's reachable.

  • Apache with mod_php and the prefork MPM is the worst case. PHP lives inside the Apache worker, and the advisory confirms the full exploit path there.
  • PHP-FPM and CGI are still affected, but the PHP process exits when request startup fails, which removes the worst path.

Many of the servers that are hardest to update are also the ones still on mod_php prefork, because they were set up years ago and nobody has had a reason to touch them since.

Step one: find out what you actually have

You can't answer "are we patched?" by looking at php -v. Plenty of the servers we looked at had a CLI version, a PHP-FPM version and an Apache module version that didn't match. Distributions also backport fixes without changing the upstream version number: an updated Ubuntu 24.04 PHP 8.3.6 package (8.3.6-0ubuntu0.24.04.13) is fixed, and so is Ubuntu 22.04's PHP 8.1.2 (8.1.2-1ubuntu2.28), even though upstream never shipped a fixed 8.1 at all.

We wrote a read-only script that answers it. It finds every PHP on the server (CLI, PHP-FPM, CGI, mod_php, and source builds under /usr/local and /opt), checks each one against the fixed versions and the package changelog for a backported fix, tells you which SAPI is in use, and flags Apache or PHP-FPM processes still running the old code after an update. It changes no files or configuration; it only reads, and runs -v on root-owned PHP binaries to get their versions.

curl -fsSL -o php2shell-check.sh https://noc.org/articles/files/php2shell-check.txt
less php2shell-check.sh          # read it first, always
sudo bash php2shell-check.sh

Exit code 0 means no unfixed PHP was found, 1 means action is needed, 2 means it couldn't decide. Add --probe http://127.0.0.1/ --host www.example.com to send three real test requests (a GET, a multipart POST and a chunked POST) to your own application and see whether anything in front of PHP rejects them. The output includes your hostname and listening addresses: redact them before you paste it anywhere public.

Using it with OpenAI's php2shell-checker

OpenAI has published php2shell-checker, a Node.js tool (also packaged as Codex skills) that reads the PHP version a website advertises in its X-Powered-By and Server headers, or from php -v with --local. The two tools answer different questions and work well together:

  • Start from the outside with theirs. Point it at every site you're responsible for to find the ones advertising a vulnerable version. It needs no access to the server, which makes it the fastest way to build a list.
  • Then run ours on each server. Headers only show one PHP, can be hidden or changed, and don't reflect distribution backports. A fully patched Ubuntu 24.04 server still says PHP 8.3.6, which their tool will report as vulnerable; ours will find the fix in the package changelog. The same goes for every PHP 8.1: upstream has no fixed 8.1 release, so their tool calls all of them vulnerable, including Ubuntu 22.04's patched build and maintained third-party 8.1 builds. Ours also covers PHP-FPM, CGI and mod_php separately, which php -v on the command line doesn't.
  • Watch which PHP --local checks. It runs the php on your PATH. If the site is served by a different build (a source-built PHP in /opt, or an FPM version that differs from the CLI), it will grade the wrong binary.
  • Read the exit codes for the tool you ran. Theirs: 0 OK, 1 vulnerable, 2 invalid arguments, 3 unknown. Ours: 0 nothing unfixed found, 1 action needed, 2 inconclusive, 64 invalid arguments. As their README says, unknown doesn't mean patched.

Once you've confirmed a server is fixed, turning expose_php off stops it advertising its version at all; outside-in checks, theirs included, will then report it as unknown.

If you can update: the traps we hit

On the servers where a fixed package existed, updating still went wrong in ways that would have left the owner believing they were patched when they weren't.

Your PHP repository may have been switched off years ago. One server had gone through a release upgrade, and the upgrader disabled every third-party apt source, including the PHP one. It hadn't received a single PHP update since. Nothing complained. apt upgrade said everything was current. Check that the source your PHP comes from is actually enabled before you trust an "up to date".

Your provider's mirror may be behind. One cloud provider's Ubuntu mirror hadn't picked up the security update when we went looking for it. Pointing the security pocket directly at security.ubuntu.com fixed it.

The popular PPA went down. The widely used third-party PHP PPA started returning 503s in the middle of the update window. The same maintainer publishes the same builds at packages.sury.org, which stayed up. If you switch, switch completely: the two publish different version strings, the PPA's sorts higher, and leaving both enabled will flip your server between builds on the next upgrade.

Check the changelog, not the version. For distribution packages, the reliable test is whether the package changelog mentions the CVEs. The pattern below matches both IDs: zcat /usr/share/doc/php8.3-cli/changelog.Debian.gz | grep CVE-2026-10961 on Debian and Ubuntu, rpm -q --changelog php-cli | grep CVE-2026-10961 on the RHEL family.

Restart, don't reload. With mod_php, PHP is a library loaded inside Apache. Updating the package replaces the file on disk, but every running worker keeps the old code in memory until Apache is restarted. The same goes for every PHP-FPM service. Our script checks for this by looking for processes that still map PHP code that's been deleted from disk.

If you can't update: options beyond the advisory

End-of-life servers exist for real reasons: an application nobody can afford to port, a vendor appliance, a box someone else owns. Here's what we've been doing on them, roughly in the order you should consider it.

1. Put a WAF in front of it and lock the origin

This is the fastest protection that doesn't involve touching the server, and the one we recommend first. The exploit has a recognisable shape on the wire, which is exactly what a web application firewall is for. The NOC WAF is already virtually patching PHP2Shell for every site behind it, so exploit traffic is stopped at the edge before it reaches your server.

A WAF only protects traffic that goes through it. Two things we keep finding:

  • Lock the origin. If your server still accepts connections on 80 and 443 from anywhere, an attacker can skip the WAF and connect directly. Restrict those ports to your provider's edge addresses (for NOC, the list is published at noc.org/ipv4).
  • Every hostname on the box counts. One server was correctly fronted for its main site, but a second hostname on the same box pointed straight at the server's IP with no WAF in between. The origin lock is effectively per hostname: either every site on the server goes through the WAF, or the ones that don't are your exposure.

2. Block the exploit shape at the web server

If you can't put a WAF in front, you can apply a narrower version of the same idea on the server. Apache can refuse the requests the exploit needs before mod_php ever sees them:

<If "%{req:Content-Type} =~ m#multipart#i">
    Require all denied
</If>
<If "%{REQUEST_METHOD} in { 'POST', 'PUT', 'PATCH' } && -z %{req:Content-Length}">
    Require all denied
</If>
LimitRequestBody 8388608

Our full configuration, which also applies the PHP settings the advisory recommends, is at php2shell-mitigation.conf. The request blocks work in front of any PHP version under Apache 2.4. Before you enable it:

  • It blocks legitimate uploads. Search your code for multipart, FormData and $_FILES first. A common casualty is JavaScript that posts a FormData object for an ordinary form; sending it URL-encoded instead is usually a one-line change.
  • The module name changes between PHP versions. Apache registers PHP 7 as mod_php7.c and PHP 8 as mod_php.c. We wrapped our PHP settings in an <IfModule mod_php7.c> block, upgraded a server to PHP 8, and the settings silently stopped applying. The config we publish carries blocks for PHP 5, 7 and 8.
  • memory_limit -1 has a cost. The advisory recommends removing the memory limit because the exploit needs a finite one. That means a runaway request can now use all of the server's memory. If that concerns you, cap Apache with a systemd MemoryMax. Don't use RLIMIT_AS or RLIMIT_DATA: a failed memory allocation is the exact condition you're trying to avoid.

Also: session.upload_progress.enabled = Off, the interim setting most write-ups mention, only addresses one of the two CVEs, and setting it in .user.ini doesn't protect you. It has to go in php.ini, the PHP-FPM pool, or the Apache config, followed by a restart.

3. Get off mod_php

Moving from mod_php to PHP-FPM doesn't fix the bug, but it removes the worst exploit path. The older apps we've moved ran unchanged. Watch for php_value lines in .htaccess: without mod_php they cause a 500 error unless they're wrapped in <IfModule> or moved into the pool config.

4. Build a current PHP beside the old operating system

Plenty of servers run an end-of-life OS with a PHP 7 that no package is ever going to fix, and rebuilding them properly takes days. A faster bridge: compile a fixed PHP 8 release from source into /opt, run it as a separate PHP-FPM service, point Apache at it, and disable mod_php. Done carefully, that's about an hour per server, with a one-command rollback.

What it cost:

  • Old extensions don't come along. Some PECL extensions don't build on PHP 8. If an application only uses one or two functions from one, a small PHP shim over a system tool is often enough to keep it running.
  • Small servers need swap to compile. On a small single-core VPS, expect to add a swap file before PHP will build.
  • You now own the updates. No package manager will ever update that PHP. The next security release is a manual rebuild, so write down how you built it.

The operating system underneath is still end of life, so this buys you time and nothing more.

5. Upgrade the operating system in place

For a group of servers on an old, unsupported Ubuntu release, an in-place upgrade, one release at a time up through each LTS, can be faster than a rebuild. Expect around two hours per server from start to finish, without interrupting its traffic.

  • Not every stop along the way is fixed. PHP changes version at each hop, and the older stops (20.04 and earlier) may never get the fix. At the time of writing, the updated 22.04 and 24.04 packages carry it, and 26.04's is still waiting for it. Keep the web server mitigation in place for the whole chain and after it, until the package you end on lists the CVEs.
  • End-of-life apt sources must be repointed to old-releases.ubuntu.com before anything will install.
  • Apache won't start after a hop if the old PHP module is still enabled and its library is gone. Disable the old module and enable the new one as part of every step.
  • Run the upgrader in a detached session (screen or tmux) and poll it. SSH restarts during the upgrade and will drop a foreground session.
  • Do them one at a time. If a group of servers does the same job, never take enough of them offline at once that whatever depends on them notices.

6. Restrict who can reach it

If a PHP endpoint only ever answers a handful of known callers, firewall 80 and 443 to those addresses. It's the most effective control on this list and the easiest to get wrong, so check two things first: that nothing else legitimately needs to reach the server, and how the firewall is restored at boot. Several older servers we've worked on rebuilt their entire iptables ruleset from a script at every reboot, which erased any rule we'd added live.

7. Rebuild it

This is the real fix for an end-of-life server. Before you promise a timeline, lint the application under the target PHP version on another machine: run php -l over every file and search for functions removed in PHP 8. Old code often survives this better than people fear.

What the public proof of concept actually does

A public proof-of-concept repository appeared the same day as the fix, claiming reliable remote code execution. Read past the README and it ships request generators and a Docker lab, not a working exploit; the full chain is advertised as available to paid subscribers. That doesn't make it harmless. Its out-of-memory trigger for CVE-2026-109618 sends three variations of the same attack, and on an unpatched mod_php server each one is built to crash the worker that handles it:

  • a multipart POST carrying thousands of MIME part headers,
  • a multipart POST with deeply nested empty file fields,
  • a chunked multipart POST with no Content-Length.

The scripts for CVE-2026-109619 also send multipart POSTs, and need application-specific conditions to go anywhere: a separate file inclusion bug, two directories with different session serializers, or a pre-planted session file on a PHP-FPM server.

We went through every request the scripts send and mapped it against the Apache block in option 2. Every request that carries the exploit is a multipart/form-data POST, so the first rule denies all of them before PHP parses a byte of the body. The chunked variant is caught twice, by the multipart rule and the missing Content-Length rule. The only request that gets through is a GET with an oversized cookie, which does nothing without a planted session file and a PHP-FPM target.

One detail matters if you're tuning the config yourself. LimitRequestFields does not stop the header-flood variant. It counts HTTP request headers, and those thousands of headers live inside the multipart body. The multipart rule is what stops it. Don't swap it for tighter header limits and assume you're covered.

And don't fire the out-of-memory script at a production server to test your mitigation. It is designed to crash workers. Use an isolated lab, or send the harmless probe in our check script.

What attempts look like in your logs

  • Segfault clusters in the Apache error log. Lines like child pid 12345 exit signal Segmentation fault, several close together, are what a failed attempt against mod_php tends to leave behind. Our check script counts them.
  • Multipart POSTs to URLs that never take uploads, especially ones sent chunked with no Content-Length.
  • Your access log probably can't show you. The default Apache log format doesn't record Content-Type. Adding %{Content-Type}i to your LogFormat for the next few weeks makes these requests easy to find.

Verifying your work

Two things made a correct change look broken during our verification. The first was a graceful reload: we tested two seconds after systemctl reload apache2, hit old workers, and concluded the config hadn't applied. Wait about 15 seconds, or restart instead of reloading. The second was testing from the command line. php -i reads a different php.ini from Apache and PHP-FPM, so send real requests over HTTP with the right Host header instead.

Then run the check script again. Exit 0 means every PHP it found is fixed. A server you've mitigated but not upgraded will still exit 1; that's expected, and it's your reminder that mitigated isn't fixed.

AI found it. Use AI to fix it.

PHP2Shell was found with AI. Both teams that reported it, Calif and Wiz, credit AI models with uncovering the bugs. Attackers have access to the same class of tools. It's only smart to put AI to work on defense too: to automate the triage, streamline the assessment, and carry out your mitigation strategy across every server you're responsible for.

That's how we've been working. AI agents are how a small team gets through more than 200 servers in a day, and it works because the agents run under the same rules we'd give a new engineer:

  • Read-only first. Every server got a triage script before anyone proposed a change. The agent reported; it didn't act.
  • Snapshot before change. Every change started with a tarball of /etc/php and the Apache config.
  • A human approves each server. Approval for one box was never approval for the next.
  • One at a time, verified before moving on. Nothing was called fixed until the check, run afterwards, said so.

We've written a few things to help you along the way. The check script and Apache config are above. The third is an agent skill that packages our process for your own AI coding agent. It works through the same four stages we did: triage the server read-only, assess which path applies, mitigate with your approval, and verify with the check script. It carries the decision tree from this article and the landmines we hit, and it uses the same check script and Apache config:

mkdir -p php2shell-remediation && cd php2shell-remediation
curl -fsSL -o SKILL.md https://noc.org/articles/files/php2shell-skill.txt
curl -fsSL -o php2shell-check.sh https://noc.org/articles/files/php2shell-check.txt
curl -fsSL -o php2shell-mitigation.conf https://noc.org/articles/files/php2shell-mitigation.conf.txt

Put the folder in your agent's skills directory and ask it to check a server for PHP2Shell. Read the skill before you use it, and read the plan the agent gives you before you approve anything. An agent with root on a production server deserves the same scrutiny as a script you downloaded from the internet, and so does this one.

The short version

  • Update to 8.5.12, 8.4.27, 8.3.36 or 8.2.35, or a distribution package whose changelog lists the CVEs. Then restart Apache and PHP-FPM.
  • If you can't update today, put the site behind a WAF that covers PHP2Shell and lock the origin to it, for every hostname on the server.
  • If you can't do that, block multipart and chunked POSTs at Apache, and get off mod_php.
  • For end-of-life servers, a source-built PHP-FPM can get you to a fixed PHP in hours. An in-place OS upgrade gets you to a supported release that receives the fix, but keep the mitigation until it lands.
  • Verify with real requests, after the reload settles.

If you want your sites behind a WAF that's already virtually patching PHP2Shell, NOC starts at $5 per site per month.

Downloads

  • php2shell-check.sh: the read-only check script. Check your download with sha256sum php2shell-check.sh; it should print b76b17ac9635ea6da85f050671f2a2c18b5938046922382e0d1c0f98e46540a5. This catches a corrupted or truncated download; it comes from the same site as the file, so it isn't a signature.
  • php2shell-mitigation.conf: the Apache 2.4 stopgap configuration.
  • SKILL.md: the agent skill.

Read anything you download before you run it as root, including these.