Removed all exploits that require LFI or FPM access: - cve-2026-109618/ (OOM + LFI) - cve-2026-109619/ (session poisoning + LFI) - session-deser/ (handler mismatch + deserialization chains) - bypass/ (patch bypasses, all LFI-dependent) - nolfi/ (served save_path + FPM pathinfo) - fcgi/ (FastCGI auto_prepend_file) - wordpress-exploit/ (LFI wrapper) - wordpress/ (vulnerable plugin) Moved the UAF RCE exploit (full ASLR bypass, no LFI, no FPM, no framework) to the repository root. Rewrote README with exploit details, usage, patch analysis, and test results. Cleaned .gitignore.
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.26-apache+ WordPress 6.8.1 (no php.ini), 5/5 RCE (first attempt each)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)