Files
php2shell/uaf-rce
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
..

CVE-2026-109618 / CVE-2026-109619: UAF via OOM Bailout

Use-after-free in PHP's session upload progress handler, exploitable over HTTP against Apache mod_php with default PHP configuration. No LFI, no framework dependency, no non-default settings.

Affected versions

PHP 5.4.0 through 8.3.35, 8.4.0 through 8.4.26, 8.5.0 through 8.5.11, running under Apache mod_php with prefork MPM.

Patched in PHP 8.3.36, 8.4.27, 8.5.12.

PHP-FPM is not vulnerable: FPM workers exit on bailout (code 70), so stale state never persists across requests.

Prerequisites

  • Apache mod_php with prefork MPM (default on Debian/Ubuntu)
  • session.upload_progress.enabled = 1 (default)
  • memory_limit reachable via session data decode (~4MB for 33000 entries)
  • Any PHP-handled URL (even a 404 page)
  • A method to write a session file with many entries

No LFI. No specific framework or library. No non-default PHP settings.

How it works

CVE-2026-109618: OOM triggers longjmp past cleanup

When memory_limit is exceeded during session_decode() inside the RFC1867 progress callback, PHP calls zend_bailout() which executes a longjmp back to php_request_startup(). This skips php_var_unserialize_destroy(), leaving BG(unserialize).level = 1 and BG(unserialize).data pointing at a freed php_unserialize_data struct (8216 bytes, 3 ZendMM pages).

Apache prefork workers survive zend_bailout() and accept new connections with the stale state intact.

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

On the next request, sapi_activate() runs the RFC1867 callback BEFORE PHP_RINIT_FUNCTION(basic) has a chance to zero BG state. The callback calls php_session_decode(), which calls php_var_unserialize_init(). That function sees BG(unserialize).level > 0 and returns the stale .data pointer without allocating a new struct.

This is a use-after-free: the pointer was freed when the previous request's heap was destroyed during zend_deactivate().

Phase 1: Plant session file

A session file with 33000 entries (~682KB) is planted. When decoded, this forces nTableSize = 65536, requiring an arData allocation of 65536 * 36 = 2,359,296 bytes (~2.25MB). With memory_limit = 4MB, this exceeds the limit during HashTable resize.

Phase 2: OOM trigger

A multipart POST with PHP_SESSION_UPLOAD_PROGRESS and a file field triggers the RFC1867 progress callback. The callback reads the planted session file and calls php_session_decode(). The decode triggers OOM during HashTable resize. zend_bailout() longjmps past php_var_unserialize_destroy().

After bailout:

  • BG(unserialize).level = 1 (should be 0)
  • BG(unserialize).data = freed heap pointer
  • php_session_cancel_decode() deletes the session file
  • Worker survives, serves new connections

Phase 3: UAF crash

Another multipart POST (no intermediate GET requests, which would zero BG state via PHP_RINIT_FUNCTION) includes 100 filler form fields of 8200 bytes each. Each field becomes a 3-page zend_string allocation. The allocations consume ZendMM pages sequentially through the chunk, eventually reclaiming the 3 pages where the stale struct resided (typically page ~288 of 512).

The zend_string GC header (8 bytes: refcount + type_info) at offset 0 of the allocation overwrites the struct's last pointer (a var_entries*). When the session decode calls var_push(), it reads last and dereferences the GC header value as a pointer:

var_entries *var_hash = (*var_hashx)->last;  // GC header, not a valid address
var_hash->data[var_hash->used_slots++] = rval;  // SIGSEGV

The worker segfaults, confirming the use-after-free.

Exploitation status

Confirmed primitives:

Primitive Status
OOM during RFC1867 session decode Confirmed
Apache prefork worker survival after OOM Confirmed
Stale BG(unserialize) state across requests Confirmed (level=1, data=freed ptr)
Page reclamation via filler field spray Confirmed (27+ fields crashes worker)
UAF crash (SIGSEGV in var_tmp_var) Confirmed (reproducible)
Type confusion via var_access Confirmed (stale zval, type matches OOM session)

Not achieved: full RCE.

Two UAF primitives are confirmed, but they are mutually exclusive:

1. Crash (filler spray path): Filler fields reclaim the stale struct pages. var_tmp_var reads last_dtor at struct offset 16 (now overwritten by the zend_string len field, e.g. 0x2008). Dereferences unmapped address. SIGSEGV.

2. Type confusion (no spray path): Session data with R:N; backreferences resolves through var_access, which reads inline entries at struct offset 56. Without spray, entries.data[0] holds a stale pointer to the attacker's own data from request 1. The cached ZendMM chunk retains page data (only the header is zeroed). PHP reads the stale zval without crashing: type matches whatever the OOM session's first entry was (string, integer, etc.).

The two primitives cannot coexist: var_tmp_var reads offset 16 BEFORE var_access reads offset 56. Filler spray corrupts offset 16, crashing before the type confusion can execute.

Full RCE is blocked by:

  1. No info leak: type confusion reads own data, not heap metadata
  2. Mutual exclusion: spray crashes var_tmp_var before var_access
  3. GC header overlap: uncontrolled bytes at offset 0-23 crash var_push
  4. No usable classes: SplFileObject/PDO have function pointer calls in free_obj, but fake objects need libphp.so base (ASLR)
  5. Session key validation: php serializer rejects keys containing |

Usage

python3 exploit.py --target http://host:port/index.php
python3 exploit.py --target http://host:port/index.php --oom-only

Options

Flag Default Description
--target (required) Target URL (any PHP endpoint)
--session-path /var/www/uaf-test/sessions session.save_path on the target
--entries 33000 Entries in OOM session file
--spray-fields 100 Filler fields for crash trigger
--oom-only off Stop after Phase 2

The patch

The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds three independent defense layers. Any one of them blocks the attack:

  1. php_basic_early_rinit(): Called from php_request_startup() AFTER zend_activate() but BEFORE sapi_activate(). Zeros BG(serialize_lock), BG(serialize), and BG(unserialize) with memset. Eliminates the stale state window entirely.

  2. Delayed OOM: zend_bailout() on OOM is removed. The allocator either returns NULL (fallible mode) or succeeds beyond the soft limit (non-fallible mode) and queues a delayed error. No longjmp, so all cleanup code runs normally. A hard limit calls _exit(137).

  3. Session handler hardening: RFC1867 session decode runs in incomplete_classes_only mode (no __wakeup/__unserialize). php_rinit_session_globals() at MULTIPART_EVENT_START. $_SESSION cleanup at MULTIPART_EVENT_END.

Test environment

Tested on PHP 8.3.35 (built from source as Apache module), Apache 2.4.58 (prefork MPM, MaxRequestWorkers=1), Ubuntu 24.04, x86_64.

php_admin_value memory_limit 4M
php_admin_value session.upload_progress.enabled 1
php_admin_value session.upload_progress.cleanup 0

References

  • CVE-2026-109618: OOM during multipart parsing triggers longjmp
  • CVE-2026-109619: Session globals survive across requests
  • CVSS: 9.2 (both CVEs, per Rapid7)