From b4cb516f70af6f30fbd876856f0bf22236dd7e76 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 10 Jun 2026 20:43:23 +0200 Subject: [PATCH] deepclone_to_array: resolve INDIRECT slots in retained __serialize() states Before PHP 8.3, Random\Randomizer::__serialize() returns the object's raw property table with only the table itself addref'd; its "engine" entry is an IS_INDIRECT slot pointing into the object (fixed for 8.3 by php-src commit c5fa7696e64, never backported to the by-then security-only 8.2). deepclone_to_array() walks such a state with dc_copy_array(), which passed IS_INDIRECT slots to dc_copy_value() unresolved, so the payload retained pointers into the source object. Once a Randomizer that did not outlive its payload was released, deepclone_from_array() dereferenced freed memory and crashed. Resolve indirects (and skip resolved UNDEF slots) at the start of the walk, exactly like the native serializer and the existing build_scoped_props loop already do. --- deepclone.c | 11 +++++++++++ tests/deepclone_randomizer.phpt | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/deepclone_randomizer.phpt diff --git a/deepclone.c b/deepclone.c index dc57b4a..3c10d36 100644 --- a/deepclone.c +++ b/deepclone.c @@ -964,6 +964,17 @@ static void dc_copy_array(dc_ctx *ctx, HashTable *src_ht, zval *dst, zval *mask_ zend_hash_real_init_mixed(Z_ARRVAL_P(mask_dst)); ZEND_HASH_FOREACH_KEY_VAL(src_ht, idx, key, src_val) { + /* __serialize() may return the object's raw property table (e.g. + * Random\Randomizer before PHP 8.3), where declared properties are + * IS_INDIRECT slots into the object. Resolve them like the native + * serializer does, or the payload would retain pointers that dangle + * once the source object is released. */ + if (UNEXPECTED(Z_TYPE_P(src_val) == IS_INDIRECT)) { + src_val = Z_INDIRECT_P(src_val); + if (Z_TYPE_P(src_val) == IS_UNDEF) { + continue; + } + } zval undef, null_marker; ZVAL_UNDEF(&undef); ZVAL_NULL(&null_marker); diff --git a/tests/deepclone_randomizer.phpt b/tests/deepclone_randomizer.phpt new file mode 100644 index 0000000..f19e2fb --- /dev/null +++ b/tests/deepclone_randomizer.phpt @@ -0,0 +1,34 @@ +--TEST-- +deepclone round-trips a Random\Randomizer that does not outlive its payload +--EXTENSIONS-- +deepclone +--SKIPIF-- + +--FILE-- +getInt(1, PHP_INT_MAX); + +$d = deepclone_to_array(new Random\Randomizer(new Random\Engine\Mt19937(42))); +gc_collect_cycles(); +$clone = deepclone_from_array($d); +var_dump($clone instanceof Random\Randomizer); +var_dump($expected === $clone->getInt(1, PHP_INT_MAX)); + +// Same with the Randomizer nested in a temporary object graph +$g = deepclone_from_array(deepclone_to_array((object) ['list' => [(object) ['r' => new Random\Randomizer(new Random\Engine\Mt19937(9))]]])); +var_dump($g->list[0]->r instanceof Random\Randomizer); + +// And behind a shared identity +$r = new Random\Randomizer(new Random\Engine\Mt19937(5)); +$c = deepclone_from_array(deepclone_to_array([$r, $r])); +var_dump($c[0] === $c[1]); +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true)