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.
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_limitreachable 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 pointerphp_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:
- No info leak: type confusion reads own data, not heap metadata
- Mutual exclusion: spray crashes
var_tmp_varbeforevar_access - GC header overlap: uncontrolled bytes at offset 0-23 crash var_push
- No usable classes: SplFileObject/PDO have function pointer calls
in
free_obj, but fake objects need libphp.so base (ASLR) - 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:
-
php_basic_early_rinit(): Called fromphp_request_startup()AFTERzend_activate()but BEFOREsapi_activate(). ZerosBG(serialize_lock),BG(serialize), andBG(unserialize)withmemset. Eliminates the stale state window entirely. -
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). -
Session handler hardening: RFC1867 session decode runs in
incomplete_classes_onlymode (no__wakeup/__unserialize).php_rinit_session_globals()atMULTIPART_EVENT_START.$_SESSIONcleanup atMULTIPART_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)