Anatomy of PHP2Shell (CVE-2026-109618 & CVE-2026-109619)
A study reference on how PHP2Shell (CVE-2026-109618 & CVE-2026-109619) actually works inside php-src, tracing both CVEs down to C structs and exploit mechanics.
Anatomy of PHP2Shell (CVE-2026-109618 & CVE-2026-109619)
When PHP2Shell became public in late October 2026, GitHub filled up overnight with fake proof-of-concept scripts promising instant remote shells. Most of them sent random oversized POST bodies and did nothing more than crash a local test container. The real vulnerability chain is much sharper, and far harder to pull off, than those scripts suggest.
This guide is written as a study reference on how PHP2Shell actually works inside php-src. Part 1 contains concepts required to understand the CVEs and the exploits: subsystem globals, persistent worker pools, setjmp/longjmp bailouts, the Pre-RINIT startup window, the Zend Memory Manager (Zend MM), and the exact byte layouts of PHP’s internal values and hash tables. Part 2 then traces both CVEs down to their C structs and walks step by step through how an attacker chains them from an unauthenticated multipart/form-data request to arbitrary code execution. Part 3 closes with the upstream patch and defensive mitigations.
What actually broke in the Zend Engine?
I have a better one: what is Zend? The name comes from the creators (Zeev + Andi = Zend) of a “new” PHP’s compiler and virtual machine, released with PHP since version 4.0.
On October 21, 2026, the PHP project shipped emergency updates (8.5.12, 8.4.27, 8.3.36, and 8.2.35) for two memory-safety flaws in the Zend Engine: CVE-2026-109618 (GHSA-w8hw-w9fx-j79w) and CVE-2026-109619 (GHSA-6wmf-4rv5-p3rp). Both carry a CVSS v4.0 score of 9.2. Together, they are known as PHP2Shell.
- CVE-2026-109618 (Intra-request out-of-memory bailout corruption): When an allocation surpasses
memory_limit, PHP aborts execution with_zend_bailout()(via alongjmp). If that jump happens mid-way through inserting an entry into aHashTable(calledzend_array) or initializing a composite structure, PHP increments the table’s active element counter before populating the slot itself. During request shutdown, PHP cleanup walks every “active” slot and passes uninitialized residual heap bytes to destructors. - CVE-2026-109619 (Cross-request stale global state): When an HTTP upload triggers
ext/sessionupload progress tracking during early request startup, and then aborts before modules finish initializing, PHP wipes the per-request heap without running module shutdown callbacks. Persistent globals in the worker process, specificallyPS(id)andPS(http_session_vars), retain dangling pointers into freed memory. The next HTTP request handled by that same worker reads and destroys those pointers before normal session initialization resets them.
Neither flaw is a string-handling mistake or a userland logic bug. PHP2Shell emerges where four design choices in the PHP runtime intersect:
- per-worker global structs;
- persistent worker processes;
- error recovery via
setjmp/longjmp; - non-zeroed memory recycling in the Zend Memory Manager.
If you already know PHP Zend Engine internals well, you can jump to Part 2; otherwise, every piece introduced in Part 1 pays off directly when building the exploit chain.
Part 1: Engine internals and memory layout
Subsystem global macros
PHP is written in C and predates modern thread-safe runtime data structures. Instead of passing a context pointer into every function, the core engine, and its bundled extensions (called also modules), store state inside subsystem global structs accessed through short C macros:
| Macro | Underlying C struct | Role and fields relevant to PHP2Shell |
|---|---|---|
PS(...) | php_ps_globals | Session extension state: PS(rfc1867_enabled), PS(rfc1867_progress), PS(session_status), PS(id), and PS(http_session_vars). |
EG(...) | zend_executor_globals | Zend VM runtime state: symbol tables, object store, current call frame, and the EG(bailout) jump buffer. |
PG(...) | php_core_globals | Core directives and lifecycle flags: PG(memory_limit), PG(post_max_size), PG(modules_activated), and PG(http_globals)[6]. |
SG(...) | sapi_globals_struct | Web server request metadata: SG(request_info).content_length, Content-Type, and POST reader callbacks. |
CG(...) | zend_compiler_globals | Compiler state and CG(function_table), where disable_functions restrictions are recorded. |
Notice that these structs mix together “entities” with different lifetimes. A single struct like php_ps_globals holds long-lived php.ini settings (PS(rfc1867_enabled)) right beside temporary per-request (raw) pointers (PS(id) and PS(http_session_vars)).
In non-threaded builds (standard for example for php-fpm on Linux), each global struct sits in the worker process’s .bss segment for the entire life of the process. Many of the pointers inside those structs, however, point to short-lived buffers allocated on the temporary per-request heap. That lifetime mismatch sets a trap: if a request aborts early and wipes the per-request heap without resetting .bss pointers back to NULL or IS_UNDEF, the worker enters the next HTTP request holding dangling pointers.
Persistent web workers
Production deployments such as php-fpm, Apache mod_php, and LiteSpeed do not spawn a fresh OS process per HTTP request. Instead, a master process forks a pool of persistent worker processes.
One worker accepts one request at a time, runs the PHP lifecycle, returns the HTTP response, and loops right back to accept() for the next connection. Because the OS process does not exit() between requests, two things stay constant across sequential requests on the same worker PID:
- Process address space (ASLR and PIE): The base addresses of the
php-fpmbinary, shared libraries (libc.so), and the main Zend heap chunk (see “The Zend Memory Manager” below) remain fixed until the worker process dies or is recycled by the master. - Global
.bssstate: Any pointer left behind inphp_ps_globalsat the end of Request $N$ is still sitting there at the start of Request $N+1$.
This is the lifecycle of a Worker.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Process Starts (Worker forked)
│
▼
1. MINIT (Module Init) ──> Runs ONCE on process startup
│ (loads php.ini, registers classes)
├─ [ HTTP Request Starts ]
│ │
│ ▼
│ 2. RINIT (Request Init) ──> Runs at the START of every request
│ │ (sets up per-request buffers)
│ ▼ [ PHP Script Runs ]
│ │
│ 3. RSHUTDOWN (Request End) ──> Runs at the END of every request
│ (cleans up request memory)
├─ [ Loops back to RINIT for next request... ]
│
▼
4. MSHUTDOWN (Module Shutdown) ──> Runs ONCE when the process exits
The poor man’s exception handling: setjmp(), longjmp(), and _zend_bailout()
Because C has no try/catch mechanism, PHP/Zend recovers from fatal runtime errors and out-of-memory conditions using <setjmp.h>:
int setjmp(jmp_buf env)saves the current CPU register state intoenvand returns0.void longjmp(jmp_buf env, int val)restores those saved registers and jumps straight back to thesetjmp(env)call site, causingsetjmp()to return a second time withval(or1ifval == 0).
On x86_64 (System V ABI), jmp_buf stores the callee-saved registers (RBX, RBP, R12..R15), the stack pointer (RSP), and the instruction pointer (RIP).
Unlike a C++ exception, longjmp() never unwinds intermediate stack frames. It teleports RSP and RIP directly to the saved checkpoint, abandoning every C function currently in progress:
Call Stack During _zend_bailout() (grows downward)
+-----------------------------------+
| php_request_startup() |
| JMP_BUF __bailout |
+-----------------------------------+ <-- 2. RSP restored
| rfc1867_post_handler() | here! Frames
| -> php_register_variable() | below are
| -> (HashTable half-updated!) | abandoned
+-----------------------------------+ without
| _emalloc() -> _zend_bailout() | cleanup.
| calls LONGJMP(bailout, -1) ---- | --+
+-----------------------------------+ |
^-- 1. RSP at OOM +-> Jumps back up
PHP/Zend wraps setjmp() and longjmp() in three macros (zend_try, zend_catch, zend_end_try()) “stacked” through EG(bailout) so nested “try/catch”es can push and pop recovery points:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* Zend/zend.h: How PHP implements zend_try / zend_catch */
#define zend_try \
{ \
JMP_BUF *__orig_bailout = EG(bailout); \
JMP_BUF __bailout; \
\
EG(bailout) = &__bailout; \
if (SETJMP(__bailout) == 0) {
#define zend_catch \
} else { \
EG(bailout) = __orig_bailout;
#define zend_end_try() \
} \
EG(bailout) = __orig_bailout; \
}
When PHP hits a fatal error or exceeds PG(memory_limit), it calls _zend_bailout():
1
2
3
4
5
6
7
8
9
10
11
12
/* Zend/zend.c: Non-local jump on fatal / OOM error */
ZEND_API ZEND_NORETURN void _zend_bailout(const char *filename, uint32_t lineno)
{
if (!EG(bailout)) {
zend_output_debug_string(true, "%s(%d) : Bailing out without a bailout address!", filename, lineno);
exit(-1);
}
CG(unclean_shutdown) = 1;
CG(in_compilation) = 0;
EG(current_execute_data) = NULL;
LONGJMP(*EG(bailout), FAILURE); /* Teleports RSP/RIP back to zend_try */
}
Keep this fact in mind for Part 2: any C function that calls emalloc() can vanish mid-flight. If line 1 increments an array length or attaches a pointer to a global struct, and line 2 calls emalloc() and hits memory_limit, line 3 never executes.
Request lifecycle and the Pre-RINIT window
A natural question when studying PHP2Shell is how this exploit can work without a single line of unserland PHP code being executed and why effective mitigations can be deployed. The reason is in php_request_startup() (main/main.c), which prepares the engine before PHP compiles or executes the target .php file.
The Server Application Programming Interface (SAPI) “bridges” the web server (php-fpm, Apache) and the Zend Engine. On every incoming HTTP request, the SAPI calls php_request_startup():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* main/main.c: Request startup sequence in vulnerable PHP versions */
int php_request_startup(void) {
int retval = SUCCESS;
PG(modules_activated) = 0;
zend_try {
php_output_activate();
zend_activate(); /* 1. Resets Zend MM heap & executor globals */
sapi_activate(); /* 2. Checks post_max_size & selects POST handler */
php_hash_environment(); /* 3. PRE-RINIT: Parses multipart POST and
* fires upload hooks! */
/* 4. Module RINIT (including PHP_RINIT_FUNCTION(session)) runs AFTER
* POST parsing */
zend_activate_modules();
PG(modules_activated) = 1;
} zend_catch {
retval = FAILURE;
} zend_end_try();
return retval;
}
Both CVEs live in the Pre-RINIT window between
zend_activate()andzend_activate_modules().
sapi_activate() validates headers and picks a body parser
Inside sapi_activate(), sapi_read_post_data() inspects the Content-Type header.
When it sees multipart/form-data, it verifies that SG(request_info).content_length does not exceed PG(post_max_size) (8 MB by default) and registers rfc1867_post_handler as the POST handler.
Because multipart/form-data has post_reader = NULL, the body stream is not parsed inside sapi_activate() itself, but it is deferred to the next step.
php_hash_environment() parses the multipart body and triggers ext/session callbacks
php_hash_environment() allocates PHP’s six built-in superglobal arrays (i.e., 6 arrays namely $_POST, $_GET, $_COOKIE, $_SERVER, $_ENV, $_FILES, stored in PG(http_globals)[0..5]).
To populate $_POST PHP invokes sapi_handle_post(), which runs rfc1867_post_handler() (main/rfc1867.c) over the raw multipart stream.
This function is where we can trigger the OOM condition, thanks to a trick explained in Part 2.
As rfc1867_post_handler() reads form fields and file parts, it fires event hooks into php_session_rfc1867_callback() inside ext/session. All of this runs before any PHP script runs and before zend_activate_modules() calls RINIT.
PG(modules_activated) gates RSHUTDOWN during teardown
As the code snippet above shows, PG(modules_activated) is initially set at 0 and only flips to 1 after zend_activate_modules() returns. If startup fails early (i.e., modules_activated == 0), PHP doesn’t call module shutdown (RSHUTDOWN) functions for extensions whose RINIT never ran.
Specifically, in php_request_shutdown() when _zend_bailout() aborts step 3 (php_hash_environment()):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* main/main.c: Simplified shutdown sequence after a Pre-RINIT bailout */
void php_request_shutdown(void *dummy) {
/* 1. Destroys $_POST, $_GET, $_COOKIE, $_SERVER, $_ENV, $_FILES */
for (int i = 0; i < NUM_TRACK_VARS; i++) {
zval_ptr_dtor(&PG(http_globals)[i]); /* -> triggers CVE-2026-109618! */
}
/* 2. SKIPPED when PG(modules_activated) == 0
* PHP_RSHUTDOWN_FUNCTION(session) never runs to clear PS(id) or
* PS(http_session_vars)
*/
if (PG(modules_activated)) {
zend_deactivate_modules();
}
/* 3. Unconditionally wipes the entire request heap! */
shutdown_memory_manager(CG(unclean_shutdown), 0); /* -> arms CVE-2026-109619! */
}
That three-step shutdown explains how the two vulnerabilities fit together: step 1 walks half-built $_POST/$_FILES tables (CVE-2026-109618), while steps 2 and 3 wipe the request heap without running ext/session’s cleanup routine, leaving session pointers in .bss orphaned (CVE-2026-109619).
The Zend Memory Manager (Zend MM)
PHP/Zend does not call libc’s malloc() and free() for ordinary PHP variables. A single web request allocates and frees tens of thousands of strings, arrays, and objects, so PHP uses its own slab allocator split into two lifetimes:
| Allocator API | Lifetime | Cleanup mechanism |
|---|---|---|
pemalloc(..., 1) / malloc() / … | Worker-persistent | Lives until explicitly freed with pefree(..., 1) / free() or worker exit. |
emalloc() / ecalloc() / estrndup() / efree() / … | Request-scoped | Bound to the current HTTP request; wiped in bulk by shutdown_memory_manager(). |
Zend MM requests memory from the kernel via mmap() in 2 MB chunks (ZEND_MM_CHUNK_SIZE = 0x200000), aligned to a strict 2 MB virtual address boundary (meaning that the least significant 21 bits of every chunk base address are zero: 0x...00000 or 0x...200000, 0x...400000, etc.).
Each 2 MB chunk is divided into 512 pages of 4 KB (0x1000 bytes):
- Page 0 (
+0x0000..+0x0fff) is special; it holds thezend_mm_chunkheader, page allocation bitmaps (Bit = 0: The 4 KB page is free, Bit = 1: The 4 KB page is allocated), and metadata. - Pages 1..511 (
+0x1000..+0x1ff000) serve request allocations.
The first 2 MB chunk (mapped when a worker boots) is heap->main_chunk. If a large request exceeds 2 MB, Zend MM maps additional secondary 2 MB chunks and links them into a circular doubly linked list.
For fast small allocations, any request of 3072 bytes or less is rounded up to one of 30 fixed-size bins (0..29), each backed by a singly linked LIFO freelist (heap->free_slot[bin_num]):
| Bin # | Slot size | Notable PHP structures that land in this bin |
|---|---|---|
#1 | 16 B | zval (16 B), raw estrndup() buffers of 8..15 chars |
#3 | 32 B | zend_reference (32 B), single Bucket (32 B), raw estrndup() buffers of 24..31 chars |
#6 | 56 B | HashTable / zend_array (56 B), base zend_object (56 B) |
#7 | 64 B | 32-character session ID zend_string (24 B header + 32 B + NUL = 57 B) |
#11 | 128 B | Raw buffers or strings of 104..128 total bytes |
#12 | 160 B | php_session_rfc1867_progress (144 B), forged zend_object/zend_execute_data polyglot (96..160 B) |
#16 | 320 B | Minimum initialized HashTable arData block (8 buckets + hash index = 288 B) |
Free lists contain unallocated or freed slots, and can be populated even before the first free is called.
When a worker starts (or when the memory manager resets), every entry in heap->free_slot[0..29] is initialized to NULL:
memset(heap->free_slot, 0, sizeof(heap->free_slot))
Then, for example, when a PHP script makes its very first 32-byte allocation (Bin 3):
- Zend MM checks
heap->free_slot[3]. Suppose it is NULL. - It takes the “slow path” (
zend_mm_alloc_small_slow), which carves off a fresh 4 KB page from the 2 MB chunk. - A 4 KB page fits 128 slots of 32 bytes (== 4096 / 32, so we have Slot 0 to Slot 127):
- It gives Slot 0 to the PHP script immediately.
- It pre-links all the remaining untouched slots in a singly linked list (Slot 1 -> Slot 2 -> … -> Slot 127 -> NULL) with head in
heap->free_slot[3]. Note that the first 8 bytes of each slot (in 64 bits architectures), will have a (heap memory) pointer to the next slot.
[ Fresh 4 KB Page Carved ]
├ Slot 0 ──> Handed immediately to the caller of emalloc()
│
└ Slot 1..127 chained into heap->free_slot[3]:
heap->free_slot[M] ─> [Slot 1] ─> [Slot 2] ─> ... ─> [Slot 127] ─> NULL
To continue with the example, this could be the Main chunk layout after some allocations have been served:
2 MB Zend MM Main Chunk (Illustrative 2 MB-aligned base: 0x7f8000200000)
+---------------------------------------------------------------------------+
| Page 0 (+0x0000): zend_mm_chunk Header & Page Allocation Bitmaps |
+---------------------------------------------------------------------------+
| Page 1 (+0x1000): 4 KB Run of 160-Byte Slots (Bin #12) |
| +-----------------------+-----------------------+-----------------------+ |
| | Slot 0 (+0x1000) | Slot 1 (+0x10a0) | Slot 2 (+0x1140) ... | |
| +-----------------------+-----------------------+-----------------------+ |
+---------------------------------------------------------------------------+
| Page 2 (+0x2000): 4 KB Run of 32-Byte Slots (Bin #3: zend_reference, ...) |
+---------------------------------------------------------------------------+
| Pages 3 .. 511: Remaining 4 KB Pages |
+---------------------------------------------------------------------------+
Three Zend MM properties are interesting for the PHP2Shell exploit.
main_chunk survives request shutdown, secondary chunks do not
At the end of every request, shutdown_memory_manager() calls munmap() on every secondary 2 MB chunk allocated during that request, returning them to the OS.
However, it keeps heap->main_chunk permanently mapped at the exact same virtual address (to avoid mmap() syscall overhead on the next request) resetting only its page bitmaps and bin heads.
Any pointer into a secondary chunk becomes unmapped memory on Request $N+1$, but any pointer into heap->main_chunk remains valid, mapped, and immediately reusable.
2 MB chunk alignment turns any leaked main_chunk pointer into the exact heap base
Because heap->main_chunk is aligned to 0x200000 (2 MB), leaking any pointer inside main_chunk yields the chunk base with a single bitwise mask: chunk_base = leaked_ptr & ~0x1fffffUL.
Once chunk_base is known, Page 1 (chunk_base + 0x1000), Page 2 (chunk_base + 0x2000), and every fresh bin run is at a deterministic offsets.
Small-bin allocations are LIFO and non-zeroed.
Calling efree(ptr) writes a next_free_slot pointer into bytes 0x00..0x07 and leaves bytes 0x08..end untouched.
Calling emalloc() pops that slot without zeroing it. Any field left unwritten before an OOM bailout still holds whatever pointers or integers lived in that slot previously.
Core Zend data structures
To understand Part 2, you need five C structures from Zend/zend_types.h. Every size and hex offset below is for x86_64 PHP 8.2–8.5 builds.
zval (16 bytes ⇒ bin #1) and zend_refcounted_h (8 bytes)
Every PHP variable, array element, or parameter is stored in a 16-byte zval:
16-Byte zval (sizeof = 0x10, Bin #1)
+----------------------------------+---------------------+--------------------+
| +0x00: zend_value value (8B) | +0x08: u1.type_info | +0x0c: u2.extra |
| (int64, double, or heap pointer) | (4B type + flags) | (4B next/num_args) |
+----------------------------------+---------------------+--------------------+
Bytes +0x00..+0x07 hold an 8-byte union (lval, dval, str, arr, obj, ref, res, counted, ast, zv, ptr, ce, func, and ww ).
Bytes +0x08..+0x0b (u1.type_info) combine a 1-byte base type in the lowest byte (IS_UNDEF = 0, IS_NULL = 1, IS_STRING = 6, IS_ARRAY = 7, IS_OBJECT = 8, IS_REFERENCE = 10) with capability flags in byte 1 (IS_TYPE_REFCOUNTED = 1 << 0, IS_TYPE_COLLECTABLE = 1 << 1). For example:
- An
IS_OBJECTzvalhasu1.type_info = IS_OBJECT_EX = 0x0308(8 | (0x03 << 8)). - An
IS_REFERENCEzval(e.g.,$a = &$b) hasu1.type_info = IS_REFERENCE_EX = 0x010a(10 | (0x01 << 8)).
A 16-byte zval is just a small container:
- For simple types (
int,float,bool,null), the value fits right inside thezval’s first 8 bytes. No extra heap allocation is needed, and nothing is reference-counted. - For complex types (
zend_string,zend_array,zend_object,zend_reference), the data is too big to fit in 8 bytes. So thezval’s first 8 bytes store a pointer to a separate structure on the heap.
Note that
IS_STRINGis something relevant for the exploit. Azvalof that type encapsulates a pointer to azend_string.
Because multiple zvals can point to the same heap structure (for example, $a = $b), PHP needs to know how many zvals are currently pointing to that heap structure so it knows when it is safe to efree() it.
To do that uniformly, every heap-allocated Zend structure starts with the exact same 8-byte header at offset +0x00 (named zend_refcounted_h gc in the C structs):
zend_refcounted_h gc (First 8 bytes of every heap-allocated Zend struct)
+-------------------------------+-------------------------------+
| +0x00: uint32_t refcount (4B) | +0x04: uint32_t type_info (4B)|
+-------------------------------+-------------------------------+
This header is always included in the structures used for complex types (see below).
zend_string (24-byte header)
PHP represents userland strings on the heap with zend_string, which prepends a 24-byte (0x18) header ahead of the character array:
1
2
3
4
5
6
7
8
9
10
zend_string on the Heap (24B header + len + 1B NUL)
+-----------------------------------------------+
| +0x00: gc (8B) |
+-----------------------------------------------+
| +0x08: zend_ulong h (cached string hash, 8B) |
+-----------------------------------------------+
| +0x10: size_t len (byte length, 8B) |
+-----------------------------------------------+
| +0x18: char val[0..len] (controlled bytes) |
+-----------------------------------------------+
Because the engine populates gc (+0x00..+0x07), the cached hash h (+0x08..+0x0f), and the byte length len (+0x10..+0x17), the actual string characters (val[]) always start at offset +0x18.
zend_reference (32 bytes, Bin #3)
When two places in PHP share a variable by reference (such as the userland $_SESSION superglobal and the session module’s internal PS(http_session_vars) slot), both point to a 32-byte zend_reference wrapper on the heap:
32-Byte zend_reference (sizeof = 0x20 => Bin #3)
+--------------------------------+---------------------+
| +0x00: gc (8B) | +0x08: val.value(8B)|
+----------------+---------------+---------------------+
| +0x10: val.u1 | +0x14: val.u2 | +0x18: sources (8B) |
+----------------+---------------+---------------------+
Notice that zend_reference includes an inner zval val spanning bytes +0x08..+0x17.
When the outer zend_reference’s gc.refcount drops to 0, PHP destroys zend_reference by calling i_zval_ptr_dtor(&ref->val) on that inner zval
HashTable (zend_array, 56 bytes, Bin #6) and Bucket (32 bytes)
Every PHP array ($_POST, $_FILES, $_SESSION) is a 56-byte HashTable (struct _zend_array) pointing to a contiguous array of 32-byte Bucket entries (ht->arData):
56-Byte HashTable (sizeof = 0x38, Bin #6)
+-----------------------+-----------------------+
| +0x00: gc (8B) | +0x08: flags/mask (8B)|
+-----------------------+-----------------------+
| +0x10: Bucket *arData | +0x18: nNumUsed (4B) |
| (8B pointer) | +0x1c: nNumOfElts (4B)|
+-----------------------+-----------------------+
| +0x20..+0x2f: nTableSize, nNextFreeElement |
+-----------------------------------------------+
| +0x30: dtor_func_t pDestructor (8B fn ptr) |
+-----------------------------------------------+
|
v ht->arData[idx]
32-Byte Bucket Entry (sizeof = 0x20)
+-----------------------------------------------+
| +0x00: zval val (16B: value + u1.type_info) |
+-----------------------+-----------------------+
| +0x10: ulong h (8B) | +0x18: *key (8B ptr) |
+-----------------------+-----------------------+
When a HashTable is first initialized (HT_MIN_SIZE = 8), ht->arData allocates 32 + (8 * 32) = 288 bytes (Bin #16: 320 B). ht->nNumUsed tracks the high-water mark of arData[0 .. nNumUsed - 1] slots that zend_array_destroy() will inspect at teardown.
Packed array (zend_array, 56 bytes, Bin #6) and zval (16 bytes)
An array built from contiguous integer keys (a[0], a[1], …) is the same 56-byte HashTable (struct _zend_array), but its +0x10 field is zval *arPacked: a flat run of 16-byte zval entries with no Bucket wrappers and no hash table (nTableMask = 0):
56-Byte HashTable (sizeof = 0x38, Bin #6)
+-----------------------+-----------------------+
| +0x00: gc (8B) | +0x08: flags/mask (8B)|
+-----------------------+-----------------------+
| +0x10: zval *arPacked | +0x18: nNumUsed (4B) |
| (8B pointer) | +0x1c: nNumOfElts (4B)|
+-----------------------+-----------------------+
| +0x20..+0x2f: nTableSize, nNextFreeElement |
+-----------------------------------------------+
| +0x30: dtor_func_t pDestructor (8B fn ptr) |
+-----------------------------------------------+
|
v arPacked[idx]
16-Byte zval Entry (sizeof = 0x10)
+-----------------------+-----------------------+
| +0x00: zend_value | +0x08: u1.type_info |
| (8B: tagged | (4B: type + |
| union) | flags) |
+-----------------------+-----------------------+
| +0x0c: u2 (4B) | |
+-----------------------+-----------------------+
When n fields are inserted, ht->arPacked allocates n * 16 bytes (rounded up to the Zend MM bin).
Each zval’s 8-byte zend_value is a tagged union: for refcounted types (IS_STRING, IS_ARRAY, IS_OBJECT, IS_RESOURCE, IS_REFERENCE) it holds a pointer to a separately allocated value; for IS_LONG / IS_DOUBLE it stores the scalar inline in the same 8 bytes.
The type byte (low byte of u1.type_info) selects the interpretation, and the IS_TYPE_REFCOUNTED flag bit (e.g. 0x06 | 0x01 << 8 = 0x0106 for a string) marks the pointer case. There is no per-element Bucket and no arHash.
zend_object (56 bytes, Bin #6) and destructor dispatch
For an IS_OBJECT zval (u1.type_info = 0x0308), zval.value.obj points to a 56-byte zend_object on the heap, whose handlers pointer at +0x18 points to a zend_object_handlers virtual function table (normally std_object_handlers in the PHP binary’s read-only .rodata segment):
56-Byte zend_object (sizeof = 0x38, Bin #6)
+-----------------------+-----------------------+
| +0x00: gc (8B) | +0x08: handle (4B) |
+-----------------------+-----------------------+
| +0x10: *ce (8B) | +0x18: *handlers (8B) |--\
+-----------------------+-----------------------+ |
| +0x20: HashTable *properties (8B) | |
+-----------------------------------------------+ |
| +0x28: zval properties_table[1] (16B) | |
+-----------------------------------------------+ |
|
+-----------------------------------------/
v
zend_object_handlers Table (.rodata or forged)
+-----------------------------------------------+
| +0x00: int offset |
| +0x08: free_obj(zend_object *object) |
| +0x10: dtor_obj(zend_object *object) |
+-----------------------------------------------+
Whenever PHP destroys a zval via i_zval_ptr_dtor(zval *zval_ptr), the engine runs the following chain:
- Checks
Z_REFCOUNTED_P(zval_ptr)(testing byte 1 ofu1.type_info); - Follows
ref = zval_ptr->value.countedand decrementsref->gc.refcount(--refcount); - Only if
refcountreaches0, it callsrc_dtor_func(ref); - If
refis anIS_OBJECT,rc_dtor_func()callszend_objects_store_del(obj), which temporarily bumpsobj->gc.refcountback to1(GC_ADDREF(obj)) and invokesobj->handlers->dtor_obj(obj)(at*(obj->handlers + 0x10)), passingobjin theRDIregister as the first argument.
That
dtor_obj(obj)call is the ultimate control-flow gadget in Part 2.
Part 2: Vulnerabilities and how the exploit is built
Armed with the knowledge from Part 1 we can examine each CVE separately, verify why CVE-2026-109618 is unreliable on its own, and then trace how chaining it with CVE-2026-109619 yields a repeatable exploit.
CVE-2026-109618: Out-of-memory bailout inside HashTable updates
In vulnerable PHP versions, PG(memory_limit) acted as an instant tripwire inside Zend/zend_alloc.c. Whenever zend_mm_alloc_pages() could not satisfy an emalloc() call without exceeding heap->limit, it called zend_mm_safe_error():
1
2
3
4
5
6
/* Zend/zend_alloc.c: Pre-patch OOM handling */
static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
const char *format, size_t limit, size_t size) {
heap->overflow = 1;
zend_error_noreturn(E_ERROR, format, limit, size); /* -> calls _zend_bailout()! */
}
Now look at what happens inside Zend/zend_hash.c when PHP inserts a string-keyed entry into a HashTable (for example, while php_register_variable_ex() populates $_POST or $_FILES from an incoming HTTP body):
1
2
3
4
5
6
7
8
9
10
11
12
/* Zend/zend_hash.c: Excerpt from _zend_hash_str_add_or_update_i() */
idx = ht->nNumUsed++;
ht->nNumOfElements++;
p = ht->arData + idx;
/* OOM HAZARD: ht->nNumUsed was ALREADY incremented above!
* If zend_string_init() crosses memory_limit, _zend_bailout() longjmps out
* BEFORE p->key or p->val are ever written!
*/
p->key = zend_string_init(str, len, 0);
p->h = h = ZSTR_H(p->key);
ZVAL_COPY_VALUE(&p->val, pData);
Trace the above, line by line:
idx = ht->nNumUsed++;claimsht->arData[idx]as an active bucket.p->key = zend_string_init(str, len, 0);callsemalloc()to allocate azend_stringfor the new array key.- If that
emalloc()hitsmemory_limit,_zend_bailout()firesLONGJMP(*EG(bailout), FAILURE). Execution teleports straight back tophp_request_startup()’szend_catchblock, skippingp->key = ...andZVAL_COPY_VALUE(&p->val, pData). - What sits inside
ht->arData[idx]right now? Because Zend MM never zeroes small-bin or page allocations,p->val(+0x00..+0x0f) andp->key(+0x18..+0x1f) contain raw residual bytes left over from whatever occupied that memory previously.
Immediately after php_request_startup() returns FAILURE, the SAPI worker calls php_request_shutdown(), which destroys PG(http_globals)[0..5] ($_POST, $_FILES, etc.) via zend_array_destroy():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Zend/zend_hash.c: Array destruction walks 0 .. nNumUsed - 1 */
ZEND_API void ZEND_FASTCALL zend_array_destroy(HashTable *ht)
{
Bucket *p, *end;
if (HT_HAS_STATIC_KEYS_ONLY(ht)) { /* ... */ }
p = ht->arData;
end = p + ht->nNumUsed;
for (; p != end; p++) {
if (Z_TYPE(p->val) != IS_UNDEF) {
/* Evaluates uninitialized residual bytes in p->val and p->key! */
if (p->key) {
zend_string_release_ex(p->key, 0);
}
i_zval_ptr_dtor(&p->val);
}
}
efree(HT_GET_DATA_ADDR(ht));
efree(ht);
}
Because ht->nNumUsed includes the half-built slot idx, zend_array_destroy() inspects p->val and p->key as if they were real Zend objects and strings:
Uninitialized Bucket ht->arData[idx] Evaluated by zend_array_destroy():
+-----------------------------------+-------------------+-------------------+
| +0x00: p->val.value (8B) | +0x08: type_info | +0x0c: u2 (4B) |
| (Residual ptr -> fake zend_object)| (e.g. 0x0308 OBJ) | |
+-----------------------------------+-------------------+-------------------+
| +0x10: p->h (8B residual hash) | +0x18: p->key (8B residual string ptr)|
+-----------------------------------+---------------------------------------+
The same partial-initialization pattern repeats in several places across php-src (allocating nested $_POST sub-arrays in php_register_variable_ex(), resizing strings, or building error backtraces).
Why CVE-2026-109618 is rarely enough by itself
On paper, CVE-2026-109618 already gives an attacker destructor dispatch (i_zval_ptr_dtor(&p->val)) when residual bytes in ht->arData[idx] carry a refcounted type_info such as IS_OBJECT_EX (0x0308).
Why not exploit CVE-2026-109618 on its own, either in a single request or across two requests on the same worker?
Try building that exploit using only CVE-2026-109618 and you hit two walls.
CVE-2026-109618 is a teardown-only sink with no info-leak channel
Forging p->val.value.obj requires knowing a valid heap address (heap->main_chunk), and forging obj->handlers->dtor_obj requires knowing the PIE base address of the PHP binary (or any dynamic library).
Because CVE-2026-109618 only triggers inside zend_array_destroy() while an OOM’d Pre-RINIT request is dying, no PHP script ever runs, no data from the uninitialized Bucket is returned in the HTTP response, and shutdown_memory_manager() wipes the HashTable immediately afterward.
You cannot use a first CVE-2026-109618 request to disclose addresses for a second request, and if you guess an address blindly, i_zval_ptr_dtor() dereferences an unmapped pointer, killing the worker with SIGSEGV and re-randomizing ASLR on the replacement child.
Even after you know ASLR (by some other means), triggering dtor_obj via zend_array_destroy() is brittle
Once you have leaked addresses, you could groom heap->main_chunk so that a subsequent CVE-2026-109618 OOM lands ht->arData[idx] on top of residual fake Bucket bytes.
However, surviving zend_array_destroy() requires aligning the entire arData allocation and ensuring both p->key (zend_string_release_ex) and p->val (i_zval_ptr_dtor) hold valid pointers or NULL/IS_UNDEF simultaneously, or teardown crashes before reaching your target destructor.
To turn an out-of-memory bailout into a reliable multi-request weapon on the same worker, you first need persistent state that survives across HTTP requests; both to read back raw heap/PIE pointers into an HTTP response (defeating ASLR) and to provide a clean, single-slot dangling pointer to hijack on the follow-up request.
That cross-request state is exactly what CVE-2026-109619 provides.
CVE-2026-109619: Cross-request dangling globals in ext/session
By default, PHP ships with session.upload_progress.enabled = 1. This feature lets a web application show an upload progress bar by writing byte counts into $_SESSION while a multipart/form-data upload is still streaming in over the network.
Because upload progress must update during upload reception, ext/session registers php_session_rfc1867_callback() (ext/session/session.c) with the SAPI multipart parser.
As we saw in Part 1, rfc1867_post_handler() invokes this callback inside php_hash_environment(), during Pre-RINIT (PG(modules_activated) == 0), before PHP_RINIT_FUNCTION(session) has run.
Upload tracking uses a 144-byte (0x90) scratch struct (php_session_rfc1867_progress, allocated in Zend MM Bin #12: 160 bytes) stored in PS(rfc1867_progress), alongside the main session globals PS(session_status), PS(id), and PS(http_session_vars):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* ext/session/session.c:
* Real 144-byte (0x90) upload progress struct -> Bin #12 (160B) */
typedef struct _php_session_rfc1867_progress {
size_t sname_len; /* +0x00 (8B:strlen(PS(session_name))) */
zval sid; /* +0x08 (16B: captured PHPSESSID string) */
smart_str key; /* +0x18 (16B: "upload_progress_<name>") */
zend_long update_step; /* +0x28 (8B) */
zend_long next_update; /* +0x30 (8B) */
double next_update_time; /* +0x38 (8B) */
bool cancel_upload; /* +0x40 (1B) */
bool apply_trans_sid; /* +0x41 (1B + 6B alignment padding) */
size_t content_length; /* +0x48 (8B) */
zval data; /* +0x50 (16B: IS_ARRAY progress table) */
zval files; /* +0x60 (16B: IS_ARRAY files sub-table) */
zval *post_bytes_processed; /* +0x70 (8B raw zval ptr into data) */
zval *current_file_bytes_processed; /* +0x78 (8B raw zval ptr
* into current_file) */
zval current_file; /* +0x80 (16B) -> Total sizeof = 0x90 */
} php_session_rfc1867_progress;
Now look at what php_session_rfc1867_callback() actually does across the multipart events of an upload that includes a PHPSESSID cookie, a PHP_SESSION_UPLOAD_PROGRESS form field, and a file part:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/* ext/session/session.c: Simplified from vulnerable
* php_session_rfc1867_callback() */
static int php_session_rfc1867_callback(unsigned int event, void *event_data,
void **extra) {
php_session_rfc1867_progress *progress = PS(rfc1867_progress);
switch (event) {
case MULTIPART_EVENT_START:
/* BUG #1: PHP_RINIT_FUNCTION(session) has NOT run yet!
* Notice PS(rfc1867_progress) is freshly allocated here,
* BUT stale PS(id), PS(session_status), and PS(http_session_vars)
* from an aborted prior request are NEVER cleared!
*/
if (!PS(rfc1867_enabled)) {
return SUCCESS;
}
progress = ecalloc(1, sizeof(php_session_rfc1867_progress));
PS(rfc1867_progress) = progress;
progress->sname_len = strlen(PS(session_name));
break;
case MULTIPART_EVENT_FORMDATA:
/* Captures PHPSESSID (or cookie) into progress->sid and
* PHP_SESSION_UPLOAD_PROGRESS value into progress->key.
*/
break;
case MULTIPART_EVENT_FILE_START:
/* The moment the first file part starts, ext/session boots a real
* PHP session right inside Pre-RINIT! In 8.4.26 the code calls
* php_rinit_session(0), then assigns PS(id) and clears
* PS(send_cookie), before php_session_rfc1867_update():
* -> php_session_initialize() sets PS(id) (Bin #7, 64B) and
PS(session_status) = active
* -> php_session_track_init() sets PS(http_session_vars)
(Bin #3, 32B zend_reference)
*/
if (Z_TYPE(progress->sid) && progress->key.s) {
php_rinit_session(0);
PS(id) = zend_string_init(Z_STRVAL(progress->sid),
Z_STRLEN(progress->sid), 0);
PS(send_cookie) = 0; /* <-- the flag that blocks the
* Set-Cookie leak (Step 2) */
php_session_rfc1867_update(progress, 0);
}
break;
case MULTIPART_EVENT_END:
/* Normal cleanup when multipart parsing finishes cleanly */
if (PS(rfc1867_progress)) {
php_session_end_upload_progress();
efree(PS(rfc1867_progress));
PS(rfc1867_progress) = NULL;
}
break;
}
return SUCCESS;
}
Look closely at what happens when Request $N$ sends PHP_SESSION_UPLOAD_PROGRESS, triggers MULTIPART_EVENT_FILE_START, and then hits memory_limit on subsequent form fields:
- At
MULTIPART_EVENT_FILE_START,php_rinit_session(0)andphp_session_rfc1867_update()start the session early. In persistent.bss(php_ps_globals), it sets:PS(session_status) = php_session_activePS(id)= pointer to azend_string(Bin#7,64 B) on Request $N$’sheap->main_chunkPS(http_session_vars)=IS_REFERENCE_EXzvalpointing to azend_reference(Bin#3,32 B) on Request $N$’sheap->main_chunk
- Later in the same multipart body, additional form parts (with a trick explained below) push Zend MM past
PG(memory_limit)and trigger_zend_bailout(). MULTIPART_EVENT_ENDis skipped becauserfc1867_post_handler()was abandoned mid-loop bylongjmp().php_request_shutdown()checksif (PG(modules_activated))(that is0 == false) and skipsPHP_RSHUTDOWN_FUNCTION(session)(includingphp_rshutdown_session_globals()).shutdown_memory_manager()wipes Request $N$’s heap, keepingheap->main_chunkmapped at its 2 MB base address with every page marked FREE.- When Request $N+1$ arrives on the same worker with a
multipart/form-databody,MULTIPART_EVENT_STARToverwritesPS(rfc1867_progress)with a newecalloc(), but leavesPS(session_status),PS(id)(a pointer tozend_string), andPS(http_session_vars), untouched and pointing straight intoheap->main_chunk.
Worker Memory Across Request N -> Request N+1 (Same Worker PID)
Worker .bss (php_ps_globals) heap->main_chunk (0x7f8000200000)
(Persists across requests) (Stays mapped; reset on shutdown)
[1] REQUEST N (Pre-RINIT: FILE_START runs, then OOM -> _zend_bailout)
+------------------------------------+ +-------------------------------+
| PS(session_status) = active | | +0x0000: zend_mm_chunk header |
| PS(id) = 0x7f8000203000|--->| +0x3000 (Bin #7, 64B): |
| PS(http_session_vars) | | zend_string session ID |
| .value.ref = 0x7f8000202000|-+ +-------------------------------+
| .u1.type_info = IS_REFERENCE | +->| +0x2000 (Bin #3, 32B): |
+------------------------------------+ | zend_reference -> $_SESSION |
+-------------------------------+
[2] REQUEST N SHUTDOWN (PG(modules_activated)==0 -> RSHUTDOWN skipped!)
+------------------------------------+ +-------------------------------+
| PS(session_status) = active | | +0x3000 (Bin #7): [ FREE ] |
| PS(id) = DANGLING! -+--->| +0x2000 (Bin #3): [ FREE ] |
| PS(http_session_vars) = DANGLING! -+--> +-------------------------------+
+------------------------------------+
[3] REQUEST N+1 (Pre-RINIT: Multipart parsed BEFORE session RINIT!)
+------------------------------------+ +-------------------------------+
| PS(id) = 0x7f8000203000 -+--->| +0x3000: Reclaimed by Req N+1 |
| PS(http_session_vars) | | objects/data (ASLR leak!) |
| .value.ref = 0x7f8000202000 -+\ +-------------------------------+
+------------------------------------+ +->| +0x2000: Reclaimed by Req N+1 |
| 32B rfc1867 buffer (hijack!)|
+-------------------------------+
Note: Under normal circumstances, PHP relies on a contract between GINIT (in
MINIT, so only once in the lifetime of the process) and RSHUTDOWN to ensure thatPS(id)is alwaysNULLbefore any request’s Pre-RINIT runs. The exploit leverages “exception handling” to make sure that contract is not fulfilled.
The same applies for
PS(http_session_vars).
Other CVE-2026-109619 surfaces: Upstream advisory GHSA-6wmf-4rv5-p3rp also notes two related stale-state paths after an OOM bailout: PHP 8.5’s fatal_error_backtraces recorder (which could inspect dangling call frames) and userland session_start() / unserialize() state after a caught or late bailout. Pre-RINIT upload_progress gets all the attention in PHP2Shell because it is reachable on default PHP installations before a single line of application code runs.
Step-by-step: How the PHP2Shell exploit chain is built
Now we can assemble every piece from Part 1 and Part 2 into the complete four-step exploit architecture, checking every constraint as an exploit developer has to solve it.
Step 1: Crossing memory_limit (128M) inside post_max_size (8M) while pinning pointers in main_chunk
The first obstacle is a size paradox: default PHP sets post_max_size = 8M and memory_limit = 128M. \
If you send a 129 MB HTTP body to exhaust memory_limit, sapi_read_post_data() rejects the request during sapi_activate() before rfc1867_post_handler() ever starts.
So, how do you allocate more than 128 MB of Zend MM memory from an HTTP body smaller than 8 MB?
Simple (LOL)! By exploiting HashTable structural overhead in php_register_variable_ex() (main/php_variables.c).
Whenever PHP parses a form variable with empty brackets like a[][][][]... (or thousands of short nested bracketed keys), every [] level allocates two heap blocks:
- A new
HashTablestruct: 56 bytes (rounded to Bin#6: 56 B). - That table’s initial 8-slot
arDatabucket array (HT_MIN_SIZE = 8):32 Bhash index +8 * 32 Bbuckets = 288 bytes (rounded to Bin#16: 320 B).
376 bytes of Zend MM heap for every 2 bytes ([]) on the wire is a 188x memory amplification ratio!
However, the plain [] trick alone cannot reach 128 MB under default PHP limits: max_input_vars = 1000 caps the number of parts and max_input_nesting_level = 64 caps the bracket depth, so it tops out around 24 MB (1000 * 64 * 376 B).
The amplifier that actually crosses 128 MB within the defaults is the empty-upload trick.
A multipart/form-data file part with filename="" is not counted toward max_file_uploads, yet rfc1867_post_handler() still registers all six $_FILES sub-entries (name, full_path, type, tmp_name, error, size) under the nested field name.
Each of those six entries walks the same nested HashTable hierarchy, so one short empty-upload record (g[i][a][a]...[a] with filename="") allocates roughly 6x the HashTable/Bucket overhead of a plain POST field.
Around 990 such records at depth 62 (990 * 62 * 6 * 376 B =~ 132 MB) blow past memory_limit while the complete body stays under 1 MB; well inside post_max_size = 8M, max_input_vars = 1000, and max_input_nesting_level = 64.
Ordering inside Request $N$’s multipart body is critical here. Remember from Part 1 that shutdown_memory_manager() munmap()s secondary 2 MB chunks while keeping heap->main_chunk mapped:
Request N Multipart Ordering (Pins Targets in main_chunk)
+----------------------------------+----------------------------------+
| Part 1: UPLOAD_PROGRESS | Sets progress->key (< 4 KB used) |
+----------------------------------+----------------------------------+
| Part 2: name="f"; filename="a" | Fires MULTIPART_EVENT_FILE_START |
| (1-byte file upload) | Allocates PS(id) & session_vars |
| | LOW in main_chunk (stays mappep) |
+----------------------------------+----------------------------------+
| Parts 3..K: Empty-upload records | ~990 parts (62-deep, filename="")|
| (Amplification payload) | Allocates 64+ secondary chunks |
| | -> OOM triggers _zend_bailout()! |
+----------------------------------+----------------------------------+
If Parts 3..K came before Part 2, PS(id) and PS(http_session_vars) would be allocated inside a secondary chunk (chunk #60), which shutdown_memory_manager() unmaps on bailout (causing an instant SIGSEGV on Request $N+1$).
Placing the file part first pins both dangling pointers inside heap->main_chunk, while the amplifier pushes secondary chunks over memory_limit afterward.
Step 2: Defeating ASLR and PIE via dangling PS(id)
Over the same HTTP/1.1 Keep-Alive TCP socket (so the web server routes the next request onto the same worker), the client sends Request $N+1$.
At startup, PS(session_status) is already php_session_active and PS(id) still points to its freed 64-byte slot (Bin #7) inside heap->main_chunk (say 0x7f8000203000).
Look at how PHP interprets PS(id) as a zend_string:
Dangling PS(id) (zend_string ptr at 0x7f8000203000, Bin #7: 64B)
+-----------------------------+---------------------------------+
| +0x00..+0x0f: gc + hash | +0x10..+0x17: size_t len |
+-----------------------------+---------------------------------+
| +0x18..+0x3f: char val[0..len-1] |
| <-- PHP reads PS(id) string bytes starting here! |
+---------------------------------------------------------------+
When early allocations in Request $N+1$ (or residual 56-byte zend_object / HashTable structures from Bin #6/#7 page reuse and CVE-2026-109618 partial initializations) populate that region of heap->main_chunk, two classes of internal pointers sit inside those recycled slots:
.rodatahandler pointers: Any standard PHP object (zend_object) stores&std_object_handlersat+0x18, pointing directly into the PHP binary’s.rodatasegment.- Heap pointers:
HashTable.arData(+0x10) andBucket.key(+0x18) point directly intoheap->main_chunk. (Zend MM freelist links also sit at+0x00of freed slots, but on PHP 8.4 they are XOR-obfuscated with the per-heapshadow_key, so they are not plain heap pointers.)
The cleanest exfiltration channel to read these bytes is the SID constant: php_session_reset_id() builds SID = "PHPSESSID=" + ZSTR_VAL(PS(id)) unconditionally (it does not depend on PS(send_cookie)), so any script that echoes SID reflects the reclaimed bytes straight into the HTTP response body.
In a real-world scenario, the Set-Cookie: PHPSESSID= header would be the ideal application-agnostic leak; however, MULTIPART_EVENT_FILE_START explicitly sets PS(send_cookie) = 0, silencing that header.
When ext/session reads PS(id), the reflected bytes disclose both pointers:
- A heap pointer (leaked_heap_ptr), leaked when
PS(id)overlaps with azvalor bucket array. - A binary
.rodatapointer (leaked_handlers_ptr), leaked whenPS(id)overlaps with azend_object’s handlers table.
From these two 8-byte pointers, the attacker computes the full memory map with two simple arithmetic operations:
1
2
3
4
5
6
/* 1. Defeat Heap ASLR (chunks are 2 MB-aligned): */
uint64_t main_chunk_base = leaked_heap_ptr & ~0x1fffffULL;
/* 2. Defeat PIE ASLR: */
uint64_t php_pie_base = leaked_handlers_ptr - OFFSET_STD_OBJECT_HANDLERS;
uint64_t addr_zif_system = php_pie_base + OFFSET_ZIF_SYSTEM;
With main_chunk_base and addr_zif_system known, every page in heap->main_chunk (main_chunk_base + 0x1000, + 0x2000, …) and every function in the PHP binary now has a known virtual address.
A full demonstration of the first 2 steps, including automated construction of a testbed with Docker, is available at https://github.com/dguerri/php2leak.git. For demo purposes, we will just provide a PHP page echoing SID.
In short, as an example of heap grooming that will defeat ASLR:
Request N
We use filename="a" to fire MULTIPART_EVENT_FILE_START, which is the only place PS(id) is written. So the zend_string pointed by PS(id) will be set to PHPSESSID.
Since we specify PHPSESSID=\x00 in the form-data, a new PHPSESSID is generated. This makes it so Zend will use a 64B bin (#7) slot.
We trigger an OOM here, so cleanup never happens and PS(id) keeps pointing to the same 64B bin slot.
Request N+1 (in the same TCP connection ⇒ same worker)
We use PHPSESSID=Z, otherwise we won’t get into MULTIPART_EVENT_END.
In this request we make the engine allocate a packed array, using form-data names c[0]...c[39], where each value is a 20B string ("V"*20). We choose 40 elements of 20B each to groom the heap so that PS(id) will point right where we want, to allow the ASLR leak.
So:
1
2
3
4
5
Request N:
PS(id) ─→ zend_string ZSTR_VAL(PS(id)) = zend_string's .val bytes
Request N+1:
PS(id) ─→ now in arPacked ZSTR_VAL(PS(id)) = c[?].value = zend_string* ─→ "V"*20
If we can read the session id, we can learn the c[?].value zend_string pointer.
Step 3: Reclaiming PS(http_session_vars) in Bin #3 using headerless rfc1867 buffers
<REDACTED - 2026-10-31>
Step 4: Turning dtor_obj(obj) into zif_system() via a zend_object / zend_execute_data polyglot
<REDACTED - 2026-10-31>
Why real servers break naive exploits, and where AI actually fits
Reading the four steps above makes it clear why every one-request “curl PoC” posted after October 21 was fake, and why turning a lab exploit into something that survives a production server is an uphill engineering problem.
Four practical hurdles trip up naive exploits immediately:
- Hitting the narrow
memory_limitwindow blindly. On a target whosephp.iniis unknown,memory_limitcould be128M,256M,512M, or-1(unlimited, which disables OOM bailout altogether!), whilepost_max_sizecould be8Mor2M. The amplifier in Step 1 has to crossmemory_limitafterMULTIPART_EVENT_FILE_STARTruns, without exceedingpost_max_sizeormax_input_vars. - Pinning the exact worker PID across requests. Behind Nginx, Envoy, or a cloud load balancer, a
php-fpmpool often runs 20 to 200 worker processes, and reverse proxies frequently multiplex upstream connections. If Request $N$ arms worker PID4182, and a normal visitor’s page load hits PID4182a millisecond before the attacker’s Request $N+1$,zend_activate_modules()runsPHP_RINIT_FUNCTION(session), resetsPS(id)andPS(http_session_vars), and disarms the UAF harmlessly. - Null bytes and post-command continuation. Canonical 64-bit Linux user-space addresses (
0x00007f8000204000) always end with two top0x00bytes, and fields likeextra_named_params(+0x48) must beNULL. While multipart file bodies andzend_stringvalues are binary-safe (carryinglenexplicitly), severalrfc1867header parsers usestrchr()/strlen()and truncate on embedded null bytes, forcing an exploit writer to weave binary-safe allocations with existing zeroed bytes or freelist leftovers. Furthermore, whenzif_systemfinishes, it writes its return boolean/string into*return_value(RSI), soRSIat the moment ofdtor_obj(object)must either point to writable scratch memory or the command itself must background its payload and call_exit(0)cleanly before returning. - Extension heap noise and symbol table variance. Every production extension (
opcache,redis,igbinary, Datadog or New Relic agents) allocates and frees small bins during startup, shifting which 32-byte and 64-byte slots sit at the top ofheap->free_slot[]. And computingaddr_zif_systemfrom&std_object_handlersrequires fingerprinted symbol offsets for that exact distro package (for instance, Ubuntu 24.04php8.3-fpmvs. official Dockerphp:8.4-fpm-alpine).
How AI changes exploit development without replacing systems expertise
Historically, building an exploit chain like PHP2Shell took a small team weeks of manual work: expanding nested C macros by hand, auditing hundreds of emalloc() paths across php-src, and writing one-off GDB scripts to watch heap->free_slot[] transitions.
Coding agents shorten the mechanical parts of that workflow dramatically. An agent can scan all 1,500+ C files in php-src in minutes to list every site where nNumUsed++ or a PS(...) assignment precedes an allocation, verify byte alignments between zend_object (0x38 bytes) and zend_execute_data (0x50 bytes) with a quick offsetof() harness, and generate GDB Python scripts to record every Bin #3 and Bin #12 allocation inside rfc1867_post_handler() across ten Docker images in parallel.
What AI cannot do on its own is invent a sound mental model when its assumptions about the runtime are wrong. Without an engineer who checks the actual php-src control flow, an LLM happily assumes MULTIPART_EVENT_START leaves PS(rfc1867_progress) intact, tries to overwrite offset +0x08 using a zend_string whose header spans +0x00..+0x17, or forgets that GC_ADDREF(object) clobbers [RDI + 0x00] right before dtor_obj. Tools accelerate the search and verification loop, while systems knowledge still separates a working exploit from a segfault.
Part 3: Upstream fix and remediation
How commit 01a40e89fb fixes the allocator and session lifecycle
On October 21, 2026, PHP core developers Arnaud Le Blanc and Ilija Tovilo shipped commit 01a40e89fb (“Avoid state corruption via bailout”) in PHP 8.2.35, 8.3.36, 8.4.27, and 8.5.12.
Auditing every single emalloc() call across PHP and third-party C extensions would have been error-prone and incomplete. Instead, the patch fixes both architectural root causes directly inside the Zend Memory Manager and ext/session.
Two-tier memory limits (Zend/zend_alloc.c)
The patch splits memory_limit into a soft ceiling and a hard ceiling controlled by a new php.ini directive, memory_hard_limit_percent:
- Soft limit (
memory_limit): Crossingmemory_limitinside ordinary, non-fallibleemalloc()calls no longer fires_zend_bailout()mid-function! Instead, Zend MM satisfies the small structural allocation, records an overflow flag, and defers the fatal out-of-memory error until execution reaches a safe Zend VM instruction boundary where C data structures are consistent. - Hard limit (
memory_hard_limit_percent): Defaults to-1(meaning 200% ofmemory_limitwhenmemory_limit <= 1G, such as a256Mhard ceiling for a128Msoft limit, ormemory_limit + 1Gabove1G). If runaway C allocations cross this hard ceiling before reaching a VM checkpoint, PHP refuses to calllongjmp()or run destructors on half-built structures. Instead,zend_mm_hard_limit_error()terminates the worker process immediately via_exit(137)so thephp-fpmmaster spawns a clean worker:
/* Zend/zend_alloc.c: Patched two-tier OOM handling (commit 01a40e89fb) */
static ZEND_COLD ZEND_NORETURN void zend_mm_hard_limit_error(
zend_mm_heap *heap, size_t limit, size_t size)
{
/* Do NOT longjmp or run destructors on potentially half-built state.
* Terminate the worker process immediately so the master spawns a clean worker.
*/
fprintf(stderr, "Fatal error: Allowed memory size of %zu bytes exhausted "
"(hard limit reached, tried to allocate %zu bytes)\n", limit, size);
_exit(137);
}
Explicit fallible allocators (femalloc, fecalloc, safe_ferealloc)
Where C parsers or built-in functions genuinely allocate huge user-controlled buffers that should fail right at the soft memory_limit without killing the worker via _exit(137), the patch introduces explicit fallible allocation functions (femalloc(), fecalloc(), safe_ferealloc(), zend_string_finit()). Unlike emalloc(), these return NULL on soft-limit exhaustion so the caller can roll back local state cleanly before raising an error.
Pre-RINIT session reset and pointer hardening (ext/session/session.c)
To close CVE-2026-109619, php_session_rfc1867_callback() now calls php_rinit_session_globals() right at MULTIPART_EVENT_START, before inspecting PS(rfc1867_enabled) or touching any session state, and cleans up session globals unconditionally if a request aborts before zend_activate_modules():
/* ext/session/session.c: Patched RFC 1867 callback */
static int php_session_rfc1867_callback(unsigned int event, void *event_data, void **extra)
{
switch (event) {
case MULTIPART_EVENT_START:
/* Wipe any leftover PS(id), PS(session_status), or PS(http_session_vars)
* in Pre-RINIT before PHP_RINIT_FUNCTION(session) has run!
*/
php_rinit_session_globals();
if (!PS(rfc1867_enabled)) {
return SUCCESS;
}
/* ... */
break;
}
}
In addition, upstream removed cached raw zval * interior pointers (post_bytes_processed and current_file_bytes_processed) from php_session_rfc1867_progress so resizing HashTables during upload tracking cannot leave stale interior pointers behind.
Defensive mitigations when immediate patching is delayed
Upgrading to PHP 8.2.35+, 8.3.36+, 8.4.27+, or 8.5.12+ is the only complete fix because HashTable and unserialize() OOM paths also exist after startup when PHP code processes untrusted input.
If a production fleet cannot be upgraded immediately, apply these php.ini mitigations to cut off the unauthenticated Pre-RINIT attack surface:
; Disables php_session_rfc1867_callback() in Pre-RINIT (blocks the primary PHP2Shell vector)
session.upload_progress.enabled = 0
; On PHP 8.5 only: disables the secondary fatal-error backtrace stale-frame surface
fatal_error_backtraces = 0
In php-fpm pool configs (www.conf), keeping pm.max_requests moderate and monitoring worker logs for repeated Allowed memory size of ... bytes exhausted errors or SIGSEGV (exit code 139 / signal 11) during Unknown on line 0 (PHP’s signature log location for Pre-RINIT crashes before any script is loaded) also gives defenders a high-signal indicator of active probing.
