The original exploit put the file upload before the field flood,
so the progress handler's serialize completed before OOM fired.
This meant BG(serialize) was not left stale.
The corrected flow:
Phase 1: Create ~500KB session file via large upload progress value
Phase 2: Fill memory with fields, then file upload triggers session
re-init which OOMs during unserialize of the large session
Phase 3: Verify stale BG(unserialize) state on the surviving worker
Confirmed on PHP 8.3.6, Apache 2.4.58: error log shows
"tried to allocate 503808 bytes" during session handling,
followed by "Session ini settings cannot be changed when a
session is active" on subsequent requests.
5.7 KiB
CVE-2026-109618 / CVE-2026-109619: UAF RCE 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.5.11 running under Apache mod_php with prefork MPM.
Patched in PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35.
PHP-FPM is not vulnerable to the cross-request UAF: FPM workers exit on bailout (code 70), so stale state never persists.
Prerequisites
- Apache mod_php with prefork MPM (default on Debian/Ubuntu)
session.upload_progress.enabled = 1(default)- Any PHP-handled URL (even a 404 page)
memory_limitreachable via multipart POST fields
No LFI. No specific framework or library. No non-default PHP settings.
How it works
Phase 1: Create large session file
A multipart POST with a ~500KB PHP_SESSION_UPLOAD_PROGRESS value
causes the progress handler to write a session file containing the
~500KB key. This is a normal request; no OOM occurs.
Phase 2: OOM during session re-init
A second request to the same session ID fills memory with form fields
(~7MB toward an 8MB limit), then includes a file upload. The file
upload triggers the progress handler, which calls
php_session_initialize() to re-read the ~500KB session file.
php_session_decode() calls php_var_unserialize_init(), which
allocates a 64-byte struct and sets BG(unserialize).level = 1.
The unserialize of the ~500KB key requires a ~500KB allocation,
which exceeds memory_limit and fires zend_bailout() (longjmp).
php_var_unserialize_destroy() is skipped.
On Apache prefork, the worker survives. BG(unserialize).level
stays at 1 and .data points to freed memory.
Phase 3: Verify stale state
A third request with a file upload hits the same worker. The progress
handler calls php_var_unserialize_init(), which sees
BG(unserialize).level > 0 (stale), increments it, and returns the
stale .data pointer. This is the UAF: the pointer was freed when
the previous request's heap was destroyed, and now references whatever
the new request allocated in the same 64-byte ZendMM bin slot.
Phase 4: Heap spray (for crash testing)
With --crash-test, 39-byte zend_string allocations (24-byte header
- 39 + 1 NUL = 64 bytes, same ZendMM bin) attempt to reclaim the
stale slot. The unserialize code treats the spray data as a struct
containing a
HashTable. ThepDestructorfunction pointer (offset 48) is under attacker control, giving RIP control duringzend_hash_destroy().
Full RCE (not implemented in this PoC)
A weaponized exploit would:
- Leak a PHP base address via CVE-2026-109619 info disclosure (stale session data leaks freed heap pointers) to defeat ASLR
- Construct a fake
HashTablewithpDestructorpointing tosystem() - Place the command string at the
arDatapointer location - Trigger unserialize, causing
pDestructorto fire
Usage
python3 exploit.py --target http://host:port/index.php
python3 exploit.py --target http://host:port/index.php --oom-only
python3 exploit.py --target http://host:port/index.php --crash-test
Options
| Flag | Default | Description |
|---|---|---|
--target |
(required) | Target URL (any PHP endpoint) |
--session-path |
/var/www/uaf-test/sessions |
session.save_path on the target |
--progress-size |
500000 | Size of PHP_SESSION_UPLOAD_PROGRESS value |
--oom-fields |
0 (auto) | Number of OOM padding fields (0 = auto-detect) |
--field-size |
40960 | Size of each padding field value |
--spray-rounds |
20 | Heap spray rounds per worker |
--oom-only |
off | Stop after Phases 1-3 (no spray) |
--crash-test |
off | Run full chain including heap spray |
What --oom-only confirms
- OOM via multipart field flood (HTTP 500)
- Apache prefork worker survival (PIDs unchanged after OOM)
- Large session file creation via upload progress handler
- OOM during session re-init (unserialize of large key)
- Stale
BG(unserialize)state available on subsequent requests
The patch
Commit 3584a98bda0f2c0d367255b046798b0d3a221a19 fixes the
vulnerability through three changes:
-
Delayed OOM: Replaces
zend_bailout()(longjmp) with deferred error handling. The allocator returns NULL and setsEG(vm_interrupt). A hard limit (memory_hard_limit_percent, default 200%) calls_exit(137)if exceeded. -
BG state reset: New
php_basic_early_rinit()resetsBG(serialize),BG(unserialize), andBG(serialize_lock)to zero before SAPI activation, eliminating cross-request stale state. -
Session handler hardening:
incomplete_classes_onlymode during progress handler session decode,$_SESSIONcleanup atMULTIPART_EVENT_END, andphp_rinit_session_globals()atMULTIPART_EVENT_START.
Confirmed primitives
Tested on PHP 8.3.6, Apache 2.4.58, Ubuntu 24.04, x86_64:
| Primitive | Status |
|---|---|
| OOM via multipart field flood | Confirmed |
| Apache prefork worker survival | Confirmed (PIDs unchanged) |
| Large session file creation | Confirmed (~500KB via upload progress) |
| OOM during session re-init | Confirmed ("tried to allocate 1003520 bytes") |
| Stale BG(unserialize).level | Confirmed (persists across requests) |
| Heap spray (64-byte bin) | Implemented |
| Worker crash via UAF | Not yet triggered (needs precise heap layout) |
| Full RCE | Not demonstrated (needs ASLR bypass + heap feng shui) |
References
- PHP patch:
php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19 - CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619)
- Attack complexity: HIGH (Rapid7)
See FINDINGS.md for the full vulnerability analysis.