Files
php2shell/uaf-rce/FINDINGS.md
T
neko 39f7f33938 UAF exploit: exhaustive analysis, five independent RCE blockers documented
Type confusion via var_access confirmed: R:N; references resolve stale
entries.data[0] without crashing, returning the attacker's own session
data from the cached ZendMM chunk (pages retained, not zeroed).

Key finding: filler spray and type confusion are mutually exclusive.
var_tmp_var reads last_dtor (struct offset 16) BEFORE var_access reads
entries (offset 56). Spray corrupts offset 16, crashing before the
type confusion can execute.

Built-in class audit: SplFileObject and PDO have function pointer calls
in free_obj, but constructing fake objects requires libphp.so base (ASLR).

Patch audit (8.5.12): all three defense layers are solid, no bypass found.

Five RCE blockers: no info leak, filler/type-confusion mutual exclusion,
GC header overlap, no usable classes without ASLR bypass, session key
validation.
2026-10-22 04:47:13 +00:00

16 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

Why var_access does NOT crash (type confusion primitive)

var_access (var_unserializer.re line 213) is used by R:N; and r:N; backreferences. Unlike var_push, it reads the INLINE entries at struct offset 56, NOT the last pointer at offset 0:

var_entries *var_hash = &(*var_hashx)->entries;  // offset 56, not 'last'
if (id >= var_hash->used_slots) return NULL;
return var_hash->data[id];  // offset 72+

Without the filler spray, the stale struct retains its request 1 layout at the critical fields:

last = &entries (self-referencing, valid)
entries.used_slots = 1 (partially overwritten by heap metadata)
entries.data[0] = 0x780c8a923010 (stale zval* from request 1)

When the session data contains x|R:1;, the unserializer calls var_access(0, stale_struct), which reads entries.data[0] (the stale pointer) and returns it. PHP then does ZVAL_COPY(rval, stale_zval).

The stale zval's type matches whatever the first OOM session entry was:

OOM session first value Stale zval type Result
String (e.g. s:8000:"BBB...") IS_STRING Original string content intact
Integer (e.g. i:42;) IS_LONG Original integer value
Boolean (e.g. b:1;) IS_TRUE Original boolean value
Object (e.g. O:8:"stdClass":0:{}) IS_OBJECT CRASH (freed zend_object dereference)
Array (e.g. a:0:{}) IS_ARRAY Self-referencing $_SESSION (infinite recursion)

The cached ZendMM chunk retains page data across requests (only the chunk header metadata is zeroed, not the data pages). This means the stale zval at entries.data[0] contains the attacker's own data from request 1, not leaked heap memory or code pointers.

This is a confirmed type confusion, but it does NOT provide an info leak. The value appears in $_SESSION['x'] and is accessible to the main PHP script, but only contains data the attacker already knows.

Why filler spray and type confusion are mutually exclusive

The session decode calls var_tmp_var(&var_hash) (var_unserializer.re line 187) BEFORE php_var_unserialize() for each session key-value pair. var_tmp_var reads (*var_hashx)->last_dtor at struct offset 16.

Without filler spray: last_dtor retains its request 1 value (NULL for simple string values). var_tmp_var allocates a new var_dtor_entries struct and writes the pointer to offset 16. No crash.

With filler spray: offset 16 is overwritten by the zend_string len field (e.g., 8200 = 0x2008). var_tmp_var dereferences 0x2008 as a var_dtor_entries*, which is an unmapped address. SIGSEGV before var_access runs.

This means there is no exploitation window where controlled data coexists with a readable stale reference. Filler spray corrupts the struct before var_access can read the controlled pages.

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 27+ fields crashes worker
UAF crash (SIGSEGV in var_push) Confirmed Reproducible, first attempt
Type confusion via var_access Confirmed R:1 resolves stale zval, type matches OOM session
Stale value = attacker's own data Confirmed Cached chunk retains pages, no info leak
Filler/type-confusion mutual exclusion Confirmed var_tmp_var crashes at offset 16 before var_access
Session key injection via | Blocked php serializer skips keys with |
Info leak via type confusion Not possible Reads own data, not heap metadata
Full RCE Not achieved ASLR + mutual exclusion + no info leak

Exploitation constraints

Why full RCE is not achieved

Five independent constraints block RCE from this primitive.

1. GC header overlap (var_push path)

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.

2. No info leak (var_access path)

The type confusion via var_access avoids the GC header crash, but entries.data[0] points to a Bucket in request 1's session HashTable arData. The ZendMM cached chunk retains data pages across requests (only the chunk header is zeroed during zend_mm_shutdown with memset(p, 0, sizeof(zend_mm_chunk))). The stale zval at entries.data[0] contains the attacker's own session data, not leaked heap metadata or code pointers.

Testing confirms the stale value's type matches the OOM session's first entry (string -> IS_STRING with original content, integer -> IS_LONG with original value, etc.). This is not an info leak.

To exploit the type confusion for RCE, the attacker would need to:

  1. Leak a heap pointer to determine the ZendMM chunk base, then
  2. Spray controlled data to overlapping pages, then
  3. Use the type confusion to read crafted objects

Step 1 is impossible through this vulnerability: no path reads heap metadata or code pointers through the type confusion.

2b. Mutual exclusion of filler spray and type confusion

The filler spray (needed to place controlled data on stale pages) and the type confusion (needed to read stale data) cannot coexist:

  • var_tmp_var() reads (*var_hashx)->last_dtor at struct offset 16 BEFORE php_var_unserialize() calls var_access at offset 56.
  • Without spray: offset 16 = NULL (from request 1), var_tmp_var allocates normally, type confusion works but reads own data.
  • With spray: offset 16 = zend_string len (e.g., 0x2008), var_tmp_var dereferences 0x2008 as a pointer -> SIGSEGV before var_access runs.

There is no exploitation window where controlled content on the stale pages is readable via the type confusion.

3. Built-in classes lack usable exploitation path

The RFC1867 session decode runs during sapi_activate(), before module RINIT and main script execution. Only PHP's built-in/internal classes are registered. An audit of all statically compiled extensions found:

Classes with exploitable free_obj handlers:

  • SplFileObject/SplFileInfo/DirectoryIterator: free_obj calls intern->oth_handler->dtor(intern), a function pointer call through object data. But oth_handler is only set by Phar.
  • PDO: free_obj calls dbh->methods->closer(dbh), another function pointer through object data.

Why these are unusable: Constructing a fake zend_object that redirects handlers to a real handlers table requires knowing libphp.so's base address. The heap chunk address is stable within a process, but code segment addresses are independent. No code address leak was found through this vulnerability.

4. Session key validation

The PHP_SESSION_UPLOAD_PROGRESS form field value becomes part of the session key (prefixed with upload_progress_). A | character in this value could inject serialized data into the session file, since the php session serializer uses | as the key-value delimiter.

However, PS_SERIALIZER_ENCODE_FUNC(php) explicitly checks for | in session keys with memchr() and skips keys containing it. The injected key is stored in $_SESSION but never written to the session file, preventing deserialization injection on subsequent requests.

What would be needed for full RCE

  1. An info leak from a separate vulnerability: The type confusion reads the attacker's own data. A separate vulnerability providing either a heap pointer or a libphp.so code pointer would enable:

    • Heap pointer: spray fake objects at known addresses
    • Code pointer: construct fake zend_object.handlers pointing to real handler tables for SplFileObject/PDO/Closure
  2. Bypass of filler/type-confusion mutual exclusion: A way to place controlled data on stale pages without corrupting struct offset 16 (last_dtor). No such technique was found. The struct occupies 3 pages (8216 bytes), and large allocations cover full pages including offset 16.

  3. Loaded classes with POP chains: If a framework's classes were loaded before session decode, deserialized objects could trigger exploitable magic methods. Standard PHP and WordPress do not load user classes before sapi_activate().

  4. Alternative allocation type: An 8216-byte allocation where the attacker controls offset 0 and 16 (blocking the GC header overlap and last_dtor corruption). No such type was identified in the multipart parsing code path.

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.