October 27, 2026
PHP2Shell: The Ten-Second Path From Request to Root (CVE-2026–109618/109619)
I went deep on PHP2Shell this week — working through the public research on CVE-2026–109618 and CVE-2026–109619, and a hands-on lab that…

By Harshavardhan
9 min read
I went deep on PHP2Shell this week — working through the public research on CVE-2026–109618 and CVE-2026–109619, and a hands-on lab that reproduces the chain end to end. This writeup goes further than a surface-level summary: it walks through the actual code pattern behind the bug, shows what the exploit's own internal trace looks like, and gives the request-level signatures worth hunting for — all without shipping anything that could be copy-pasted into a working attack.
Why application security doesn't touch this
Start with the detail that reframes everything else: this bug has nothing to do with your application. It doesn't matter if your login form is airtight or your framework is fully patched. PHP parses the incoming request — decodes the body, builds internal structures for form fields and session data — before a single line of your application code runs. If the flaw fires during that parsing stage, your app never gets a chance to matter. A script that does nothing but print "OK" is exactly as exposed as a complex authentication handler, because the vulnerable surface sits one layer below the application, in the PHP runtime itself.
How a worker actually serves requests
Under a typical deployment — Apache's prefork model with mod_php, or PHP-FPM — the same worker process serves request after request without restarting in between. That's efficient by design. It also means anything left behind incorrectly during cleanup is still sitting in that worker's memory when the next request lands. That single fact is the hinge the entire vulnerability class turns on.
Where the defect actually lives
PHP caps memory per request via memory_limit. When an operation would exceed it, PHP performs what the engine itself calls a bailout — internally a longjmp(), an abrupt jump back to a safe recovery point, abandoning whatever operation was mid-flight. That's the mechanism behind the familiar "Allowed memory size exhausted" error.
A request that finishes normally cleans up fully: memory is freed, and every pointer referencing it is cleared. A bailout skips the second half of that. It's an emergency exit, not an orderly shutdown.
bailout skipping cleanup — memory freed, pointer-clearing step skipped, dangling pointer left behind, next request on the same worker follows the stale pointer
The actual code pattern, in plain terms
Here's the part worth sitting with, because it's a pattern you'll recognize instantly once you see it: when PHP inserts a new entry into a hash table (the structure backing $_POST, among other things), it does two things in sequence — it bumps the table's internal "how many entries do I have" counters, and then it goes and allocates memory for the actual entry data. In pseudocode, the order looks roughly like this:
function insert_entry(table, key_name):
slot = table.next_free_slot
table.entry_count += 1 # counter updated first
table.used_slots += 1
entry = table.slots[slot]
entry.key = allocate_and_copy(key_name) # allocation happens second
entry.value = ...function insert_entry(table, key_name):
slot = table.next_free_slot
table.entry_count += 1 # counter updated first
table.used_slots += 1
entry = table.slots[slot]
entry.key = allocate_and_copy(key_name) # allocation happens second
entry.value = ...If the allocation on that last line is the one that blows past memory_limit, PHP aborts right there with a bailout. The counters already say "there are N entries." The Nth entry itself was never actually populated — it's just whatever bytes happened to occupy that memory beforehand.
PHP still runs its shutdown routine for that request, because a fatal error doesn't skip cleanup entirely — it just skips the mid-operation cleanup that would have happened if the insert had completed normally. Shutdown walks every slot the counter claims exists, including the one that's uninitialized garbage, and treats it as a real value. That's the entire root defect. One line of bookkeeping runs before the thing it's counting actually exists.
Tracing it through to execution
Once you understand that one ordering mistake, the path from "a request failed" to "a shell command ran" is a short, mechanical chain — no exotic steps, just one consequence after another:
Request parsing hits the memory limit mid-insertion
→ PHP bails out, counters already updated, entry never populated
→ Request shutdown still runs and walks every counted entry
→ The uninitialized entry is read and interpreted as a PHP value
→ If that value looks like an array, PHP calls its cleanup routine
→ The cleanup routine's function pointer has been redirected
→ That redirected call executes with attacker-supplied inputRequest parsing hits the memory limit mid-insertion
→ PHP bails out, counters already updated, entry never populated
→ Request shutdown still runs and walks every counted entry
→ The uninitialized entry is read and interpreted as a PHP value
→ If that value looks like an array, PHP calls its cleanup routine
→ The cleanup routine's function pointer has been redirected
→ That redirected call executes with attacker-supplied inputEvery arrow in that chain is PHP doing something it was always going to do anyway — count, clean up, call a destructor. None of it is unusual engine behavior in isolation. The vulnerability is entirely in what data happens to be sitting in that one uninitialized slot when shutdown gets to it.
CVE-2026–109618 — turning the leak into an ASLR bypass
Modern systems randomize where code and data sit in memory specifically so an attacker can't predict useful addresses — that's ASLR. Before anything in the chain above can reliably redirect execution toward something like a shell-spawning function, an attacker needs real addresses to work with, not guesses.
This CVE gets there by triggering the same bailout condition while PHP is handling the session identifier. A dangling reference to the session data survives the failed request. A following request on the same worker reoccupies that freed memory, and when PHP next reads the session value to build the response cookie, it reads it as a string far longer than a real session ID — because the length field associated with that stale reference no longer matches the real data. Whatever memory follows gets reflected straight back to the client inside the Set-Cookie: PHPSESSID response header.
Public research measured this leak at up to roughly 67 KB of process memory in a single response. Buried in that leak are the runtime addresses of the heap and of PHP's own library code in that specific worker — exactly what's needed to defeat ASLR without guessing.
cookie header disclosure — real session ID followed by leaked worker memory, including code addresses, reflected in the Set-Cookie response header
CVE-2026–109619 — turning a read into a write
With those addresses known, the same underlying bug gets reused in the opposite direction: instead of reading data out of the freed memory slot, a forged internal structure gets written into it, shaped to match what PHP expects an array-like value to look like. That structure carries a function pointer — a destructor, the routine PHP calls to release a value's resources when it's done with it — and that pointer has been redirected to point at a command-execution function in the PHP library, using the address obtained from the leak above.
When shutdown walks into that planted structure and calls what it believes is a routine cleanup step, it instead executes a command with attacker-supplied input, running with whatever privileges the PHP worker has — commonly www-data on Linux.
the two CVEs built on the shared CWE-824 root defect — one path leaks addresses, the other reuses the leak to redirect cleanup into command execution
What the delivery traffic tends to look like
Independent analysis of multiple public proof-of-concept tools found a consistent shape to the opening request: a multipart form submission that touches PHP's session machinery before anything else happens. Stripped down to just the structure — no sizes, no trigger values, nothing that would actually reproduce the bug — it looks roughly like this:
POST / HTTP/1.1
Content-Type: multipart/form-data; boundary=----BOUNDARY
------BOUNDARY
Content-Disposition: form-data; name="PHPSESSID"
<session value>
------BOUNDARY
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"
<progress key>
------BOUNDARY
Content-Disposition: form-data; name="payload_field"; filename="file.txt"
Content-Type: application/octet-stream
<additional parts or a large fill value, used to force memory pressure>
------BOUNDARY--POST / HTTP/1.1
Content-Type: multipart/form-data; boundary=----BOUNDARY
------BOUNDARY
Content-Disposition: form-data; name="PHPSESSID"
<session value>
------BOUNDARY
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"
<progress key>
------BOUNDARY
Content-Disposition: form-data; name="payload_field"; filename="file.txt"
Content-Type: application/octet-stream
<additional parts or a large fill value, used to force memory pressure>
------BOUNDARY--What makes this recognizable isn't any single field — plenty of legitimate upload forms use PHPSESSID and PHP_SESSION_UPLOAD_PROGRESS without anything malicious going on. It's the combination, especially on a route that has no business handling uploads at all, paired with an unusually high part count or a field sized to deliberately pressure the memory limit. That combination, correlated with the log and process signals below, is what separates a real attempt from a false positive.
What a successful exploit looks like, concretely
This is the part worth internalizing for detection, because it doesn't match the instinctive picture of "an attack crashed something":
- The HTTP response often comes back as a plain 200 — sometimes with a memory-exhaustion error visible in the body, sometimes not at all.
- The worker process doesn't necessarily crash or restart. Documented testing shows the same worker continuing to run normally immediately after executing an attacker's command.
- The real evidence sits in the process tree: a web server process —
apache2,php-fpm, or equivalent, commonly running aswww-data— spawning a shell (sh,bash,dash), which then runs whatever command the attacker supplied. On Windows, the parallel is the web server or PHP process spawningcmd.exeorpowershell.exe. - A failed attempt, by contrast, tends to leave two distinct traces. In the PHP error log:
Allowed memory size of N bytes exhausted, reported atUnknown on line 0— meaning the failure happened during request parsing, before any application script ran. And a bad address guess shows up as the worker segfaulting outright, logged as a child process exiting on signal 11.
Detection: building correlation, not single-signal alerts
Layer the signals by how far downstream they sit, and trust them accordingly:
Request-level signals (supporting evidence only):
- Multipart POST requests with unusually high field counts or deeply nested field name structures, particularly on endpoints that don't normally take uploads
- Presence of
PHP_SESSION_UPLOAD_PROGRESSor similar session-progress fields on routes that have nothing to do with file uploads - A
Set-Cookie: PHPSESSID=value dramatically longer, or differently formatted, than your application's normal session ID — worth baselining once, since a real PHP session ID is a short, fixed-length string
PHP and web server log signals:
Allowed memory size exhaustederrors reported at line 0 — the fingerprint of a failure during request parsing rather than during script execution- Worker segmentation faults clustered in a short time window on the same host
- A short burst of memory-exhaustion errors followed by the logs going quiet — don't read "quiet" as "resolved"
Host and EDR signals (the strongest evidence by far):
- Any shell interpreter spawned with a web server or PHP process as its direct parent
- New outbound network connections originating from that shell, or from the web/PHP process itself
- New files written into the web root, or command-line arguments that don't match your application's normal behavior
The correlation that matters: an anomalous request, followed by a parse-time memory error or an oversized session cookie, followed by a shell spawned from the web process, close together in time, on the same host. That sequence, seen together, is close to conclusive. Any one signal alone is noise.
What actually fixes it, and what only buys time
The only complete fix is updating the PHP runtime — to 8.5.12, 8.4.27, 8.3.36, or 8.2.35 depending on your branch — then restarting every worker, since a running process keeps executing the old code even after the package on disk changes.
Interim mitigations reduce exposure without closing the hole:
session.upload_progress.enabled = 0, applied before PHP parses any request and paired with a full worker restart, removes the trigger for most reported upload-progress techniquesfatal_error_backtraces = 0, on PHP 8.5 specifically, addresses the backtrace-handling path tied to CVE-2026-109619opcache.enable = 0makes the code-execution path harder to reach, at a real performance cost- Rejecting or constraining
multipart/form-dataat a reverse proxy or network boundary — ahead of PHP entirely — is one of the few controls that genuinely works, because it stops the request before it reaches the vulnerable parser. A WAF running as a PHP module can't do this; by the time it inspects anything, PHP has already finished parsing and the damage is done.
None of this replaces patching. It buys time, nothing more.
Checking your own exposure
Before patching, you need an accurate inventory of which hosts are actually running a vulnerable build — including containers and bundled interpreters that won't show up if you're only checking a system-level PHP install. A pure version-fingerprinting scanner (reads response headers, compares against the fixed-version table, flags what's outdated) is a reasonable first pass for that inventory step, and is meaningfully different from anything that tries to trigger the actual bug — it's read-only, and it can't crash or corrupt anything it touches.
Introducing the PHP2Shell Exposure Scanner
To make the initial exposure-assessment step easier, I put together a lightweight Python-based PHP2Shell Exposure Scanner.
The tool uses standard HTTP requests to inspect response headers and landing-page HTML for PHP version information, then compares any detected version against the fixed-version thresholds configured in the script. It supports single-target and bulk scans, with table, JSON, and CSV output.
What it helps with:
- Identify hosts that disclose a potentially vulnerable PHP version.
- Assess multiple URLs from a single target list.
- Generate reports for inventory reviews and remediation tracking.
- Integrate preliminary checks into CI pipelines or scheduled assessments.
Usage:
python3 php2shell_scan.py https://yourapp.examplepython3 php2shell_scan.py https://yourapp.exampleFor multiple targets:
python3 php2shell_scan.py -i urls.txt --format json --out scan.jsonpython3 php2shell_scan.py -i urls.txt --format json --out scan.jsonThe scanner is detection-only: it does not attempt exploitation or verify remote code execution. Because it relies on observable version information, results may be incomplete when version banners are suppressed, and vendor-backported fixes may not be reflected in the reported version. Treat its output as an initial screening result, not a definitive vulnerability assessment.
Use it only against systems you own or are authorized to assess.
Source code: https://github.com/HARSHA2396/php2shell-exposure-scanner
Closing thought
What stays with me most is how unglamorous the root cause actually is. This isn't an exotic memory-corruption technique — it's a counter getting incremented one line before the thing it's counting was actually created, in cleanup code that's been shipping in production PHP for a long time. Everything downstream — the ASLR bypass through a cookie header, the forged destructor, the cross-request state reuse — is just that one ordering mistake being exploited in increasingly deliberate ways. The scariest bugs are rarely the cleverest ones to find. They're the simplest ones nobody double-checked.