Rewrote the exploit to correctly avoid GET requests between OOM trigger and crash verification (RINIT zeros BG state on every request). Filler field spray (100 x 8200B = 300 pages) reliably reclaims the stale struct pages. Crash confirmed on first attempt. Updated README and FINDINGS to accurately describe: - The 8216-byte struct (3 pages), not the earlier 64-byte bin - GC header overlap that blocks controlled RCE (offsets 0-23) - Three independent patch layers in PHP 8.3.36/8.4.27/8.5.12 - Honest exploitation status: UAF crash confirmed, RCE not achieved
9.2 KiB
CVE-2026-109618 / CVE-2026-109619: Vulnerability Analysis
Overview
PHP's multipart parser (rfc1867.c) and session upload progress handler
(ext/session/session.c) contain a use-after-free exploitable over HTTP
against Apache mod_php with default PHP configuration. No LFI, no
framework dependency, no non-default settings.
| Property | Value |
|---|---|
| CVE | CVE-2026-109618, CVE-2026-109619 |
| CVSS | 9.2 (both, per Rapid7) |
| Affected | PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11 |
| Patched | PHP 8.3.36, 8.4.27, 8.5.12 |
| Prerequisites | Apache mod_php (prefork), session.upload_progress.enabled=1 (default) |
| Exploit status | UAF crash confirmed, full RCE not achieved |
Root cause
CVE-2026-109618: OOM triggers longjmp past cleanup
When memory_limit is exceeded during php_session_decode() inside
the RFC1867 progress callback, PHP calls zend_mm_safe_error() which
invokes zend_bailout(). This is a longjmp that unwinds the call
stack without running cleanup code, specifically
php_var_unserialize_destroy().
The state that survives the bailout (in BG module globals):
// ext/standard/basic_functions.c
struct {
uint32_t level; // should be 0, stays at 1
php_unserialize_data_t data; // freed 8216-byte struct pointer
} unserialize;
php_var_unserialize_init() allocates a php_unserialize_data struct
(8216 bytes = 3 ZendMM pages) and sets level = 1.
php_var_unserialize_destroy() frees it and sets level = 0. When OOM
fires between init and destroy, level stays at 1 and data points to
freed memory.
CVE-2026-109619: Stale state reused on next request
The request startup flow in main/main.c (lines 1784-1838) wraps the
entire block from zend_activate() through zend_activate_modules() in
one zend_try/zend_catch. The execution order:
zend_activate()(line 1796)sapi_activate()(line 1797): multipart parsing and RFC1867 callbackphp_hash_environment()(line 1833)zend_activate_modules()(line 1834): module RINITs run here, includingPHP_RINIT_FUNCTION(basic)which zeros BG statePG(modules_activated) = 1(line 1835)
The RFC1867 callback at step 2 runs BEFORE RINIT at step 4. So stale BG state from the previous request IS used during RFC1867 decode.
When php_var_unserialize_init() is called during RFC1867 decode:
- It checks
BG(unserialize).level - If level > 0 AND
BG(serialize_lock) == 0, it returnsBG(unserialize).data(the stale, freed pointer) and increments level
Why Apache prefork survives (and FPM does not)
Apache prefork: The bailout is caught by Apache's request handler. The child process continues its event loop. BG globals persist because they are module-level, not request-scoped.
PHP-FPM: The bailout propagates to fpm_main.c's outer
zend_try/zend_catch. The catch block sets exit_status = 70 and the
worker exits. A new clean worker is spawned.
OOM trigger mechanism
A session file with 33000 entries (~682KB) forces
nTableSize = 65536 during decode. The arData allocation:
65536 * 36 = 2,359,296 bytes (~2.25MB). With memory_limit = 4MB,
this exceeds the limit during HashTable resize in
zend_hash_real_init_mixed_ex().
The OOM triggers during sapi_activate() -> RFC1867 callback ->
php_session_initialize() -> php_session_decode() -> unserialize ->
HashTable resize.
After OOM:
php_session_cancel_decode()callsphp_session_destroy()- Session file is DELETED (on unpatched PHP)
BG(unserialize).level = 1,.data= freed pointer to 8216-byte struct- Worker survives, serves new connections
The UAF mechanism
php_unserialize_data struct layout (8216 bytes, 3 pages)
Offset Field Size Notes
------ ----- ---- -----
0 last (var_entries*) 8 Points to inline entries
8 first_dtor 8 NULL initially
16 last_dtor 8 NULL initially
24 allowed_classes 8 NULL = allow all
32 ref_props 8 NULL
40 cur_depth 4 0
44 (padding) 4
48 max_depth 8 From ini setting
56 entries.used_slots 8 0 initially
64 entries.next 8 NULL
72 entries.data[1018] 8144 zval* array
8216 (end)
Why var_push crashes
After OOM, the heap is destroyed and rebuilt for the next request. The
stale pointer references freed pages in the ZendMM chunk. Filler form
fields in the crash trigger create 3-page zend_string allocations
that consume pages sequentially. When one lands on the stale struct's
pages (~page 288 of 512), the zend_string GC header overlaps the
struct's pointer fields:
Struct offset 0 (last) <- gc_refcount(4) + gc_type_info(4)
Struct offset 8 (first_dtor) <- hash(8)
Struct offset 16 (last_dtor) <- len(8)
Struct offset 24+ <- string value (attacker-controlled)
var_push (var_unserializer.re line 118) reads (*var_hashx)->last:
var_entries *var_hash = (*var_hashx)->last; // = GC header ~= 0x0000030600000001
var_hash->data[var_hash->used_slots++] = rval; // dereferences invalid address
// SIGSEGV
Confirmed primitives
Tested on PHP 8.3.35 (source build), Apache 2.4.58 (prefork, MaxRequestWorkers=1), Ubuntu 24.04, x86_64:
| Primitive | Status | Notes |
|---|---|---|
| OOM during RFC1867 session decode | Confirmed | 33000-entry session, 4MB limit |
| Apache prefork worker survival | Confirmed | Same PID after OOM |
| Stale BG(unserialize) state | Confirmed | level=1, data=freed ptr (GDB) |
| Page reclamation via filler spray | Confirmed | 100 x 8200B fields |
| UAF crash (SIGSEGV in var_push) | Confirmed | Reproducible, first attempt |
| Full RCE | Not achieved | GC header overlap blocks pointer control |
Exploitation constraints
Why full RCE is not achieved
The struct's three pointer fields at offsets 0-23 overlap with the
zend_string header, which is set by the allocator:
-
last(offset 0) =gc_refcount+gc_type_info: the allocator sets refcount=1 and type_info=IS_STRING with GC flags. This produces a low, invalid address like0x0000030600000001. The attacker cannot control these bytes. -
first_dtor(offset 8) =hash: lazily computed, initially 0 (NULL). This is safe:var_destroychecksfirst_dtor != NULLbefore iterating the dtor chain. -
last_dtor(offset 16) =len: the string length (e.g., 8200 =0x2008). This is also a low, unmapped address.
The attacker controls bytes from offset 24 onward (allowed_classes,
ref_props, max_depth, entries.used_slots, entries.next,
entries.data[]), but var_push crashes reading last (offset 0)
before any of these fields are reached.
What would be needed for full RCE
-
Info leak: A separate vulnerability to leak a valid heap address. The leaked address would need to be placed at offset 0 of the allocation, which is impossible with a
zend_stringoverlay (GC header is set by the allocator). -
Alternative allocation type: A different 3-page (8216-byte) allocation where the attacker controls offset 0. No such allocation type has been identified during multipart parsing.
-
Alternative reclamation path: A way to overwrite only the struct fields WITHOUT overlapping a
zend_stringheader. This would require a non-contiguous write primitive.
The patch (PHP 8.3.36, 8.4.27, 8.5.12)
Three independent defense layers, any one sufficient:
1. php_basic_early_rinit()
New function called from php_request_startup() AFTER zend_activate()
but BEFORE sapi_activate():
PHPAPI void php_basic_early_rinit(void) {
BG(serialize_lock) = 0;
memset(&BG(serialize), 0, sizeof(BG(serialize)));
memset(&BG(unserialize), 0, sizeof(BG(unserialize)));
}
Eliminates the stale state window entirely. Even if OOM occurs, the BG state is zeroed before RFC1867 processing on the next request.
2. Delayed OOM
zend_bailout() on OOM is completely removed. The allocator now either:
- Returns NULL (fallible mode) for checked allocations
- Succeeds beyond the soft limit (non-fallible mode) and queues a
delayed error via
EG(delayed_oom)+ VM interrupt
No longjmp means all cleanup code runs normally, including
php_var_unserialize_destroy().
A hard limit (memory_hard_limit_percent, default 200%) calls
_exit(137) as a safety net.
3. Session handler hardening
PS(in_rfc1867_update)flag forcesincomplete_classes_onlymode during RFC1867 session decode (no__wakeup/__unserialize)php_rinit_session_globals()called atMULTIPART_EVENT_START$_SESSIONcleanup atMULTIPART_EVENT_ENDPG(modules_activated)check prevents RFC1867 callback duringrequest_parse_body()- ~30
ZEND_HAS_OOM()checks throughoutrfc1867.cfor graceful handling of allocation failures
Why this does not apply to latest PHP
The latest stable PHP versions (8.3.36+, 8.4.27+, 8.5.12+) include all three defense layers. The patch is comprehensive:
- Layer 1 alone blocks the attack (no stale state)
- Layer 2 alone blocks the attack (no longjmp, cleanup runs)
- Layer 3 alone limits exploitability (no object instantiation)
No bypass path has been identified for any of the three layers.