October 23, 2026
PHP2Shell: Anatomy of a 20-Year-Old PHP RCE
One request. No login. A decade-old flaw in the PHP engine, finally weaponized.

By N33b_0x1
9 min read
CVE-2026โ109618 & CVE-2026โ109619 ยท CVSS v4.0 9.2 (Critical) ยท Affects PHP 5.4 โ 8.5 ยท Fixed in 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35
On 20 October 2026, two CVE identifiers started circulating on Hacker News hours before anyone was supposed to know they existed. A screenshot went around; the post was deleted. By the time the dust settled, the PHP security team had pulled its emergency release forward by a full day, and the industry had a new name to worry about: php2shell.
This is the story of two flaws that break one of the oldest assumptions in web infrastructure: that a bug has to be practically exploitable to matter. With roughly 70% of all websites running PHP somewhere in their stack, these two matter a great deal. Let's trace where they came from, how they work at the engine level, and what to do about them.
A twenty-year-old ghost
To understand php2shell you have to rewind. Back in 2004, PHP security researcher Stefan Esser found the first PHP memory_limit remote vulnerability โ a bug so sensitive he never released a working exploit. The core idea has haunted the engine ever since: what happens to PHP's internal state when an allocation fails mid-operation and a fatal error interrupts native code before cleanup finishes?
That question kept resurfacing. In 2020 a seemingly harmless report (bug #78876) noted that long multipart/form-data variables could cause out-of-memory conditions and leave temp files uncleaned. In 2024 an OSTIF and Quarkslab audit rediscovered a related OOM issue โ but exploitation was never proven, so it was never fixed. The ghost stayed in the machine.
Timeline
- 2004 โ The origin. Stefan Esser discovers the first PHP memory_limit remote vulnerability, deemed too dangerous to publish.
- 2020 โ The clue. Bug #78876: long multipart variables cause OOM and leave temp files behind. Shrugged off.
- 2024 โ The audit. OSTIF and Quarkslab rediscover a related OOM condition. Exploitation unproven, so unfixed.
- 1 Aug 2026 โ The discovery. Under OpenAI's "Patch the Planet," Calif's Ryan Austin audits PHP with an LLM and within days surfaces the first OOM-fatal and an interruption point leading to RCE.
- 11โ24 Aug 2026 โ Coordinated disclosure. Calif reports to PHP (11th), shares a working RCE PoC (14th), PHP confirms it (15th). Wiz, having independently found the bugs with AI, reports on the 24th.
- 17 Oct 2026 โ First seen in the wild. Amazon's MadPot honeypot records a php2shell attempt, four days before disclosure.
- 20 Oct 2026 โ The leak. Both CVE IDs circulate on Hacker News with a wrong CVSS (10.0). PHP moves its release forward by a day.
- 21 Oct 2026 โ The patch. PHP ships 8.5.12, 8.4.27, 8.3.36 and 8.2.35. CISA, NSA, UK NCSC and CERT-EU issue advisories; Calif, Wiz and OpenAI publish their writeups.
The AI angle._ The flaws are over a decade old and had survived years of scrutiny. What changed in 2026 wasn't the code โ it was the cost of proving exploitability. Wiz said a single researcher did in about a week what a world-class team would once have spent months on. Frontier models helped chain the bugs and adapt exploits across memory layouts. But Calif's takeaway is grounded: "know your target" still won. No model finds this from a naive prompt._
One pattern, two CVEs
Both vulnerabilities are memory-safety issues, and both follow the same structure. PHP marks state as live, performs some fallible work, then commits or initializes that state. The danger is a non-local fatal โ classically an out-of-memory condition hitting memory_limit โ firing during that fallible middle step.
When the fatal fires mid-step, the commit never runs, but the "this is live" marker was already set. Later, a cleanup routine or a subsequent request trusts that marker and consumes half-initialized or already-freed memory as if it were valid. The two CVEs differ only in where and when it happens.
- CVE-2026โ109618 โ RCE via OOM corruption. An allocation fails while request data is only partially initialized; request cleanup then processes that invalid state as valid, within the same request's teardown. Validated over HTTP against Apache mod_php.
- CVE-2026โ109619 โ RCE via stale global state. Request-owned state survives after its backing heap is freed; a later request on the same worker uses it before init resets it โ a cross-request use-after-free. Also enables Heartbleed-style memory disclosure. Validated on both mod_php and PHP-FPM.
Vendors slice the naming differently. Wiz groups four exploitable primitives under the two CVEs โ an upload-progress unserializer UAF, a memory-disclosure path, a fatal-error backtrace UAF and an OPcache type confusion. The engine fix addresses the pattern at its root, closing these and untracked variants together.
Corrupting a HashTable mid-insert
Let's get concrete with CVE-2026โ109618. Deep in the engine, _zend_hash_str_add_or_update_i() increments the table's element counters before it finishes building the new bucket:
/* _zend_hash_str_add_or_update_i() โ simplified */
idx = ht->nNumUsed++; // bucket now visible to teardown
ht->nNumOfElements++;
p = ht->arData + idx;
p->key = zend_string_init(str, len, persistent); /* may zend_bailout() */
p->h = ZSTR_H(p->key) = h; // never reached if the line above fatals
ZVAL_COPY_VALUE(&p->val, pData);/* _zend_hash_str_add_or_update_i() โ simplified */
idx = ht->nNumUsed++; // bucket now visible to teardown
ht->nNumOfElements++;
p = ht->arData + idx;
p->key = zend_string_init(str, len, persistent); /* may zend_bailout() */
p->h = ZSTR_H(p->key) = h; // never reached if the line above fatals
ZVAL_COPY_VALUE(&p->val, pData);Bumping nNumUsed first makes the new bucket visible to teardown. If zend_string_init() pushes the request over memory_limit, Zend's memory manager raises a fatal ending in zend_bailout(), and the key, hash and value assignments never happen. The array destructor then walks every slot up to nNumUsed โ including the half-built one โ and treats stale bytes as a live bucket:
/* zend_array_destroy() โ the teardown trusts the count */
for (p = ht->arData; p != ht->arData + ht->nNumUsed; p++) {
i_zval_ptr_dtor(&p->val);
if (p->key) {
zend_string_release(p->key); // stale / attacker-shaped
}
}/* zend_array_destroy() โ the teardown trusts the count */
for (p = ht->arData; p != ht->arData + ht->nNumUsed; p++) {
i_zval_ptr_dtor(&p->val);
if (p->key) {
zend_string_release(p->key); // stale / attacker-shaped
}
}Reaching the state: the OOM-fatal primitive
To trigger this on demand, an attacker has to exhaust memory_limit at a chosen allocation without being stopped by post_max_size first. Calif found two tricks:
- Bypass the size gate. PHP checks post_max_size against the declared Content-Length before parsing, but doesn't re-check the bytes it actually consumes. A chunked HTTP/1.1 (or lengthless HTTP/2) multipart body โ or an input-decompression filter โ breaks that assumption.
- Amplify memory with empty uploads. A file part with filename="" carries no data and isn't counted toward max_file_uploads, yet PHP still creates all six $_FILES entries. Nest the field name like files[a][b][c]โฆ and each entry expands into a hierarchy of HashTables, Buckets and string keys. A few hundred tiny records blow past the default 128 MiB heap while the body stays under the 8 MiB post_max_size.
The registration path runs during php_request_startup() โ before the target PHP file ever executes. That's why authentication can't save you: the engine processes the malicious request before a single line of application code runs.
Why Apache gets hit harder than FPM
The fatal happens, now what? It depends on your SAPI, because mod_php and PHP-FPM make opposite decisions after a startup failure.
/* mod_php: cleanup runs even after startup failure */
php_request_shutdown(NULL); // destroys malformed $_POST / $_FILES
// worker survives and is reused
/* --- */
/* PHP-FPM: startup failure exits the worker */
php_module_shutdown();
return FPM_EXIT_SOFTWARE; // no request-level table destruction/* mod_php: cleanup runs even after startup failure */
php_request_shutdown(NULL); // destroys malformed $_POST / $_FILES
// worker survives and is reused
/* --- */
/* PHP-FPM: startup failure exits the worker */
php_module_shutdown();
return FPM_EXIT_SOFTWARE; // no request-level table destructionApache mod_php is the high-risk target. After a failed startup it still runs request-level shutdown, reaching the poisoned teardown. The worker survives and handles the next request, giving the attacker a reusable, exploitable process. PHP confirmed full RCE here with default limits and normal prefork configs.
PHP-FPM discards the poisoned state by terminating the worker, so the simplest 109618 path doesn't land on FPM. But don't relax: for CVE-2026โ109619 the session-lifecycle path was validated on both, because that fatal happens after successful startup and FPM keeps the worker alive. FPM exploitation depends on a suitable application operation such as session_start(), unserialize(), or WordPress's maybe_unserialize().
To reach FPM, Calif leaned on an old friend โ PHP's generic unserializer. Its shared bookkeeping lives in BG(unserialize). A fatal mid-decode skips the destroy step, leaving a dangling pointer with a non-zero nesting level. A later upload-progress request reuses that stale bookkeeping, and a signed used_slots counter driven negative becomes a controlled write primitive.
The Heartbleed echo
RCE gets the headlines, but CVE-2026โ109619 has a quieter, arguably scarier mode: memory disclosure without code execution. Session upload-progress handling can initialize session state early and publish a session ID in PS(id). An OOM aborts the request before cleanup, the heap is freed, and PS(id) is left dangling.
A later multipart request reaches the upload-progress cleanup before session init resets that pointer. PHP trusts the stale pointer as a live string and URL-encodes the reclaimed heap into a PHPSESSID response cookie โ a chosen-size window into live worker memory. The app never has to call session_start().
That leaked memory can contain passwords, authentication tokens, API keys and TLS private keys belonging to other requests โ or the server itself. โ Wiz Research
Because this leaks secrets without running malicious code, it's harder to catch with tooling focused on process execution. If a vulnerable server was exposed before patching, assume its in-memory secrets could have walked out the door. Rotate them.
Versions, fixes and the long tail
The earliest affected code path Calif found dates to PHP 5.4.0 (2012). Nearly every PHP version since is affected by at least one of these issues.
- PHP 8.5โ8.5.0โ8.5.11 affected โ fixed in 8.5.12
- PHP 8.4โ8.4.0โ8.4.26 affected โ fixed in 8.4.27
- PHP 8.3โ8.3.0โ8.3.35 affected โ fixed in 8.3.36
- PHP 8.2โ8.2.0โ8.2.34 affected โ fixed in 8.2.35
- PHP 7.4โ8.1 โ no release; minimal source backports only
- PHP 5.4โ7.3 โ end-of-life, no fix
The hard part isn't your web server. PHP hides inside vendor appliances, control panels, Windows software bundling php.exe, and embedded installs that will never be patched. CISA warns that PHP is often a component of other software and its prevalence may not be directly observable. Inventory beyond the obvious.
Scores differ by source: PHP rates both CVSS v4.0 9.2; NVD scored them CVSS v3.1 9.8. The gap isn't only the version โ PHP rates attack complexity high, NVD low. Either way: critical.
What to actually do
There is exactly one complete fix: update and restart. Everything else buys time. Datadog reproduced the bugs and confirmed that disabling upload progress alone does not fully protect you.
- Patch to a fixed release โ 8.5.12, 8.4.27, 8.3.36 or 8.2.35 โ then restart Apache, PHP-FPM and every long-running worker. Updating WordPress or Drupal does not update the PHP runtime beneath it.
- Inventory every PHP install โ web servers, then appliances, containers, control panels and bundled runtimes. Record version and owner.
- Prioritize mod_php and internet-facing hosts. Don't defer FPM โ 109619 RCE was validated there too.
- If you truly can't patch yet (partial, temporary): for 109618 set memory_limit=-1 so apps can't override it (never substitute RLIMIT_AS/RLIMIT_DATA); for 109619 set session.upload_progress.enabled=0 and, on 8.5, fatal_error_backtraces=0 in php.ini or the FPM pool, then restart workers.
- Hunt from 17 October, not the 21st โ the first known exploitation predates disclosure by four days.
- Rotate exposed secrets on any server reachable before patching.
Detection and hunting
These bugs execute below the application layer, so app logs may be nearly empty. The highest-fidelity signal is behavioral: a PHP worker spawning something it never should.
# Suspicious children of a PHP worker
parent in (php-fpm, php-cgi, httpd, apache2, php-cgi.exe)
AND child in (
sh, bash, cmd.exe, powershell.exe, # shells
python, perl, # interpreters
curl, wget, nc, certutil.exe # download / net tools
)# Suspicious children of a PHP worker
parent in (php-fpm, php-cgi, httpd, apache2, php-cgi.exe)
AND child in (
sh, bash, cmd.exe, powershell.exe, # shells
python, perl, # interpreters
curl, wget, nc, certutil.exe # download / net tools
)Also flag reverse-shell patterns (/dev/tcp/, fsockopen, mkfifo) and quick recon (whoami, id, uname -a, ipconfig) launched straight from the web server.
- Unusual multipart/form-data requests followed by memory-limit errors or repeated HTTP 500s.
- Unexpectedly large or malformed PHPSESSID response cookies โ the disclosure tell.
- Unexplained PHP worker churn, crashes or restarts.
A single rule won't cover every path, and successful exploitation doesn't necessarily crash the worker. The absence of these signals does not prove you weren't hit. In a SIEM, start with any web-server or PHP parent spawning a shell or network utility, enriched with the keyword lists above, and pair it with a vuln-management sweep for PHP builds below the fixed versions.
Indicators:
PHP, CERT-EU and the NCSC published no IOCs. Amazon shared IPs tied to its honeypot probes but warns they are shared anonymization infrastructure โ investigate, don't blanket-block:
103.216.220[.]92 31.223.184[.]166 37.19.200[.]5
134.209.159[.]64 103.114.163[.]188 95.217.98[.]37103.216.220[.]92 31.223.184[.]166 37.19.200[.]5
134.209.159[.]64 103.114.163[.]188 95.217.98[.]37Furthermore, attempts were observed tried to use PHP2Shell to recruit systems into a Mirai variant called Potassium. Sophos calls it an updated, significantly expanded Mirai-derived botnet with downloader and network-disruption functionality. The botnet queries thunder.godofthunder[.]st, resolves to 134.199.154[.]64, and uses a C2 channel on TCP port 50792.
Also, SOCRadar found tooling on 206.188.197[.]105:9999, including aslr_exploit.py, labelled a "one-click" exploit for PHP 8.4.26 under Apache mod_php. It recycles the worker, leaks addresses via the session cookie to defeat ASLR, then abuses session upload progress to run a shell command. Hunt.io says that actor targeted 123 servers; about 57 run PHP, many on unsupported 5.xโ7.x, and none was exploited.
Beware fake PoCs. Unverified "exploit" code is a classic malware delivery vector. Don't run it on anything you care about.
Why this one is different
php2shell isn't remarkable because the bugs are new โ they're over a decade old. It's remarkable because AI collapsed the cost of turning a theoretically exploitable flaw into a reliable, cross-deployment exploit. The industry has long triaged bugs partly on how hard they'd be to weaponize. That filter just got a lot weaker.
The optimistic reading is that the same capability found and helped fix these before attackers probably did. The whole response โ PHP maintainers, Calif, Wiz, OpenAI and the big CDNs and clouds โ happened before public disclosure. That's the model worth repeating.
But the operational lesson is older than any of this: patch fast, inventory everything, assume the long tail, and watch what your web servers spawn. The ghost in PHP's memory manager was always there. Now everyone knows how to wake it.
Sources and further reading
- Calif โ php2shell technical writeup
- Wiz โ PHP2Shell: AI uncovers critical RCE
- OpenAI โ Patch the Planet coordinated disclosure
- Huntress โ Rapid Response: php2shell
- Tenable โ php2shell FAQ
- The Hacker News โ Exploit attempts hit millions, Mirai
- Datadog Security Labs โ Detection and remediation
- SOCRadar โ PHP2Shell
- PHP advisories โ GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp
Written for educational and defensive purposes.