Resources
    The Request Died but Its ...
    29 October 26

    The Request Died but Its Ghost Lived On: Inside PHP’s CVE-2026-109619

    Posted byAshish Bhangale
    news-featured

    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 

    PHP2Shell: Exploiting Stale Global State


    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:

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

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

    Kali Linux desktop with INE-branded wallpaper and common security testing tools visible.

    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

    Terminal showing an Nmap scan of northstar.local, identifying Apache HTTP Server 2.4.69 on port 80.

    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.

    Terminal showing HTTP response headers from northstar.local, including Apache 2.4.69 and PHP 8.4.26.

    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.

    Northstar Claims Gateway homepage showing a claims intake interface and 99.98% gateway availability.

    Step 3: Discover hidden paths

    Read robots.txt:

    curl -s http://northstar.local/robots.txt

    It reveals three disallowed paths: /support/, /ops/, and /storage/.

    Terminal displaying the northstar.local robots.txt file, which reveals the hidden /support/, /ops/, and /storage/ paths.

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

    Terminal showing ffuf directory enumeration results that identify 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

    Terminal displaying the Northstar Claims Gateway change log, including PHP 8.4.26, Apache prefork, and persistent-worker configuration details.

    Read the diagnostics page:

    curl -s http://northstar.local/support/runtime.php

    Terminal showing the contents of the Northstar runtime diagnostics endpoint used to inspect server configuration.

    The browser-rendered diagnostics page provides the same runtime evidence:

    Northstar runtime support page showing PHP 8.4.26, apache2handler, hostname northstar, and the application release version.

    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.

    Kali terminal listing files on the desktop, with the exploit-109619.py proof-of-concept script highlighted.

    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.

    Terminal output from the CVE-2026-109619 exploit showing leaked memory addresses, worker rotation, and repeated exploit attempts.

    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.

    Terminal showing successful remote command execution as the www-data user and retrieval of the lab 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.

    Explore Training on INE at https://my.ine.com/

    Share this post with your network

    twitter Logofacebook Logolinkedin Logowhatsapp Logoemail Logo
    © 2026 INE. All Rights Reserved. All logos, trademarks and registered trademarks are the property of their respective owners.
    instagram Logofacebook Logox Logolinkedin Logoyoutube Logo