/ 00 · OVERVIEWThe root cause, in two listings
PHP runs roughly three-quarters of the web, so a flaw in the interpreter itself is not a flaw in one application. It is a flaw in all of them at once. PHP2Shell is exactly that: two critical bugs in how the PHP engine aborts a request when it runs out of memory or hits a fatal error. Neither is a buffer overflow or a classic injection. Both come down to a single, almost mundane decision made deep in the engine's C code, and the clearest way to see why they happen is to read that code.
/* Zend HashTable insert — the array that backs $_POST and $_FILES.
Simplified from _zend_hash_str_add_or_update_i(). */
idx = ht->nNumUsed++; /* (1) the bucket is now "live" to the rest of PHP... */
ht->nNumOfElements++; /* ...before it holds anything */
p = ht->arData + idx;
p->key = zend_string_init(str, len, persistent); /* (2) may fail -> longjmp() straight out */
p->h = ZSTR_H(p->key) = h; /* (3) never reached if (2) bailed out: */
ZVAL_COPY_VALUE(&p->val, pData); /* key and val stay uninitialised */
longjmp()) that escapes this function entirely; step (3) never runs. The result is a bucket the count insists is valid but whose contents were never written. (Calif)The danger surfaces later, at the end of the request, when PHP tears the array down and trusts that count:
/* Request teardown — zend_array_destroy() walks every bucket the count claims exists */
for (p = ht->arData; p != ht->arData + ht->nNumUsed; p++) {
i_zval_ptr_dtor(&p->val); /* reads a value that was never written */
if (p->key) { zend_string_release(p->key); } /* frees a key that was never set */
}
nNumUsed and releases each bucket's value and key (including the half-built bucket from Listing 1), reading and freeing memory that was never initialised. (Calif)Publish a count, then do work that can fail, then let the failure skip the step that finishes the object, while a later consumer still trusts the count.
Everything else in this report (the second CVE, the web shells, the botnet traffic, the government intrusion) grows from that one pattern repeated across the engine.
/ 01Introduction
In the week of 21 October 2026 the PHP project shipped an emergency set of releases and two GitHub Security Advisories for a pair of critical, unauthenticated remote-code-execution flaws that the research community had already nicknamed PHP2Shell. We reviewed the two advisories, the engine-level write-up from Calif, and the vendor telemetry published in the days that followed, and reproduced the root-cause reasoning against public PHP source so that we could describe the defect precisely without publishing anything that advances exploitation. This report is the result.
Our goal is narrow and defensive. We explain why these bugs exist at the level of engine logic, how attackers are turning them into code execution and what they do next, and what Saudi operators and defenders should do about it: patch guidance, detection content, indicators of compromise, and an ATT&CK mapping. We have deliberately withheld the material that would turn this into a recipe: there are no tuned request payloads, no heap-grooming steps, no memory offsets, no ASLR-bypass chain and no web-shell code here. The illustrative listings above and below are root-cause explanation, nothing more.
/ 02Key findings
Two critical CVEs, one design flaw.
PHP uses a non-local jump (longjmp() via zend_bailout()) to abort a request on out-of-memory or fatal errors. CVE-2026-109618 (RCE via OOM corruption) leaves request memory half-built when that jump fires mid-allocation; CVE-2026-109619 (RCE via stale global state) leaves a worker's global pointers aimed at a request heap that has already been freed. Both carry CVSS v4.0 9.2 (Critical), both are network-reachable and require no authentication (GHSA-w8hw-w9fx-j79w; GHSA-6wmf-4rv5-p3rp).
Every web-serving PHP is in scope, mod_php worst.
The flaws affect Apache mod_php, PHP-FPM, php-cgi, FrankenPHP and LiteSpeed; PHP rates the risk highest on Apache mod_php, where the bad state is consumed during request startup, before any application code runs (php.net).
The code is old; the exploit is new.
The vulnerable paths reach back to PHP 5.4.0 (2012). Fixes landed on 21 October 2026 in 8.5.12, 8.4.27, 8.3.36 and 8.2.35, with source backports for the end-of-life 7.4, 8.0 and 8.1 branches. Only updating closes every variant (php.net).
Exposure is enormous, by any definition.
Palo Alto's Unit 42 counts over 22.6 million exposed instances potentially vulnerable to both CVEs via Cortex Xpanse; VulnCheck counts roughly 403,000 internet-facing hosts on supported branches that have a patch available, out of ~2.6 million PHP hosts it sees (Unit 42; VulnCheck).
Attempts are confirmed and widespread; compromises are not.
The first attempts hit sensors on 17 October, four days before disclosure, and scanning escalated within hours of the patch. But as of 23 October, multiple vendors (watchTowr, SOCRadar's own screening, CISA's KEV catalogue) report no confirmed successful compromise of a production target, with one exception: Unit 42's account of a single European-government intrusion driven by agentic AI tooling (SecurityWeek; Unit 42).
AI is the force multiplier.
Memory-corruption exploits normally need hand-tuning for every build and allocator layout. Wiz and VulnCheck both report that AI assistance collapses that specialist effort to hours, which weakens the "high complexity" in the CVSS vector as a real-world defence (Wiz; VulnCheck).
Security leads at regulated Saudi SMEs and mid-market firms running PHP applications; DFIR and SOC analysts building detection; and platform and hosting teams responsible for PHP fleets.
/ 03The non-local jump at the heart of PHP2Shell
To understand both CVEs you only need to understand one mechanism. PHP handles fatal errors (including out-of-memory) with zend_bailout(), a wrapper around the C standard-library function longjmp(). When the engine wraps a critical section in zend_first_try { ... } zend_end_try();, it is setting up a setjmp/longjmp pair; an allocation failure inside that block jumps straight to the request-shutdown handler and skips every step in between. Any structure being built at that instant is simply abandoned where it stands (Calif; GHSA-w8hw-w9fx-j79w).
A non-local jump like this is safe only under one condition: that every allocation sitting between "make this object visible" and "finish building this object" is infallible. PHP's OOM-via-longjmp design broke that invariant everywhere such a window exists. The advisory states it plainly:
"In some contexts, request memory is temporarily reachable before it is fully initialized. If any allocation fails at that point, PHP does not properly account for the early exit and leaves the request memory in an invalid state... The request shutdown procedure may then read or branch on it."
(GHSA-w8hw-w9fx-j79w) Crucially, the allocation that fails need not be related to the memory that becomes invalid: an attacker can force an unrelated, chosen allocation to be the one that tips the request over its memory limit.
PHP does not classify this as a single weakness but as a cluster: use-after-free (CWE-416), improper handling of exceptional conditions (CWE-755), access of an uninitialised pointer (CWE-824), improperly controlled sequential memory allocation (CWE-1325) and incorrect initialisation of a resource (CWE-1419) (GHSA-w8hw-w9fx-j79w). Two failure modes follow from the same design, and they map onto the two CVEs.
/ 04CVE-2026-109618: counting a bucket before it exists
The first mode is the one in Listings 1 and 2: a failed allocation interrupts a context that has already published its memory but has not yet filled it, and shutdown then reads or branches on that invalid data. The HashTable insert is the canonical example because the HashTable is the structure that backs PHP's superglobal arrays, and it is populated during request startup, before any application script runs, as the engine registers request variables. The producing call chain runs entirely inside php_request_startup() (Calif):
URL-encoded POST root[x]=v
php_std_post_handler() -> php_register_variable_ex()
-> zend_symtable_str_update_ind() -> _zend_hash_str_add_or_update_i() /* Listing 1 */
RFC1867 upload root[x], filename="..."
rfc1867_post_handler() -> register_http_post_files_variable_ex()
-> php_register_variable_ex() -> ... -> _zend_hash_str_add_or_update_i()
The remaining question is how an attacker reaches an out-of-memory condition while staying inside PHP's apparent limits, so that the request is accepted and parsed rather than rejected up front. The advisory lists the conditions at a conceptual level: a multipart request with no usable declared content length can pass the post_max_size check yet keep supplying body data; a part may carry an unbounded number of headers whose in-memory representation dwarfs their wire size; and empty file controls do not count toward max_file_uploads but still create all six $_FILES metadata entries, which nested field names multiply into many allocations (GHSA-w8hw-w9fx-j79w; Calif). We describe these only in principle; the point for defenders is that the trigger is a multipart request, and the fault lands in request setup, not in a script.
Capping memory with operating-system limits (RLIMIT_AS, RLIMIT_DATA, Apache RLimitMEM, systemd LimitAS= or ulimit -v) makes allocations fail at the limit, and that failure takes the vulnerable longjmp() path. These limits make exposure worse, not better.
/ 05CVE-2026-109619: a worker that remembers a freed request
The second mode is subtler and, on modern PHP 8.5, the stronger one. A PHP worker process handles many requests in sequence. If one request stores a pointer to its own request-owned memory in a worker-lifetime global, and a fatal error skips the step that would clear that global, the request heap is freed while the global still points into it. The next request on the same worker can then touch that dangling pointer before normal initialisation replaces it. The advisory frames this as a producer and a consumer, and the essence reduces to a few lines:
/* CVE-2026-109619, reduced to its essence (illustrative pseudo-code) */
/* --- Request N: the "producer" --- */
GLOBAL.ptr = request_owned_allocation(); /* publish a pointer into THIS request's heap */
...
longjmp(); /* a fatal error (often OOM) escapes here... */
GLOBAL.ptr = NULL; /* <-- ...so this reset never runs */
free_request_heap(); /* heap released; GLOBAL.ptr now dangles */
/* --- Request N+1 on the SAME worker: the "consumer" --- */
if (GLOBAL.ptr) { /* runs BEFORE normal re-initialisation clears it */
use(GLOBAL.ptr); /* read / free / branch on freed memory */
}
PHP gives five conditions for a concrete instance: (1) a reachable operation stores request-owned state in a globally accessible location; (2) that state is not reset, or is repopulated after its normal reset; (3) PHP frees the request memory while the persistent worker keeps the reference; (4) the same worker handles another request; (5) an operation reads, frees or branches on the stale state before normal initialisation resets it (GHSA-6wmf-4rv5-p3rp).
Producers are fatal interruptions that abandon an operation before it tidies up: parse and compile failures, uncaught errors, a failed require, Maximum execution time exceeded, and OOM after state was already published. Consumers are operations that run before a request is fully initialised. Two are confirmed. The first is INI diagnostics: a warning, notice or deprecation raised while applying configuration can consume stale error state; it need not be fatal, displayed or logged, so error_reporting, the @ operator, display_errors and log_errors offer no protection. The second is session upload progress, which can reach session handling before ordinary session initialisation, is enabled by default, and does not require the application to call session_start() (GHSA-6wmf-4rv5-p3rp).
Three concrete cases belong to this CVE, and this is where published aggregators frequently mis-map the two identifiers. The authoritative mapping from php.net and the GHSAs places all of the following under 109619, not 109618:
- Fatal-error backtrace (PHP 8.5, the strongest demonstrated case). With the new
fatal_error_backtracessetting enabled, fatal-error reporting stores backtrace state owned by the request, and "resetting of this global state is always missed during shutdown." A diagnostic on a later request reaches the stale value first. The advisory calls this "the basis of the confirmed mod_php exploitation." The exact C field name circulating in secondary summaries is unconfirmed in a primary source; the behaviour (a reset that is always skipped) is confirmed (GHSA-6wmf-4rv5-p3rp; php.watch). - Session upload-progress lifecycle. Request startup parses input before modules are activated, and shutdown gates module cleanup on the same flag, so an early failure frees the heap while the session globals still point into it. Listing 5 shows why the reset is skipped.
- Generic unserializer bookkeeping (the PHP-FPM path). An interrupted
unserialize()leaves the standard-extension globals pointing at a freed decoder structure with a non-zero nesting level, which the next request's session handling treats as a live decode. Reached in real apps throughsession_decode(),unserialize(), and framework helpers such as WordPress'smaybe_unserialize()(Calif). The downstream memory-reclamation primitive this enables is deliberately omitted from this report.
/* main/main.c — request startup parses input BEFORE modules are "activated" */
PG(modules_activated) = 0;
php_hash_environment(); /* parses multipart input; session upload-progress runs here */
zend_activate_modules(); /* normal module RINIT */
PG(modules_activated) = 1;
/* php_request_shutdown() — extension shutdown is gated on that same flag */
if (PG(modules_activated)) { /* still 0 if we failed early, so... */
zend_deactivate_modules(); /* ...the session RSHUTDOWN never runs */
}
shutdown_memory_manager(...); /* ...yet the request heap is freed anyway */
PG(modules_activated) is set, and shutdown only runs module cleanup when that flag is set, a worker can survive with session globals still pointing into a freed heap. (Calif)We note in passing that some secondary coverage attributes an additional OPcache map_ptr type-confusion case to this family. We could not confirm that case in any primary source (the two GHSAs and the Calif write-up), and we do not present it as confirmed.
/ 06What the patch changed
The fix is as instructive as the bug, and it shows why no configuration change is a full substitute for it.
/* CVE-2026-109618 */
/* BUG: OOM unwinds immediately, in the middle of an operation */
if (allocation_failed) longjmp(bailout); /* escapes with half-built state */
/* FIX: defer the unwind to a safe point */
if (allocation_failed) set_soft_oom_flag(); /* raise a flag, keep going */
/* ...the unwind now happens only at the next VM safe point, where no */
/* half-built bucket is observable; a second, higher threshold calls _exit(). */
/* CVE-2026-109619 */
/* FIX: reset request-owned globals UNCONDITIONALLY at the request boundary, */
/* regardless of how the previous request ended */
GLOBAL.ptr = NULL;
longjmp() with a delayed "soft OOM" raised at the next VM safe point (plus a hard _exit() ceiling); 109619 resets the wider request-owned state so nothing survives into operations that precede normal initialisation. (GHSA-w8hw-w9fx-j79w; GHSA-6wmf-4rv5-p3rp)The soft-OOM design restores the invariant that the original longjmp() broke: because unwinding now occurs only at a VM safe point, shutdown never observes a half-built bucket or a half-published global. PHP states bluntly that for 109619 "updating to a release containing the complete state reset patch is the only reliable mitigation". There is no generic configuration that closes every producer and consumer (GHSA-6wmf-4rv5-p3rp).
/ 07Affected versions and SAPIs
The vulnerable session upload-progress path dates to PHP 5.4.0; the HashTable case is reachable from PHP 7.3 onward per Calif. Fixes shipped on 21 October 2026.
| Component | Affected | Fixed in |
|---|---|---|
| PHP 8.5 | < 8.5.12 | 8.5.12 |
| PHP 8.4 | < 8.4.27 | 8.4.27 |
| PHP 8.3 | < 8.3.36 | 8.3.36 |
| PHP 8.2 | < 8.2.35 | 8.2.35 (branch EOL 31 Dec 2026) |
| PHP 7.4 / 8.0 / 8.1 | all releases | source backports only (branches otherwise unsupported) |
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H. The raised attack complexity (AC:H) and required attack prerequisites (AT:P) reflect the need to land OOM precisely and reach the right worker, not any need for credentials. (GHSA-w8hw-w9fx-j79w; Calif; php.net)The server API (SAPI) in front of PHP decides how reachable the flaw is, because each SAPI handles a failed request differently:
| SAPI | CVE-2026-109618 | CVE-2026-109619 |
|---|---|---|
| Apache mod_php | Highest risk. Calls request shutdown even when startup failed, consuming the bad state before any application code runs. Application-independent and pre-auth. | RCE validated (backtrace and session paths). |
| PHP-FPM | Child exits on startup failure, removing itself from the pool; susceptible via a post-startup OOM producer by source analysis. | Session path validated on a normal dynamic pool; INI consumer only via front-end-supplied values. |
| FrankenPHP | Calls shutdown unconditionally like mod_php; likely susceptible to application-independent attacks. | No early consumer found in the default config. Not immune. |
| LiteSpeed / LSAPI | Models FPM; application-specific exploits likely but unvalidated. | Conditional, via early environment INI values. |
| php-cgi | Listed by PHP as at risk. | Listed by PHP as at risk. |
The mod_php-versus-FPM difference is itself a clean illustration of the mechanism: on mod_php the startup-failure path runs php_request_shutdown(NULL), so the consumer is reachable with no authentication and no application cooperation, whereas FPM's fpm_main.c handles startup failure by shutting the module down and returning FPM_EXIT_SOFTWARE, killing the worker so that no consumer runs (GHSA-w8hw-w9fx-j79w; Calif).
/ 08How attackers weaponise it
We describe the attack at the level a defender needs to recognise it, not reproduce it. The observed and expected chain is:
Fingerprinting. VulnCheck's canary network recorded reconnaissance preceding exploitation: probes of / and /index.php, requests for /phpinfo.php and /info.php, and broad browser-user-agent sweeps. Distinctive scanner user-agents included php2shell-scan/109618, php2shell-scan/1.3 (eigen-oppervlak controle; CVE-2026-109618/109619), php2shell-verify/1.0 and Mozilla/5.0 (php2shell-poc) (VulnCheck).
Forcing the fault. Most observed attempts abuse PHP's session upload-progress path through multipart/form-data requests (per Hadrian via SecurityWeek). Behaviourally, Datadog describes the chain as: a request fails during initialisation and produces a memory-exhaustion error; shutdown destroys $_POST; PHP reads an unfinished upload bucket as a valid array; and cleanup invokes a callback that ultimately runs a command. Calif demonstrated that hundreds of empty file-upload fields can exhaust the default memory limit while staying within the upload size limit (SecurityWeek; Datadog; Island.io).
Calibration bursts. Because the exploit is fragile with respect to heap layout, ASLR and display_errors, attackers calibrate before firing. VulnCheck's telemetry shows the sequence clearly: a scan burst, then "verification" checks (php2shell-verify/1.0), then a short exploitation-attempt window. That is a fingerprint pass to confirm the version and memory conditions before the trigger (VulnCheck).
The optional leak. On the 109618 disclosure path, a memory-disclosure variant returns reclaimed worker memory to the attacker. Wiz demonstrated the server returning a chunk of its own memory inside a session cookie; Datadog describes PHPSESSID leaks supplying valid addresses to defeat ASLR. This stage matters twice over: it is what turns an opportunistic failed attempt into reliable code execution, and it can exfiltrate secrets without any code execution at all. Another request's passwords, tokens and keys can leave the server in a cookie (Island.io; Datadog).
Exploit maturity. The picture is uneven and worth stating honestly. VulnCheck holds a weaponised exploit for 109618 (customers only) that chains a memory leak into RCE on PHP 8.4.26, x86-64, with ASLR enabled and display_errors=off; it found one public GitHub proof-of-concept that bypassed ASLR for that exact build and default heap configuration only, and failed elsewhere. Sansec reports that the public exploits it saw do not work against a default install and mostly need a separate file-inclusion bug in the target application. The consistent theme across sources is that reliable, portable RCE remains hard today, but is expected to generalise, and that AI assistance is what shortens the per-build adaptation (VulnCheck; Sansec; Wiz).
/ 09The second stage: web shells, botnets and an agentic intrusion
Once code runs inside the PHP worker, reaching the operating system follows the same patterns as any PHP RCE, and this is where host telemetry sees the attack regardless of how clever the memory corruption was. A successful execution in VulnCheck's observed attempt printed the marker string PWNED:: in the HTTP response; the follow-up requests tried to read a dropped session file and all returned 404, which VulnCheck recorded as an unsuccessful drop (VulnCheck).
How a web shell is deployed and used (behaviour, not code)
In the attempts observed so far, the attacker writes a small PHP file into a path the worker can serve (commonly the document root or a session/upload directory) so that subsequent commands can be sent over ordinary HTTP and survive the worker restart that the exploit tends to cause. Defenders do not need the shell's source to catch this: the tell-tales are a new .php file appearing with no change-management record, and, far more reliably, the PHP worker spawning a child process (a shell or an interpreter) to run the attacker's commands. We describe the shell only at this behavioural level by design.
Commodity botnet
Researchers tied PHP2Shell attempts to a Mirai-derived DDoS botnet and demonstrated an ASLR bypass (SecurityWeek). A named "Potassium" botnet with specific file and DNS indicators has circulated in secondary reporting; we could not confirm that name, its attribution, or those indicators in any primary source, and treat them as unverified. The generic Mirai second-stage pattern (a dropper fetches an architecture-matched binary, the host joins a DDoS swarm and scans for more victims) is well established for PHP and IoT targets (Qualys TRU).
Targeted, agentic intrusion
The most serious reported case is Unit 42's. At a European-government target, within two hours of initial exploitation the attacker reached password hashes and MFA secrets, stood up an NTLM-relay tunnel into Active Directory, and attempted Domain Admin escalation. Reconnaissance ran through a web shell in short, serially-spaced batches with quick pivots after failures, and the command output was wrapped in section-header delimiters (==SHELLUSER== and ==PASSWD==) producing machine-parseable text for an AI model to consume. Unit 42 attributes the post-exploitation to agentic AI attack platforms, and notes a European government took its website offline as a precaution (Unit 42). (Unit 42's 23 October brief contains a typo rendering the second identifier as "CVE-2019-109619"; the correct identifier is CVE-2026-109619.)
The two profiles (opportunistic botnet operators and at least one capable, AI-assisted actor) are consistent with multiple actors using the same bug, which SOCRadar also infers from the variety of payloads and the shared anonymisation infrastructure (SOCRadar).
/ 10Impact
PHP2Shell sits below the application, and that single fact drives its severity. Patching your application, adding a framework WAF rule, or hardening your code does not remove it. Sansec notes its own Shield rules run after the vulnerable code, and Calif reports the condition reachable behind Magento, WordPress, Drupal, Joomla, phpMyAdmin, Nextcloud, Laravel, Symfony and others. On mod_php the bad state is consumed during request startup, so application logs may hold little context. A successful attacker gets code execution as the web worker account (www-data, apache, nginx, wwwrun, iusr, or an IIS app-pool identity), and from there the usual path is application config and .env files, database credentials, cloud metadata and API keys, then lateral movement. The Unit 42 case shows that reaching hashes and MFA secrets can take two hours, not days (Sansec; Calif; Unit 42).
Two consequences deserve emphasis for Saudi operators weighing their exposure:
- The memory-disclosure path leaks even without a shell, so any worker that has been internet-exposed since mid-October should have its reachable secrets treated as potentially compromised.
- Failure is cheap for the attacker: a crashed worker is simply replaced, so brute-force calibration costs almost nothing, which is why honeypots see hundreds of near-identical attempts from a single source.
/ 11Timeline
The weakness class is not new. Interrupting PHP through memory_limit was documented as far back as 2004, and a 2024 OSTIF/Quarkslab audit noted early signs of the OOM problem without proving exploitation. The exploitable chains, and the coordinated response, came in 2026.
- 2004
Interrupting PHP through
memory_limitis first documented. - 2024
An OSTIF/Quarkslab audit notes early signs of the OOM problem, without proving exploitation.
- 11 AUG 2026
Calif reports a first bug to PHP (under OpenAI's "Patch the Planet" effort).
- 24 AUG 2026
Wiz reports its findings to PHP (same effort).
- 17 OCT 2026
Amazon's honeypots record the first exploitation attempts, four days before the patch, from commercial anonymisation infrastructure.
- 20 OCT 2026
Details leak publicly and a proof-of-concept video is mistakenly published ahead of schedule, which the PHP Foundation says accelerated PoC development. VulnCheck reproduces the bugs that evening.
- 21 OCT 2026
PHP releases 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 with both advisories. Cloudflare, which shipped WAF protection at 03:02 UTC hours ahead of the release, observes attackers mutating payloads to evade it.
- 23 OCT 2026
PHP publishes an informational advisory on a third, lower-severity variant, CVE-2026-115560 (a session-upload-progress edge case when
session.serialize_handleris switched at runtime), to be handled in the regular release cycle. The same day, Unit 42 publishes its post-exploitation observations.
Sources: php.net; SecurityWeek; VulnCheck; Wiz; Unit 42.
/ 12Recommendations
For site operators and developers (prevention)
- Update. This is the only complete fix. Move internet-facing Apache mod_php first, then PHP-FPM, FrankenPHP and LiteSpeed, then internal systems, to 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 or a vendor backport whose changelog names
CVE-2026-109618. A backport keeps its old version number, so verify by changelog, not byphp -v(php.net). - Restart every worker. Restart PHP-FPM, Apache or the container, and rebuild any image that bundles PHP, so no old worker survives.
- Apply the stop-gaps only as a bridge, never as a destination. If you genuinely cannot patch yet: set
session.upload_progress.enabled=0inphp.inior pool config (notini_set()), setfatal_error_backtraces=0on PHP 8.5, lowerpost_max_size,max_file_uploadsandmax_input_nesting_level, and blockmultipart/form-dataat the edge where the app does not use it. PHP's own developers warn each setting removes only one producer or consumer (GHSA-6wmf-4rv5-p3rp; php.net). - Do not cap memory with OS limits.
RLIMIT_AS,RLIMIT_DATA, ApacheRLimitMEM, systemdLimitAS=andulimit -vtrigger the vulnerable path and make things worse. - Inventory every PHP you run, including bundled copies inside appliances and legacy servers outside the patch cycle.
For defenders (detection and response)
- Lead with the host signal. A PHP worker spawning a shell or interpreter is the single strongest indicator; build and tune the rule in the next section before anything else.
- Rotate exposed secrets. Treat memory disclosure as possible for any worker internet-exposed since 17 October: rotate database, API, session and signing keys, and any reachable TLS keys, and review access logs for that window.
- After a confirmed hit, isolate and preserve the host and its memory, rebuild from a known-good image where a shell ran, and do not rely on deleting the web shell alone.
- Harden to limit the next one: run workers as low-privilege accounts in per-site pools or containers, deny outbound connections from web servers by default, keep secrets out of files the worker can read, and alert on any child process of a PHP worker.
/ 13Detection content
The exploit runs inside the engine, so network signatures catch attempts and host telemetry catches success. The exploit can return HTTP 200 and leave the worker running, so detection must be behaviour-led, not response-code-led (Datadog).
Tier 1: host process lineage (highest value)
Alert when any PHP worker spawns a shell or interpreter:
title: PHP worker spawns shell or interpreter (possible PHP RCE / PHP2Shell)
logsource:
category: process_creation
detection:
parent_php:
ParentImage|endswith: ['/php-fpm', '/php-cgi', '/httpd', '/apache2', '/frankenphp', '/lsphp']
ParentImage|contains: 'php' # covers php-fpmN.N, lsphpNN
parent_iis:
ParentImage|endswith: '\w3wp.exe' # PHP under IIS
child_exec:
Image|endswith: ['/sh', '/bash', '/dash', '/python', '/python3', '/perl',
'/ruby', '/node', '\cmd.exe', '\powershell.exe', '\pwsh.exe']
condition: (parent_php or parent_iis) and child_exec
falsepositives:
- Monitoring plugins (Nagios, Zabbix, LibreNMS)
- Hosting panels (cPanel, Plesk, DirectAdmin)
- Phusion Passenger spawn helpers
level: high
potential_web_shell_v2 / potential_web_shell_parent_v2 and Cloud SIEM rule def-000-9h1. (Datadog; Unit 42)Tier 1b: second-stage behaviour (XQL-style logic)
Raise severity when the child of a PHP worker also matches Unit 42's signal categories: any standalone revshell / pipe_to_shell / writable_exec, OR three or more distinct recon tools, OR recon plus a follow-on signal.
| Signal | Matches |
|---|---|
revshell / pipe_to_shell | /dev/tcp/, nc -e, socat ... exec:, mkfifo, sh -i, pty.spawn(, curl ... | sh |
writable_exec | Execution from /tmp, /var/tmp, /dev/shm, a memfd: or a (deleted) image |
download / staging | curl -o, wget to a file, certutil -urlcache, bitsadmin /transfer, then chmod +x in a temp path |
cred_access | Reads of .env, .aws/credentials, /proc/<pid>/environ, 169.254.169.254, metadata.google.internal |
recon | Bursts of whoami, id, uname -a, cat /etc/passwd, ip a, ps aux from the web account, especially when output is wrapped in section markers |
Tier 2: log strings
The strongest log tell is a memory-exhaustion error located in startup rather than a script:
# PHP / server error logs: OOM in request startup, not in a script (Datadog-confirmed)
PHP Fatal error: Allowed memory size of 16777216 bytes exhausted ... in Unknown on line 0
# Apache worker segfault logged when an exploit guessed an address wrong (Datadog-confirmed)
AH00052: child pid NNNN exit signal Segmentation fault (11)
The in Unknown on line 0 location means the failure happened before any script ran; treat a short burst on one worker as possible setup activity even without a crash. Patched builds may log a worker _exit(137) when memory crosses the new hard ceiling, which is useful only when correlated with the above, not as a standalone signal. (The strings zend_mm_heap corrupted and bare "SIGSEGV", and 500/502 bursts as a primary signal, circulate in some write-ups but are not supported by Datadog's article; treat them as unverified.) (Datadog)
Tier 3: network / WAF (attempt detection)
Alert on multipart/form-data bodies with hundreds of parts, a multi-megabyte fill field, or deeply nested machine-generated field names; on PHPSESSID and PHP_SESSION_UPLOAD_PROGRESS fields in apps that do not use them; on stalled uploads (a large Content-Length that never completes); and, as the memory-disclosure tell, on unusually long, percent-encoded or binary Set-Cookie: PHPSESSID= values, including on pages that never start a session. WAF engines may truncate bodies or miss chunked content, so verify what yours actually inspects. Vendor coverage shipped quickly: Palo Alto Threat ID 543004 (plus best-practice signatures 97622–97625), and managed rules from Cloudflare, F5, Fastly and Imperva. That is virtual patching, not a fix (Datadog; Unit 42).
/ 14Summary
PHP2Shell is a textbook demonstration of a rule the Zend engine quietly violated for over a decade: a non-local jump is only safe if nothing between "publish" and "finish" can fail. Two CVEs, two listings (a count raised before a bucket is filled, and a global pointer left aimed at a freed heap) account for the entire class, and the patch is as simple to describe as the bug: defer the unwind to a safe point, and reset request-owned state unconditionally.
For defenders the practical shape is equally simple. Patch to the October releases and restart every worker; nothing short of that is reliable. Until then, watch the one thing the attacker cannot hide no matter how elegant the memory corruption: a web server process spawning a shell. Exploitation attempts are already global and began before the patch existed; reliable, portable RCE is still hard today but is being generalised with AI assistance, so the window in which "high complexity" protects an unpatched host is closing.
Treat any internet-facing PHP as a priority, and treat the secrets it has touched as already spent.
/ APPENDIX AIndicators of compromise
All network indicators are defanged. Confidence reflects how specific each indicator is to this activity.
| Indicator | Type | Confidence | Note |
|---|---|---|---|
PHP worker spawns sh/bash/python/perl/node/php -r/cmd/powershell | Behaviour | High | The core detection (Listing 7) |
Output wrapped in ==SHELLUSER==, ==PASSWD== section markers | Behaviour | High | Unit 42: machine-parseable output for an AI model |
php2shell-scan/109618 | User-Agent | High | VulnCheck scanner |
php2shell-scan/1.3 (eigen-oppervlak controle; CVE-2026-109618/109619) | User-Agent | High | VulnCheck scanner |
php2shell-verify/1.0 | User-Agent | High | VulnCheck verification pass |
Mozilla/5.0 (php2shell-poc) | User-Agent | High | VulnCheck PoC |
PWNED:: in any HTTP response body | String | High | VulnCheck: marker of successful execution |
Multipart boundary ------p2s9rdme2st6guc9g0rafic; upload filename p2s.txt | Network | Medium | VulnCheck observed attempt |
Request path /download.php?file=php://filter/... | Network | Medium | VulnCheck: session-file read attempt |
Session file sess_xqsuvwz3n97wu8j3emz1axuo5y (PHPSESSID xqsuvwz3n97wu8j3emz1axuo5y) | Host | Medium | VulnCheck observed attempt |
Allowed memory size ... exhausted ... in Unknown on line 0 | Log string | Medium | OOM in startup; also benign under heavy load |
Oversized / malformed Set-Cookie: PHPSESSID= on multipart responses | Network | Medium | Memory-disclosure signature (Wiz/Datadog) |
New .php file in web root or /tmp with no change record | Host | Medium | Possible web shell |
85[.]11[.]167[.]243 (AS209630, NL) | IP · scanning | Low | VulnCheck, 21 Oct; hit 188 canaries |
45[.]198[.]224[.]169 (AS215925) | IP · probing | Low | VulnCheck, 21 Oct |
109[.]71[.]252[.]97 (Datalix, DE) | IP · probe | Low | VulnCheck, 21 Oct |
221[.]14[.]159[.]134 (AS4837, CN) | IP · exploit attempt | Low | VulnCheck, 21 Oct (drop 404'd) |
91[.]90[.]123[.]25 (AS9009, M247) | IP · verification | Low | VulnCheck, 21 Oct |
Scanning sources sit largely on commercial anonymisation infrastructure, which SOCRadar notes points to multiple actors. Use these IPs for retrospective log search, not blocklisting. Unverified indicators to avoid over-trusting: the "Potassium" botnet name and its specific file/process/DNS artifacts (e.g. /tmp/nfs, fallback resolvers) circulate in secondary reporting but are not confirmed in any primary source; only a generic Mirai-variant tie-in is confirmed (VulnCheck; SOCRadar; SecurityWeek).
/ APPENDIX BMITRE ATT&CK mapping
No retrieved primary source published an explicit ATT&CK matrix; the mapping below is Cignify's analytical derivation, each technique tied to observed behaviour (Unit 42; VulnCheck; Datadog).
| Tactic | Technique |
|---|---|
| Initial Access TA0001 | Exploit Public-Facing Application (T1190) |
| Execution TA0002 | Command and Scripting Interpreter (T1059), incl. Unix Shell (T1059.004), PowerShell (T1059.001) |
| Persistence TA0003 | Server Software Component: Web Shell (T1505.003), Scheduled Task/Job: Cron (T1053.003), Account Manipulation: SSH Authorized Keys (T1098.004), Create Account (T1136), Create or Modify System Process (T1543) |
| Privilege Escalation TA0004 | Valid Accounts: Domain Accounts (T1078.002) |
| Defense Evasion TA0005 | Reflective / memfd execution (T1620), File and Directory Permissions Modification (T1222), writable-path execution from /tmp, /dev/shm, memfd |
| Credential Access TA0006 | OS Credential Dumping (T1003), Unsecured Credentials: Credentials in Files (T1552.001) / Private Keys (T1552.004) / Cloud Instance Metadata API (T1552.005), Multi-Factor Authentication Interception (T1111), Forced Authentication / NTLM relay (T1187) |
| Discovery TA0007 | File and Directory Discovery (T1083), System Information Discovery (T1082), System Owner/User Discovery (T1033), System Network Connections Discovery (T1049), Remote System Discovery (T1018), Account Discovery (T1087), Network Service Discovery (T1046) |
| Lateral Movement TA0008 | Remote Services: SMB/Windows Admin Shares (T1021.002), Use Alternate Authentication Material: Pass-the-Hash (T1550.002) |
| Command and Control TA0011 | Application Layer Protocol (T1071), Non-Application-Layer Protocol (T1095), Non-Standard Port (T1571), Ingress Tool Transfer (T1105) |
| Exfiltration TA0010 | Exfiltration Over C2 Channel (T1041) |
| Impact TA0040 | Network Denial of Service (T1498), Endpoint Denial of Service (T1499), Mirai-derived second stage |
/ REFERENCESSources
Primary sources
- php.net: 2026 security advisories, release notes and 23 Oct informational advisory
- GHSA-w8hw-w9fx-j79w: RCE via OOM corruption (CVE-2026-109618)
- GHSA-6wmf-4rv5-p3rp: RCE via stale global state (CVE-2026-109619)
- Calif: PHP2Shell engine-level analysis
- Wiz Research: PHP2Shell technical analysis
- Unit 42: threat brief on CVE-2026-109618 / CVE-2026-109619 (updated 23 Oct)
- Sansec: PHP2Shell research and EOL backport table
- VulnCheck: PHP2Shell canary telemetry and exploit status
- Datadog Security Labs: detection content and log signals
Additional reporting (secondary; claims attributed in text)
- SecurityWeek: patches land as first exploitation attempts hit sensors (Amazon, watchTowr, Hadrian, Mirai tie-in)
- SOCRadar: PHP2Shell overview and screening
- Island.io: memory-disclosure and multipart-OOM behaviour
- php.watch: PHP 8.5 fatal_error_backtraces
- Qualys TRU: generic Mirai second-stage pattern on PHP/IoT targets
The "Potassium" botnet name and its file/DNS IOCs (unconfirmed in any primary source; only a generic Mirai tie-in is confirmed); the OPcache map_ptr type-confusion case (not found in a primary source); the exact C symbol for the PHP 8.5 fatal-error backtrace global (behaviour confirmed, identifier not); and the zend_mm_heap corrupted / bare-SIGSEGV / 500-502-burst log signals (not supported by Datadog).