Files
php2shell/uaf-rce/FINDINGS.md
T
neko a5396a6ab0 Fix UAF exploit to use confirmed two-request OOM approach
The original exploit put the file upload before the field flood,
so the progress handler's serialize completed before OOM fired.
This meant BG(serialize) was not left stale.

The corrected flow:
  Phase 1: Create ~500KB session file via large upload progress value
  Phase 2: Fill memory with fields, then file upload triggers session
           re-init which OOMs during unserialize of the large session
  Phase 3: Verify stale BG(unserialize) state on the surviving worker

Confirmed on PHP 8.3.6, Apache 2.4.58: error log shows
"tried to allocate 503808 bytes" during session handling,
followed by "Session ini settings cannot be changed when a
session is active" on subsequent requests.
2026-10-21 22:17:29 +00:00

249 lines
10 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 (two-request approach)
1. **Request 1** (prep):
- Multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` = ~500KB value
- File upload triggers progress handler
- `php_session_rfc1867_update()` writes session file with the ~500KB key
- No OOM; just creates the large session file on disk
2. **Request 2** (OOM):
- Same `PHPSESSID` cookie (references the large session file)
- `PHP_SESSION_UPLOAD_PROGRESS` = small value
- Form fields fill memory to near `memory_limit` (~7.5MB of 8MB)
- File upload triggers progress handler:
- `php_session_initialize()` -> reads ~500KB session file
- `php_session_decode()` -> `php_var_unserialize_init()`:
- `BG(unserialize).level = 1`
- `BG(unserialize).data = emalloc(64)` (64-byte ZendMM bin)
- Unserialize of the ~500KB key string: `emalloc(~500KB)` -> **OOM**
- `zend_bailout()` (longjmp)
- `php_var_unserialize_destroy()` never runs
- Worker survives on Apache prefork
- `BG(unserialize).level` stays at 1, `.data` points to freed 64-byte slot
- Confirmed by error log: "tried to allocate 1003520 bytes" during
session handling
3. **Between requests**:
- `php_request_shutdown()` calls `zend_deactivate()` -> ZendMM heap destroyed
- `BG(unserialize).data` pointer is now dangling (freed heap)
- ZendMM chunk caching preserves mmap'd pages; next request's heap
reuses the same virtual addresses
4. **Request 3** (spray + trigger):
- POST fields allocate `zend_string` objects via `emalloc`
- Strings of 39 bytes (24-byte header + 39 + 1 NUL = 64) land in the
same 64-byte bin, potentially reclaiming the stale slot
- Upload progress handler calls `php_session_decode()`:
- `php_var_unserialize_init()` checks `BG(unserialize).level > 0`
- Level is 1 (stale), so it increments to 2 and returns `.data`
- The pointer now references attacker-controlled spray data
- The unserialize code treats it as a `php_unserialize_data` struct
containing a `HashTable`
- `pDestructor` (offset 48) is called during `zend_hash_destroy()`
- If `pDestructor` points to `system()`, `zend_execute_ex()`, or a
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**: Analysis of mitigation options and affected versions