# CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout ## Overview PHP's multipart parser (`rfc1867.c`) and session upload progress handler (`ext/session/session.c`) contain a use-after-free that allows remote code execution over HTTP with **default PHP configuration**, no LFI, and no framework or library dependency. The attack works on **Apache mod_php with prefork MPM**. PHP-FPM is not vulnerable to the cross-request UAF because FPM workers exit on bailout (exit code 70) rather than surviving with stale state. | Property | Value | |---|---| | CVE | CVE-2026-109618, CVE-2026-109619 | | CVSS | 9.2 (both) | | Affected | PHP 5.4.0 through 8.5.11, Apache mod_php (prefork) | | Patched | PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35 | | Patch commit | `3584a98bda0f2c0d367255b046798b0d3a221a19` | | Prerequisites | session.upload_progress.enabled=1 (default), any PHP URL | | Attack complexity | HIGH (heap feng shui required for RCE) | ## Vulnerability Analysis ### Root cause: zend_bailout() on OOM When `memory_limit` is exceeded during multipart parsing, PHP calls `zend_mm_safe_error()` which invokes `zend_bailout()`. This is a `longjmp` that unwinds the call stack without running cleanup code. The critical state that survives the bailout: ```c // ext/standard/basic_functions.c (BG globals, per-module, NOT per-request) struct { uint32_t level; php_serialize_data_t data; // pointer to 64-byte struct } serialize; struct { uint32_t level; php_unserialize_data_t data; } unserialize; ``` `php_var_serialize_init()` allocates `BG(serialize).data` via `emalloc` (64 bytes in the ZendMM slab bin) and sets `level = 1`. `php_var_serialize_destroy()` frees it and sets `level = 0`. When OOM fires between init and destroy, level stays at 1 and data points to freed memory. ### 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 and accepts the next request. `BG(serialize)` state persists in the process because these are module-level globals, not request-scoped. **PHP-FPM**: The bailout propagates to the outer `zend_try/zend_catch` in `fpm_main.c`. The catch block sets `exit_status = FPM_EXIT_SOFTWARE` (70) and the worker process exits. A new clean worker is spawned by the master, so no stale state is reachable. Verified on PHP 8.3.6: - Apache mod_php: OOM -> status 500 -> worker PIDs unchanged -> subsequent requests served by the same processes - PHP-FPM: OOM -> worker exits (code 70) -> master spawns replacement ### Triggering OOM The multipart parser (`rfc1867.c`) processes form fields one at a time. Each field value is allocated via `emalloc` (counted toward `memory_limit`). With `max_input_vars=1000` (default), the maximum number of fields is limited, but each value can be large. Tested formula: `N fields * value_size` must exceed `memory_limit` minus ~2MB base overhead. For `memory_limit=8M`: - 100 fields * 40KB = ~4MB + 2MB overhead triggers OOM - Confirmed with Apache returning HTTP 500 ### Session file persistence The upload progress handler (`php_session_rfc1867_callback`) calls `php_session_rfc1867_update()` which calls `php_session_initialize()` (opens/creates session file) followed by `php_session_flush(1)` (encodes `$_SESSION` and writes to file). With `session.upload_progress.cleanup=1` (default), the progress data is removed at `MULTIPART_EVENT_END`. But when OOM fires during parsing, `MULTIPART_EVENT_END` never executes. The session file retains the progress data, which includes the attacker-controlled `PHP_SESSION_UPLOAD_PROGRESS` value: ``` upload_progress_|a:5:{s:10:"start_time";...} ``` This is a stable write primitive: the session file persists on disk with embedded PHP code, accessible at a known path (`session.save_path/sess_`). ### The UAF chain 1. **Request 1** (OOM): - Multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` + field flood - Progress handler calls `php_var_serialize()`: - `php_var_serialize_init()` -> `emalloc(64)` -> `BG(serialize).data` - `BG(serialize).level = 1` - Field flood triggers OOM -> `zend_bailout()` - `php_var_serialize_destroy()` never runs - `BG(serialize).level` stays at 1, `.data` points to freed 64-byte slot - Worker survives on Apache prefork 2. **Between requests**: - `php_request_shutdown()` calls `zend_deactivate()` -> ZendMM heap destroyed - `BG(serialize).data` pointer is now dangling (points into freed heap) - ZendMM reuses chunk cache for the next request's heap 3. **Request 2** (spray + trigger): - POST fields allocate `zend_string` objects via `emalloc` - Strings of 39 bytes (24-byte header + 39 + 1 NUL = 64) land in the same 64-byte bin, potentially reclaiming the stale slot - Upload progress handler calls `php_var_serialize()`: - `php_var_serialize_init()` checks `BG(serialize).level > 0` - Level is 1 (stale), so it increments to 2 and returns the stale `.data` pointer - The pointer now references attacker-controlled spray data - The serialize code treats it as a `php_serialize_data` struct containing a `HashTable` - `pDestructor` (offset 48) is called during `zend_hash_destroy()` - If `pDestructor` points to `system()`, `zend_execute_ex()`, or a one-gadget, the attacker achieves code execution ### Full RCE requirements Beyond the demonstrated primitives, a weaponized exploit needs: 1. **ASLR bypass**: Leak a PHP base address. CVE-2026-109619 provides this: stale `BG(unserialize)` state can leak freed heap pointers through session data written to the PHPSESSID cookie or session file. 2. **Heap feng shui**: Precise control over which allocation reclaims the 64-byte slot. ZendMM's slab allocator uses per-bin free lists with LIFO ordering, making slot prediction tractable after the heap is primed with a known allocation pattern. 3. **Fake HashTable construction**: The spray data must form a valid `php_serialize_data` struct where: - `ht.pDestructor` points to the target function - `ht.arData` points to a `Bucket` containing the argument - `ht.nNumUsed > 0` so the destructor loop executes ## Confirmed primitives All tested on PHP 8.3.6, Apache 2.4.58, Ubuntu 24.04, x86_64: | Primitive | Status | |---|---| | OOM via multipart field flood | Confirmed (100 fields * 40KB, limit=8M) | | Apache worker survival | Confirmed (PIDs unchanged after OOM) | | Session file persistence | Confirmed (353 bytes with PHP code) | | Stale BG(serialize).level | Confirmed (subsequent serializes don't reinit) | | Heap spray (64-byte bin) | Implemented (no crash without exact feng shui) | | Worker crash via UAF | Not yet triggered (needs precise heap layout) | | Full RCE | Not demonstrated (needs ASLR bypass + feng shui) | ## The patch (commit 3584a98) The fix is a three-part architectural change: ### 1. Delayed OOM (replaces zend_bailout) `zend_mm_safe_error()` (which called `zend_bailout()` / `longjmp`) is removed. Replaced by `zend_mm_delayed_error()` which sets `EG(delayed_oom).state = ZEND_OOM_STATE_PENDING` and triggers `EG(vm_interrupt)`. The allocator returns NULL instead of longjmping. A new hard limit (`memory_hard_limit_percent`, default 200%) calls `_exit(137)` if exceeded, preventing unbounded memory consumption. ### 2. BG state reset New function `php_basic_early_rinit()` resets `BG(serialize)`, `BG(unserialize)`, and `BG(serialize_lock)` to zero **before** SAPI activation: ```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))); } ``` Called from `php_request_startup()` right after `zend_activate()`. ### 3. Session handler hardening - `PS(in_rfc1867_update)` flag enables `incomplete_classes_only` mode during progress handler session decode: no autoloading, no `__wakeup()`, no `__unserialize()`, no destructors - `$_SESSION` is deleted from the global scope at `MULTIPART_EVENT_END` - `php_rinit_session_globals()` called at `MULTIPART_EVENT_START` - Guard against `request_parse_body()` re-entry ## Why this does not apply to PHP-FPM PHP-FPM's main request loop in `fpm_main.c` wraps request handling in `zend_first_try / zend_catch`. When bailout fires (from OOM during multipart parsing, which runs during SAPI activation before any inner try/catch), it propagates to this outer catch block: ```c zend_catch { exit_status = FPM_EXIT_SOFTWARE; // 70 } zend_end_try(); ``` The worker exits with code 70. FPM master spawns a replacement with clean state. The stale `BG(serialize)` dies with the process. On Apache prefork, the bailout is caught by `ap_process_request_internal` or similar internal handler. The child process continues its accept loop, and the stale BG globals persist. ## References - **Patch commit**: php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19 - **JFrog**: Session write-back calls fake object's serialize function, RIP control - **Rapid7**: RCE over HTTP with default PHP config; mod_php highest risk; attack complexity HIGH - **Wiz**: Confirmed RCE against stock WordPress, Drupal, Joomla on affected versions - **0x4148 lab**: Heap grooming targets ZendMM 320-byte slab bin (note: our analysis finds 64-byte bin for `php_serialize_data`) - **Sansec**: Disabling `session.upload_progress.enabled` alone is NOT sufficient (stale BG state persists from any OOM during multipart)