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

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

  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:

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:

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.enabled alone is NOT sufficient (stale BG state persists from any OOM during multipart)