- Expand patch section: five independent defenses, not three - Defense 1 (rinit_session_globals at MULTIPART_EVENT_START) alone is sufficient to block the entire exploit - Document post_max_size enforcement that blocks chunked TE bypass - Add display_errors=Off impossibility analysis with technical detail - Confirm PHP 8.3.35 RCE working (not just stages 1+2) - Note 8.4.27 tested and confirmed blocked
11 KiB
CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray
Remote code execution in PHP's session upload progress handler, exploitable over plain HTTP against Apache mod_php with completely stock configuration. No LFI, no framework dependency, no non-default PHP settings required.
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.
What this exploit achieves
Full remote code execution (arbitrary command) from a single unauthenticated
HTTP client, against any PHP-handled URL, with full ASLR bypass. The target
page does not need session_start() or any specific code.
Tested: 5/5 successful RCE against stock php:8.4.26-apache Docker image
with zero configuration changes (memory_limit=128M, post_max_size=8M,
display_errors=On, MaxRequestWorkers=150).
Prerequisites
All of these are default on stock PHP Docker images and common Debian/Ubuntu Apache + mod_php installations:
- Apache mod_php with prefork MPM
session.upload_progress.enabled = 1(default: on)session.upload_progress.cleanup = 1(default: on)- Any PHP-handled URL (even a 404 error page)
display_errors = OnorSTDOUT(default when no php.ini is deployed)
The exploit bypasses PHP's post_max_size limit via HTTP chunked
Transfer-Encoding (see below), so the post_max_size=8M default is not
an obstacle despite needing to send ~140MB to exhaust memory_limit=128M.
For multi-worker Apache (the default), the --workers N spray mode sends
the OOM trigger to all workers before probing for the leak.
How it works
The vulnerability chain
CVE-2026-109618: When memory_limit is exceeded during multipart POST
parsing, PHP calls zend_bailout() which executes a longjmp back to
the request shutdown handler. This skips session cleanup code in
php_session_rfc1867_update(), leaving PS(id) as a dangling pointer
and PS(send_cookie) = 1.
CVE-2026-109619: On the next request to the same Apache worker,
sapi_activate() runs the RFC1867 callback BEFORE session state is
properly reinitialized. The callback sees the stale PS(id) and
PS(send_cookie) = 1, and calls php_session_send_cookie() with the
dangling pointer. The php_url_encode(PS(id)) call reads heap memory
through the stale pointer and emits it as a Set-Cookie header.
post_max_size bypass via chunked Transfer-Encoding
PHP checks Content-Length against post_max_size before invoking the
multipart parser. If Content-Length exceeds the limit, the body is
rejected entirely. However, when the request uses Transfer-Encoding: chunked instead of Content-Length, no upfront size check occurs.
PHP's sapi_read_post_block() reads chunked data from Apache's input
filter chain without enforcing post_max_size, allowing the full body
to be processed regardless of the configured limit.
This means the exploit works against memory_limit=128M with
post_max_size=8M by sending ~140MB of chunked POST data.
Three-stage exploit (full ASLR bypass)
All traffic is plain HTTP multipart POST requests:
Stage 1: Heap ASLR bypass (Zend MM chunk base)
Request A sends 1001+ fields (triggering max_input_vars warning which
sets SG(headers_sent) = 1) plus PHPSESSID=\x00 and
PHP_SESSION_UPLOAD_PROGRESS=pwn (to activate session handling) plus a
large field via chunked TE to trigger OOM. The warning output prevents
php_session_send_cookie() from sending the cookie, but PS(send_cookie)
remains 1 and PS(id) becomes a dangling pointer.
Request B sends PHPSESSID + progress fields plus ~841 filler fields
with a forge field containing a fake zend_string header (len = 0x1000).
The filler fields' heap allocations overlap the dangling PS(id) address.
When php_session_send_cookie() runs with the stale pointer, it reads
4096 bytes of heap data and emits it as a Set-Cookie value. The leaked
bytes contain Zend MM free-list pointers, which reveal the chunk base
address (2MB aligned).
Stage 2: Code ASLR bypass (libphp.so base)
Similar to stage 1, but request B's field layout places a real PHP
HashTable structure at the dangling pointer offset. The pDestructor
field of the HashTable is a function pointer into libphp.so
(zval_ptr_dtor). Reading this via Set-Cookie leaks the libphp.so
load address.
Stage 3: RCE (zif_system)
Request A poisons the worker with stale session state. Request B sprays
the heap with a fake IS_REFERENCE zval pointing to a fake HashTable
whose pDestructor = zif_system. When session cleanup calls
zend_hash_destroy() on this fake table, it invokes zif_system() with
attacker-controlled arguments, executing an arbitrary shell command as
the web server user (www-data).
Usage
# Auto-detect PHP version, look up offsets, exploit
python3 rce_exploit.py -u http://target/index.php
# Auto-pull matching Docker image for offset extraction
python3 rce_exploit.py -u http://target/index.php --auto-pull
# Verify RCE via docker exec (for testing)
python3 rce_exploit.py -u http://target/index.php \
--docker-proof CONTAINER /tmp/pwn
# Multi-worker Apache spray mode
python3 rce_exploit.py -u http://target/ --workers 150
# Custom command (max 14 bytes, use 'sh</tmp/c' for longer)
python3 rce_exploit.py -u http://target/ -c 'id>/tmp/pwn'
# Extract offsets for a new PHP build
python3 rce_exploit.py calibrate --docker-image php:8.3.35-apache \
--php-version 8.3.35
Options
| Flag | Default | Description |
|---|---|---|
-u URL |
(required) | Target URL (any PHP endpoint) |
-c CMD |
id>/tmp/pwn |
Shell command (max 14 bytes) |
--workers N |
1 | Apache workers to spray |
--huge-mb N |
140 | OOM field size in MB |
--docker-proof C P |
Verify via docker exec C cat P |
|
--auto-pull |
off | Pull Docker image for offset extraction |
--retries N |
5 | Attempts per stage |
--profile |
auto | Heap profile (bookworm or lab) |
--timeout |
30.0 | Socket timeout in seconds |
Built-in offset database
| PHP version | Distro | Status |
|---|---|---|
| 8.4.26 | bookworm | Last vulnerable 8.4.x |
| 8.3.35 | bookworm | Last vulnerable 8.3.x |
| 8.4.27 | bookworm | Patched (offsets extracted, bypass not implemented) |
| 8.3.36 | bookworm | Patched (offsets extracted, bypass not implemented) |
The patch
The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds five independent defense layers. Any one of the first three alone is sufficient to block the attack:
-
php_rinit_session_globals()atMULTIPART_EVENT_START: The session RFC1867 callback now callsphp_rinit_session_globals()at the very start of multipart parsing, unconditionally zeroingPS(id),PS(session_status), andPS(send_cookie). This destroys the danglingPS(id)from the previous request before B's parsing begins. This single defense is sufficient to kill the entire exploit chain. -
Delayed OOM:
zend_mm_safe_error(which calledzend_bailout/ longjmp) is replaced byzend_mm_delayed_verror. OOM no longer triggers a longjmp during allocation. Instead, a soft limit records the error and setsvm_interrupt. Allocations return NULL in fallible mode or succeed up to a hard limit (default 200% ofmemory_limit). A hard limit calls_exit(137). No longjmp means all cleanup code runs normally, so stale state never persists. -
post_max_sizeenforcement inmultipart_buffer_fill(): A new check infill_buffer()enforcespost_max_sizeagainstSG(read_post_bytes)regardless of Transfer-Encoding. The chunked TE bypass (which previously allowed sending 140MB despite 8M limit) is blocked. OOM cannot be triggered via multipart data alone. -
php_basic_early_rinit(): Called fromphp_request_startup()beforesapi_activate(). ZerosBG(serialize_lock),BG(serialize), andBG(unserialize), eliminating the stale serialization state window from CVE-2026-109618. -
Session deserialization hardening: RFC1867 session decode runs in
incomplete_classes_onlymode (no__wakeup/__unserialize).$_SESSIONis deleted atMULTIPART_EVENT_END. APG(modules_activated)guard preventsrequest_parse_body()from corrupting session state.
Test environment
The docker/ directory contains a minimal test setup:
cd docker && docker compose up -d
# Stock settings variant (no custom php.ini):
docker run -d --name stock -p 8093:80 php:8.4.26-apache
The exploit has been tested on:
php:8.4.26-apachestock Docker image, 5/5 RCE (default settings)php:8.4.26-apachewith single worker +memory_limit=8M, 9/9 RCEphp:8.3.35-apachestock Docker image, RCE confirmed (first attempt)php:8.4.27-apachepatched: all three exploit primitives blocked (see below)
display_errors and WordPress
The exploit's info leak (stages 1 and 2) relies on display_errors = On.
The max_input_vars warning must produce visible output to set
SG(headers_sent) = 1 before the session cookie attempt, which causes
php_session_send_cookie() to fail and leaves PS(send_cookie) = 1 for
the next request's leak.
With display_errors = Off, the warning is logged but produces no output.
The session cookie succeeds, PS(send_cookie) resets to 0, and no leak
cookie is emitted on the subsequent request.
Why stock PHP is still vulnerable: PHP's compile-time default for
display_errors is On. Stock Docker images (php:*-apache) ship
without a php.ini, so the default applies. Only when a system
administrator deploys php.ini-production (which sets display_errors = Off)
or adds explicit INI configuration is the target hardened against this leak.
WordPress: WordPress calls ini_set('display_errors', 0) at runtime
in wp-settings.php. This ini_set executes during script startup, which
is AFTER multipart POST parsing (where the OOM and session leak occur).
Therefore, WordPress installed on a stock PHP image (no php.ini)
is vulnerable, because display_errors is still On at the time the
exploit code path runs. The official wordpress:* Docker images ship
a custom error-logging.ini that sets display_errors = Off at the INI
level, which does block the leak.
Why display_errors=Off cannot be bypassed: The info leak requires
SG(headers_sent) = 1 before php_session_send_cookie() runs during
MULTIPART_EVENT_FILE_START. The only code path that sets
SG(headers_sent) during multipart parsing is the error display path
(php_error_cb -> zend_write -> sapi_module.ub_write), which is
gated on display_errors. With display_errors = Off, warnings go to
the error log only, producing no output. There is no alternative
mechanism to force HTTP output during multipart parsing: the SAPI write
function is the only path, and it requires display_errors or explicit
script output (which has not started yet at this stage of request
processing). Precision OOM calibration (attempting to trigger OOM inside
the ~500-byte php_session_send_cookie window) was tested and fails
due to Zend MM bin-size boundaries causing ~1MB allocation jumps that
cannot be calibrated to the required precision.
References
- CVE-2026-109618: OOM during multipart parsing triggers longjmp past session cleanup
- CVE-2026-109619: Session globals (PS(id), PS(send_cookie)) survive across requests
- CVSS: 9.2 (both CVEs, per Rapid7)