# CVE-2026-109618 / CVE-2026-109619: UAF RCE 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.5.11 running under Apache mod_php with prefork MPM. Patched in PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35. PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers exit on bailout (code 70), so stale state never persists. ## Prerequisites - Apache mod_php with prefork MPM (default on Debian/Ubuntu) - `session.upload_progress.enabled = 1` (default) - Any PHP-handled URL (even a 404 page) - `memory_limit` reachable via multipart POST fields No LFI. No specific framework or library. No non-default PHP settings. ## How it works ### Phase 1: Create large session file A multipart POST with a ~500KB `PHP_SESSION_UPLOAD_PROGRESS` value causes the progress handler to write a session file containing the ~500KB key. This is a normal request; no OOM occurs. ### Phase 2: OOM during session re-init A second request to the same session ID fills memory with form fields (~7MB toward an 8MB limit), then includes a file upload. The file upload triggers the progress handler, which calls `php_session_initialize()` to re-read the ~500KB session file. `php_session_decode()` calls `php_var_unserialize_init()`, which allocates a 64-byte struct and sets `BG(unserialize).level = 1`. The unserialize of the ~500KB key requires a ~500KB allocation, which exceeds `memory_limit` and fires `zend_bailout()` (longjmp). `php_var_unserialize_destroy()` is skipped. On Apache prefork, the worker survives. `BG(unserialize).level` stays at 1 and `.data` points to freed memory. ### Phase 3: Verify stale state A third request with a file upload hits the same worker. The progress handler calls `php_var_unserialize_init()`, which sees `BG(unserialize).level > 0` (stale), increments it, and returns the stale `.data` pointer. This is the UAF: the pointer was freed when the previous request's heap was destroyed, and now references whatever the new request allocated in the same 64-byte ZendMM bin slot. ### Phase 4: Heap spray (for crash testing) With `--crash-test`, 39-byte `zend_string` allocations (24-byte header + 39 + 1 NUL = 64 bytes, same ZendMM bin) attempt to reclaim the stale slot. The unserialize code treats the spray data as a struct containing a `HashTable`. The `pDestructor` function pointer (offset 48) is under attacker control, giving RIP control during `zend_hash_destroy()`. ### Full RCE (not implemented in this PoC) A weaponized exploit would: 1. Leak a PHP base address via CVE-2026-109619 info disclosure (stale session data leaks freed heap pointers) to defeat ASLR 2. Construct a fake `HashTable` with `pDestructor` pointing to `system()` 3. Place the command string at the `arData` pointer location 4. Trigger unserialize, causing `pDestructor` to fire ## Usage ``` python3 exploit.py --target http://host:port/index.php python3 exploit.py --target http://host:port/index.php --oom-only python3 exploit.py --target http://host:port/index.php --crash-test ``` ### Options | Flag | Default | Description | |------|---------|-------------| | `--target` | (required) | Target URL (any PHP endpoint) | | `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target | | `--progress-size` | 500000 | Size of PHP_SESSION_UPLOAD_PROGRESS value | | `--oom-fields` | 0 (auto) | Number of OOM padding fields (0 = auto-detect) | | `--field-size` | 40960 | Size of each padding field value | | `--spray-rounds` | 20 | Heap spray rounds per worker | | `--oom-only` | off | Stop after Phases 1-3 (no spray) | | `--crash-test` | off | Run full chain including heap spray | ### What `--oom-only` confirms 1. OOM via multipart field flood (HTTP 500) 2. Apache prefork worker survival (PIDs unchanged after OOM) 3. Large session file creation via upload progress handler 4. OOM during session re-init (unserialize of large key) 5. Stale `BG(unserialize)` state available on subsequent requests ## The patch Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the vulnerability through three changes: 1. **Delayed OOM**: Replaces `zend_bailout()` (longjmp) with deferred error handling. The allocator returns NULL and sets `EG(vm_interrupt)`. A hard limit (`memory_hard_limit_percent`, default 200%) calls `_exit(137)` if exceeded. 2. **BG state reset**: New `php_basic_early_rinit()` resets `BG(serialize)`, `BG(unserialize)`, and `BG(serialize_lock)` to zero before SAPI activation, eliminating cross-request stale state. 3. **Session handler hardening**: `incomplete_classes_only` mode during progress handler session decode, `$_SESSION` cleanup at `MULTIPART_EVENT_END`, and `php_rinit_session_globals()` at `MULTIPART_EVENT_START`. ## Confirmed primitives Tested on PHP 8.3.6, Apache 2.4.58, Ubuntu 24.04, x86_64: | Primitive | Status | |---|---| | OOM via multipart field flood | Confirmed | | Apache prefork worker survival | Confirmed (PIDs unchanged) | | Large session file creation | Confirmed (~500KB via upload progress) | | OOM during session re-init | Confirmed ("tried to allocate 1003520 bytes") | | Stale BG(unserialize).level | Confirmed (persists across requests) | | Heap spray (64-byte bin) | Implemented | | Worker crash via UAF | Not yet triggered (needs precise heap layout) | | Full RCE | Not demonstrated (needs ASLR bypass + heap feng shui) | ## References - PHP patch: `php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19` - CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619) - Attack complexity: HIGH (Rapid7) See [FINDINGS.md](FINDINGS.md) for the full vulnerability analysis.