Type confusion via var_access confirmed: R:N; references resolve stale entries.data[0] without crashing, returning the attacker's own session data from the cached ZendMM chunk (pages retained, not zeroed). Key finding: filler spray and type confusion are mutually exclusive. var_tmp_var reads last_dtor (struct offset 16) BEFORE var_access reads entries (offset 56). Spray corrupts offset 16, crashing before the type confusion can execute. Built-in class audit: SplFileObject and PDO have function pointer calls in free_obj, but constructing fake objects requires libphp.so base (ASLR). Patch audit (8.5.12): all three defense layers are solid, no bypass found. Five RCE blockers: no info leak, filler/type-confusion mutual exclusion, GC header overlap, no usable classes without ASLR bypass, session key validation.
16 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
Why var_access does NOT crash (type confusion primitive)
var_access (var_unserializer.re line 213) is used by R:N; and
r:N; backreferences. Unlike var_push, it reads the INLINE entries
at struct offset 56, NOT the last pointer at offset 0:
var_entries *var_hash = &(*var_hashx)->entries; // offset 56, not 'last'
if (id >= var_hash->used_slots) return NULL;
return var_hash->data[id]; // offset 72+
Without the filler spray, the stale struct retains its request 1 layout at the critical fields:
last = &entries (self-referencing, valid)
entries.used_slots = 1 (partially overwritten by heap metadata)
entries.data[0] = 0x780c8a923010 (stale zval* from request 1)
When the session data contains x|R:1;, the unserializer calls
var_access(0, stale_struct), which reads entries.data[0] (the stale
pointer) and returns it. PHP then does ZVAL_COPY(rval, stale_zval).
The stale zval's type matches whatever the first OOM session entry was:
| OOM session first value | Stale zval type | Result |
|---|---|---|
String (e.g. s:8000:"BBB...") |
IS_STRING | Original string content intact |
Integer (e.g. i:42;) |
IS_LONG | Original integer value |
Boolean (e.g. b:1;) |
IS_TRUE | Original boolean value |
Object (e.g. O:8:"stdClass":0:{}) |
IS_OBJECT | CRASH (freed zend_object dereference) |
Array (e.g. a:0:{}) |
IS_ARRAY | Self-referencing $_SESSION (infinite recursion) |
The cached ZendMM chunk retains page data across requests (only the
chunk header metadata is zeroed, not the data pages). This means the
stale zval at entries.data[0] contains the attacker's own data
from request 1, not leaked heap memory or code pointers.
This is a confirmed type confusion, but it does NOT provide an info
leak. The value appears in $_SESSION['x'] and is accessible to the
main PHP script, but only contains data the attacker already knows.
Why filler spray and type confusion are mutually exclusive
The session decode calls var_tmp_var(&var_hash) (var_unserializer.re
line 187) BEFORE php_var_unserialize() for each session key-value
pair. var_tmp_var reads (*var_hashx)->last_dtor at struct offset 16.
Without filler spray: last_dtor retains its request 1 value (NULL for
simple string values). var_tmp_var allocates a new var_dtor_entries
struct and writes the pointer to offset 16. No crash.
With filler spray: offset 16 is overwritten by the zend_string len
field (e.g., 8200 = 0x2008). var_tmp_var dereferences 0x2008 as a
var_dtor_entries*, which is an unmapped address. SIGSEGV before
var_access runs.
This means there is no exploitation window where controlled data
coexists with a readable stale reference. Filler spray corrupts the
struct before var_access can read the controlled pages.
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 | 27+ fields crashes worker |
| UAF crash (SIGSEGV in var_push) | Confirmed | Reproducible, first attempt |
| Type confusion via var_access | Confirmed | R:1 resolves stale zval, type matches OOM session |
| Stale value = attacker's own data | Confirmed | Cached chunk retains pages, no info leak |
| Filler/type-confusion mutual exclusion | Confirmed | var_tmp_var crashes at offset 16 before var_access |
Session key injection via | |
Blocked | php serializer skips keys with | |
| Info leak via type confusion | Not possible | Reads own data, not heap metadata |
| Full RCE | Not achieved | ASLR + mutual exclusion + no info leak |
Exploitation constraints
Why full RCE is not achieved
Five independent constraints block RCE from this primitive.
1. GC header overlap (var_push path)
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.
2. No info leak (var_access path)
The type confusion via var_access avoids the GC header crash, but
entries.data[0] points to a Bucket in request 1's session HashTable
arData. The ZendMM cached chunk retains data pages across requests
(only the chunk header is zeroed during zend_mm_shutdown with
memset(p, 0, sizeof(zend_mm_chunk))). The stale zval at
entries.data[0] contains the attacker's own session data, not
leaked heap metadata or code pointers.
Testing confirms the stale value's type matches the OOM session's first entry (string -> IS_STRING with original content, integer -> IS_LONG with original value, etc.). This is not an info leak.
To exploit the type confusion for RCE, the attacker would need to:
- Leak a heap pointer to determine the ZendMM chunk base, then
- Spray controlled data to overlapping pages, then
- Use the type confusion to read crafted objects
Step 1 is impossible through this vulnerability: no path reads heap metadata or code pointers through the type confusion.
2b. Mutual exclusion of filler spray and type confusion
The filler spray (needed to place controlled data on stale pages) and the type confusion (needed to read stale data) cannot coexist:
var_tmp_var()reads(*var_hashx)->last_dtorat struct offset 16 BEFOREphp_var_unserialize()callsvar_accessat offset 56.- Without spray: offset 16 = NULL (from request 1),
var_tmp_varallocates normally, type confusion works but reads own data. - With spray: offset 16 = zend_string
len(e.g., 0x2008),var_tmp_vardereferences 0x2008 as a pointer -> SIGSEGV before var_access runs.
There is no exploitation window where controlled content on the stale pages is readable via the type confusion.
3. Built-in classes lack usable exploitation path
The RFC1867 session decode runs during sapi_activate(), before module
RINIT and main script execution. Only PHP's built-in/internal classes
are registered. An audit of all statically compiled extensions found:
Classes with exploitable free_obj handlers:
SplFileObject/SplFileInfo/DirectoryIterator:free_objcallsintern->oth_handler->dtor(intern), a function pointer call through object data. Butoth_handleris only set by Phar.PDO:free_objcallsdbh->methods->closer(dbh), another function pointer through object data.
Why these are unusable: Constructing a fake zend_object that
redirects handlers to a real handlers table requires knowing
libphp.so's base address. The heap chunk address is stable within a
process, but code segment addresses are independent. No code address
leak was found through this vulnerability.
4. Session key validation
The PHP_SESSION_UPLOAD_PROGRESS form field value becomes part of the
session key (prefixed with upload_progress_). A | character in this
value could inject serialized data into the session file, since the
php session serializer uses | as the key-value delimiter.
However, PS_SERIALIZER_ENCODE_FUNC(php) explicitly checks for | in
session keys with memchr() and skips keys containing it. The injected
key is stored in $_SESSION but never written to the session file,
preventing deserialization injection on subsequent requests.
What would be needed for full RCE
-
An info leak from a separate vulnerability: The type confusion reads the attacker's own data. A separate vulnerability providing either a heap pointer or a libphp.so code pointer would enable:
- Heap pointer: spray fake objects at known addresses
- Code pointer: construct fake
zend_object.handlerspointing to real handler tables for SplFileObject/PDO/Closure
-
Bypass of filler/type-confusion mutual exclusion: A way to place controlled data on stale pages without corrupting struct offset 16 (
last_dtor). No such technique was found. The struct occupies 3 pages (8216 bytes), and large allocations cover full pages including offset 16. -
Loaded classes with POP chains: If a framework's classes were loaded before session decode, deserialized objects could trigger exploitable magic methods. Standard PHP and WordPress do not load user classes before
sapi_activate(). -
Alternative allocation type: An 8216-byte allocation where the attacker controls offset 0 and 16 (blocking the GC header overlap and
last_dtorcorruption). No such type was identified in the multipart parsing code path.
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.