Files
php2shell/uaf-rce/README.md
T
neko e02c9f3122 Bypass post_max_size via chunked Transfer-Encoding for stock PHP RCE
PHP checks Content-Length against post_max_size before parsing multipart
bodies, but Transfer-Encoding: chunked requests have no Content-Length.
The full chunked body is processed without any size check, allowing the
exploit to send 140MB+ to exhaust memory_limit=128M despite post_max_size=8M.

This removes the last non-default configuration requirement. The exploit
now achieves full RCE against stock php:*-apache Docker images with zero
configuration changes (4/4 confirmed on php:8.4.26-apache).

Changes:
- post() auto-switches to chunked TE for bodies over 8MB
- Default --huge-mb raised from 12 to 140 (exceeds stock 128M limit)
- README rewritten to document the working three-stage RCE chain
- docker-compose.yml adds stock variant for testing
2026-10-22 12:58:04 +00:00

7.9 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: 4/4 successful RCE against stock php:8.4.26-apache Docker image with zero configuration changes (memory_limit=128M, post_max_size=8M, display_errors=STDOUT, 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 = On or STDOUT (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 three independent defense layers. Any one of them blocks the attack:

  1. php_basic_early_rinit(): Called from php_request_startup() AFTER zend_activate() but BEFORE sapi_activate(). Zeros BG(serialize_lock), BG(serialize), and BG(unserialize) with memset. Eliminates the stale state window entirely.

  2. Delayed OOM: zend_bailout() on OOM is removed. The allocator 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).

  3. Session handler hardening: RFC1867 session decode runs in incomplete_classes_only mode (no __wakeup/__unserialize). php_rinit_session_globals() at MULTIPART_EVENT_START. $_SESSION cleanup at MULTIPART_EVENT_END.

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-apache stock Docker image, 4/4 RCE (default settings)
  • php:8.4.26-apache with single worker + memory_limit=8M, 9/9 RCE
  • php:8.3.35-apache stock Docker image (offsets in DB)

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)