Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `deepclone_from_array()` rejects markers of masks that match no value, and
of `refMasks` that match no reference, like the polyfill does, instead of
ignoring them.
- `deepclone_from_array()` rejects the internal classes that keep their state
out of their properties, like `deepclone_to_array()` and
`deepclone_hydrate()` do, instead of creating them without that state when a
payload names them: some, like `Relay\Table`, crash PHP that way. This
includes `IteratorIterator` and the other internal iterators wrapping
another one.
- The three functions reject the user classes that extend such internal
classes without declaring a serialization API, eg a subclass of
`RedisCluster`, which crashed PHP the same way.

## [0.8.5] - 2026-09-23

Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,13 @@ classes, interfaces, traits and enums, and throw
`InvalidArgumentException`. Malformed input and classes missing from
`$allowed_classes` throw `ValueError`.

`deepclone_to_array()` and `deepclone_hydrate()` also reject internal classes
that keep their state out of their properties and declare no serialization
API, eg `Redis` or `Imagick`: `unserialize()` and the polyfill create them
without that state, but some crash when used that way. Only
`MultipleIterator`, heaps before PHP 8.5 and the classes of the dom, xsl,
All three functions also reject internal classes that keep their state out of
their properties and declare no serialization API, eg `Redis` or `Imagick`,
and the user classes that extend them without declaring one: `unserialize()`
creates them without that state, but some crash when used, or even destroyed,
that way. The polyfill can't tell these classes apart and
rejects the ones of the extensions it knows, eg zip, redis, relay or imagick.
Only `MultipleIterator`, heaps before PHP 8.5 and the classes of the dom, xsl,
mysqli and soap extensions, eg `DOMNodeList`, are created like `unserialize()`
does.

Expand Down
68 changes: 46 additions & 22 deletions deepclone.c
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,36 @@ static bool dc_unserializes_stateless(zend_class_entry *ce)
return module && (!strcmp(module, "dom") || !strcmp(module, "xsl") || !strcmp(module, "mysqli") || !strcmp(module, "soap"));
}

/* Whether the class is an internal one that keeps its state out of its
* properties and declares no serialization API, like Redis, other than the
* ones above, or a user class that extends one without declaring such an API.
* All three functions reject these: unserialize() creates them without that
* state, and some crash when used, or even destroyed, that way.
* deepclone_to_array() and deepclone_hydrate() probe the final ones instead. */
static zend_never_inline bool dc_needs_internal_state_slow(zend_class_entry *ce)
{
if (ce->serialize != NULL || ce->__serialize || ce->__unserialize
|| ce == php_ce_incomplete_class
|| zend_hash_find_known_hash(&ce->function_table, ZSTR_KNOWN(ZEND_STR_SLEEP))
|| zend_hash_find_known_hash(&ce->function_table, ZSTR_KNOWN(ZEND_STR_WAKEUP))) {
return false;
}

/* User classes inherit create_object from their closest internal parent */
while (ce && ce->type != ZEND_INTERNAL_CLASS) {
ce = ce->parent;
}

return ce && ce->create_object != NULL
&& !(ce->ce_flags & ZEND_ACC_FINAL)
&& !dc_unserializes_stateless(ce);
}

static zend_always_inline bool dc_needs_internal_state(zend_class_entry *ce)
{
return UNEXPECTED(ce->create_object != NULL) && dc_needs_internal_state_slow(ce);
}


/* ── Helpers ────────────────────────────────────────────────── */

Expand Down Expand Up @@ -590,15 +620,16 @@ static zend_always_inline bool dc_refuses_serialization(zend_class_entry *ce)

/* Look up the class of payload objects, rejecting the ones unserialize() can't
* create: abstract classes, interfaces, traits and enums, and the classes that
* refuse serialization. */
* refuse serialization; and the ones it would create without their internal
* state, which deepclone_to_array() rejects too. */
static zend_class_entry *dc_lookup_payload_class(zend_string *class_name)
{
zend_class_entry *ce = zend_lookup_class(class_name);

if (UNEXPECTED(!ce)) {
zend_throw_exception_ex(dc_ce_class_not_found_exception, 0,
"Class \"%s\" not found.", ZSTR_VAL(class_name));
} else if (UNEXPECTED((ce->ce_flags & ZEND_ACC_UNINSTANTIABLE) || dc_refuses_serialization(ce))) {
} else if (UNEXPECTED((ce->ce_flags & ZEND_ACC_UNINSTANTIABLE) || dc_refuses_serialization(ce) || dc_needs_internal_state(ce))) {
zend_throw_exception_ex(dc_ce_not_instantiable_exception, 0,
"Type \"%s\" is not instantiable.", ZSTR_VAL(ce->name));
ce = NULL;
Expand Down Expand Up @@ -666,12 +697,7 @@ static uint8_t dc_get_class_info(dc_ctx *ctx, zend_class_entry *ce)
} else {
zval_ptr_dtor(&probe);
}
} else if (ce->type == ZEND_INTERNAL_CLASS
&& ce->create_object != NULL
&& ce->serialize == NULL
&& !(flags & (DC_CI_HAS_SERIALIZE | DC_CI_HAS_UNSERIALIZE | DC_CI_HAS_SLEEP | DC_CI_HAS_WAKEUP))
&& ce != php_ce_incomplete_class
&& !dc_unserializes_stateless(ce)) {
} else if (dc_needs_internal_state(ce)) {
flags |= DC_CI_NOT_INSTANTIABLE;
}

Expand Down Expand Up @@ -5832,9 +5858,8 @@ PHP_FUNCTION(deepclone_hydrate)
}
/* Reject classes that cannot function without their constructor,
* using the same rules as dc_get_class_info / deepclone_from_array.
* Internal classes are checked and cached; user classes pass unless
* they refuse serialization, checked each time as the cache is
* persistent and their names aren't. */
* Internal classes are checked and cached; user classes are checked
* each time as the cache is persistent and their names aren't. */
if (UNEXPECTED(ce->type == ZEND_INTERNAL_CLASS)) {
/* Per-thread cache (via module globals). Packs ce pointer + ok-bit
* into the stored value: low bit is ok, high bits are the ce. A ce
Expand Down Expand Up @@ -5865,18 +5890,17 @@ PHP_FUNCTION(deepclone_hydrate)
if (ok && dc_refuses_serialization(ce)) {
ok = false;
}
if (ok && ce->create_object != NULL && ce != php_ce_incomplete_class && !has_ser_api
if (ok && dc_needs_internal_state(ce)) {
ok = false;
} else if (ok && ce->create_object != NULL && (ce->ce_flags & ZEND_ACC_FINAL)
&& ce != php_ce_incomplete_class && !has_ser_api
&& !(ce->__serialize) && !(zend_hash_find_known_hash(&ce->function_table, ZSTR_KNOWN(ZEND_STR_SLEEP)))) {
if (ce->ce_flags & ZEND_ACC_FINAL) {
zval probe;
if (object_init_ex(&probe, ce) != SUCCESS || EG(exception)) {
zend_clear_exception();
ok = false;
} else {
zval_ptr_dtor(&probe);
}
} else if (!dc_unserializes_stateless(ce)) {
zval probe;
if (object_init_ex(&probe, ce) != SUCCESS || EG(exception)) {
zend_clear_exception();
ok = false;
} else {
zval_ptr_dtor(&probe);
}
}
/* Internal final classes with create_object: the engine refuses
Expand Down Expand Up @@ -5935,7 +5959,7 @@ PHP_FUNCTION(deepclone_hydrate)
RETURN_THROWS();
}
}
} else if (UNEXPECTED(dc_refuses_serialization(ce))) {
} else if (UNEXPECTED(dc_refuses_serialization(ce) || dc_needs_internal_state(ce))) {
zend_throw_exception_ex(dc_ce_not_instantiable_exception, 0,
"Type \"%s\" is not instantiable.", ZSTR_VAL(ce->name));
RETURN_THROWS();
Expand Down
83 changes: 83 additions & 0 deletions tests/deepclone_from_array_internal_state.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
--TEST--
deepclone_from_array() rejects the internal classes that keep their state out of their properties, like deepclone_to_array() and deepclone_hydrate()
--EXTENSIONS--
deepclone
--FILE--
<?php

class UserIterator extends IteratorIterator { public $a; }
class UserIteratorWithWakeup extends IteratorIterator { public function __wakeup(): void {} }

function check(string $label, callable $fn): void
{
try {
$r = $fn();
echo "$label: ", get_debug_type($r), "\n";
} catch (Throwable $e) {
echo "$label: ", $e::class, ': ', $e->getMessage(), "\n";
}
}

function payload(string $class): array
{
// What the polyfill produces for an object with no properties
return ['classes' => $class, 'objectMeta' => 1, 'prepared' => 0];
}

check('to_array IteratorIterator', fn () => deepclone_to_array(new IteratorIterator(new ArrayIterator([1]))));
check('hydrate IteratorIterator', fn () => deepclone_hydrate('IteratorIterator'));
check('from_array IteratorIterator', fn () => deepclone_from_array(payload('IteratorIterator')));
check('from_array iteratoriterator', fn () => deepclone_from_array(payload('iteratoriterator')));
check('from_array AppendIterator', fn () => deepclone_from_array(payload('AppendIterator')));

// Objects holding closures are created as lazy ghosts on PHP 8.4+
$payload = payload('IteratorIterator') + ['properties' => ['stdClass' => ['f' => [[null, 'strlen']]]], 'resolve' => ['stdClass' => ['f' => [0]]]];
check('from_array lazy IteratorIterator', fn () => deepclone_from_array($payload, null, true));

// Classes of other extensions, when loaded: some crash when used, or even
// destroyed, without their constructor
foreach (['XMLWriter', 'XMLReader', 'ZipArchive', 'Redis', 'RedisCluster', 'Imagick', 'ImagickPixel', 'Relay\Table', 'AMQPConnection', 'APCUIterator'] as $class) {
if (!class_exists($class) || method_exists($class, '__unserialize')) {
continue;
}
$subclass = 'User'.strtr($class, '\\', '_');
eval("class $subclass extends $class {}");
foreach ([$class, $subclass] as $class) {
try {
deepclone_from_array(payload($class));
echo "from_array $class: accepted\n";
} catch (DeepClone\NotInstantiableException $e) {
if ($e->getMessage() !== "Type \"$class\" is not instantiable.") {
echo "from_array $class: ", $e->getMessage(), "\n";
}
}
}
}

// User subclasses, unless they declare a serialization API, and the internal
// classes unserialize() creates all the same
check('to_array UserIterator', fn () => deepclone_to_array(new UserIterator(new ArrayIterator([1]))));
check('from_array UserIterator', fn () => deepclone_from_array(payload('UserIterator')));
check('hydrate UserIterator', fn () => deepclone_hydrate('UserIterator'));
check('from_array UserIteratorWithWakeup', fn () => deepclone_from_array(payload('UserIteratorWithWakeup')));
check('hydrate UserIteratorWithWakeup', fn () => deepclone_hydrate('UserIteratorWithWakeup'));
check('from_array MultipleIterator', fn () => deepclone_from_array(payload('MultipleIterator')));
check('round-trip SplMinHeap', fn () => deepclone_from_array(deepclone_to_array(new SplMinHeap())));
if (class_exists(DOMNodeList::class) && !deepclone_from_array(payload('DOMNodeList')) instanceof DOMNodeList) {
echo "from_array DOMNodeList: rejected\n";
}
?>
--EXPECT--
to_array IteratorIterator: DeepClone\NotInstantiableException: Type "IteratorIterator" is not instantiable.
hydrate IteratorIterator: DeepClone\NotInstantiableException: Type "IteratorIterator" is not instantiable.
from_array IteratorIterator: DeepClone\NotInstantiableException: Type "IteratorIterator" is not instantiable.
from_array iteratoriterator: DeepClone\NotInstantiableException: Type "IteratorIterator" is not instantiable.
from_array AppendIterator: DeepClone\NotInstantiableException: Type "AppendIterator" is not instantiable.
from_array lazy IteratorIterator: DeepClone\NotInstantiableException: Type "IteratorIterator" is not instantiable.
to_array UserIterator: DeepClone\NotInstantiableException: Type "UserIterator" is not instantiable.
from_array UserIterator: DeepClone\NotInstantiableException: Type "UserIterator" is not instantiable.
hydrate UserIterator: DeepClone\NotInstantiableException: Type "UserIterator" is not instantiable.
from_array UserIteratorWithWakeup: UserIteratorWithWakeup
hydrate UserIteratorWithWakeup: UserIteratorWithWakeup
from_array MultipleIterator: MultipleIterator
round-trip SplMinHeap: SplMinHeap
12 changes: 6 additions & 6 deletions tests/deepclone_uninstantiable.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ foreach (['AbstractThing', 'Thing', 'ThingTrait', 'ThingEnum'] as $class) {
$payload = ['classes' => 'AbstractThing', 'objectMeta' => 1, 'prepared' => 0, 'properties' => ['stdClass' => ['f' => [[null, 'strlen']]]], 'resolve' => ['stdClass' => ['f' => [0]]]];
check('from_array lazy AbstractThing', fn () => deepclone_from_array($payload, null, true));

// unserialize() creates the classes whose state serialize() loses, and so
// does deepclone_from_array(), while deepclone_to_array() rejects them
// unserialize() creates the classes whose state serialize() loses, and their
// user subclasses, while the three functions reject them
foreach (['IteratorIterator', 'LimitIterator', 'UserIterator'] as $class) {
check("unserialize $class", fn () => unserialize('O:'.strlen($class).':"'.$class.'":0:{}'));
check("from_array $class", fn () => deepclone_from_array(['classes' => $class, 'objectMeta' => 1, 'prepared' => 0]));
Expand All @@ -52,11 +52,11 @@ from_array ThingEnum: DeepClone\NotInstantiableException: Type "ThingEnum" is no
hydrate ThingEnum: DeepClone\NotInstantiableException: Type "ThingEnum" is not instantiable.
from_array lazy AbstractThing: DeepClone\NotInstantiableException: Type "AbstractThing" is not instantiable.
unserialize IteratorIterator: IteratorIterator
from_array IteratorIterator: IteratorIterator
from_array IteratorIterator: DeepClone\NotInstantiableException: Type "IteratorIterator" is not instantiable.
unserialize LimitIterator: LimitIterator
from_array LimitIterator: LimitIterator
from_array LimitIterator: DeepClone\NotInstantiableException: Type "LimitIterator" is not instantiable.
unserialize UserIterator: UserIterator
from_array UserIterator: UserIterator
from_array UserIterator: DeepClone\NotInstantiableException: Type "UserIterator" is not instantiable.
to_array IteratorIterator: DeepClone\NotInstantiableException: Type "IteratorIterator" is not instantiable.
hydrate IteratorIterator: DeepClone\NotInstantiableException: Type "IteratorIterator" is not instantiable.
hydrate UserIterator: UserIterator
hydrate UserIterator: DeepClone\NotInstantiableException: Type "UserIterator" is not instantiable.
Loading