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
9.3 KiB
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:
// 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
-
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).dataBG(serialize).level = 1
- Field flood triggers OOM ->
zend_bailout() php_var_serialize_destroy()never runsBG(serialize).levelstays at 1,.datapoints to freed 64-byte slot- Worker survives on Apache prefork
- Multipart POST with
-
Between requests:
php_request_shutdown()callszend_deactivate()-> ZendMM heap destroyedBG(serialize).datapointer is now dangling (points into freed heap)- ZendMM reuses chunk cache for the next request's heap
-
Request 2 (spray + trigger):
- POST fields allocate
zend_stringobjects viaemalloc - 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()checksBG(serialize).level > 0- Level is 1 (stale), so it increments to 2 and returns the stale
.datapointer - The pointer now references attacker-controlled spray data
- The serialize code treats it as a
php_serialize_datastruct containing aHashTable pDestructor(offset 48) is called duringzend_hash_destroy()- If
pDestructorpoints tosystem(),zend_execute_ex(), or a one-gadget, the attacker achieves code execution
- POST fields allocate
Full RCE requirements
Beyond the demonstrated primitives, a weaponized exploit needs:
-
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. -
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.
-
Fake HashTable construction: The spray data must form a valid
php_serialize_datastruct where:ht.pDestructorpoints to the target functionht.arDatapoints to aBucketcontaining the argumentht.nNumUsed > 0so 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:
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 enablesincomplete_classes_onlymode during progress handler session decode: no autoloading, no__wakeup(), no__unserialize(), no destructors$_SESSIONis deleted from the global scope atMULTIPART_EVENT_ENDphp_rinit_session_globals()called atMULTIPART_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:
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@3584a98bda
- 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.enabledalone is NOT sufficient (stale BG state persists from any OOM during multipart)