CVE-2026-109619: How PHP's Stale Global State Turns One Freed Request Into Remote Code Execution

A visual walk through the use-after-free at the heart of PHP2Shell. One request hits a fatal error and leaves a global pointer aimed at memory that no longer exists, and the next request on the same worker trusts it.

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.

A PHP worker serves many requests; each per-request arena is freed at the end while global state persists, and a global that is not reset can keep pointing into freed memory.One PHP worker, many requests, one lifetime of memoryGLOBAL STATEPS(id) EG(last_fatal_error_backtrace) BG(unserialize)worker lifetimenewer requestsRequest 1 · freedhdrbuf······Request 2 · freedhdrbuf······Request 3 · live arenahdrbufkeyvalvalid: into the live arenathe bug: points into freed memoryEach request gets a fresh arena that is freed when it ends. A global is meant to be reset every time; when it is not, it dangles.valid pointerstale pointerfreed memory
Each worker handles many requests in a row. Per-request arenas are freed at the end of every request, while global state lives on across all of them, and is sometimes allowed to point into the current arena.

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.

  1. 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 exceeded max_execution_time. The error aborts the normal flow before the global is cleaned up.
  2. 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.

Two-request use-after-free: request one triggers a fatal error that leaves a dangling global, and request two on the same worker trusts that stale pointer.CVE-2026-109619: a dangling global crosses into the next requestsame workerRequest 1 · producerA request is shaped to hit a fatal error(out of memory, or a timeout).the arena is freed, the global is notrequest arenafreedglobalPS(id)danglinga dangling pointer survives in the workerthe next request lands on the same workerRequest 2 · consumerReaches the worker before normalinitialization has reset the global.the stale pointer is trusted as livePHP reads or follows a pointer intomemory that belongs to no one now.use-after-free → disclosure, then code executionThe one hard requirement: both requests must be served by the same worker. More workers mean more attempts, not safety.freed memorydangling pointer
A producer request triggers a fatal error that leaves a dangling global. A consumer request that lands on the same worker then reads that global before initialization can reset it.

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.”

Scope of PHP2Shell: PHP runs roughly 70 percent of the web, every release since 5.4.0 is affected, and millions of hosts are internet-facing.How far it reaches~70%of the webPHP's share of sites whose server-sidelanguage is known (W3Techs: 69.3%).every releaseaffected since PHP 5.4.022.6Mexposed instances (Cortex Xpanse)1.3-1.9Minternet-facing PHP hostsCVSS 9.2critical, unauthenticated RCE
PHP runs roughly 70 percent of the web, every release since 5.4.0 is affected by at least one of the two bugs, and millions of hosts are internet-facing.

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=1 on 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=0 and restart every worker. This blocks the confirmed session paths, not other variants. Apply it in php.ini, an FPM pool, or server config. The advisory warns against relying on ini_set() or a .user.ini.
  • On PHP 8.5, set fatal_error_backtraces=0 and 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_errors or @ 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

Companion article: CVE-2026-109618, RCE via OOM corruption.

Share: X (Twitter) Facebook LinkedIn