CVE-2026-109618 — crash primitive characterization (ASan → gdb)
This document records the memory-corruption primitive behind CVE-2026-109618, derived by instrumenting the vulnerable PHP 8.4.26 runtime. It answers “what exactly is dangling / overwritten, and what can an attacker control?” — the question any RCE chain has to be built on top of.
For “how does that turn into a callback shell”, see
FROM_CRASH_TO_SHELL.md.
1. TL;DR
| Crash site | zend_array_destroy() → subl $0x1,(%rdi) |
| Caller chain | php_handler (mod_php) → php_request_shutdown → zend_array_destroy |
| Corrupted object | the arData (bucket array) of the $_FILES zend_array |
| Corrupted field | bucket->val.value — the zval’s 8-byte value union |
| Corrupting data | the multipart field name (attacker-controlled bytes) |
| Resulting primitive | dereference an attacker-controlled pointer P and do *(uint32_t*)P -= 1 |
| Second primitive | efree(arData - nTableMask*4) at the end of zend_array_destroy |
Proven empirically: with name="AAAA…[k]" the faulting pointer is
0x4141414141414141; with name="NNNN…[k]" it is 0x4e4e4e4e4e4e4e4e; with
pad='P' + name='N' it is still 0x4e4e… (so the field name, not the
pad value, is the byte source). The attacker controls the exact 8 bytes the
runtime treats as a zend_refcounted*.
2. Method — and why ASan alone could not see it
The natural first move was an AddressSanitizer build. I built PHP 8.4.26 with
-fsanitize=address -O1 -g, --disable-all --enable-cli --enable-cgi
--enable-session --enable-debug, and drove php-cgi with a multipart body on
stdin.
Three discoveries fell out of that:
2.1 USE_ZEND_ALLOC=0 alone disables memory_limit
PHP’s allocator selection in Zend/zend_alloc.c::alloc_globals_ctor:
tmp = getenv("USE_ZEND_ALLOC");
if (tmp && !ZEND_ATOL(tmp)) {
bool tracked = (tmp = getenv("USE_TRACKED_ALLOC")) && ZEND_ATOL(tmp);
...
if (!tracked) {
/* Use system allocator. */ /* plain malloc: NO limit */
mm_heap->custom_heap._malloc = __zend_malloc;
...
} else {
/* Use system allocator and track allocations for auto-free. */
mm_heap->custom_heap._malloc = tracked_malloc; /* tracks + enforces limit */
...
}
return;
}
USE_ZEND_ALLOC=0 switches to the system allocator, but only
USE_TRACKED_ALLOC=1 selects the tracked system allocator that still enforces
memory_limit. Without the latter, a 9 MB allocation against an 8 MB limit
succeeds silently — so an ASan run without it never even triggers the OOM.
Both env vars are required:
USE_ZEND_ALLOC=0 USE_TRACKED_ALLOC=1
2.2 php-cgi does not reproduce the corruption — mod_php does
php-cgi’s startup-failure path (sapi/cgi/cgi_main.c):
if (php_request_startup() == FAILURE) {
if (fastcgi) { fcgi_finish_request(request, 1); }
SG(server_context) = NULL;
php_module_shutdown();
return FAILURE; /* <-- no php_request_shutdown() */
}
Apache mod_php (sapi/apache2handler/sapi_apache2.c::php_handler) runs
php_request_shutdown() even when php_request_startup() failed (via
php_apache_request_dtor). That unconditional shutdown is what walks the
half-built state and corrupts — the advisory’s “mod_php performs cleanup even
when request startup fails”. I patched cgi_main.c to mimic it:
if (php_request_startup() == FAILURE) {
...
php_request_shutdown((void *) 0); /* added: mimic mod_php */
SG(server_context) = NULL;
php_module_shutdown();
return FAILURE;
}
2.3 The corruption is Zend-MM-specific
Even with the cgi patch and the tracked allocator, the shutdown ran cleanly —
no ASan report. The corruption lives inside the Zend MM allocator (its 2 MB
chunk arena and OOM realloc path), which ASan cannot instrument. So the right
instrument is gdb against the real mod_php build (Zend MM). The
php:8.4.26-apache-bookworm image ships libphp.so not stripped, so
symbols resolve.
Takeaway: ASan only sees PHP bugs that reproduce under
USE_ZEND_ALLOC=0 USE_TRACKED_ALLOC=1. Zend-MM-internal corruption needs gdb + the real build.
3. The definitive backtrace
Running apache2 -X under gdb:
Program received signal SIGSEGV, Segmentation fault.
0x00007f8a4002d14e in zend_array_destroy () from /usr/lib/apache2/modules/libphp.so
#1 php_request_shutdown () from libphp.so
#2 php_handler () from libphp.so
#3 ap_run_handler → ap_invoke_handler → ap_process_async_request → ap_process_request
php_handler (mod_php) → php_request_shutdown → zend_array_destroy → boom.
This is the pre-RINIT path: the multipart parser OOM’d during request startup,
php_request_startup returned FAILURE, mod_php ran shutdown anyway, and
shutdown destroyed the half-built $_FILES array.
4. The relevant structure layouts (offsets matter for the write primitive)
All from PHP 8.4.26 Zend/zend_types.h, x86-64, non-ZTS.
zend_refcounted_h (8 bytes) — the garbage-collection header every heap object starts with
typedef struct _zend_refcounted_h {
uint32_t refcount; /* offset 0 */
union {
uint32_t type_info; /* offset 4: type + flags */
} u;
} zend_refcounted_h;
zval (16 bytes)
struct _zval_struct {
zend_value value; /* offset 0: 8-byte union (lval/dval/str/arr/obj/res/ref/ptr) */
union {
uint32_t type_info;
struct {
uint8_t type; /* offset 8 */
uint8_t type_flags; /* offset 9 */
uint16_t extra; /* offset 10 */
} v;
} u1;
union {
uint32_t next; /* offset 12: hash collision chain / cache_slot / ... */
...
} u2;
};
Bucket (32 bytes)
typedef struct _Bucket {
zval val; /* offset 0 (16 bytes) */
zend_ulong h; /* offset 16 (hash or numeric index) */
zend_string *key; /* offset 24 (string key, NULL for numeric) */
} Bucket;
zend_array / HashTable (56 bytes) — what $_FILES is
struct _zend_array {
zend_refcounted_h gc; /* offset 0 (refcount@0, type_info@4) */
union { struct { uint8_t flags; ... } v; uint32_t flags; } u; /* offset 8 */
uint32_t nTableMask; /* offset 12 (negative; hash area size) */
union { uint32_t *arHash; Bucket *arData; zval *arPacked; }; /* offset 16 */
uint32_t nNumUsed; /* offset 24 */
uint32_t nNumOfElements; /* offset 28 */
uint32_t nTableSize; /* offset 32 */
uint32_t nInternalPointer; /* offset 36 */
zend_long nNextFreeElement; /* offset 40 */
dtor_func_t pDestructor; /* offset 48 */
};
HT_GET_DATA_ADDR(ht) is the allocation base (arData + nTableMask, where
nTableMask is negative); ht->arData is the bucket start inside that
allocation.
5. The exact faulting instruction
Full annotated disassembly of zend_array_destroy (the hot path):
zend_array_destroy:
mov %rdi,%rbp ; rbp = ht
cmpl $0x3ff,0x4(%rdi) ; if type_info > 0x3ff → slow path (cached/immutable)
mov 0x18(%rbp),%eax ; eax = ht->nNumUsed
movl $0x11,0x4(%rbp) ; ht->type_info = 0x11 ("being destroyed" marker)
test %eax,%eax ; if nNumUsed == 0 → skip to efree
mov 0xc31e43(%rip),%rcx ; load default pDestructor
cmp %rcx,0x30(%rbp) ; compare ht->pDestructor (offset 0x30=48)
jne 6051c0 ; custom destructor → slow path
mov 0x8(%rbp),%edx ; ht->u.flags
mov 0x10(%rbp),%rbx ; rbx = ht->arData
mov %eax,%r12d ; r12 = nNumUsed
test $0x4,%dl ; HT_FLAG_PACKED?
... ; compute end = arData + nNumUsed*stride
; -- bucket iteration loop --
605011: cmpb $0x0,0x9(%rbx) ; bucket occupied?
605017: mov (%rbx),%rdi ; rdi = bucket->val.value (offset 0)
60501a: subl $0x1,(%rdi) ; *(uint32_t*)rdi -= 1 <-- the write primitive
60501d: jne 604fe8 ; refcount != 0 → next bucket
60501f: call rc_dtor_func ; refcount hit 0 → destroy the object
; -- after the loop --
605030: mov 0xc(%rbp),%eax ; ht->nTableMask
605033: mov 0x10(%rbp),%rdi ; ht->arData
605037: neg %eax ; shl $0x2,%rax ; nTableMask*4
60503d: sub %rax,%rdi ; rdi = arData - nTableMask*4 (allocation base)
605040: call _efree ; efree(arData - nTableMask*4)
The crash is the subl $0x1,(%rdi) at +446 (60514e in the non-packed
variant, same instruction). Two primitives are visible:
*(uint32_t*)P -= 1whereP = bucket->val.value(attacker bytes). If the decremented refcount reaches 0,rc_dtor_func(P)runs.efree(arData - nTableMask*4)— an arbitrary-ish free reachable if the loop survives.
6. Register-level proof of full attacker control
Three gdb runs, three field-name payloads:
| field name | rdi at fault |
meaning |
|---|---|---|
AAAA…[k] |
0x4141414141414141 |
‘A’ = 0x41 |
NNNN…[k] |
0x4e4e4e4e4e4e4e4e |
‘N’ = 0x4e |
pad='P', name='N' |
0x4e4e4e4e4e4e4e4e |
field name, not pad, is the source |
rbp (=ht) and rbx (=arData) are valid heap pointers in every run; only
rdi is corrupted. The attacker controls the exact 8 bytes of
bucket->val.value.
7. The mechanism: use-after-free of arData
gdb memory dump at the crash (x/12gx $rbp for ht, x/96gx $rbx for
arData):
ht header (rbp = 0x7f8a3f8570a8), intact:
0x0000001100000000 gc.refcount=0, type_info=0x11 (the "destroying" marker set by zend_array_destroy)
0xfffffff000000010 u.flags=0x10, nTableMask=0xfffffff0 (-16)
0x00007f8a3f950400 arData → 0x7f8a3f950400 <-- STALE
0x0000000100000001 nNumUsed=1, nNumOfElements=1
0x0000000000000008 nTableSize=8, nInternalPointer=0
0x8000000000000000 nNextFreeElement
0x00007f8a40076b60 pDestructor (valid function pointer)
arData (rbx = 0x7f8a3f950400), the freed-and-reused block:
0x4e4e4e4e4e4e4e4e 0x4e4e4e4e4e4e4e4e ('N' bytes, hundreds of them)
0x4e4e4e4e4e4e4e4e 0x4e4e4e4e4e4e4e4e
... (mostly 0x4e4e…)
0xb50b316e9bd98065 (a surviving hash value)
0x00007f8a3f950640 (a pointer, past the 8-bucket array)
Interpretation:
- The
htheader is intact and itsarDatafield points at0x7f8a3f950400. - That
arDataregion is now filled with the attacker’s field-name bytes (‘N’). It has been freed and its storage reused by a field-name buffer. nNumUsed=1, sozend_array_destroyiterates the (stale) bucket array and readsbucket[0].val.value = 0x4e4e…as azend_refcounted*.
This is a use-after-free: the bucket array’s storage has been recycled for
attacker data while ht->arData still references it. The exact allocator
interaction that produces it is the vulnerable resize/realloc path:
Zend/zend_hash.c::zend_hash_do_resize (8.4.26, before the fix):
void *new_data, *old_data = HT_GET_DATA_ADDR(ht);
uint32_t nSize = ht->nTableSize + ht->nTableSize;
Bucket *old_buckets = ht->arData;
new_data = pemalloc(HT_SIZE_EX(nSize, HT_SIZE_TO_MASK(nSize)), ...); /* OOM bailout can fire here */
ht->nTableSize = nSize;
ht->nTableMask = HT_SIZE_TO_MASK(ht->nTableSize);
HT_SET_DATA_ADDR(ht, new_data); /* ht->arData = new_data */
memcpy(ht->arData, old_buckets, sizeof(Bucket) * ht->nNumUsed);
pefree(old_data, ...); /* old bucket storage freed */
zend_hash_rehash(ht);
The vulnerable allocator (below) turns an OOM into zend_bailout() inside
this sequence (and inside the field-name allocations that follow it), so the
parser exits with the array in a half-updated state. The freed bucket storage
is then handed back out for one of the ~700 KB field-name copies, and mod_php’s
shutdown walks it as if it were still valid buckets.
Confirmed vs. inferred. It is confirmed that
ht->arDatareferences storage filled with attacker field-name bytes (the dump above). The precise allocation ordering — which of the several field-name copies (param,abuf,lbuf, thezend_stringkey, the headersmart_str) reuses which freed block, at whichzend_bailoutpoint — is the one remaining unpinned detail; pinning it needs a hardware watchpoint onarData.
7.1 Additional confirmations
pDestructorresolves tozval_ptr_dtor(info symbolin gdb) — the array being destroyed is an ordinary PHP array (a superglobal), not a special-cased structure. Its buckets are destroyed value-by-value throughzval_ptr_dtor, which is exactly the*(uint32_t*)P -= 1/rc_dtor_func(P)path the primitive abuses.- A wide dump (
x/1024gx $rbx) shows thearDatasits inside a large field-name buffer. The'N'region spans far more than the 8-bucket array, and adjacent freed 320-byte blocks (spaced0x140) still contain surviving valid buckets — e.g.val.value = 0x00007f3366e01000— mixed with'N'overwrites. So the heap around the crash is a mixture of stale pointers and attacker bytes.
7.2 Partial-overwrite route is blocked (empirically)
The obvious ASLR bypass — clobber only the low bytes of bucket->val.value so
the high bytes of a real pointer survive — is not reachable for this bug:
- The field name must be ~700 KB: it is the “weight” that pushes the request
over
memory_limitat the$_FILESregistration step. A short field name does not OOM there, and a larger pad cannot compensate — the pad itself OOMs first. (Sweep: field names of 4/16/64/256/700 bytes against pads 4000–8000 KB produced zero crashes except in the 700 KB case, and only at pad 640–1280 KB.) - A ~700 KB field-name buffer is a large allocation, and it fully overwrites
the small (320-byte)
arDatablock — all 8 bytes ofbucket->val.valuebecome attacker bytes, so no pointer high-bytes survive.
Consequence: turning the primitive into RCE now hinges on an ASLR info leak
(see FROM_CRASH_TO_SHELL.md §4 Step 1b), not on a
partial overwrite.
8. The vulnerable allocator code
The old OOM handler (deleted by the fix, commit 01a40e89fb):
static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
const char *format, size_t limit,
#if ZEND_DEBUG
const char *filename, uint32_t lineno,
#endif
size_t size)
{
heap->overflow = 1;
zend_try {
zend_error_noreturn(E_ERROR, format, limit, ..., size);
} zend_catch {
} zend_end_try();
heap->overflow = 0;
zend_bailout(); /* longjmp() out of the allocator, mid-operation */
exit(1);
}
zend_bailout() is setjmp/longjmp: it abandons the current C call stack
without running destructors or finishing whatever the caller of emalloc was
building. When the caller was half-way through a zend_hash_update/resize or a
field-name allocation, the longjmp skips the assignment that would have kept
the data structures consistent.
The old realloc grow/truncation path (zend_mm_realloc_heap) also lacked NULL
checks:
/* small extension / truncation */
ret = zend_mm_alloc_small(heap, ZEND_MM_SMALL_SIZE_TO_BIN(size) ...); /* may return NULL on re-entrant OOM */
copy_size = use_copy_size ? MIN(old_size, copy_size) : old_size;
memcpy(ret, ptr, copy_size); /* if ret == NULL, memcpy(NULL, ...) */
zend_mm_free_small(heap, ptr, old_bin_num);
The fix (zend_mm_check_limit, ZEND_HAS_OOM(), fallible femalloc/ferealloc
returning NULL, a delayed-OOM state machine, and a hard _exit(137) limit)
replaces this “longjmp out of the allocator” behavior with “defer the error to
a safe point and let callers unwind cleanly”. That is the entire substance of
the patch.
9. What this means for a shell
The primitive is a real, attacker-controlled arbitrary-pointer dereference.
The full exploitation path (ASLR, heap spraying, fake objects, control-flow
hijack) is explained step-by-step, with the underlying concepts and structures,
in FROM_CRASH_TO_SHELL.md.
10. Reproducing this analysis
# 1. Build vulnerable PHP (cgi) with ASan — see /home/ubuntu/php-src-asan/build.sh
# CFLAGS="-O1 -g -fsanitize=address -fno-omit-frame-pointer"
# LDFLAGS="-fsanitize=address"
# ./configure --disable-all --enable-cli --enable-cgi --enable-session --enable-debug
# 2. Reproduce under ASan (needs BOTH env vars):
# USE_ZEND_ALLOC=0 USE_TRACKED_ALLOC=1 \
# sapi/cgi/php-cgi -C -n -d memory_limit=8M ... < body
# (with cgi_main.c patched to call php_request_shutdown() on startup failure)
# 3. Reproduce under gdb against real mod_php (Zend MM):
docker run -d --name dbg -p 18090:80 php2shell:vuln sleep infinity
docker exec dbg apt-get install -y gdb
docker cp gdb.cmd dbg:/tmp/gdb.cmd # gdb.cmd in this repo
docker exec -d dbg bash -c 'source /etc/apache2/envvars; mkdir -p /run/apache2 /var/lock/apache2; gdb -batch -x /tmp/gdb.cmd --args /usr/sbin/apache2 -X > /tmp/gdb.out 2>&1'
# send the files_registration payload (pad + name="<700KB>[k]"), then:
docker exec dbg cat /tmp/gdb.out
The trigger payload is files_registration from poc/exploit.py (pad field +
a ~700 KB field name, memory_limit=8M, parser limits raised).