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

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 (100 x 8200B = 300 pages)
UAF crash (SIGSEGV in var_push) Confirmed (first attempt, reproducible)

Not achieved: full RCE.

The struct's critical pointer fields (offsets 0-23) overlap with the zend_string GC header and cannot be controlled by the attacker:

Struct field Offset Overlaps with Value
last 0 gc_refcount + gc_type_info ~0x00000306_00000001 (invalid pointer)
first_dtor 8 hash 0 (lazily computed, safe)
last_dtor 16 len 8200 (low address, crash)

Controlled bytes start at offset 24 (allowed_classes, ref_props, max_depth, entries.used_slots, entries.next, entries.data[]), but var_push crashes at offset 0 before reaching them.

Full RCE would require either an info leak to construct a valid pointer in the GC header position, or a different allocation type that provides control over the first 24 bytes of an 8216-byte ZendMM allocation.

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)