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
6.7 KiB
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 (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:
-
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)