Files
php2shell/uaf-rce/FINDINGS.md
T
neko 76bc2bc2a3 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
2026-10-21 22:12:06 +00:00

236 lines
9.3 KiB
Markdown

# 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)