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.
249 lines
10 KiB
Markdown
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
|