Files
php2shell/uaf-rce/FINDINGS.md
T
neko fbaced0a77 UAF exploit: confirmed crash, accurate docs, fix stale-state race
Rewrote the exploit to correctly avoid GET requests between OOM
trigger and crash verification (RINIT zeros BG state on every
request). Filler field spray (100 x 8200B = 300 pages) reliably
reclaims the stale struct pages. Crash confirmed on first attempt.

Updated README and FINDINGS to accurately describe:
- The 8216-byte struct (3 pages), not the earlier 64-byte bin
- GC header overlap that blocks controlled RCE (offsets 0-23)
- Three independent patch layers in PHP 8.3.36/8.4.27/8.5.12
- Honest exploitation status: UAF crash confirmed, RCE not achieved
2026-10-22 03:48:05 +00:00

9.2 KiB

CVE-2026-109618 / CVE-2026-109619: Vulnerability Analysis

Overview

PHP's multipart parser (rfc1867.c) and session upload progress handler (ext/session/session.c) contain a use-after-free exploitable over HTTP against Apache mod_php with default PHP configuration. No LFI, no framework dependency, no non-default settings.

Property Value
CVE CVE-2026-109618, CVE-2026-109619
CVSS 9.2 (both, per Rapid7)
Affected PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11
Patched PHP 8.3.36, 8.4.27, 8.5.12
Prerequisites Apache mod_php (prefork), session.upload_progress.enabled=1 (default)
Exploit status UAF crash confirmed, full RCE not achieved

Root cause

CVE-2026-109618: OOM triggers longjmp past cleanup

When memory_limit is exceeded during php_session_decode() inside the RFC1867 progress callback, PHP calls zend_mm_safe_error() which invokes zend_bailout(). This is a longjmp that unwinds the call stack without running cleanup code, specifically php_var_unserialize_destroy().

The state that survives the bailout (in BG module globals):

// ext/standard/basic_functions.c
struct {
    uint32_t level;                    // should be 0, stays at 1
    php_unserialize_data_t data;       // freed 8216-byte struct pointer
} unserialize;

php_var_unserialize_init() allocates a php_unserialize_data struct (8216 bytes = 3 ZendMM pages) and sets level = 1. php_var_unserialize_destroy() frees it and sets level = 0. When OOM fires between init and destroy, level stays at 1 and data points to freed memory.

CVE-2026-109619: Stale state reused on next request

The request startup flow in main/main.c (lines 1784-1838) wraps the entire block from zend_activate() through zend_activate_modules() in one zend_try/zend_catch. The execution order:

  1. zend_activate() (line 1796)
  2. sapi_activate() (line 1797): multipart parsing and RFC1867 callback
  3. php_hash_environment() (line 1833)
  4. zend_activate_modules() (line 1834): module RINITs run here, including PHP_RINIT_FUNCTION(basic) which zeros BG state
  5. PG(modules_activated) = 1 (line 1835)

The RFC1867 callback at step 2 runs BEFORE RINIT at step 4. So stale BG state from the previous request IS used during RFC1867 decode.

When php_var_unserialize_init() is called during RFC1867 decode:

  • It checks BG(unserialize).level
  • If level > 0 AND BG(serialize_lock) == 0, it returns BG(unserialize).data (the stale, freed pointer) and increments level

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. BG globals persist because they are module-level, not request-scoped.

PHP-FPM: The bailout propagates to fpm_main.c's outer zend_try/zend_catch. The catch block sets exit_status = 70 and the worker exits. A new clean worker is spawned.

OOM trigger mechanism

A session file with 33000 entries (~682KB) forces nTableSize = 65536 during decode. The arData allocation: 65536 * 36 = 2,359,296 bytes (~2.25MB). With memory_limit = 4MB, this exceeds the limit during HashTable resize in zend_hash_real_init_mixed_ex().

The OOM triggers during sapi_activate() -> RFC1867 callback -> php_session_initialize() -> php_session_decode() -> unserialize -> HashTable resize.

After OOM:

  • php_session_cancel_decode() calls php_session_destroy()
  • Session file is DELETED (on unpatched PHP)
  • BG(unserialize).level = 1, .data = freed pointer to 8216-byte struct
  • Worker survives, serves new connections

The UAF mechanism

php_unserialize_data struct layout (8216 bytes, 3 pages)

Offset  Field                   Size    Notes
------  -----                   ----    -----
0       last (var_entries*)      8       Points to inline entries
8       first_dtor              8       NULL initially
16      last_dtor               8       NULL initially
24      allowed_classes         8       NULL = allow all
32      ref_props               8       NULL
40      cur_depth               4       0
44      (padding)               4
48      max_depth               8       From ini setting
56      entries.used_slots      8       0 initially
64      entries.next            8       NULL
72      entries.data[1018]      8144    zval* array
8216    (end)

Why var_push crashes

After OOM, the heap is destroyed and rebuilt for the next request. The stale pointer references freed pages in the ZendMM chunk. Filler form fields in the crash trigger create 3-page zend_string allocations that consume pages sequentially. When one lands on the stale struct's pages (~page 288 of 512), the zend_string GC header overlaps the struct's pointer fields:

Struct offset 0 (last)      <- gc_refcount(4) + gc_type_info(4)
Struct offset 8 (first_dtor) <- hash(8)
Struct offset 16 (last_dtor) <- len(8)
Struct offset 24+            <- string value (attacker-controlled)

var_push (var_unserializer.re line 118) reads (*var_hashx)->last:

var_entries *var_hash = (*var_hashx)->last;  // = GC header ~= 0x0000030600000001
var_hash->data[var_hash->used_slots++] = rval;  // dereferences invalid address
// SIGSEGV

Confirmed primitives

Tested on PHP 8.3.35 (source build), Apache 2.4.58 (prefork, MaxRequestWorkers=1), Ubuntu 24.04, x86_64:

Primitive Status Notes
OOM during RFC1867 session decode Confirmed 33000-entry session, 4MB limit
Apache prefork worker survival Confirmed Same PID after OOM
Stale BG(unserialize) state Confirmed level=1, data=freed ptr (GDB)
Page reclamation via filler spray Confirmed 100 x 8200B fields
UAF crash (SIGSEGV in var_push) Confirmed Reproducible, first attempt
Full RCE Not achieved GC header overlap blocks pointer control

Exploitation constraints

Why full RCE is not achieved

The struct's three pointer fields at offsets 0-23 overlap with the zend_string header, which is set by the allocator:

  • last (offset 0) = gc_refcount + gc_type_info: the allocator sets refcount=1 and type_info=IS_STRING with GC flags. This produces a low, invalid address like 0x0000030600000001. The attacker cannot control these bytes.

  • first_dtor (offset 8) = hash: lazily computed, initially 0 (NULL). This is safe: var_destroy checks first_dtor != NULL before iterating the dtor chain.

  • last_dtor (offset 16) = len: the string length (e.g., 8200 = 0x2008). This is also a low, unmapped address.

The attacker controls bytes from offset 24 onward (allowed_classes, ref_props, max_depth, entries.used_slots, entries.next, entries.data[]), but var_push crashes reading last (offset 0) before any of these fields are reached.

What would be needed for full RCE

  1. Info leak: A separate vulnerability to leak a valid heap address. The leaked address would need to be placed at offset 0 of the allocation, which is impossible with a zend_string overlay (GC header is set by the allocator).

  2. Alternative allocation type: A different 3-page (8216-byte) allocation where the attacker controls offset 0. No such allocation type has been identified during multipart parsing.

  3. Alternative reclamation path: A way to overwrite only the struct fields WITHOUT overlapping a zend_string header. This would require a non-contiguous write primitive.

The patch (PHP 8.3.36, 8.4.27, 8.5.12)

Three independent defense layers, any one sufficient:

1. php_basic_early_rinit()

New function called from php_request_startup() AFTER zend_activate() but BEFORE sapi_activate():

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)));
}

Eliminates the stale state window entirely. Even if OOM occurs, the BG state is zeroed before RFC1867 processing on the next request.

2. Delayed OOM

zend_bailout() on OOM is completely removed. The allocator now either:

  • Returns NULL (fallible mode) for checked allocations
  • Succeeds beyond the soft limit (non-fallible mode) and queues a delayed error via EG(delayed_oom) + VM interrupt

No longjmp means all cleanup code runs normally, including php_var_unserialize_destroy().

A hard limit (memory_hard_limit_percent, default 200%) calls _exit(137) as a safety net.

3. Session handler hardening

  • PS(in_rfc1867_update) flag forces incomplete_classes_only mode during RFC1867 session decode (no __wakeup/__unserialize)
  • php_rinit_session_globals() called at MULTIPART_EVENT_START
  • $_SESSION cleanup at MULTIPART_EVENT_END
  • PG(modules_activated) check prevents RFC1867 callback during request_parse_body()
  • ~30 ZEND_HAS_OOM() checks throughout rfc1867.c for graceful handling of allocation failures

Why this does not apply to latest PHP

The latest stable PHP versions (8.3.36+, 8.4.27+, 8.5.12+) include all three defense layers. The patch is comprehensive:

  • Layer 1 alone blocks the attack (no stale state)
  • Layer 2 alone blocks the attack (no longjmp, cleanup runs)
  • Layer 3 alone limits exploitability (no object instantiation)

No bypass path has been identified for any of the three layers.