
Intro
On 21 October 2026 the PHP project shipped emergency fixes for two critical, unauthenticated remote code execution bugs in PHP core: CVE-2026-109618 ("RCE via OOM corruption") and CVE-2026-109619 ("RCE via stale global state"), both CVSS v4.0 9.2. Neither lives in application code, both are reachable before your code runs, from a single HTTP request, and were being exploited in the wild before the patch landed.
Fixed releases are PHP 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. If you run PHP, patch and restart your workers now, then read on for why.
This article is an attempt to a technical walkthrough of the two PHP core vulnerabilities collectively nicknamed PHP2Shell, with the vulnerable code paths, the official fixes, the affected and patched versions, and how to scan for exposure.
Why "PHP2Shell" is not what the early write-ups said
When details leaked ahead of coordinated disclosure, the circulating analysis blamed a use-after-free in PHP's session upload-progress handler (php_session_rfc1867_callback in ext/session/session.c). That path is a real trigger, but it turned out to be a symptom, not the root cause. Independent lab testing (the rdwr-namikb verification lab) explicitly notes that the widely circulated session.upload_progress UAF write-up did not reproduce as the root cause.
The official GitHub Security Advisories shows two separate memory-safety defects in the request lifecycle itself, each of which can be reached through the upload-progress code, but also through other pre-initialization paths. That is why disabling upload progress only partly reduces exposure, and why the only reliable fix is the patch.
The two advisories:
CVE-2026-109618: RCE via OOM corruption
For most of its history PHP handled an out-of-memory condition by calling zend_bailout(), which is a longjmp() back to a saved recovery point. The jump unwinds the C stack without running intervening cleanup.
The problem is, request-owned memory can be reachable before it is fully initialized. If an allocation fails during that window (for example while the multipart parser is building request state), the longjmp() leaves that memory in a half-initialized, invalid condition. Request shutdown then runs and reads or branches on that invalid state. Because an attacker can steer which allocation fails (by tuning memory_limit against a crafted request body), the "invalid state" becomes attacker-influenced, and that is the corruption primitive. Multipart parsing is a confirmed trigger, which is what links this bug to the upload-progress theory.
The vulnerable code path (PHP 8.4.4 and earlier, abridged)
In the pre-patch allocator, a soft-limit breach routed into zend_mm_safe_error(), which raised a fatal error and bailed out via longjmp :
The danger is the zend_bailout() on a stack where request memory is only partially set up. The jump skips the code that would have finished initializing or validating that memory, and later shutdown consumes it.
The fix (PHP 8.4.27 / 8.3.36 / 8.2.35 / 8.5.12)
The patch on commit 3584a98bda0f2c0d367255b046798b0d3a221a19: "Avoid state corruption via bailout." removes the longjmp from the OOM path entirely. Instead the allocator does two things:
- Soft limit: queue a delayed OOM error and let the VM raise it at its next safe point, so the engine is never abandoned mid-operation.
- Hard limit: if usage blows past a second, higher threshold, call
zend_mm_hard_limit_error, which calls_exit(137)immediately rather than trying to recove
Here is the patched soft/hard limit check, verbatim from 8.4.27:
The delayed error is stored in EG(delayed_oom) and signaled through EG(vm_interrupt) :
And the hard-limit escape hatch:
Net effect: PHP no longer abandons a partially-initialized request through a longjmp, so shutdown can never consume corrupted half-built state.
CVE-2026-109619: RCE via stale global state
PHP workers are persistent: one OS process serves many requests. The engine keeps global state across that lifetime, including error state, session state, object state and extension state. Some of that global state points into memory owned by the current request. The invariant is simple, that state must be reset before the request heap is freed.
Two things break the invariant:
- Fatal errors skip the reset: An OOM or a timeout can abort a request through a path that does not reset the globals.
- Shutdown can repopulate state after it was cleared. Non-fatal diagnostics produced during shutdown can write error state back after it was reset.
Either way, the request heap gets freed while a global still refers to it. Then a later request on the same worker, running an operation that fires before normal request initialization (INI diagnostics, session upload-progress handling, or on PHP 8.5 the fatal_error_backtraces path), dereferences the stale pointer. That is a classic use-after-free (CWE-416), and the strongest confirmed case in testing was the PHP 8.5 fatal_error_backtraces path, where backtrace state is not reset during shutdown.
From GitHub Advisory, a concrete vulnerability would arise under the following conditions:
- A reachable PHP operation stores request-owned state in a globally accessible location, acting as the producer.
- The state is not reset, or another operation repopulates it after its normal reset.
- PHP releases the request memory while the persistent worker retains the referring global state.
- The same worker handles another request.
- A PHP operation reads, frees, branches or otherwise acts on the stale state before normal request initialization resets it, acting as the consumer.
The fix
The patched release ships a complete change. The engine resets the wider global state (error, session, object, extension) so none of it can survive into operations that run before normal initialization. The advisory is blunt that configuration cannot fully substitute for this, and that updating is the only reliable mitigation.
In case an immediate fix cannot be applied, it is recommended to reduce the reachable surface but do not fix the bug:
- PHP 8.5:
fatal_error_backtraces=0, then restart workers. - Session path:
session.upload_progress.enabled=0, then restart workers.
Applying the patch
CVE-2026-109618
Fixes were handed to Linux distributions and LTS vendors under embargo, so distro packages were available at or near the public release. Confirm the fix by the package changelog and CVE ID, not by the php -v string, because distros backport without bumping the version.
Debian and Ubuntu (including the ondrej PPA and packages.sury.org):
RHEL, AlmaLinux and Rocky:
Note: the volunteer-run rpms.remirepo.net had no updates on release day. Switch to distribution packages or build from source if you depend on it.
CVE-2026-109619
After applying the patch, restart workers. A patched file on disk does not protect an already-running worker. Restarting the service is the first step, then each of the workers.
Verify nothing still runs old PHP:
Scanning for exposure
Scanning a public exposure in this attack will be easy, as the attack relies on the version of PHP currently running on the system. So, searching for headers such as X-Powered-By or Server can help in detecting vulnerable sites. There are though cases when the version is vulnerable, but some preemptive measurements have been taken, which lower the risk. That is why, three public approaches have emerged, all defensive. None requires a working exploit.
1. Inert primitive fingerprinting (Masriyan/php2shell)
This tool fingerprints the target, detects the PHP version and support branch, then sends a single inert probe: a multipart request whose canary is a PHP comment (not code). It never executes anything. The signal is whether PHP issues a session cookie for a client-supplied session id, which indicates the pre-initialization session path is reachable.
Seven-phase flow: parse target, CMS fingerprint plus config read (session.upload_progress.enabled, cleanup, memory_limit versus post_max_size, SAPI), PHP version and branch, a confirmation gate, the inert probe, an exposure verdict, and an optional read-only include-sink and pearcmd.php scan.
2. Worker-death differential scanning (0xrsydn/php2shell-scan)
This scanner sends a corpus of roughly 24 spec-legal multipart requests to a POST route and counts worker deaths rather than trusting HTTP status codes. A worker death is a 5xx or a hang while a static path still responds. The caution it builds in: "PHP-FPM respawns children, so a crash can masquerade as a clean 200," so the death counter matters more than the status line.
It saves a pre-patch baseline and compares a retest against it, emitting verdicts like mitigated, exposed, inconclusive, improved, changed, unchanged and no_signal. It is read-only, rate-limited and capped.
3. Local differential lab (rdwr-namikb)
For defenders who want ground truth, the verification lab builds PHP 8.4.4 (vulnerable) and 8.4.27 (patched) with AddressSanitizer and runs a 2x2 matrix:
verify_109619.py: togglesupload_progresson/off againstphp-cgi. On 8.4.4 with upload progress on, the session was destroyed and$_SESSIONleaked. On 8.4.27, the startup diagnostics were absent and the session survived.verify_109618.py: runs the OOM test withmemory_limit8M (trigger) versus -1 (control). On 8.4.4 at 8M, the response was an empty connection close and a partial session record remained. On 8.4.27 it returned a clean 500 and no session record.- Upstream regression tests back this up:
ext/session/tests/rfc1867_no_user_code.phptfails 3/3 on 8.4.4 and passes 3/3 on 8.4.27, andZend/tests/oom_after_main_script.phptshows the same pattern.
One subtlety worth repeating: the Zend allocator hides intra-chunk use-after-free from ASAN, so the 109618 harness checks the observable condition (empty close plus a lingering partial session record) rather than waiting for a sanitizer crash.
Local Detection and threat hunting
No confirmed file-hash IOCs were published at release and as of now. It is advised to hunt on behavior instead.
Process level detection
Process-behavior signals, where a PHP worker (php-fpm, php-cgi, httpd, apache2) spawns:
shells: sh,bash,cmd.exe,powershell.exe- interpreters:
python,perl - download tools:
curl,wget,nc,certutil.exe - recon as the web-server user:
whoami,id,uname -a,ipconfig
Command-line strings worth alerting on: /dev/tcp/, fsockopen, mkfifo.
A quick triage, since PHP-FPM workers normally have no children:
Network-level detection
A burst of 5xx (often 502) from PHP workers on POST routes, especially carrying the upload-progress field, followed by a 200. The multipart parser tolerates loose headers, so do not rely on exact-string signatures. Review logs from 20 October 2026 onward at minimum, and ideally retro-hunt back to June 2026 for web shells, new files or users, and unexpected outbound connections.
Affected and patched versions
Every SAPI that serves web requests is affected: PHP-FPM, Apache mod_php, php-cgi, FrankenPHP and LiteSpeed. mod_php is rated the highest risk and is where a working exploit was demonstrated.
ExaPHP2Shell
This article led to the creation of ExaPHP2Shell, a tool used to scan PHP pages for vulnerable PHP installation, by parsing the response headers. The tool offers a front-end in HTML/CSS/JS and a backend in AWS Lambda. S3 alone can't read another site's headers due to browser same-origin policy, so the Lambda is what scans the site and gets returns the output.
The frontend can be hosted on an AWS S3 Bucket configured to be a website and it will be able to call the Lambda Function.
The Lambda Function should have a URL configured and the URL can be passed on the Backend settings of the front-end, or for a hardcoded value, on the BACKEND variable on the JS code inside the front-end.
The backend code will look for Server and X-Powered-By variables and the versions they will return.
The output is then passed to the frontend webpage, which then outputs the verdict of vulnerable or not.
Bottom line
CVE-2026-109618 and CVE-2026-109619 are not a plugin bug or a misconfiguration. They are two memory-safety defects in the heart of the PHP runtime, triggerable pre-authentication from a single request, and they were exploited before the patch shipped. The early "session upload-progress use-after-free" framing was a useful trigger but the wrong root cause: the real fixes remove a dangerous OOM longjmp (109618) and enforce a complete global-state reset so request-owned pointers cannot outlive the request heap (109619).
Patch to 8.5.12, 8.4.27, 8.3.36 or 8.2.35, restart every worker, verify by changelog, and retro-hunt. If you are stuck on an end-of-life branch, take a source backport or get off it.
Sources and further reading
- PHP advisory GHSA-w8hw-w9fx-j79w (CVE-2026-109618), php/php-src
- PHP advisory GHSA-6wmf-4rv5-p3rp (CVE-2026-109619), php/php-src
- Fix commit
3584a98bda0f2c0d367255b046798b0d3a221a19: "Avoid state corruption via bailout" - Help Net Security: PHP2Shell patched, update now
- Mattias Geniar: how to apply the patches
- Brandefense: CVE-2026-109618 explained
- Sansec: PHP2Shell research
- Patchstack: attacks ongoing
- rdwr-namikb verification lab (8.4.4 vs 8.4.27)
- Masriyan/php2shell
- 0xrsydn/php2shell-scan
- Official PHP 8 ChangeLog




