Technical details

How PHP2Shell works

One root cause, two CVEs, four bugs: PHP does not reliably clean up after a fatal error — especially running out of memory — mid-request.

Last reviewed 2026-10-22

Root cause

When PHP hits memory_limit it raises a fatal error and unwinds with longjmp(). That skips any code that would have finished initialising or resetting state. Two things go wrong:

  • Half-built data (CVE-2026-109618). Memory that was allocated but not yet initialised is later read during request shutdown as if it were valid.
  • Stale globals (CVE-2026-109619). Per-request global pointers (error, session, object, extension state) are not cleared, but the memory they point to is freed when the request ends. The next request on the same worker uses them before normal initialisation.

Attackers reach these paths with crafted multipart requests that drive PHP into OOM while it parses input — before your router, framework or authentication run. That is why a “Hello World” script is as exposed as WordPress.

The exploit chain

  1. Step 1

    Leak

    Use the memory disclosure to read live worker memory and defeat ASLR.

  2. Step 2

    Corrupt

    Use a use-after-free (unserializer or backtrace) to write controlled data.

  3. Step 3

    Execute

    Turn the corruption into code execution as the worker user — before any login.

Exploitation is not one-shot: the attacker probes repeatedly to adapt to each target’s memory layout. Researchers combined the chain into a script named php2shell (unpublished) and demonstrated it against the then-current wordpress, drupal and joomla container images.

The four bugs

CVE-2026-109618

Upload-progress unserializer use-after-free

The session module decodes data for upload-progress tracking before request initialisation finishes and keeps bookkeeping in BG(unserialize). An allocation failure tears down the request heap but leaves that global set. The next request on the same worker treats attacker-reclaimed memory as live unserializer state.

CVE-2026-109618

Remote process-memory disclosure

During early RFC 1867 multipart parsing, upload-progress code can publish a session ID in PS(id). After an OOM abort that pointer dangles. A later multipart request trusts its pointer and length and URL-encodes whatever now lives there into a PHPSESSID cookie — even if the app never calls session_start().

CVE-2026-109619

Fatal-error backtrace use-after-free

EG(last_fatal_error_backtrace) survives an OOM that destroys the request heap. An early diagnostic in the next request follows the dangling pointer. Most direct on PHP 8.5 with fatal_error_backtraces enabled.

CVE-2026-109619

OPcache shared-memory type confusion

In zend_accel_script_persist(), CG(map_ptr_last) advances before the shared frontier is published. An allocation failure between the two leaves map-pointer slots that workers disagree on, so one worker can reuse a slot for a different object type.

Bug-to-CVE grouping follows the Wiz Research write-up. The PHP advisories describe the classes more broadly and note further variants.

Exposure by server setup

Based on the PHP advisories. “Susceptible” means vulnerable by source analysis, without a published generic exploit for that SAPI.

Setup…109618…109619Notes
Apache + mod_phpRCE confirmedRCE confirmedBoth CVEs validated to RCE with default limits. Highest-risk setup.
PHP-FPM (nginx, Apache, Caddy…)SusceptibleRCE confirmedSession-lifecycle RCE validated on FPM. OOM path confirmed through application-level code paths; no generic FPM exploit published.
FrankenPHP / LiteSpeed LSAPILikelyLikelyAdvisory rates them likely susceptible. Long-lived workers make cross-request state more relevant, not less.
CLI (cron, queue workers, artisan)Not coveredNot coveredNot discussed by the advisories and not reachable by HTTP requests in normal use. Upgrade anyway — it is the same package.

CVE records

CVE-2026-109618GHSA-w8hw-w9fx-j79wCVSS 4.0 · 9.2

RCE via out-of-memory (OOM) corruption

PHP aborts the current context with longjmp() when it runs out of memory. If that happens while request memory is only partly initialised, request shutdown later reads or branches on invalid data. Several multipart-parsing paths can push PHP into OOM within normal limits, before any application code runs.

Impact:
Unauthenticated code execution as the PHP web-worker user.
Demonstrated:
Pre-auth RCE on WordPress, Drupal and Joomla (mod_php, default limits).
Affected
<8.2.35, <8.3.36, <8.4.27, <8.5.12 (and EOL branches back to 5.4)
CWE
CWE-416, CWE-755, CWE-824, CWE-1325, CWE-1419
CVE-2026-109619GHSA-6wmf-4rv5-p3rpCVSS 4.0 · 9.2

RCE via stale global state across requests

A fatal error (including OOM) can skip the per-request reset, leaving global error, session, object or extension state pointing at memory freed at the end of the request. The next request on the same worker can use that freed memory before normal initialisation.

Impact:
Use-after-free leading to worker memory disclosure or code execution.
Demonstrated:
Pre-auth RCE on WordPress and Drupal; application-agnostic remote memory disclosure.
Affected
<8.2.35, <8.3.36, <8.4.27, <8.5.12 (and EOL branches back to 5.4)

Vector (both): CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N. Network-reachable, no privileges or user interaction; “AC:H / AT:P” reflects that the exploit must be tuned to the target. Early leaked reports quoted CVSS 10.0 — that figure is wrong.

What the fix changes

The patch stops using longjmp() for out-of-memory. Instead, reaching memory_limit sets a soft OOM that is raised at the next safe point in the VM, where state is consistent. A second, higher threshold triggers a hard OOM that ends the process with _exit() — no shutdown handlers, no half-cleaned state.

Because it fixes the failure pattern rather than individual entry points, the PHP team states that it covers the known variants too.

Side effect: code paths that depended on shutdown functions running after an OOM will behave differently once the hard limit is reached.

Related