---
date: '2026-10-22'
description: Two flaws in PHP's core allow unauthenticated remote code execution on any version since 5.4. What it means, whether it touches you, how to mitigate it and how to tell if someone already got in.
lang: en
readingMinutes: 15
tags:
- security
- iocs
title: 'PHP2Shell: five days of attacks on a flaw nobody could patch'
url: https://foron.dev/en/blog/php2shell/
---


{{< callout type="warn" label="tl;dr" >}}
Two flaws in PHP's core, [CVE-2026-109618](https://www.cve.org/CVERecord?id=CVE-2026-109618) and [CVE-2026-109619](https://www.cve.org/CVERecord?id=CVE-2026-109619) (9.2), give **unauthenticated remote code execution**: control of the server, whatever application sits on top. A hello world page is enough. Affects everything back to PHP 5.4.

The second impact is disclosure of process memory, including passwords and tokens belonging to other requests.

Update to 8.2.35, 8.3.36, 8.4.27 or 8.5.12.

**Restart the processes**, not the machine: PHP-FPM, Apache, and any worker that has been up for months.

Check every runtime, not just the main one: containers, base images, the old admin panel.

Look for oversized `PHPSESSID` response cookies and multipart POSTs to paths that don't exist.

If you can't patch today: `session.upload_progress.enabled=0` and a genuinely enforced `memory_limit` of `-1`. Not `RLIMIT_AS`, not `RLIMIT_DATA`.

If you find a signal, rotate tokens and keys. The worker's memory left the building.
{{< /callout >}}

If you had to patch yesterday, or you're patching right now, this is for you. I'm not going to repeat the advisory, you've already read it. I'm going to show you what sits underneath, down to the `longjmp`, because I went digging to work out how on earth this chains together and I'd rather you didn't have to.

## Why this one matters

The first thing I did was check whether I had PHP anywhere. I didn't. This machine doesn't run a single line of PHP. I breathed out.

It's worth saying plainly what "remote code execution" means, because advisories make it sound like a category rather than a consequence. It means someone runs whatever they like as the web server user. From there they read the database config, take whatever credentials they find, write into the docroot, drop a miner or use the box as a stepping stone. Your application doesn't need a single flaw of its own, because the flaw being exploited sits below it. Updating the CMS doesn't save you.

The memory leak works like an IDOR: you manipulate an identifier and the application hands back data that isn't yours. Here the identifier is your session, and what comes back is process memory with passwords, tokens and API keys from other requests[^10][^11]. For the victim the effect is a broken access control bug. The mechanism is different: the pointer PHP held for that session still points at the previous request's heap, and nothing checks that it's still his.

That matters for the fix. An IDOR gets fixed by adding the authorisation check that was missing. This gets fixed by fixing memory lifetime ([CWE-416](https://cwe.mitre.org/data/definitions/416.html)), which is considerably harder, and that's why it took twenty-two years.

The severity isn't what made me remember the CVE number. Counting how many times this flaw was already written down and nobody closed it is. Three times.

2004 first. Stefan Esser published advisory 11/2004, PHP memory_limit remote vulnerability, covering PHP 4.3.7 and earlier. It wasn't vague: it said memory_limit termination fires "in places where an interruption is unsafe", pointed at Zend HashTables being initialised in two steps, and named the file upload code and the session extension. It also noted that with POST "it is quite easy to eat 100 (and more) times the amount of sent bytes". That's the amplification trick, described in 2004[^18]. Esser withheld the exploit, and PHP fixed the specific places rather than the mechanism.

2019 second. Bug #78876, filed in November by jr at coredu dot mp: long variables in multipart/form-data cause OOM and leave temp files behind. Closed in May 2020 with CVE-2019-11048. The first maintainer reply downgraded it to a DoS: "Allocation failure strictly speaking is not a crash, but not cleaning up temp file may be indeed bad and cause DOS"[^19]. The temp file got fixed. The memory corruption underneath stayed.

2024 third. The OSTIF and Quarkslab audit finds it again, and again it doesn't get fixed, this time on a different argument: no proof of exploitation. The 2020 commit carries the `security-audit-2024` tag, so the trail was sitting in the repository itself[^2].

After that, two years of fixes that never landed. The PHP Foundation says it plainly in its 21 October note: several competing proposals, each with a cost in compatibility, in new denial-of-service paths, or in changes that dragged third-party extensions along, and the team couldn't decide. "The delay was a structural failure," they wrote[^4].

Three advisories and two years of deadlock. The exploit didn't appear because nobody had looked before. It appeared because someone could finally afford the work of chaining it.

The deadlock broke in August. With frontier models, someone went from "this should be exploitable" to an exploit that works against WordPress, Drupal and Joomla in a matter of days[^1]. The code didn't get worse. Understanding it was the expensive part, and that's no longer expensive.

The handling of the last few days deserves its own section. Here's what happened, in order:

- **11 August.** Calif reports a chained RCE. OpenAI reports the same root cause the same day, independently[^4].
- **19 August.** Wiz finds the flaws.
- **24 August.** Wiz reports them to the PHP security team.
- **17 October.** AWS detects attempts against MadPot, its honeypot, from an IP tied to a commercial anonymisation network. Mid-embargo, four days before a patch existed[^17].
- **20 October, 15:00 UTC.** Both identifiers start circulating on forums, with an invented score of 10.0 and the wrong release time.
- **20 October, 19:30 UTC.** PHP pulls its pre-announcement forward. The notice lands before the packages do.
- **21 October, 07:41 UTC.** Chainguard publishes patched images, six hours ahead of the coordinated release[^4].
- **21 October, 14:00 UTC.** Official release: 8.2.35, 8.3.36, 8.4.27 and 8.5.12.
- **21 October, 45 minutes later.** Tens of thousands of attempts at Cloudflare. Hundreds of thousands within two hours. Millions now[^13].

None of that started on the 17th. Anyone watching traffic was already seeing it before there was an announcement. Cloudflare saw thousands of attempts in the ten hours before disclosure[^14]. Fastly says there is evidence of in-the-wild attacks pre-disclosure[^15].

Not everyone saw the same thing, and that counts too: Imperva says it saw no requests matching the patterns it was tracking during that period[^16]. A provider not seeing it doesn't mean it didn't happen, it means their network didn't see it. AWS's honeypot did.

And the WAFs got there before the patch did: Cloudflare had rules live from 03:02 UTC on the 21st, about nine hours ahead of the announcement[^14]. Even so, every vendor says the same thing, correctly: a rule buys time, the patch is the fix.

Which is where the firefighter shift starts. You're checking whether the PHP you'd forgotten you had is affected, with a pre-announcement in front of you and no package to install. And with the detail AWS put on the table on Wednesday: the attempts started on the 17th, so by the time the patch landed, part of the job was already done. That turns "bad day" into "we were late before we started".

Then there's the tone. OpenAI published its Patch the Planet post like a platform launch: the "Defender's Window", a billion dollars in credits, a five-phase framework for coordinating disclosures, and a version checker that, according to the announcement text, "will be launching"[^8]. The checker has existed since that same day: `openai/php2shell-checker`, two Codex skills, a one-minute install[^12]. The announcement was running behind its own artefact.

What the checker actually does matters more than what the post says about it. It reads `X-Powered-By` and `Server`, sends a HEAD request and compares the version against cutoffs dated 20 October. If the server doesn't advertise a version, it answers `UNKNOWN`. And `UNKNOWN` does not mean patched. It means it doesn't know. It happens that the best-configured servers, the ones with `expose_php` off, are exactly the ones that get `UNKNOWN`.

With the tool in hand, the outcome is a "don't know" that reads as "you're fine" when you're in a hurry. That's worse than a badly coordinated disclosure: it's the feeling of having checked something nobody checked.

And one correction to the most repeated version of the story, because it's part of the same noise: a person found this. Calif credits LCFR, and OpenAI later found additional flaws that completed the picture[^13]. AI sped the research up. It didn't discover anything. Not the same thing, though it fits a shorter headline.

## What it means for you

If your site is static or generated, stop reading. This doesn't touch you.

If you serve PHP in any shape (FPM, mod_php, php-cgi, FrankenPHP, LiteSpeed), keep going.

**mod_php is the bad case.** The process survives the failure, and exploitation happens before the script is even resolved. What application sits behind it doesn't matter, because the application hasn't entered the picture yet. The file doesn't need to exist either: a 404 will do.

With **PHP-FPM** the worker dies if the failure lands during startup, so you need an application path that touches the session: a `session_start()`, an `unserialize()`, WordPress's `maybe_unserialize()`. Still exploitable, but it asks for more.

The useful question is which of the things I serve runs PHP, at what version, on which SAPI. Answering it is duller than it sounds, because the answer usually lives in three different places and one of them is a container nobody has rebuilt since 2023.

## How it works

<figure>
  <img src="/img/posts/php2shell/ciclo-en.png" alt="The lifecycle that breaks: one request's state outlives the process and the next request picks it up" width="2200" height="1781" loading="lazy">
  <figcaption>Fig. 01 · click to open full size.</figcaption>
</figure>

When an allocation doesn't fit inside `memory_limit`, PHP doesn't throw an exception or return an error. It jumps. `longjmp` takes execution to another point on the stack without passing through the end of the function: no cleanup, no closing, no warning[^2].

The whole pattern comes out of that, and it's worth holding onto because it shows up in four places in the engine. Code publishes some state, makes an allocation that can fail, and only then commits it. If the jump lands between publishing and committing, that state is left half-built, holding pointers to memory that isn't its any more, and someone reads it as valid.

In practice it's three requests against the same process.

The first one breaks it. A chunked multipart body walks past `post_max_size`, exhausts memory exactly where it needs to and dies leaving session state published and the heap freed. The process stays up, which is the important part.

The second one reads. It claims that memory and the engine hands back a `PHPSESSID` cookie with pieces of the heap inside. Two passes: one for the heap base, one for `libphp`'s. That takes care of ASLR.

The third one writes. It places a forged `zval` at the calculated offset and the engine runs it as if it were its own.

There's a detail almost nobody mentions, and it explains why this isn't "one curl and done". The state is per worker. If the three requests land on different Apache children, there's no chain; the public toolkit solves that by keeping N-1 workers busy with keep-alive connections[^7]. Between that and the dependence on each build's memory layout, there are your twenty-two years.

## How to mitigate it

The only thing that fully works: update and restart. And by restart I mean the processes, not the box: PHP-FPM, Apache, and any PHP process that's been up for months.

I keep coming back to the restart because it's the most common mistake. Installing the package restarts nothing. If the process is still alive with the old code loaded, you're still exposed, and the package manager will tell you you're up to date.

Fixed versions: 8.2.35, 8.3.36, 8.4.27 and 8.5.12[^3]. Distros and LTS vendors already have packages.

If you can't patch today, this is what you've got:

- A genuinely enforced `memory_limit` of `-1`, in the pool, the directory and the application. It removes the controllable OOM, which is the precondition for everything else.
- `session.upload_progress.enabled=0` on FPM.
- A conservative `post_max_size`, plus a limit on the decoded body at the edge.
- Require `content-length` on multipart requests, and cap parts, headers and nesting.
- `pm.max_requests = 50` in the FPM pool: it recycles the worker every fifty requests and shortens the window in which one request's state can reach the next. It does nothing for the hash table bug, which happens inside a single request, and at a value of 1 you've thrown away your throughput[^15].

One warning, because it's counterintuitive and I've seen it recommended: don't use `RLIMIT_AS` or `RLIMIT_DATA`. They cause exactly the `mmap()` failure that reopens the vulnerable path.

And if you have room for something structural: moving off mod_php to PHP-FPM already removes the "any script, even a nonexistent one" case. The patch itself removes `longjmp` for OOM and replaces it with a deferred OOM at the next VM safe point, plus `_exit()` if a second, higher threshold is crossed[^5][^6]. It fixes the pattern, not the four paths that got published.

## How to tell if someone already got in

Signature hunting is close to useless: a live server has thousands of lines that look like an attack, and the noise eats you before you find anything. Grouping by what the attacker is trying to do works better[^9], because each step leaves traces somewhere different and can be cross-checked against something else.

**Initial access and execution** ([T1190](https://attack.mitre.org/techniques/T1190/), [T1505.003](https://attack.mitre.org/techniques/T1505/003/)). Memory or multipart errors in the PHP log with no request behind them in the access log. If no request explains the error, it wasn't a clumsy client. In the same window, process crashes: segfaults, double frees, httpd or php-fpm aborts, dumps in `/var/crash`. Fastly points at one concrete pattern, dozens of segfaults within minutes followed by a single slow 200, which matches the brute-force phase for recovering addresses[^15]. And PHP files owned by the web server user, outside cache directories: Apache does not write PHP, so your deploy didn't put that there.

**Command execution and shells** ([T1059.004](https://attack.mitre.org/techniques/T1059/004/), [T1571](https://attack.mitre.org/techniques/T1571/)). Processes running as the web user that aren't Apache, Apache children that aren't Apache, outbound connections to ports that make no sense, binaries living in `/tmp`. The cleanest of the lot: a `/bin/sh` child of a PHP worker. With the exploits seen so far no application code runs, so if that shell shows up, the payload is firing[^15].

**Credential access** ([T1552.001](https://attack.mitre.org/techniques/T1552/001/), [T1552.004](https://attack.mitre.org/techniques/T1552/004/)). `.env`, `.aws/credentials`, `.htpasswd`, private keys and database dumps served with a 200, and `.bak` copies with credentials inside. The cross-check that saves you a bad night: confirm the file actually exists on disk, because plenty of applications return 200 on their "not found" page.

**Persistence** ([T1053.003](https://attack.mitre.org/techniques/T1053/003/), [T1543.002](https://attack.mitre.org/techniques/T1543/002/), [T1546.004](https://attack.mitre.org/techniques/T1546/004/), [T1574.006](https://attack.mitre.org/techniques/T1574/006/), [T1098.004](https://attack.mitre.org/techniques/T1098/004/), [T1136.001](https://attack.mitre.org/techniques/T1136/001/), [T1548.001](https://attack.mitre.org/techniques/T1548/001/)). SSH keys authorised for system accounts, UID 0 accounts, cron jobs that download and execute, systemd units from no package, `ld.so.preload`, setuid binaries in the wrong place.

**Defence evasion** ([T1027](https://attack.mitre.org/techniques/T1027/), [T1140](https://attack.mitre.org/techniques/T1140/), [T1562.001](https://attack.mitre.org/techniques/T1562/001/), [T1564.001](https://attack.mitre.org/techniques/T1564/001/)). Base64 blobs, hex strings, chained `chr()`, `error_reporting(0)` next to `eval`, `ini_set` touching `disable_functions` or `open_basedir`, hidden PHP, `auto_prepend_file` loading code on every request.

**Reconnaissance** ([T1595.002](https://attack.mitre.org/techniques/T1595/002/)). Scanner bursts, and that one stray request whose only job is to read your `memory_limit` out of the error message.

One more cross-check, and it saves more false positives than any other: a config change at 04:12 is usually certbot rewriting the vhost, not an intruder.

My rule, if I have to automate any of this: multipart with no reliable length, plus nested `filename=""`, plus an anomalously long `PHPSESSID`, all inside the same one-minute window. On their own each one is internet noise. Together they have no legitimate use.

And if any of it shows up, assume the worst: the worker's memory left the building. Rotate tokens, API keys, database credentials and sessions from that machine. Don't waste the afternoon looking for the payload in the application log. It isn't there, because it never went through there.

Between Tuesday and Wednesday there were people doing it right: PHP removing `longjmp` across every branch, distributions with packages ready on day one, Wiz and Calif landing in the same place separately. And there were people sprinting: a mis-scored CVE, a wrong time, one video too many, a product launch on top of a fire, a tool that answers "don't know" precisely on the best-configured servers. The difference between the two groups wasn't talent. It was whether someone had decided in advance what to do when the secret breaks. And even so, a day later over a million were still unpatched[^20].

Good luck out there.

## References

[^1]: [PHP2Shell: AI uncovers critical RCE vulnerabilities affecting 70% of the Internet](https://www.wiz.io/blog/php2shell-php-rce) · Wiz Research, 21 October 2026
[^2]: [A tale of two PHP remote vulnerabilities](https://calif.io/php2shell/) · Ryan Austin, Calif Research, 21 October 2026
[^3]: [PHP October 2026 security release](https://www.php.net/archive/2026.php#2026-10-21-5) · php.net, 21 October 2026
[^4]: [Today's PHP security release, and how we're strengthening PHP security](https://thephp.foundation/blog/2026/10/21/todays-php-security-release-and-how-were-strengthening-php-security/) · PHP Foundation, 21 October 2026
[^5]: [GHSA-w8hw-w9fx-j79w: RCE via OOM corruption](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w) · PHP security advisory
[^6]: [GHSA-6wmf-4rv5-p3rp: RCE via stale state](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp) · PHP security advisory
[^7]: [devploit/php2shell](https://github.com/devploit/php2shell) · toolkit documenting the three stages; authorised testing only
[^8]: [Patch the Planet: global response coordination as agents uncover critical vulnerabilities](https://openai.com/index/coordinated-disclosure-of-critical-vulnerabilities/) · OpenAI, 21 October 2026
[^9]: [MITRE ATT&CK](https://attack.mitre.org/) · the technique IDs cited above resolve here
[^10]: [CWE-639: Authorization Bypass Through User-Controlled Key](https://cwe.mitre.org/data/definitions/639.html) · MITRE
[^11]: [A01:2021 Broken Access Control](https://owasp.org/Top10/A01_2021-Broken_Access_Control/) · OWASP Top 10
[^12]: [openai/php2shell-checker](https://github.com/openai/php2shell-checker) · two Codex skills (`phpcheck-server` and `phpcheck-url`); it reads `X-Powered-By` and `Server` and compares against 20 October cutoffs, so `UNKNOWN` does not mean patched
[^13]: [Amazon and Cloudflare report PHP2Shell cyberattack attempts as millions of websites race to patch](https://cybernews.com/security/php2shell-wordpress-amazon-cloudflare-ai-cyberattacks/) · Cybernews, 22 October 2026 (AWS MadPot, Cloudflare's curve, LCFR's authorship)
[^14]: [Cloudflare deploys protections against php2shell vulnerability](https://blog.cloudflare.com/php-vulnerabilities-waf-protection/) · Daniele Molteni, Dan Jones and Kuber Nandwani, Cloudflare, 21 October 2026 (rules live from 03:02 UTC, nine hours ahead of the announcement). The "thousands of attempts in the ten hours before" figure is Matthew Prince's, via [SecurityWeek](https://www.securityweek.com/php2shell-exploitation-started-days-before-patches/)
[^15]: [Fastly's Proactive Protection for PHP2Shell](https://www.fastly.com/blog/fastlys-proactive-protection-critical-php-rce-cve-2026-109618) · Kelly Shortridge, Fastly, 21 October 2026: "There is evidence of active attacks in-the-wild pre-disclosure", plus the three signals cited (segfaults and slow 200, shell child of a worker, `pm.max_requests`)
[^16]: [PHP2Shell: When PHP Runs Out of Memory at the Wrong Moment](https://www.imperva.com/blog/php2shell-vulnerability-updates/) · Imperva, October 2026: during the pre-disclosure monitoring period it observed no traffic matching the patterns it was tracking
[^17]: [Amazon Threat Intelligence identifies PHP2Shell exploitation prior to public disclosure in MadPot](https://aws.amazon.com/blogs/security/amazon-threat-intelligence-identifies-php2shell-exploitation-prior-to-public-disclosure-in-madpot/) · CJ Moses, AWS Security Blog, 21 October 2026
[^18]: [Advisory 11/2004: PHP memory_limit remote vulnerability](https://marc.info/?l=bugtraq&m=108981780109154&w=2) · Stefan Esser, e-matters, 14 July 2004 (CAN-2004-0594). The advisory ships no exploit: "e-matters is not going to release an exploit for this vulnerability to the public"
[^19]: [Bug #78876: Long variables in multipart/form-data cause OOM and temp files are not cleaned](https://bugs.php.net/bug.php?id=78876) · filed 28 November 2019, closed 11 May 2020 (CVE-2019-11048)
[^20]: [PHP2Shell Exploitation Started Days Before Patches](https://www.securityweek.com/php2shell-exploitation-started-days-before-patches/) · Ionut Arghire, SecurityWeek, 22 October 2026, carrying the Censys, VulnCheck and VirusTotal counts of unpatched and end-of-life PHP servers

