Skip to content

serialize() called from a destructor during var_hash destruction reuses the outer var_hash (wrong back references, use-after-free) #24373

Closed

Description

@janhelke

Description

serialize() is not reentrant while php_var_serialize_destroy() tears down its var_hash. If a destructor runs at that moment (an object kept alive only by the var_hash, or a garbage cycle collected by a GC run triggered by the release) and calls serialize() itself, the nested call reuses the outer var_hash:

  1. back references of the nested result continue the outer numbering and are wrong;
  2. if the nested call grows the table, the outer destroy continues on freed memory and segfaults.

This is very likely the root cause of #20539 (closed for lack of a reproducer). We hit it in a PHPUnit functional suite (TYPO3 testing framework): serialize() of a package cache releases enough objects to trigger the GC, which collects an old DI container whose destructor writes a cache entry via serialize().

Reproducer 1 – wrong back reference, no crash:

<?php
class D {
    public function __serialize(): array { return []; }
    public function __destruct() {
        $o = new stdClass();
        $GLOBALS['inner'] = serialize([$o, $o]);
    }
}
class Outer {
    public function __serialize(): array { return ["d" => new D()];
}
serialize([new Outer(), new stdClass()]);
echo $GLOBALS['inner'], "\n";

Resulted in:

a:2:{i:0;O:8:"stdClass":0:{}i:1;r:6;}

But I expected:

a:2:{i:0;O:8:"stdClass":0:{}i:1;r:2;}

Reproducer 2 – use-after-free when the nested call grows the table:

<?php
class D {
    public function __serialize(): array { return []; }
    public function __destruct() {
        $a = [];
        for ($i = 0; $i < 2000; $i++) { $o = new stdClass(); $a[] = $o; $a[] = $o; }
        serialize($a);
    }
}
class Outer {
    public function __serialize(): array { return ["d" => new D()];
}
$shared = [];
for ($i = 0; $i < 200; $i++) { $shared[] = new stdClass(); }
$list = [new Outer()];
foreach ($shared as $o) { $list[] = $o; $list[] = $o; }
serialize($list);
echo "survived\n";

Resulted in: Segmentation fault (exit 139). Expected: survived.

Reproducer 3 – the same via the GC (the path we hit in practice):

<?php
class D {
    public $self;
    public function __destruct() {
        $a = [];
        for ($i = 0; $i < 6000; $i++) { $o = new stdClass(); $a[] =
        serialize($a);
    }
}
$shared = [];
for ($i = 0; $i < 30000; $i++) { $shared[] = new stdClass(); }
$list = [];
foreach ($shared as $o) { $list[] = $o; $list[] = $o; }
gc_collect_cycles();
$d = new D(); $d->self = $d; unset($d);
serialize($list);
echo "survived\n";

Resulted in: Segmentation fault (exit 139). With `-d zend.enable_g

Backtrace (PHP 8.5.8 NTS, release build without debug symbols, frameump):

#0 zval_ptr_dtor
#1 zend_hash_destroy
#2 php_var_serialize_destroy
#3 zif_serialize

Workaround for test suites: call gc_collect_cycles() before each tle objects do not fire inside serialize().

PHP Version

PHP 8.5.11 (cli) (NTS) – official docker image php:8.5-cli
PHP 8.4.26 (cli) (NTS) – official docker image php:8.4-cli

Operating System

Debian (official php docker images); also reproduced in a DDEV container on macOS/arm64

Activity

  1. self-assigned this
    on Oct 20, 2026
  2. added 4 commits that reference this issue on Oct 22, 2026
    08f4b64
    267ec8e
    2c3ae32
    da7da45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions