# 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 ``` ## 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 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. ### What would be needed for full RCE 1. **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_string` overlay (GC header is set by the allocator). 2. **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. 3. **Alternative reclamation path**: A way to overwrite only the struct fields WITHOUT overlapping a `zend_string` header. 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()`: ```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.