[php-src] Issue #24373: serialize() called from a destructor during var_hash destruction reuses the outer var_hash (wrong back references,
use-after-free)

From: Date: Fri, 16 Oct 2026 13:39:53 +0000
Subject: [php-src] Issue #24373: serialize() called from a destructor during var_hash destruction reuses the outer var_hash (wrong back references,
use-after-free)
Groups: php.bugs 
Request: Send a blank email to php-bugs+get-253007@lists.php.net to get a copy of this message
Issue: https://github.com/php/php-src/issues/24373 Author: 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 <?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 <?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 <?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 ```plain 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

« previous php.bugs (#253007) next »