On 21 October 2026 the PHP project shipped an out-of-cycle security release and disclosed two critical vulnerabilities in the core engine. The pair got the nickname PHP2Shell, and together they let an unauthenticated attacker run code on a large share of the web. This article is about the second of the two, CVE-2026-109619, “RCE via stale global state.” Its sibling, CVE-2026-109618, has its own write-up.
Both carry a CVSS v4.0 base score of 9.2 (Critical), and both were announced by Jakub Zelenka on behalf of the PHP security team on the oss-security mailing list at 16:44 CEST on 21 October. The reference document is GitHub Security Advisory GHSA-6wmf-4rv5-p3rp.
What follows is what the bug actually is, at the level of memory inside a running application, rather than just the headline.
The one-sentence version
Here is how the PHP security team described it in the advisory and the mailing-list announcement:
“PHP maintains global state that temporarily points into request memory. In some scenarios, this state is not correctly reset, or is repopulated after being reset. Once the request memory is freed, a subsequent request handled by the same worker may access the stale state before normal initialization replaces it, causing a use-after-free and potentially allowing remote code execution.” (Jakub Zelenka, PHP security team, oss-security, 21 Oct 2026)
Every important word in that paragraph is about memory and time: global state, request memory, freed, a later request, the same worker. To see why those words matter, you have to picture how a PHP worker actually uses memory.
How a PHP worker reuses memory
A PHP web server does not start a fresh process for every HTTP request. For performance a worker process is long-lived and handles request after request. Each request gets its own slab of request memory, PHP’s per-request allocator arena, which is handed out when the request starts and freed in one go when it ends.
Sitting above those per-request arenas is global state that belongs to the worker and survives between requests. Examples are the current session id in PS(id), the last fatal-error backtrace in EG(last_fatal_error_backtrace), unserializer bookkeeping in BG(unserialize), and OPcache’s pointer-map frontier CG(map_ptr_last). For convenience, some of that global state is allowed to point into the current request’s arena for a while.
There is an unwritten contract that keeps this safe: before each request the global must be reset, so that it only ever references the current arena and never a dead one. CVE-2026-109619 is what happens when that contract quietly breaks.
The bug: a pointer that outlives the memory it points to
Two things can break the reset, and the advisory names both.
- A fatal error interrupts PHP while it is resetting state. The fatal errors that matter here include out-of-memory conditions, uncaught errors, a failed
require, and an exceededmax_execution_time. The error aborts the normal flow before the global is cleaned up. - A late shutdown diagnostic repopulates state after it was reset. A warning or deprecation notice emitted during shutdown can write the global back after cleanup has already run.
Either way, the request’s arena is freed but a global pointer is left aimed at that freed memory. In memory-safety terms it is a dangling pointer, and because it lives in the worker, it survives into the next request.
That is the core of a use-after-free (CWE-416). The advisory also tags it CWE-824 (access of an uninitialized pointer) and CWE-755 (improper handling of exceptional conditions), which is exactly the “a fatal error interrupts cleanup” shape.
The scenario: crossing from one request to the next
The flaw only becomes an exploit when two requests cooperate. It helps to think of a producer request and a consumer request.
The producer request is shaped to trigger a fatal error, most reliably an out-of-memory error, at a moment that leaves a global pointing into the arena that is about to be freed. The consumer request is a later request that reaches the same worker and touches the stale global before normal initialization has had a chance to overwrite it.
At that point PHP trusts a pointer into freed memory. If the attacker has arranged for that freed memory to be reclaimed with data they control, the stale pointer becomes a memory-corruption primitive. Combined with a separate memory-disclosure trick, which I come back to below, it was developed into full unauthenticated remote code execution running with the privileges of the web worker.
The single most important precondition is the one printed at the bottom of the diagram: both requests must land on the same worker. A busy server with many workers does not make you safe. It only means an attacker needs more attempts to get two of their requests onto the same one. As the advisory puts it, exploitability “depends on the affected state, configuration, and application behavior.”
Which concrete paths were confirmed
The advisory separates the paths it confirmed as exploitable from the wider class of stale-state bugs. Two consumers were confirmed.
The first is session upload progress. PHP parses multipart/form-data uploads very early, before normal module initialization, and the session.upload_progress feature, which is on by default, touches session and decoder state at that early stage. A fatal error during input processing can leave PS(id) dangling, and a later multipart request reaches the cleanup path first and follows it. Wiz showed that this path can URL-encode reclaimed worker memory straight into a PHPSESSID response cookie, a Heartbleed-style leak, even when the application never calls session_start(). It was confirmed against both Apache mod_php and PHP-FPM.
The second is fatal-error backtraces on PHP 8.5. PHP 8.5 keeps the backtrace of the most recent fatal error in EG(last_fatal_error_backtrace). An out-of-memory fatal error can free the arena without undefining that executor global, and a diagnostic early in the next request follows the dangling backtrace. It was confirmed as RCE against mod_php.
Wiz also documented an OPcache type-confusion variant through CG(map_ptr_last) and the shared-memory frontier, used to build an RCE chain against WordPress. The researchers were explicit that these are representative cases, not the full set: “there are other tracked, and likely untracked, variants of this issue in the PHP codebase.” (Wiz Research, PHP2Shell, 21 Oct 2026)
How far it reaches
This is a bug in the PHP engine itself, so it does not matter how careful your application code is. In the words of Wiz, “even a simple ‘Hello World’ application running on an affected PHP version can be vulnerable.”
The numbers, with sources:
- Reach. PHP powers about 70 percent of all websites according to Wiz, and 69.3 percent of sites whose server-side language is known according to W3Techs (cited by Tenable). WordPress, Drupal, Joomla, Laravel, Magento and MediaWiki all run on it.
- Version range. The PHP advisory lists the fixed branches as 8.2.35, 8.3.36, 8.4.27 and 8.5.12, but according to the Calif.io write-up the earliest affected code path it found dates to PHP 5.4.0. Nearly every PHP release of the last decade is affected by at least one of the two CVEs.
- Exposure. Palo Alto Networks Cortex Xpanse reported “over 22.6 million exposed instances potentially vulnerable” to the pair at publication (Unit 42, 21 Oct 2026). More conservative host-level counts put it at 1.3 million (Silas Cutler) to 1.9 million internet-exposed PHP hosts (a Shodan search by Henk Poley), both cited by Tenable.
For comparison, Wiz placed this disclosure in the same bracket as Heartbleed (2014) and Log4Shell (2021).
What makes it more or less dangerous
Not every PHP install is equally exposed. Here is the honest risk picture, taken from the advisory and the vendor analyses.
What raises the risk:
- Apache
mod_php. The PHP team “consider[s] the risk highest for mod_php, with a demonstrated exploit where an unauthenticated request can trigger the attack before any application code runs” (php.net advisory). The malicious request is consumed during start-up, so authentication and application-level filtering never run. - Default configuration. Both confirmed consumer paths, session upload progress and, on 8.5, fatal-error backtraces, are on by default.
- AI-assisted exploitation. Wiz’s central point is that the hard part was never finding the bug but turning it into a working exploit across different memory layouts, and that current models collapsed that cost: “a single researcher completed in a week what previously would have taken a world-class team months of work.” That lowers the barrier to adapting an exploit for each target.
- Active exploitation. Amazon Threat Intelligence saw a php2shell attempt against its MadPot honeypots on 17 October 2026, four days before disclosure, and says it is “now being broadly exploited in the wild” (via Tenable). watchTowr reported it “has observed exploitation of both CVE-2026-109618 and CVE-2026-109619 in the wild.”
What lowers the risk, without removing it:
- The same-worker requirement. The producer and consumer requests must hit the same worker. More workers, aggressive worker recycling (for example
pm.max_requests=1on FPM), and process churn all reduce the success rate per attempt. They do not remove the flaw. - Exploit portability is hard. Rapid7 found that public proof-of-concept code “works only for a specific combination of operating system, PHP version, and web server, and none appears broadly reliable,” and PHP said it “did not identify any functional exploits at the time of release.” Treat that as a window to patch, not a reason to wait.
- FrankenPHP. The advisory notes that an equivalent early consumer was not established for FrankenPHP’s default configuration.
The fix, and the non-fixes
The PHP team is blunt in the release notes: “There are no complete mitigations, updating is the only safe choice.” The patch resets request-owned global state before anything can reuse it. It clears error and exception state during shutdown, and initializes serialization and session state before early request processing can reach them.
Update to a patched release for your branch:
| Branch | Fixed version |
|---|---|
| PHP 8.5.x | 8.5.12 |
| PHP 8.4.x | 8.4.27 |
| PHP 8.3.x | 8.3.36 |
| PHP 8.2.x | 8.2.35 |
| PHP 7.4 to 8.1 | No official release; the security team published minimal source backports |
| PHP 5.4 to 7.3 | End of life, so upgrade to a supported branch |
After updating, restart every worker. A long-lived worker keeps running the old binary until it is recycled. And check the version serving web requests, with phpinfo() or your package manager, not just the CLI binary.
If you genuinely cannot patch right away, the advisory lists stop-gaps that “reduce exposure to specific attack paths but do not make an installation safe”:
- Set
session.upload_progress.enabled=0and restart every worker. This blocks the confirmed session paths, not other variants. Apply it inphp.ini, an FPM pool, or server config. The advisory warns against relying onini_set()or a.user.ini. - On PHP 8.5, set
fatal_error_backtraces=0and restart every worker. This blocks only the confirmed backtrace path. - Remove per-request settings that emit diagnostics before initialization. Note that hiding errors with
error_reporting,display_errorsor@does not help, since the stale state is still accessed.
Datadog’s team is worth quoting here, because it disposes of a popular half-measure: “Mitigations circulating in the community, such as setting session.upload_progress.enabled=0 in the PHP configuration file, don’t fully protect you.” (Datadog Security Labs, 21 Oct 2026)
What to look for
Because the bug runs below the application layer, your application logs may be nearly empty. Wiz suggests watching for unusual multipart requests followed by memory-limit errors or repeated HTTP 500s, unexpectedly large or malformed PHPSESSID response cookies, and unexplained PHP worker churn. A successful exploit does not necessarily crash a worker, so it pays to correlate signals across the request layer, the PHP error log, and the processes your workers spawn.
In short
CVE-2026-109619 is an ordinary use-after-free that PHP’s reach and its long-lived-worker model turn into something far larger. The mechanism is both elegant and unsettling: a pointer that stayed valid for a few milliseconds too long, trusted by the very next visitor to the same worker. The remedy is unglamorous and absolute. Update PHP, restart your workers, and verify the version that actually serves your traffic.
Primary sources
- PHP security team, oss-security announcement, CVE-2026-109619, 21 Oct 2026, 16:44 CEST
- GitHub Security Advisory GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)
- php.net, Security advisory: Critical remote code execution vulnerabilities, 21 Oct 2026
- MITRE CVE-2026-109619, NVD entry
- Wiz Research, PHP2Shell: AI uncovers critical RCE vulnerabilities, 21 Oct 2026 (Sagi Tzadik, Shir Tamari, Nir Ohfeld, Tomer Schwartz)
- Tenable, php2shell FAQ (Satnam Narang, 21 to 23 Oct 2026)
- Datadog Security Labs, php2shell: critical vulnerabilities in PHP, 21 Oct 2026
- Unit 42 (Palo Alto Networks), Threat Brief, 21 Oct 2026
- Rapid7, Emergent Threat Response, 21 to 22 Oct 2026
- watchTowr, PHP2Shell RCE Exploited, 21 Oct 2026
Companion article: CVE-2026-109618, RCE via OOM corruption.