The Request Died but Its Ghost Lived On: Inside PHP’s CVE-2026-109619
Imagine you are running a standard PHP application behind Apache. It serves pages, handles file uploads, tracks session state. Nothing exotic. One afternoon, two HTTP requests arrive at your server a few hundred milliseconds apart. The first one is a multipart upload that is deliberately too large. It crashes against the memory limit, and PHP tears it down. The second is another multipart upload that looks almost identical. It lands on the same Apache worker. By the time it finishes, someone who never authenticated, never exploited any application-level bug, and never touched your code is running shell commands as your web server user.
That is CVE-2026-109619, and it targets something most PHP administrators never think about: what happens to worker-global state when a request dies mid-initialization. This post walks through the vulnerability, why it exists in PHP’s architecture, and how a pair of unauthenticated HTTP requests turns into remote code execution. The interesting part is that this is not an application bug. It is a lifetime management failure in the PHP runtime itself, hiding in the gap between session initialization and request initialization.
Vulnerability Overview
CVE | CVE-2026-109619. Its sibling, CVE-2026-109618, is a separate bug fixed alongside it and is not covered here. |
Affected runtime | PHP, the server-side scripting engine, when running as an Apache module (mod_php / apache2handler) with the prefork MPM |
Affected versions | PHP across multiple branches: 8.4.x before 8.4.27, 8.3.x before 8.3.36, 8.2.x before 8.2.35, and 8.5.x before 8.5.12. Source-only backports exist for 7.4, 8.0, and 8.1. The lab targets PHP 8.4.26. |
Type | Use-after-free in worker-global session state, leading to unauthenticated remote code execution. |
Severity | CVSS v4.0 score of 9.2 Critical, assigned by the PHP project. |
Attack Vector | In the demonstrated exploit chain, two sequential HTTP multipart requests to any PHP endpoint on a prefork Apache worker with session.upload_progress.enabled set to its default of On. The advisory describes additional stale-state paths beyond session upload progress. |
Exploitation in the Wild | Early exploitation attempts were detected by Cloudflare WAF telemetry and independently reproduced by watchTowr. Multiple public proof-of-concept scripts exist, though few work reliably against default configurations due to ASLR on x86-64. The vulnerability has been dubbed “PHP2Shell” alongside its sibling, CVE-2026-109618. |
Confirmed impact | Unauthenticated RCE over HTTP, demonstrated against Apache with mod_php on default settings, before any application code runs. |
Skill Dive CVE |
The Setup: How Apache Keeps PHP Alive
To understand this bug, you need to understand one architectural decision that makes it possible. Apache’s prefork MPM spawns a pool of child processes. Each child handles one request at a time, sequentially. When PHP runs as mod_php (reported by PHP as the apache2handler SAPI), the PHP interpreter lives inside that Apache child process. It is not started fresh for each request. It persists.
That persistence is the key. Between requests, the PHP runtime resets most of its state: it tears down the request heap, destroys variables, and reinitializes for the next request through a phase called RINIT (Request INITialization). But some state is deliberately kept across requests at the worker-global level, managed through module globals rather than per-request allocations.
PHP’s session extension maintains one such piece of worker-global state: PS(http_session_vars). The PS() macro, defined in ext/session/php_session.h, accesses the session module’s global structure. PS(http_session_vars) is a pointer that, during a request, references the session data stored in the request’s own memory. Between requests, it is supposed to be reset to NULL so the next request starts clean.
Now add one more feature to the picture: session upload progress. When session.upload_progress.enabled is On (which it is by default), PHP hooks into Apache’s multipart parsing very early, before RINIT even runs, to start tracking upload progress in the session. This early initialization is the crack in the wall.
The entire vulnerability lives in the gap between “session state was initialized early” and “request state was cleaned up normally.” When something goes wrong in that gap, PS(http_session_vars) can be left pointing at memory that no longer belongs to the current request, or to any request at all.
Act One: The Same-Request Sibling (CVE-2026-109618)
CVE-2026-109619 is not PHP’s first encounter with this particular class of lifetime error. Its sibling, CVE-2026-109618, describes a related vulnerability in the same session-initialization code, but with a crucial difference: the trigger and the effect both happen within a single request.
In CVE-2026-109618, the failure mode is a partially initialized cleanup path. A single request can reach a state where the session module’s initialization started (because of upload progress) but did not complete normally, and the cleanup path during that same request’s teardown does not properly account for the partial state. The result is a use-after-free, but it is scoped to the lifetime of one request.
The fix for CVE-2026-109618 addressed the same-request cleanup path. But the deeper architectural issue, that PS(http_session_vars) is a worker-global pointer that can reference request-scoped memory, was not fully resolved. The pointer could still escape the request boundary.
Act Two: The State That Crossed the Boundary
Now think about what happens when the failure is not contained within a single request.
Request A arrives as a multipart upload. Because session.upload_progress.enabled is on, PHP’s session module hooks into the multipart parser early and begins session initialization before RINIT runs. During this early phase, PS(http_session_vars) is set to point into the request’s memory. Then the upload’s deliberately oversized field hits the configured memory limit, and PHP bails out with a fatal error.
Here is where the lifetime violation occurs. During this abnormal shutdown, the session module’s reset logic is skipped. The request heap is freed, variables are torn down, the request is gone. But PS(http_session_vars) still holds its old value. It is a worker-global pointer, and it now points to freed memory from a dead request.
The Apache child process does not exit. It is a prefork worker. It goes back to the connection pool and waits for the next request.
Request B arrives. It is also a multipart upload, carefully constructed. When PHP begins processing it, the new request gets a fresh heap. The attacker’s controlled multipart data is allocated into this heap. With the right sizes and contents, this new data occupies the exact memory that the dead Request A’s session data used to occupy. The stale PS(http_session_vars) pointer now points to attacker-controlled data.
When session initialization runs for Request B, it consumes PS(http_session_vars) before replacing it. It interprets the attacker’s data as a HashTable, PHP’s internal dictionary structure. The attacker has crafted this fake HashTable so that its destructor function pointer field points to zif_system, the C-level implementation of PHP’s system() function. When PHP’s cleanup code calls the destructor, it executes zif_system with attacker-controlled arguments.
The result is arbitrary command execution as the web server user, typically www-data.
How It Actually Works: The Producer/Consumer Chain
Let us walk through the two requests in detail, because the exploit is a precisely sequenced two-phase operation.
Phase 1: The Producer
The producer request is a multipart POST to any PHP endpoint. Its job is to corrupt the worker’s global state and then die. The request carries:
A PHP_SESSION_UPLOAD_PROGRESS field in the multipart body. This triggers the session upload progress handler, which runs before RINIT and initializes session state early, setting PS(http_session_vars) to point into the request’s memory.
An oversized field that, when PHP tries to process it, pushes memory usage past the configured limit (in the lab, 8 MB). PHP raises a fatal error and begins abnormal shutdown.
During this abnormal shutdown, the session module’s cleanup does not execute its full reset sequence. Specifically, it does not set PS(http_session_vars) back to NULL. The request heap is freed, but the pointer remains.
The worker is now poisoned. It returns to the connection pool carrying a dangling pointer in PS(http_session_vars).
Phase 2: The Consumer
The consumer request targets the same worker. It is also a multipart POST, but its purpose is different: it fills the new request’s heap with controlled data that lands at the address the stale pointer references.
This is a standard heap-spray technique adapted to PHP’s Zend Memory Manager. The exploit needs two pieces of information to succeed:
The Zend MM chunk base address. PHP’s heap allocator organizes memory into chunks. Knowing the chunk base lets the exploit predict where allocations will land.
The libphp base address. PHP is compiled as a shared library (libphp.so). Knowing its base address lets the exploit calculate the address of zif_system, the function it wants to call.
The exploit leaks both addresses through earlier producer/consumer rounds, each tuned to produce a distinguishable pattern in PHP’s error output or response behavior.
With both addresses in hand, the final consumer request places a forged HashTable structure at the address PS(http_session_vars) still points to. The forged table’s pDestructor field contains the calculated address of zif_system. When PHP’s session initialization code processes this structure during cleanup, it invokes the destructor, and the attacker’s command runs.
Where it lives in the source
The critical code path spans two files in the PHP source tree:
ext/session/session.c contains the session module’s implementation. The upload progress callback (php_session_rfc1867_callback) runs during multipart parsing before RINIT. The session initialization function (php_session_initialize) reads PS(http_session_vars) early in the next request. The gap between the two, across the request boundary, is where the stale pointer survives.
ext/session/php_session.h defines the PS() macro that accesses the session module’s global state. PS(http_session_vars) is a zval * that, during normal operation, points to the current request’s session data. When the request dies abnormally, this pointer is not reset.
Put the full chain in one sentence: the producer corrupts PS(http_session_vars) and dies, the consumer fills the freed memory with a fake HashTable whose destructor is zif_system, and PHP’s own session initialization runs the attacker’s command.
The Lab: Exploiting It Yourself
Reading about a vulnerability is one thing. Triggering a use-after-free across request boundaries is another. Here is a walkthrough of exploiting CVE-2026-109619 end-to-end in a lab environment, from a Kali box to code execution on the target.
The Environment
The lab target runs the Northstar Claims Gateway on PHP 8.4.26 with Apache mod_php in prefork mode, reachable from a Kali attack box at:
http://northstar.local
Item | Value |
|---|---|
Target | northstar.local |
Service | HTTP on TCP/80 |
Runtime | PHP 8.4.26, Apache mod_php/prefork |
SAPI | apache2handler |
Exploit script | /root/Desktop/exploit-109619.py |
Fixed version | PHP 8.4.27 or later |
Step 1: Enumerate the service and headers
Run a focused service scan:
nmap -Pn -sT -sV --version-light -p 80 northstar.local
Then inspect the root response:
curl -sI http://northstar.local/
The nmap scan identifies Apache 2.4.69 on port 80. The HTTP response headers reveal X-Powered-By: PHP/8.4.26 and Server: Apache/2.4.69 (Debian). Apache is reachable, and PHP is running.
Step 2: Inspect the application
Open Firefox in the Kali desktop and browse to:
http://northstar.local/
Northstar appears as a claims data gateway with an adjuster workspace, validation workflow, and service-status page. The application itself is not the target. The vulnerability is in the PHP runtime beneath it.
Step 3: Discover hidden paths
Read robots.txt:
curl -s http://northstar.local/robots.txt
It reveals three disallowed paths: /support/, /ops/, and /storage/.
Enumerate both operational paths:
ffuf -w /usr/share/dirb/wordlists/common.txt \
-u http://northstar.local/ops/FUZZ \
-mc 200,301,302,401,403 -ac -s
ffuf -w /usr/share/dirb/wordlists/common.txt \
-u http://northstar.local/support/FUZZ \
-mc 200,301,302,401,403 -ac -s
The directory enumeration reveals change-log.txt under /ops/ and runtime.php under /support/.
Step 4: Gather runtime and deployment evidence
Read the operations note:
curl -s http://northstar.local/ops/change-log.txt
Read the diagnostics page:
curl -s http://northstar.local/support/runtime.php
The browser-rendered diagnostics page provides the same runtime evidence:
Together, the change log and diagnostics page identify:
PHP 8.4.26 as the runtime.
apache2handler as the SAPI, confirming mod_php.
A persistent single-worker prefork gateway.
Session-backed multipart uploads and upload-progress telemetry still active.
These four conditions, PHP 8.4.26 under apache2handler with a single persistent prefork worker and session upload progress enabled, are exactly the preconditions for CVE-2026-109619.
Step 5: Locate the exploit
The lab provides a commit-pinned PoC from the public CVE-2026-109619 repository on the Kali desktop:
cd /root/Desktop/
ls
The file exploit-109619.py is the lab’s driver script. It implements the full producer/consumer chain: heap leak, library leak, and final stale-reference trigger.
Step 6: Run the producer/consumer chain
Use three leak retries. The exploit performs five final-stage attempts (retries + 2). The || true suffix allows subsequent verification commands to run regardless of the driver’s local proof-file exit status, because the standalone target shares no filesystem with Kali:
python3 exploit-109619.py \
-u http://northstar.local/service-status.php \
-c 'sh</opt/lab/x' \
--proof /tmp/not-mounted \
--retries 3 || true
The 13-byte command sh</opt/lab/x invokes a lab-only helper that writes id output and the flag beneath the web-served upload directory. The helper does not provide an injection vulnerability on its own; it executes only after the PHP control-flow redirection succeeds.
Watch the output for:
A leaked Zend MM chunk base (e.g. Zend MM chunk base = 0x77...).
A leaked libphp base (e.g. libphp base = 0x77...).
Calculated HTDATA and zif_system addresses for the final payload.
Repeated worker rotation between stages, giving each phase a clean worker.
The driver reports no proof produced at the end because the local --proof path does not exist on a filesystem shared with the target. This is expected. The effect is verified independently over HTTP in the next step.
Step 7: Verify execution and retrieve the flag
Check the artifacts the exploit created on the target:
curl -s http://northstar.local/storage/uploads/p
curl -s http://northstar.local/storage/uploads/flag.txt
The first response returns uid=33(www-data) gid=33(www-data) groups=33(www-data), proving the controlled command ran as www-data. The second returns the flag.
If the heap or library leak misses on every attempt, rerun the identical exploit command. The PoC is deliberately calibrated to the pinned image and endpoint.
What Just Happened
No application vulnerability was exploited. No authentication was bypassed. The exploit targeted the PHP runtime itself: a worker-global pointer that was not reset when a request died abnormally. Two HTTP requests, one to poison the worker and one to reclaim the freed memory, turned a session-management lifetime error into arbitrary code execution.
The defining failure is that request-scoped memory remained reachable through worker-global state after the request heap was freed. The code assumed that request shutdown always runs to completion. It does not, and the session upload progress handler creates a window where early initialization leaves state that late cleanup does not reach.
The Fix in PHP 8.4.27
PHP 8.4.27 corrects the lifetime management so that PS(http_session_vars) is properly reset during abnormal request shutdown, regardless of whether the session module’s full cleanup path executes. The pointer no longer survives the request boundary.
Disabling session.upload_progress.enabled removes this specific producer/consumer route, because the early session initialization that sets PS(http_session_vars) before RINIT no longer fires. However, PHP’s own advisory warns that configuration changes are not a complete generic mitigation for stale worker-global state. The authoritative fix is updating to PHP 8.4.27 or later and restarting all Apache workers.
Detection
Defensive evidence of exploitation attempts includes:
Repeated multipart requests containing PHP_SESSION_UPLOAD_PROGRESS fields.
Memory-exhaustion errors (Allowed memory size exhausted) occurring before normal application execution begins.
Corrupted-heap or segmentation fault messages in Apache error logs, followed by worker crashes.
Rapid worker replacement between pairs of similar multipart requests.
Unexpected files or processes created by www-data or the web server user.
References
Try this exploit for yourself within Skill Dive’s CVE lab collection, regularly updated with the latest vulnerabilities.
