On 21 October 2026, PHP shipped emergency patches for two critical vulnerabilities, CVE-2026-109618 and CVE-2026-109619, nicknamed PHP2Shell. Both are CVSS v4.0 9.2. What matters:

  • The flaws are in the PHP interpreter, not in any application. A fully patched WordPress and a ten-line “Hello World” are equally exposed if the PHP under them is vulnerable.
  • Exploitation is unauthenticated and remote. In the cleanest case, one request triggers it before any application code runs.
  • Nearly every PHP release since 5.4 is affected by at least one of the two.
  • At release, the PHP security team reviewed the proof-of-concept code circulating after the leak and found none of it functional. Exploitation attempts have been reported since. No compromise is publicly confirmed, and neither CVE is in CISA’s KEV catalogue as of 21 October.

Do this, in order: upgrade to 8.5.12, 8.4.27, 8.3.36 or 8.2.35, or your distribution’s backport. Restart every worker and confirm the version that is actually running, not the one on disk. Treat 8.1 and older as exposed.


A bug everyone was right to ignore

For twenty-two years, every security person who looked hard at this corner of PHP made the same call: interesting, but too hard to exploit, move on. That judgment was right in 2004. It was right in 2024. By August 2026 it was wrong, and nothing about the bug had changed in between.

When digging in trying to think what did change, we should start with what the bug is, because you cannot see why it was dismissed until you see how awkward it is to reach.

PHP abandons a request by jumping out of whatever it is doing, using longjmp(), when an allocation would blow past memory_limit. Jumping out of half-finished C is a good way to leave a mess behind. Calif’s write-up has the cleanest illustration, in PHP’s hash tables, the structure behind every array and every $_POST:

idx = ht->nNumUsed++; /* counted as present */
p = ht->arData + idx;
p->key = zend_string_init(...); /* allocate the key: may bail out here */
ZVAL_COPY_VALUE(&p->val, pData); /* fill in the value */

The slot is counted as live before its key and value exist. If that key allocation is the one that trips the memory limit, PHP jumps away and the count stays. Later, when the table is torn down, PHP walks every “live” slot and frees a key and value that were never written. It is acting on garbage. That is the whole family: do the bookkeeping, then do the fallible work, and leave a non-local exit in the gap between them.

The two CVEs are two faces of it. In CVE-2026-109618 the damaged state is consumed by the same request’s own shutdown. In CVE-2026-109619 it is left behind, and the next request to land on the same worker trips over it.

Two things kept this theoretical for a long time. The first is reaching out-of-memory on demand, from outside, cheaply. You would assume post_max_size (8 MiB by default) stops you well short of the 128 MiB heap. It does not: PHP checks that limit against the declared Content-Length, then never holds the decoded bytes to it. An upload part with an empty filename carries no data, dodges max_file_uploads, and still builds all six $_FILES entries. Nest the field names and each tiny part explodes into a heap of internal allocations. A request under 8 MiB can exhaust 128 MiB, which hands the attacker the one thing the bug needs: the exact moment PHP runs out of memory.

The second is getting the broken cleanup to run at all. On Apache with mod_php it runs unconditionally, even after a failed startup, so the corruption is reachable before any application code executes. PHP-FPM is tougher: it kills the worker on a failed startup, so you have to make the fatal error happen later, inside something the application does, such as decoding a session. The consumer that ties the second bug together is a feature almost nobody uses: session upload progress, on by default since PHP 5.4.0, which touches session state during request parsing, in exactly the fragile window.

So: a small innocent-looking request, a feature from 2012 most operators forgot was on, and a twenty-year-old habit of counting before committing. Reaching all three at once, reliably, across targets, was genuinely hard. That difficulty is the entire reason the bug survived until it stopped being that difficult.

Four times the price was wrong

Exploit difficulty is not a fixed property of a bug. It is a price, and everyone in this story quoted it as if it were fixed.

In 2004, when the OOM-interruption hazard was first raised, the price of turning it into code execution looked prohibitive, so it was deprioritised. Correct, for 2004.

In 2024, PHP’s first external audit in over a decade, run through OSTIF and performed by Quarkslab, got close. A core developer fixing one of the audit findings noticed the same OOM pattern and theorised it might be exploitable. No concrete path turned up. The potential fixes each broke something: compatibility, performance, or thousands of third-party extensions. A small security team running on consensus could not agree which cost to accept, so the work stalled and restarted for months. The PHP Foundation has been unusually candid about this, calling the delay “a structural failure” in its own post. But read why it stalled: part of the reason the fix kept losing to other priorities was that there was no known path to remote code execution. The price of exploitation was quoted as “too high to rush”, and that quote shaped the decision.

Today, after everything, CVSS still scores both of these 9.2 with Attack Complexity: High. The score is not wrong by its own rules. But “high complexity” is precisely the input that patch-triage processes use to move something down the queue. The same reasoning that stalled the 2024 fix is wired into how most organisations decide what to patch first.

That is three mispricings: the researchers, the maintainers, the scoring. The fourth is the defender reading a 9.2/AC:H and deciding it can wait for the maintenance window.

Then, in the summer of 2026, the price collapsed. A researcher at Calif who knew this exact lineage, back to the 2004 work, pointed a model at the source and asked it to look for the old pattern’s living relatives. The most useful thing the model did was not finding the first bug. It was generalising the pattern (publish state, do fallible work, commit later) into a reusable search, which then surfaced the session upload-progress variant. One finding became a family. Wiz, working independently a few weeks later, reached the same bugs and built the memory-disclosure and RCE demonstrations. The hard part, chaining the flaws and adapting the exploit to each target’s memory layout, was the repetitive, fiddly work that models now compress from weeks into days.

Nothing in the C changed. The cost of weaponising it did, and every actor upstream had priced that cost as stable.

Secrets don’t buy the time they used to

Coordinated disclosure rests on a simple bet: keep the technical details secret and you buy defenders time to patch before attackers can build anything. PHP2Shell is a stress test of that bet, and the bet mostly lost.

On 20 October around 15:00 UTC, two CVE IDs leaked onto social media, attached to a wrong release time and an inflated CVSS of 10.0. That was almost all that leaked. No write-up, no patch, no working exploit. PHP pulled its announcement forward and moved the release up a full day. Within hours, a proof-of-concept scramble was under way, accelerated when one of the reporting firms accidentally published a short demo video. Overnight, Cloudflare quietly switched on WAF rules at 03:02, and at 07:41, more than six hours before the coordinated release, Chainguard published built container images containing the fix, handing anyone who wanted it a patched binary to diff against while everyone else waited.

PHP is open so the source code is no secret. The only thing secrecy protected was the knowledge of where in millions of lines to look. Once that knowledge leaked, even in the thin form of two CVE numbers and a vague “critical RCE in PHP core”, the distance from hint to working code got a lot shorter, because public source plus a model is a decent substitute for a leaked write-up, and a patched binary plus a model is a decent substitute for a leaked patch.

The FUD version of this is still wrong. The embargo did not collapse into instant mass exploitation. PHP’s team reviewed the PoCs circulating at release and found none of them functional. A genuinely reliable exploit still took real skill and had not clearly surfaced in public by release day. Good to know secrecy is not completely worthless yet, but the lead time it buys is shrinking, from three directions at once (detail leaks faster, patched binaries leak early, and models shorten the diff-to-exploit path). Our own patch window should be planned for that time to keep shrinking.

What to do, and what not to trust

Patch. It is the only complete fix.

Branch Fixed version
8.5 8.5.12
8.4 8.4.27
8.3 8.3.36
8.2 8.2.35

On 7.4, 8.0 or 8.1 there is no supported release, but given the severity the PHP team published minimal source backports on dedicated branches, and most distributions shipped patched packages with the upstream version string unchanged. Check your vendor, not the raw version number.

Two traps. First, patched on disk is not patched in memory: if you only upgrade the package but skip the restart, the old interpreter keeps serving. You must restart Apache, PHP-FPM and every long-running worker, then confirm the version actually running. Second, PHP hides, in container base images, control panels, appliances, and software that bundles it silently.

If you truly cannot patch yet, the official mitigations reduce exposure to specific paths and nothing more. For CVE-2026-109618, set memory_limit=-1 where applications cannot override it, and do not substitute RLIMIT_AS or RLIMIT_DATA, which cause the very allocation failure that triggers the bug. For CVE-2026-109619, set session.upload_progress.enabled=0, and on 8.5 set fatal_error_backtraces=0. Suppressing errors does nothing: the vulnerable code runs before PHP decides whether to log or display anything.

And while you are in there, consider this: session upload progress has been on by default since 2012, and almost nobody uses it. PHP’s own advisory suggests turning it off even after you patch.

What stays with me

The setjmp/longjmp error-handling style that makes this bug possible is not unique to PHP. The “publish state, then do fallible work” anti-pattern shows up wherever C code exits non-locally on error, and which other interpreters and libraries deserve the same look Calif gave PHP is a question worth its own post. We will write that one, too, eventually.

For now, three numbers. Twenty-two years from the first warning to a working exploit. Two months from that exploit to a coordinated patch across every supported branch. Twenty-three hours from the leak to the release.

You do not control the first, and the people who do are a handful of funded developers and volunteers who just worked a weekend straight on infrastructure you depend on. You barely touch the third. The one you own is how long “too hard to exploit” stays on your list of reasons to wait. For this bug, that reason expired in August. Go restart your workers.


References