Skip to content

deepclone_hydrate: reject hydrating shell-less internal final classes (BcMath\Number)#20

Merged
nicolas-grekas merged 1 commit into
mainfrom
bcmath-number-hydrate
Jun 8, 2026
Merged

deepclone_hydrate: reject hydrating shell-less internal final classes (BcMath\Number)#20
nicolas-grekas merged 1 commit into
mainfrom
bcmath-number-hydrate

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Jun 8, 2026

Copy link
Copy Markdown
Member

BcMath\Number is a final internal class with a custom create_object and __serialize/__unserialize. deepclone_hydrate() builds an instance via object_init_ex() (i.e. create_object) and then injects the supplied properties. For BcMath\Number that shell has a NULL bc_num, and its value/scale are virtual read-only properties with no injectable backing, so hydrate produced a broken instance that crashed (or, with the companion bcmath guard, threw) on first use. deepclone_hydrate(BcMath\Number::class, ['value' => '7.5']) was the reproducer from symfony/symfony#64323.

The engine already refuses ReflectionClass::newInstanceWithoutConstructor() for INTERNAL + create_object + final classes, and the polyfill refuses to hydrate them. This brings the extension in line: for such a class with a serialization API, deepclone_hydrate() now probes whether an empty O:/C: payload reconstructs an object and, if not, throws the documented \DeepClone\NotInstantiableException (Class "..." is not instantiable.) instead of returning a half-built shell.

The probe runs under serialize_lock, so when deepclone_hydrate() is called from inside another inline unserialize() (e.g. Serializable::unserialize(), which does not raise the lock) it gets an isolated unserialize buffer. Otherwise the probe's deferred __unserialize([]) would not fire (defeating the check) and its throwaway object would leak into the caller's dtor list and corrupt the outer unserialize().

Round-trip (deepclone_to_array() / deepclone_from_array()) is unaffected: it reconstructs BcMath\Number by replaying the real state through __unserialize(), and round-trips value, scale and shared identity. New .phpts cover the round-trip, the hydrate refusal, the reentrancy isolation, and that an (abnormally) uninitialized instance throws cleanly rather than crashing.

Companion bcmath hardening: php/php-src#22259
Companion polyfill fix: symfony/polyfill#628

BcMath\Number (and any final internal class with a serialization API
whose empty serialization payload does not reconstruct an instance) was
hydrated by injecting properties into a bare create_object() shell. The
engine refuses ReflectionClass::newInstanceWithoutConstructor() for such
classes precisely because the shell is not usable on its own; for
BcMath\Number the bc_num stays NULL, so the result crashed on first use.

deepclone_hydrate() now probes an empty serialization payload (under
serialize_lock, so it stays isolated when called from inside another
unserialize() such as Serializable::unserialize()) and throws the
documented \DeepClone\NotInstantiableException when no valid shell can be
built, matching the polyfill. Round-trip via deepclone_to_array() /
deepclone_from_array() is unaffected: it replays the real state through
__unserialize().
@nicolas-grekas nicolas-grekas reopened this Jun 8, 2026
@nicolas-grekas nicolas-grekas merged commit ce24d38 into main Jun 8, 2026
30 of 40 checks passed
@nicolas-grekas nicolas-grekas deleted the bcmath-number-hydrate branch June 8, 2026 20:22
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