Skip to content

deepclone_to_array: resolve INDIRECT slots in retained __serialize() states#23

Merged
nicolas-grekas merged 1 commit into
mainfrom
deepclone-indirect-states
Jun 10, 2026
Merged

deepclone_to_array: resolve INDIRECT slots in retained __serialize() states#23
nicolas-grekas merged 1 commit into
mainfrom
deepclone-indirect-states

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Member

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. php-src fixed it for 8.3 by duplicating the table (php/php-src@c5fa7696e64), but the fix was never backported to the by-then security-only 8.2, which still ships the dangling behavior in its final release.

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:

$d = deepclone_to_array(new Random\Randomizer(new Random\Engine\Mt19937(42)));
gc_collect_cycles();
deepclone_from_array($d); // SIGSEGV on PHP 8.2

This is what segfaults the symfony/polyfill 1.x CI on the PHP 8.2 jobs that install this extension via PIE (the polyfill-only jobs are fixed by the companion PR below); the released v0.7.1 is affected, so those CI legs stay red until this ships in a tagged release.

The fix resolves indirects (and skips resolved UNDEF slots) at the start of the dc_copy_array() walk, exactly like the native serializer and the extension's existing build_scoped_props loop already do. Verified against a stock PHP 8.2 build: the new deepclone_randomizer.phpt segfaults before the fix and passes after; the full suite is green on 8.2 and 8.6, and the polyfill test suite passes against the patched extension.

Companion polyfill fix: symfony/polyfill#632

…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.
@nicolas-grekas nicolas-grekas force-pushed the deepclone-indirect-states branch from 9e9f2ee to b4cb516 Compare June 10, 2026 19:21
@nicolas-grekas nicolas-grekas merged commit b4cb516 into main Jun 10, 2026
@nicolas-grekas nicolas-grekas deleted the deepclone-indirect-states branch June 10, 2026 19:21
nicolas-grekas added a commit to symfony/polyfill that referenced this pull request Jun 10, 2026
… instances on PHP 8.2 (nicolas-grekas)

This PR was merged into the 1.x branch.

Discussion
----------

[DeepClone] Fix round-tripping short-lived Random\Randomizer instances on PHP 8.2

| Q             | A
| ------------- | ---
| Branch?       | 1.x
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Issues        | -
| License       | MIT

This is what has been segfaulting the PHP 8.2 CI jobs since the Randomizer round-trip tests landed in #629 (and what #630 inherits).

Minimal reproducer, no polyfill involved, on PHP 8.2.31:

```php
$state = (new Random\Randomizer(new Random\Engine\Mt19937(7)))->__serialize();
serialize($state); // SIGSEGV
```

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, so once the Randomizer is released the retained state dangles. php-src fixed this for 8.3 by duplicating the table (php/php-src@c5fa7696e64), but 8.2 was already security-only and the fix was never backported; 8.2.31 still crashes.

`deepclone_to_array()` retains exactly such a state in its payload, so round-tripping a Randomizer that does not outlive the payload segfaulted on 8.2. There is no copy-based userland fix: plain array reads copy `IS_INDIRECT` slots verbatim instead of resolving them (`gettype()` reports `unknown type`), which is also how the contamination is detected. The serializer is the one userland-reachable code path that resolves these slots, so when a `__serialize()` result is contaminated (checked while the source object is still alive, and only on PHP < 8.3), it is passed through a `serialize()`/`unserialize()` round trip to materialize real values.

No new tests: the #629 Randomizer tests are the regression tests; they segfault on PHP 8.2 before this change and pass after (suite verified green on 8.1, 8.2, 8.3 and 8.5). The nested engine becomes a fresh copy rather than a shared instance on the affected versions, matching the already-documented identity behavior of nested `__serialize()` states.

Companion extension fix (the with-extensions 8.2 CI legs run the tagged ext via PIE and stay red until it ships in a release): symfony/php-ext-deepclone#23

Perf note: `Random\Randomizer` is special-cased instead of scanned for, after auditing PHP 8.2 core for the pattern: of the internal classes whose `__serialize()` retains the un-duplicated property table (Randomizer, `HashContext`, the Random engines), only Randomizer declares a property, and only declared properties create `IS_INDIRECT` slots (GMP uses the legacy serialize handler, consumed while the object is alive; the date classes already duplicate). Measured on 8.2: no overhead for any other class; Randomizer itself pays one serialize round trip (~190 us for a Mt19937 state), the price of not crashing. On PHP 8.3+ the version guard makes the branch dead code. The trade-off versus scanning every internal state: a hypothetical third-party extension class combining the same pattern with declared properties would keep crashing, as it does today.

Companion extension fix (the with-extensions 8.2 CI legs run the tagged ext via PIE, fixed in v0.7.2): symfony/php-ext-deepclone#23

Commits
-------

ae1778e [DeepClone] Fix round-tripping short-lived Random\Randomizer instances on PHP 8.2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant