Pure memory corruption path: OOM during multipart parsing leaves stale BG(serialize) state in Apache mod_php prefork workers. Heap spray reclaims the freed 64-byte slot, giving control of a HashTable's pDestructor for arbitrary function call. No LFI, no framework, no non-default PHP settings required. Tested on PHP 8.3.6 + Apache 2.4.58 (prefork MPM). Confirmed: - OOM via field flood, worker survival, session file persistence - Stale BG(serialize).level across requests - Heap spray targeting ZendMM 64-byte bin
CVE-2026-109618 / CVE-2026-109619: UAF RCE via OOM Bailout
Use-after-free in PHP's multipart parser and session upload progress handler. Achieves code execution over HTTP with default PHP configuration, no LFI, no framework, no application code dependency.
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: OOM trigger
A multipart POST includes PHP_SESSION_UPLOAD_PROGRESS (with embedded PHP
code), a file upload (which activates the progress handler), and a flood of
large form fields. The progress handler calls php_var_serialize(), which
allocates a 64-byte struct (php_serialize_data) in the ZendMM slab allocator
and sets BG(serialize).level = 1.
The field flood exhausts memory_limit. PHP calls zend_bailout() (longjmp),
which skips php_var_serialize_destroy(). The serialize state survives:
BG(serialize).level stays at 1, and .data points to a freed 64-byte slot.
On Apache prefork, the worker survives the bailout and accepts new requests
with this stale state. The session file also persists on disk with the
attacker's PHP code, because MULTIPART_EVENT_END cleanup never ran.
Phase 2: Heap spray
Subsequent requests to the same worker allocate zend_string objects via
emalloc. Strings of exactly 39 bytes (24-byte header + 39 + 1 NUL = 64)
land in the same ZendMM 64-byte bin, potentially reclaiming the freed
BG(serialize).data slot with attacker-controlled content.
Phase 3: UAF trigger
Another multipart POST with PHP_SESSION_UPLOAD_PROGRESS. The progress
handler calls php_var_serialize_init(), which sees BG(serialize).level > 0
(stale), increments it, and returns the stale .data pointer. The serialize
code treats the attacker-controlled spray data as a php_serialize_data struct
containing a HashTable. The pDestructor function pointer (offset 48) is
under attacker control, giving arbitrary function call during
zend_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 serialize, causing
pDestructorto fire on the fake data
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 |
--cmd |
id |
Command to embed in session payload |
--session-path |
/var/www/uaf-test/sessions |
session.save_path on the target |
--oom-fields |
0 (auto) | Number of OOM padding fields |
--field-size |
40960 | Size of each padding field value |
--spray-rounds |
20 | Heap spray rounds per worker |
--trigger-rounds |
50 | UAF trigger attempts per worker |
--oom-only |
off | Stop after Phase 1 (OOM + session poison) |
--crash-test |
off | Run full chain, report crash status |
What --oom-only confirms
- OOM via multipart field flood (HTTP 500 response)
- Apache prefork worker survival (PIDs unchanged after OOM)
- Session file persistence with embedded PHP code (cleanup skipped)
- Stale
BG(serialize)state available for subsequent requests
The patch
Commit 3584a98bda0f2c0d367255b046798b0d3a221a19 fixes the vulnerability
through three changes:
-
Delayed OOM: Replaces
zend_bailout()(longjmp) with a deferred error that setsEG(vm_interrupt)and returns NULL from the allocator. A hard limit (memory_hard_limit_percent, default 200%) calls_exit(137)if exceeded. -
BG state reset: New
php_basic_early_rinit()function 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.
References
- PHP patch:
php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19 - Wiz: Confirmed RCE against WordPress, Drupal, Joomla using framework gadgets
- Rapid7: RCE over HTTP with default config, mod_php highest risk, AC:H
- JFrog: Session write-back calls fake object's serialize function, RIP control
- 0x4148 lab: Heap grooming targeting ZendMM slab bins
See FINDINGS.md for the full vulnerability analysis.