# CVE-2026-109618 / CVE-2026-109619: UAF via OOM Bailout Use-after-free in PHP's session upload progress handler, exploitable over HTTP against Apache mod_php with **default PHP configuration**. No LFI, no framework dependency, no non-default settings. ## Affected versions PHP 5.4.0 through 8.3.35, 8.4.0 through 8.4.26, 8.5.0 through 8.5.11, running under Apache mod_php with prefork MPM. Patched in PHP 8.3.36, 8.4.27, 8.5.12. PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70), so stale state never persists across requests. ## Prerequisites - Apache mod_php with prefork MPM (default on Debian/Ubuntu) - `session.upload_progress.enabled = 1` (default) - `memory_limit` reachable via session data decode (~4MB for 33000 entries) - Any PHP-handled URL (even a 404 page) - A method to write a session file with many entries No LFI. No specific framework or library. No non-default PHP settings. ## How it works ### CVE-2026-109618: OOM triggers longjmp past cleanup When `memory_limit` is exceeded during `session_decode()` inside the RFC1867 progress callback, PHP calls `zend_bailout()` which executes a `longjmp` back to `php_request_startup()`. This skips `php_var_unserialize_destroy()`, leaving `BG(unserialize).level = 1` and `BG(unserialize).data` pointing at a freed `php_unserialize_data` struct (8216 bytes, 3 ZendMM pages). Apache prefork workers survive `zend_bailout()` and accept new connections with the stale state intact. ### CVE-2026-109619: Stale state reused on next request On the next request, `sapi_activate()` runs the RFC1867 callback BEFORE `PHP_RINIT_FUNCTION(basic)` has a chance to zero `BG` state. The callback calls `php_session_decode()`, which calls `php_var_unserialize_init()`. That function sees `BG(unserialize).level > 0` and returns the stale `.data` pointer without allocating a new struct. This is a use-after-free: the pointer was freed when the previous request's heap was destroyed during `zend_deactivate()`. ### Phase 1: Plant session file A session file with 33000 entries (~682KB) is planted. When decoded, this forces `nTableSize = 65536`, requiring an `arData` allocation of 65536 * 36 = 2,359,296 bytes (~2.25MB). With `memory_limit = 4MB`, this exceeds the limit during `HashTable` resize. ### Phase 2: OOM trigger A multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` and a file field triggers the RFC1867 progress callback. The callback reads the planted session file and calls `php_session_decode()`. The decode triggers OOM during `HashTable` resize. `zend_bailout()` longjmps past `php_var_unserialize_destroy()`. After bailout: - `BG(unserialize).level = 1` (should be 0) - `BG(unserialize).data` = freed heap pointer - `php_session_cancel_decode()` deletes the session file - Worker survives, serves new connections ### Phase 3: UAF crash Another multipart POST (no intermediate GET requests, which would zero `BG` state via `PHP_RINIT_FUNCTION`) includes 100 filler form fields of 8200 bytes each. Each field becomes a 3-page `zend_string` allocation. The allocations consume ZendMM pages sequentially through the chunk, eventually reclaiming the 3 pages where the stale struct resided (typically page ~288 of 512). The `zend_string` GC header (8 bytes: `refcount` + `type_info`) at offset 0 of the allocation overwrites the struct's `last` pointer (a `var_entries*`). When the session decode calls `var_push()`, it reads `last` and dereferences the GC header value as a pointer: ``` var_entries *var_hash = (*var_hashx)->last; // GC header, not a valid address var_hash->data[var_hash->used_slots++] = rval; // SIGSEGV ``` The worker segfaults, confirming the use-after-free. ## Exploitation status **Confirmed primitives:** | Primitive | Status | |---|---| | OOM during RFC1867 session decode | Confirmed | | Apache prefork worker survival after OOM | Confirmed | | Stale `BG(unserialize)` state across requests | Confirmed (level=1, data=freed ptr) | | Page reclamation via filler field spray | Confirmed (27+ fields crashes worker) | | UAF crash (SIGSEGV in `var_tmp_var`) | Confirmed (reproducible) | | Type confusion via `var_access` | Confirmed (stale zval, type matches OOM session) | **Not achieved: full RCE.** Two UAF primitives are confirmed, but they are mutually exclusive: **1. Crash (filler spray path):** Filler fields reclaim the stale struct pages. `var_tmp_var` reads `last_dtor` at struct offset 16 (now overwritten by the `zend_string` `len` field, e.g. 0x2008). Dereferences unmapped address. SIGSEGV. **2. Type confusion (no spray path):** Session data with `R:N;` backreferences resolves through `var_access`, which reads inline entries at struct offset 56. Without spray, entries.data[0] holds a stale pointer to the attacker's own data from request 1. The cached ZendMM chunk retains page data (only the header is zeroed). PHP reads the stale zval without crashing: type matches whatever the OOM session's first entry was (string, integer, etc.). The two primitives cannot coexist: `var_tmp_var` reads offset 16 BEFORE `var_access` reads offset 56. Filler spray corrupts offset 16, crashing before the type confusion can execute. Full RCE is blocked by: 1. **No info leak**: type confusion reads own data, not heap metadata 2. **Mutual exclusion**: spray crashes `var_tmp_var` before `var_access` 3. **GC header overlap**: uncontrolled bytes at offset 0-23 crash var_push 4. **No usable classes**: SplFileObject/PDO have function pointer calls in `free_obj`, but fake objects need libphp.so base (ASLR) 5. **Session key validation**: php serializer rejects keys containing `|` ## Usage ``` python3 exploit.py --target http://host:port/index.php python3 exploit.py --target http://host:port/index.php --oom-only ``` ### Options | Flag | Default | Description | |---|---|---| | `--target` | (required) | Target URL (any PHP endpoint) | | `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target | | `--entries` | 33000 | Entries in OOM session file | | `--spray-fields` | 100 | Filler fields for crash trigger | | `--oom-only` | off | Stop after Phase 2 | ## The patch The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds three independent defense layers. Any one of them blocks the attack: 1. **`php_basic_early_rinit()`**: Called from `php_request_startup()` AFTER `zend_activate()` but BEFORE `sapi_activate()`. Zeros `BG(serialize_lock)`, `BG(serialize)`, and `BG(unserialize)` with `memset`. Eliminates the stale state window entirely. 2. **Delayed OOM**: `zend_bailout()` on OOM is removed. The allocator either returns NULL (fallible mode) or succeeds beyond the soft limit (non-fallible mode) and queues a delayed error. No longjmp, so all cleanup code runs normally. A hard limit calls `_exit(137)`. 3. **Session handler hardening**: RFC1867 session decode runs in `incomplete_classes_only` mode (no `__wakeup`/`__unserialize`). `php_rinit_session_globals()` at `MULTIPART_EVENT_START`. `$_SESSION` cleanup at `MULTIPART_EVENT_END`. ## Test environment Tested on PHP 8.3.35 (built from source as Apache module), Apache 2.4.58 (prefork MPM, MaxRequestWorkers=1), Ubuntu 24.04, x86_64. ``` php_admin_value memory_limit 4M php_admin_value session.upload_progress.enabled 1 php_admin_value session.upload_progress.cleanup 0 ``` ## References - CVE-2026-109618: OOM during multipart parsing triggers longjmp - CVE-2026-109619: Session globals survive across requests - CVSS: 9.2 (both CVEs, per Rapid7)