# 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): ```c // 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: 1. `zend_activate()` (line 1796) 2. `sapi_activate()` (line 1797): multipart parsing and RFC1867 callback 3. `php_hash_environment()` (line 1833) 4. `zend_activate_modules()` (line 1834): module RINITs run here, including `PHP_RINIT_FUNCTION(basic)` which zeros BG state 5. `PG(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 returns `BG(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()` calls `php_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`: ```c 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: ```c 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 like `0x0000030600000001`. The attacker cannot control these bytes. - `first_dtor` (offset 8) = `hash`: lazily computed, initially 0 (NULL). This is safe: `var_destroy` checks `first_dtor != NULL` before 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: 1. Leak a heap pointer to determine the ZendMM chunk base, then 2. Spray controlled data to overlapping pages, then 3. 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_dtor` at struct offset 16 BEFORE `php_var_unserialize()` calls `var_access` at offset 56. - Without spray: offset 16 = NULL (from request 1), `var_tmp_var` allocates normally, type confusion works but reads own data. - With spray: offset 16 = zend_string `len` (e.g., 0x2008), `var_tmp_var` dereferences 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_obj` calls `intern->oth_handler->dtor(intern)`, a function pointer call through object data. But `oth_handler` is only set by Phar. - `PDO`: `free_obj` calls `dbh->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 1. **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.handlers` pointing to real handler tables for SplFileObject/PDO/Closure 2. **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. 3. **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()`. 4. **Alternative allocation type**: An 8216-byte allocation where the attacker controls offset 0 and 16 (blocking the GC header overlap and `last_dtor` corruption). 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()`: ```c 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 forces `incomplete_classes_only` mode during RFC1867 session decode (no `__wakeup`/`__unserialize`) - `php_rinit_session_globals()` called at `MULTIPART_EVENT_START` - `$_SESSION` cleanup at `MULTIPART_EVENT_END` - `PG(modules_activated)` check prevents RFC1867 callback during `request_parse_body()` - ~30 `ZEND_HAS_OOM()` checks throughout `rfc1867.c` for 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.