[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: | janhelke | 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