From 732604fd9c8ac99493721c51200ec34d8a032ccc Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 10 Jun 2026 18:20:44 +0200 Subject: [PATCH] Serialize closures declared in constant expressions --- NEWS | 4 + UPGRADING | 8 + .../serialization_allowed_classes.phpt | 57 ++ .../serialization_basic.phpt | 88 ++ .../serialization_code_hash.phpt | 84 ++ .../serialization_graph.phpt | 39 + .../serialization_inheritance.phpt | 50 ++ .../serialization_invalid_payload.phpt | 81 ++ .../serialization_misc.phpt | 55 ++ .../serialization_named.phpt | 112 +++ .../serialization_nested.phpt | 67 ++ .../serialization_not_allowed.phpt | 72 ++ .../serialization_tagged_format.phpt | 50 ++ Zend/zend_ast.c | 8 + Zend/zend_ast.h | 1 + Zend/zend_closures.c | 788 +++++++++++++++++- Zend/zend_closures.h | 6 + Zend/zend_closures.stub.php | 5 +- Zend/zend_closures_arginfo.h | 15 +- Zend/zend_compile.c | 16 + Zend/zend_compile.h | 9 +- Zend/zend_execute.h | 7 + Zend/zend_execute_API.c | 1 + ext/reflection/php_reflection.c | 1 + ext/standard/var.c | 56 +- 25 files changed, 1662 insertions(+), 18 deletions(-) create mode 100644 Zend/tests/closures/closure_const_expr/serialization_allowed_classes.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_basic.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_code_hash.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_graph.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_inheritance.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_misc.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_named.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_nested.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_not_allowed.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_tagged_format.phpt diff --git a/NEWS b/NEWS index 63e4fdf0c244..b88955a1f4cb 100644 --- a/NEWS +++ b/NEWS @@ -210,6 +210,10 @@ PHP NEWS - Core: . Added first-class callable cache to share instances for the duration of the request. (ilutov) + . Closures declared in constant expressions (anonymous closures and + first-class callables) can now be serialized: serialize() records a + reference to their declaration site, and unserialize() recreates them + bounded to what the class declares. (nicolas-grekas) . It is now possible to use reference assign on WeakMap without the key needing to be present beforehand. (ndossche) . Added `clamp()`. (kylekatarnls, thinkverse) diff --git a/UPGRADING b/UPGRADING index 97670e09246d..6708e4fd9dd7 100644 --- a/UPGRADING +++ b/UPGRADING @@ -248,6 +248,14 @@ PHP 8.6 UPGRADE NOTES RFC: https://wiki.php.net/rfc/override_constants . Implemented partial function application RFC: https://wiki.php.net/rfc/partial_function_application_v2 + . Closures declared in a class's constant expressions (anonymous closures + and first-class callables, in attribute arguments and parameter default + values) can now be serialized. The payload is a reference to the + declaration site and carries no code; unserialize() re-evaluates that + declaration, so it can only produce a closure the named class declares. + References are verified on resolve and are valid for the code revision + that produced them. Runtime-created and bound closures still refuse. + RFC: https://wiki.php.net/rfc/serializable_closures - Curl: . curl_getinfo() return array now includes a new size_delivered key, which diff --git a/Zend/tests/closures/closure_const_expr/serialization_allowed_classes.phpt b/Zend/tests/closures/closure_const_expr/serialization_allowed_classes.phpt new file mode 100644 index 000000000000..17510d027470 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_allowed_classes.phpt @@ -0,0 +1,57 @@ +--TEST-- +Serializable closures are gated by the unserialize() allowed_classes filter +--FILE-- +getAttributes(); +$payloads = [ + 'anonymous' => serialize($attrs[0]->getArguments()[0]), + 'fcc site' => serialize($attrs[1]->getArguments()[0]), +]; + +// The recommended safe-unserialize practice (allowed_classes => false) blocks +// every Closure payload: it becomes __PHP_Incomplete_Class and __unserialize() +// is never invoked, exactly like any other object-injection gadget. +foreach ($payloads as $name => $payload) { + $r = unserialize($payload, ['allowed_classes' => false]); + var_dump($name, $r instanceof __PHP_Incomplete_Class); +} + +// A list that does not contain Closure also blocks it. +$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['stdClass']]); +var_dump($r instanceof __PHP_Incomplete_Class); + +// Resolving a reference autoloads its declaring class and pulls a closure out +// of it, so that class is subject to the filter too: allowing Closure alone is +// not enough when the reference points into another class. +try { + unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure']]); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} + +// Both the Closure and the referenced class must be opted in. +$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure', 'Demo']]); +var_dump($r instanceof Closure, $r('test')); + +?> +--EXPECT-- +string(9) "anonymous" +bool(true) +string(8) "fcc site" +bool(true) +bool(true) +Invalid serialization data for Closure object (class "Demo" is not allowed) +bool(true) +int(4) diff --git a/Zend/tests/closures/closure_const_expr/serialization_basic.phpt b/Zend/tests/closures/closure_const_expr/serialization_basic.phpt new file mode 100644 index 000000000000..bfcfc0d2e903 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_basic.phpt @@ -0,0 +1,88 @@ +--TEST-- +Closures in constant expressions are serializable as declaration-site references +--FILE-- + (new ReflectionClass(Demo::class))->getAttributes()[0]->getArguments()[0], + 'const' => (new ReflectionClassConstant(Demo::class, 'FOO'))->getAttributes()[0]->getArguments()[0], + 'prop-1' => (new ReflectionProperty(Demo::class, 'name'))->getAttributes()[0]->getArguments()['cb'][0], + 'prop-2' => (new ReflectionProperty(Demo::class, 'name'))->getAttributes()[0]->getArguments()['cb'][1], + 'method' => (new ReflectionMethod(Demo::class, 'm'))->getAttributes()[0]->getArguments()[0], + 'param' => (new ReflectionParameter([Demo::class, 'm'], 'x'))->getAttributes()[0]->getArguments()[0], + 'case' => (new ReflectionClassConstant(E::class, 'X'))->getAttributes()[0]->getArguments()[0], +]; + +// The reference lives in the __serialize() payload: [ [], ["const-expr", +// [class, site, key, hash]] ]. These read it out and rebuild a closure from a +// bare reference the way an exporter would. +$refOf = static fn (Closure $c) => $c->__serialize()[1][1]; // [class, site, key, hash] +$fromRef = static fn (array $ref) => unserialize('O:7:"Closure":' . substr(serialize([[], ['const-expr', $ref]]), 2)); + +foreach ($closures as $expected => $closure) { + $unserialized = unserialize(serialize($closure)); + $recreated = $fromRef($refOf($closure)); + + var_dump($expected === $closure() && $expected === $unserialized() && $expected === $recreated()); +} + +// References are assigned in canonical walk order: class attributes first, then +// constants, then properties, then methods (including parameters). The display +// below is "@"; the code hash (the fourth field) is not part of the +// addressing contract (see serialization_code_hash.phpt). +$ids = array_map( + static fn ($c) => $refOf($c)[1] . '@' . $refOf($c)[2], + $closures +); +var_dump($ids); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +array(7) { + ["class"]=> + string(2) "@0" + ["const"]=> + string(5) "FOO@0" + ["prop-1"]=> + string(7) "$name@0" + ["prop-2"]=> + string(7) "$name@1" + ["method"]=> + string(5) "m()@0" + ["param"]=> + string(5) "m()@1" + ["case"]=> + string(3) "X@0" +} diff --git a/Zend/tests/closures/closure_const_expr/serialization_code_hash.phpt b/Zend/tests/closures/closure_const_expr/serialization_code_hash.phpt new file mode 100644 index 000000000000..fed591a8a8cb --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_code_hash.phpt @@ -0,0 +1,84 @@ +--TEST-- +Anonymous const-expr closure references carry a code hash +--FILE-- +getAttributes()[0]->getArguments()[0] + ->__serialize()[1][1]; + +// The reference is [class, site, rank, hash]: rank 0 of $p, with a non-zero +// code hash in the fourth field. +var_dump($ref[1] === '$p' && $ref[2] === 0 && is_int($ref[3]) && $ref[3] !== 0); +$hash = $ref[3]; + +$make = static fn (string $class, string $site, int|string $key, int $hash): string => + 'O:7:"Closure":' . substr(serialize([[], ['const-expr', [$class, $site, $key, $hash]]]), 2); + +// Same class: resolves. +var_dump(unserialize($make('Ordered', '$p', 0, $hash))()); + +// The swapped class has B at rank 0 of $q; same rank, but the hash guards it. +try { + unserialize($make('Swapped', '$q', 0, $hash)); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} + +// The hash is over the closure's code, insensitive to layout and comments: a +// reference resolves against a reformatted body... +class Reformatted { + #[A(static function () { + // a comment; different whitespace and layout + return 'a' ; + })] + public int $p = 0; +} +var_dump(unserialize($make('Reformatted', '$p', 0, $hash))()); + +// ...but not against a changed body. +class Changed { + #[A(static function () { return 'CHANGED'; })] + public int $p = 0; +} +try { + unserialize($make('Changed', '$p', 0, $hash)); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} + +// A reference without a hash (a producer that cannot compute it) resolves +// positionally, unverified. +var_dump(unserialize($make('Ordered', '$p', 0, 0))()); + +?> +--EXPECT-- +bool(true) +string(1) "a" +Invalid serialization data for Closure object (constant-expression closure "$q@0" of class Swapped changed) +string(1) "a" +Invalid serialization data for Closure object (constant-expression closure "$p@0" of class Changed changed) +string(1) "a" diff --git a/Zend/tests/closures/closure_const_expr/serialization_graph.phpt b/Zend/tests/closures/closure_const_expr/serialization_graph.phpt new file mode 100644 index 000000000000..d57e5ca567c0 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_graph.phpt @@ -0,0 +1,39 @@ +--TEST-- +Const-expr closures inside serialized object graphs +--FILE-- +c)(), "\n"; + } +} + +$h = new Holder(); +$h->c = (new ReflectionProperty(Demo::class, 'p'))->getAttributes()[0]->getArguments()[0]; + +$u = unserialize(serialize([$h, $h->c, [$h->c]])); + +echo "after: ", ($u[1])(), "\n"; +// Shared instances are preserved within the graph. +var_dump($u[0]->c === $u[1], $u[1] === $u[2][0]); + +?> +--EXPECT-- +wakeup sees: ok +after: ok +bool(true) +bool(true) diff --git a/Zend/tests/closures/closure_const_expr/serialization_inheritance.phpt b/Zend/tests/closures/closure_const_expr/serialization_inheritance.phpt new file mode 100644 index 000000000000..ff3b3ed6683c --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_inheritance.phpt @@ -0,0 +1,50 @@ +--TEST-- +Serialization of const-expr closures with inheritance and traits +--FILE-- +getAttributes()[0]->getArguments()[0]; +var_dump((new ReflectionFunction($c))->getClosureScopeClass()->name); +$payload = serialize($c); +var_dump(str_contains($payload, '"Base"')); +var_dump(unserialize($payload)()); + +// Attribute on a parameter of a trait method: the copied method is scoped +// to the using class and the reference resolves through it. +$c = (new ReflectionParameter([UsesTrait::class, 'm'], 'x'))->getAttributes()[0]->getArguments()[0]; +var_dump((new ReflectionFunction($c))->getClosureScopeClass()->name); +$u = unserialize(serialize($c)); +var_dump($u()); + +?> +--EXPECT-- +string(4) "Base" +bool(true) +string(4) "base" +string(9) "UsesTrait" +string(5) "trait" diff --git a/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt b/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt new file mode 100644 index 000000000000..d7c860aa4990 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt @@ -0,0 +1,81 @@ +--TEST-- +Unserializing invalid or stale Closure declaration-site references +--FILE-- +getAttributes()[0]->getArguments()[0]; +// The reference is [class, site, key, hash]. For an anonymous closure the key +// is an int rank and hash verifies its code; a producer that cannot compute the +// hash may pass 0, and resolution is then positional. +$hash = $closure->__serialize()[1][1][3]; +$stale = ($hash ^ 1) ?: 2; + +// Serialize an array and rebrand it as a Closure object payload, so we can +// feed __unserialize() arbitrary tagged-union shapes. +$obj = static fn (array $data): string => 'O:7:"Closure":' . substr(serialize($data), 2); + +// Tagged-union payload: [ [], ["const-expr", [class, site, key, hash]] ]. +$mk = static fn (string $class, string $site, int|string $key, int $hash): string => $obj([[], ['const-expr', [$class, $site, $key, $hash]]]); + +// Sanity checks: a full reference works, and so does a hash-less one. +var_dump(unserialize($mk('Demo', '$p', 0, $hash))()); +var_dump(unserialize($mk('Demo', '$p', 0, 0))()); + +$payloads = [ + 'empty data' => $obj([]), + 'one element' => $obj([[]]), + 'unknown tag' => $obj([[], ['whatever', ['Demo', '$p', 0, $hash]]]), + 'tag not list' => $obj([[], 'const-expr']), + 'short reference' => $obj([[], ['const-expr', ['Demo', '$p', 0]]]), + 'wrong key type' => $obj([[], ['const-expr', ['Demo', '$p', [], 0]]]), + 'wrong hash type' => $obj([[], ['const-expr', ['Demo', '$p', 0, '0']]]), + 'unknown class' => $mk('NoSuchClass', '$p', 0, $hash), + 'internal class' => $mk('stdClass', '$p', 0, $hash), + 'unknown site' => $mk('Demo', '$nope', 0, 0), + 'unknown rank' => $mk('Demo', '$p', 9, 0), + 'stale hash' => $mk('Demo', '$p', 0, $stale), +]; + +foreach ($payloads as $name => $payload) { + try { + unserialize($payload); + echo "$name: unserialized!?\n"; + } catch (Exception $e) { + echo "$name: {$e->getMessage()}\n"; + } +} + +// __unserialize() cannot be used to reinitialize a live closure. +try { + $closure->__unserialize([[], ['const-expr', ['Demo', '$p', 0, $hash]]]); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +string(2) "ok" +string(2) "ok" +empty data: Invalid serialization data for Closure object +one element: Invalid serialization data for Closure object +unknown tag: Invalid serialization data for Closure object +tag not list: Invalid serialization data for Closure object +short reference: Invalid serialization data for Closure object +wrong key type: Invalid serialization data for Closure object +wrong hash type: Invalid serialization data for Closure object +unknown class: Invalid serialization data for Closure object (cannot load class "NoSuchClass") +internal class: Invalid serialization data for Closure object (cannot load class "stdClass") +unknown site: Invalid serialization data for Closure object (constant-expression closure "$nope@0" of class Demo not found) +unknown rank: Invalid serialization data for Closure object (constant-expression closure "$p@9" of class Demo not found) +stale hash: Invalid serialization data for Closure object (constant-expression closure "$p@0" of class Demo changed) +Cannot unserialize an already initialized Closure diff --git a/Zend/tests/closures/closure_const_expr/serialization_misc.phpt b/Zend/tests/closures/closure_const_expr/serialization_misc.phpt new file mode 100644 index 000000000000..db77b3181bdd --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_misc.phpt @@ -0,0 +1,55 @@ +--TEST-- +Misc behaviors of serializable const-expr closures: static vars, identity, scope binding, invalid reference errors +--FILE-- +getAttributes(); +$counter = $attributes[0]->getArguments()[0]; +$scoped = $attributes[1]->getArguments()[0]; + +// A reference does not carry the state of static variables: unserializing +// produces the closure as if it was freshly evaluated. +var_dump($counter(), $counter()); +$u = unserialize(serialize($counter)); +var_dump($u()); + +// Unserialized closures are new instances. +var_dump($u === $counter); + +// Scope binding is restored: private members of the class are accessible. +var_dump(unserialize(serialize($scoped))()); + +// Invalid reference errors: resolution happens inside unserialize(). +$fromRef = static fn (string $class, string $site, int|string $key) => unserialize('O:7:"Closure":' . substr(serialize([[], ['const-expr', [$class, $site, $key, 0]]]), 2)); + +foreach ([['NoSuchClass', '$p', 0], ['stdClass', '$p', 0], ['Demo', '$p', 999]] as [$class, $site, $key]) { + try { + $fromRef($class, $site, $key); + } catch (Exception $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; + } +} + +?> +--EXPECT-- +int(1) +int(2) +int(1) +bool(false) +string(6) "secret" +Exception: Invalid serialization data for Closure object (cannot load class "NoSuchClass") +Exception: Invalid serialization data for Closure object (cannot load class "stdClass") +Exception: Invalid serialization data for Closure object (constant-expression closure "$p@999" of class Demo not found) diff --git a/Zend/tests/closures/closure_const_expr/serialization_named.phpt b/Zend/tests/closures/closure_const_expr/serialization_named.phpt new file mode 100644 index 000000000000..fcbf891f66f2 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_named.phpt @@ -0,0 +1,112 @@ +--TEST-- +First-class callable references serialize as name-keyed declaration sites +--FILE-- +getAttributes(); +foreach ($attrs as $i => $attr) { + $closure = $attr->getArguments()[0]; + $payload = serialize($closure); + $u = unserialize($payload); + $args = $i === 4 ? ['abcd'] : []; + // The reference is [class, site, key, hash]; for a first-class callable the + // key is "Called::method" / "function" and the hash is 0. The recreated + // closure behaves identically (including static:: binding) and re-serializes + // byte-identically. + $ref = $closure->__serialize()[1][1]; + var_dump( + $ref[1] . '@' . $ref[2] + . ' | ' . var_export($u(...$args) === $closure(...$args), true) + . ' | ' . var_export(serialize($u) === $payload, true) + ); +} +echo serialize($attrs[0]->getArguments()[0]), "\n"; + +// parent:: and self:: forms resolve their distinct static:: bindings. +var_dump(unserialize(serialize($attrs[1]->getArguments()[0]))()); +var_dump(unserialize(serialize($attrs[2]->getArguments()[0]))()); + +// No rank is consumed by first-class callable references. +$qattrs = (new ReflectionProperty(Demo::class, 'q'))->getAttributes(); +$qref = $qattrs[1]->getArguments()[0]->__serialize()[1][1]; +var_dump($qref[1] . '@' . $qref[2]); + +// Runtime-created named closures are not declaration sites and refuse, +// even when an identical reference exists in an attribute. +foreach ([strlen(...), Validators::check(...), Closure::fromCallable('strlen')] as $closure) { + try { + serialize($closure); + echo "serialized!?\n"; + } catch (Exception $e) { + echo $e->getMessage(), "\n"; + } +} + +// The name is the address, the declaration is the guard: references that no +// element declares do not resolve, whatever they name. +foreach ([['$p', 'system', 0], ['$p', 'Demo::nope', 0], ['$q', 'Validators::check', 0], ['$p', 0, 0]] as [$site, $key, $hash]) { + try { + unserialize('O:7:"Closure":' . substr(serialize([[], ['const-expr', ['Demo', $site, $key, $hash]]]), 2)); + echo "resolved!?\n"; + } catch (Exception $e) { + echo "$site@$key: ", $e->getMessage(), "\n"; + } +} + +} +?> +--EXPECT-- +string(27) "$p@Demo::priv | true | true" +string(27) "$p@Demo::prot | true | true" +string(27) "$p@Base::prot | true | true" +string(34) "$p@Validators::check | true | true" +string(23) "$p@strlen | true | true" +string(27) "$p@App\helper | true | true" +O:7:"Closure":2:{i:0;a:0:{}i:1;a:2:{i:0;s:10:"const-expr";i:1;a:4:{i:0;s:4:"Demo";i:1;s:2:"$p";i:2;s:10:"Demo::priv";i:3;i:0;}}} +string(9) "prot-Demo" +string(9) "prot-Base" +string(4) "$q@0" +Serialization of 'Closure' is not allowed +Serialization of 'Closure' is not allowed +Serialization of 'Closure' is not allowed +$p@system: Invalid serialization data for Closure object (constant-expression closure "$p@system" of class Demo not found) +$p@Demo::nope: Invalid serialization data for Closure object (constant-expression closure "$p@Demo::nope" of class Demo not found) +$q@Validators::check: Invalid serialization data for Closure object (constant-expression closure "$q@Validators::check" of class Demo not found) +$p@0: Invalid serialization data for Closure object (constant-expression closure "$p@0" of class Demo not found) diff --git a/Zend/tests/closures/closure_const_expr/serialization_nested.phpt b/Zend/tests/closures/closure_const_expr/serialization_nested.phpt new file mode 100644 index 000000000000..04db35f23c53 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_nested.phpt @@ -0,0 +1,67 @@ +--TEST-- +Serialization of const-expr closures in parameter defaults, hooks and nested functions +--FILE-- + $this->v; + } + + public function withDefault($cb = static function () { return 'default'; }) { + return $cb; + } + + public function makeClosure() { + return #[A(static function () { return 'inner'; })] static function () {}; + } +} + +class Nested { + #[A(static function ( + #[A(static function () { return 'deep'; })] $x = null, + ) { return 'outer'; })] + public int $p = 0; +} + +$roundtrip = static function (Closure $c) { + $u = unserialize(serialize($c)); + $ref = $c->__serialize()[1][1]; // [class, site, key, hash] + var_dump($u(), $ref[1] . '@' . $ref[2]); +}; + +// Attribute on a property hook +$hook = (new ReflectionProperty(Demo::class, 'v'))->getHook(PropertyHookType::Get); +$roundtrip($hook->getAttributes()[0]->getArguments()[0]); + +// Parameter default value +$roundtrip((new Demo)->withDefault()); + +// Attribute on a runtime closure declared in a method body +$runtime = (new Demo)->makeClosure(); +$roundtrip((new ReflectionFunction($runtime))->getAttributes()[0]->getArguments()[0]); + +// Const-expr closure nested in the parameter attribute of another one +$outer = (new ReflectionProperty(Nested::class, 'p'))->getAttributes()[0]->getArguments()[0]; +$inner = (new ReflectionFunction($outer))->getParameters()[0]->getAttributes()[0]->getArguments()[0]; +$roundtrip($outer); +$roundtrip($inner); + +?> +--EXPECT-- +string(8) "get-hook" +string(11) "$v::get()@0" +string(7) "default" +string(15) "withDefault()@0" +string(5) "inner" +string(15) "makeClosure()@0" +string(5) "outer" +string(4) "$p@0" +string(4) "deep" +string(4) "$p@1" diff --git a/Zend/tests/closures/closure_const_expr/serialization_not_allowed.phpt b/Zend/tests/closures/closure_const_expr/serialization_not_allowed.phpt new file mode 100644 index 000000000000..77e2d8b310b4 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_not_allowed.phpt @@ -0,0 +1,72 @@ +--TEST-- +Closures that are not addressable by a declaration-site reference still refuse to serialize +--FILE-- +getAttributes()[0]->getArguments()[0]; + +$cases = [ + 'runtime closure' => function () {}, + 'static runtime closure' => static function () {}, + 'arrow function' => fn () => 1, + 'runtime function callable' => strlen(...), + 'runtime method callable' => Demo::privateFcc(...), + 'bound method callable' => (new Demo)->boundMethod(...), + 'private method callable' => Demo::privateFcc(), + '__callStatic trampoline' => Demo::someUndefinedMethod(...), + 'class constant value' => Demo::BAD, + 'property default' => (new Demo)->bad, + 'rebound scope' => Closure::bind($attrClosure, null, A::class), + 'free function attribute' => (new ReflectionFunction('freeFunction'))->getAttributes()[0]->getArguments()[0], + 'anonymous class attribute' => (new ReflectionClass($anon))->getAttributes()[0]->getArguments()[0], +]; + +foreach ($cases as $name => $closure) { + try { + serialize($closure); + echo "$name: serialized!?\n"; + } catch (Exception $e) { + echo "$name: {$e->getMessage()}\n"; + } +} + +?> +--EXPECT-- +runtime closure: Serialization of 'Closure' is not allowed +static runtime closure: Serialization of 'Closure' is not allowed +arrow function: Serialization of 'Closure' is not allowed +runtime function callable: Serialization of 'Closure' is not allowed +runtime method callable: Serialization of 'Closure' is not allowed +bound method callable: Serialization of 'Closure' is not allowed +private method callable: Serialization of 'Closure' is not allowed +__callStatic trampoline: Serialization of 'Closure' is not allowed +class constant value: Serialization of 'Closure' is not allowed +property default: Serialization of 'Closure' is not allowed +rebound scope: Serialization of 'Closure' is not allowed +free function attribute: Serialization of 'Closure' is not allowed +anonymous class attribute: Serialization of 'Closure' is not allowed diff --git a/Zend/tests/closures/closure_const_expr/serialization_tagged_format.phpt b/Zend/tests/closures/closure_const_expr/serialization_tagged_format.phpt new file mode 100644 index 000000000000..0f3ac070084d --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_tagged_format.phpt @@ -0,0 +1,50 @@ +--TEST-- +Closure serialization payload is a tagged union of [class, site, key, hash] references +--FILE-- +getAttributes(); +$anon = $attrs[0]->getArguments()[0]; +$fcc = $attrs[1]->getArguments()[0]; + +// The payload is [ , [ , [class, site, key, hash] ] ]. +// The properties slot is empty, the tag is "const-expr". An anonymous closure's +// key is an int rank with a non-zero code hash; a first-class callable's key is +// its name, which cannot drift and carries a zero hash. +$anonPayload = $anon->__serialize(); +$fccPayload = $fcc->__serialize(); +var_dump($anonPayload[0], $anonPayload[1][0]); +var_dump(count($anonPayload[1][1]), count($fccPayload[1][1])); + +// anonymous: [class, site, rank, hash] +var_dump($anonPayload[1][1][0], $anonPayload[1][1][1], $anonPayload[1][1][2]); +var_dump($anonPayload[1][1][3] !== 0); + +// first-class callable: [class, site, name, 0] +var_dump($fccPayload[1][1][1], $fccPayload[1][1][2], $fccPayload[1][1][3]); + +?> +--EXPECT-- +array(0) { +} +string(10) "const-expr" +int(4) +int(4) +string(4) "Demo" +string(2) "$p" +int(0) +bool(true) +string(2) "$p" +string(6) "strlen" +int(0) diff --git a/Zend/zend_ast.c b/Zend/zend_ast.c index 1b293c832c91..cdf74cbac96c 100644 --- a/Zend/zend_ast.c +++ b/Zend/zend_ast.c @@ -119,6 +119,7 @@ ZEND_API zend_ast * ZEND_FASTCALL zend_ast_create_op_array(zend_op_array *op_arr ast->kind = ZEND_AST_OP_ARRAY; ast->attr = 0; ast->lineno = CG(zend_lineno); + ast->code_hash = 0; ast->op_array = op_array; return (zend_ast *) ast; @@ -1250,6 +1251,12 @@ static zend_result ZEND_FASTCALL zend_ast_evaluate_inner( zend_create_fake_closure(result, fptr, fptr->common.scope, called_scope, NULL); + if (scope) { + /* Remember the declaration site, so that the closure can be + * serialized as a reference to it. */ + zend_closure_mark_as_constexpr_fcc(result, scope); + } + return SUCCESS; } case ZEND_AST_OP_ARRAY: @@ -1416,6 +1423,7 @@ static void* ZEND_FASTCALL zend_ast_tree_copy(zend_ast *ast, void *buf) new->kind = old->kind; new->attr = old->attr; new->lineno = old->lineno; + new->code_hash = old->code_hash; new->op_array = old->op_array; function_add_ref((zend_function *)new->op_array); buf = (void*)((char*)buf + sizeof(zend_ast_op_array)); diff --git a/Zend/zend_ast.h b/Zend/zend_ast.h index 26f54102a143..65c98180bdf8 100644 --- a/Zend/zend_ast.h +++ b/Zend/zend_ast.h @@ -213,6 +213,7 @@ typedef struct _zend_ast_op_array { zend_ast_kind kind; zend_ast_attr attr; uint32_t lineno; + uint32_t code_hash; /* of the pretty-printed declaration, for serialization */ zend_op_array *op_array; } zend_ast_op_array; diff --git a/Zend/zend_closures.c b/Zend/zend_closures.c index 91f690c73ed0..49987cc82e0e 100644 --- a/Zend/zend_closures.c +++ b/Zend/zend_closures.c @@ -19,8 +19,11 @@ #include "zend.h" #include "zend_API.h" +#include "zend_ast.h" +#include "zend_attributes.h" #include "zend_closures.h" #include "zend_exceptions.h" +#include "zend_execute.h" #include "zend_interfaces.h" #include "zend_objects.h" #include "zend_objects_API.h" @@ -38,6 +41,10 @@ typedef struct _zend_closure { zval this_ptr; zend_class_entry *called_scope; zif_handler orig_internal_handler; + /* The class whose constant expressions declared this closure, when it was + * created by evaluating a first-class callable in one of them (a fake + * closure carrying ZEND_ACC2_CONSTEXPR_CLOSURE). */ + zend_class_entry *constexpr_site; } zend_closure; /* non-static since it needs to be referenced */ @@ -46,6 +53,8 @@ static zend_object_handlers closure_handlers; static zend_result zend_closure_get_closure(zend_object *obj, zend_class_entry **ce_ptr, zend_function **fptr_ptr, zend_object **obj_ptr, bool check_only); static void zend_create_closure_ex(zval *res, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr, bool is_fake, uint32_t flags); +static ZEND_COLD ZEND_NAMED_FUNCTION(zend_closure_uninitialized_handler); +static void zend_closure_init_ex(zend_closure *closure, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr, bool is_fake); static inline uint32_t zend_closure_flags(const zend_closure *closure) { @@ -497,6 +506,749 @@ ZEND_METHOD(Closure, getCurrent) RETURN_OBJ_COPY(obj); } +/* Closures declared in constant expressions + * + * Closures declared in constant expressions are static, capture no variables + * and are fully described by their compiled op_array. This makes them + * addressable by (class, id), where the id is the position of the closure in + * a canonical walk over all constant expression ASTs that are reachable from + * the class and that are not subject to in-place evaluation: attribute + * arguments and parameter default values, including those of nested + * functions. Class constant values and property default values are evaluated + * (and their AST freed) in place, so closures declared there are not + * addressable this way. + * + * The walk visits candidates in a deterministic order that only depends on + * the compiled class, not on runtime evaluation state, so ids computed by one + * process can be resolved by another process running the same code. + */ + +/* Reflection-element kinds for the site descriptor. */ +typedef enum _zend_constexpr_site_kind { + ZEND_CEXPR_SITE_CLASS, + ZEND_CEXPR_SITE_CONST, + ZEND_CEXPR_SITE_PROP, + ZEND_CEXPR_SITE_HOOK, + ZEND_CEXPR_SITE_METHOD, +} zend_constexpr_site_kind; + +typedef struct zend_constexpr_closure_walk { + /* Rank of the next closure WITHIN the current reflection element; reset to + * 0 at each element boundary by zend_constexpr_closure_walk_class. A + * reference is (element site, local rank), not a single class-global rank, + * so adding/removing/reordering closures in one element does not renumber + * closures in other elements. */ + uint32_t next_id; + /* When by_id is true, search for target_id (a LOCAL rank among the + * anonymous closures of a single element; the caller has already + * positioned the walk on that element). When target_key is set, search + * for the first-class callable reference matching that "Called::method" + * / "function" key. Otherwise search for the op_array of an anonymous + * closure identified by its opcodes. Serialization and resolution both + * match first-class callables by key, so a closure is serializable + * exactly when its reference resolves. */ + bool by_id; + uint32_t target_id; + const char *target_key; + size_t target_key_len; + const zend_op *opcodes; + /* The class being walked; used to resolve self/parent references. */ + zend_class_entry *site_class; + /* The reflection element currently being walked, kept as a cheap + * (kind, borrowed name, hook) descriptor rather than a built string, so no + * allocation happens for the elements the walk merely passes through. The + * "@" string is materialized once, from found_*, on a match. */ + zend_constexpr_site_kind cur_kind; + zend_string *cur_name; + uint8_t cur_hook; + /* The found site: either a ZEND_AST_OP_ARRAY node, or a ZEND_AST_CALL / + * ZEND_AST_STATIC_CALL node whose argument list is a first-class callable + * placeholder. */ + zend_ast *found; + uint32_t found_id; + zend_constexpr_site_kind found_kind; + zend_string *found_name; + uint8_t found_hook; +} zend_constexpr_closure_walk; + +static bool zend_constexpr_closure_walk_op_array(zend_constexpr_closure_walk *w, zend_op_array *op_array); + +/* Whether the first-class callable declared by the given site matches a + * "Called::method" / "function" key. Pure string comparisons: class + * references are compile-time resolved names ('self'/'parent' resolve + * against the walked class), so no class or function is ever loaded. */ +static bool zend_constexpr_fcc_key_matches(const zend_constexpr_closure_walk *w, const zend_ast *ast) +{ + const char *key = w->target_key; + size_t key_len = w->target_key_len; + const char *sep = NULL; + + for (size_t i = 0; i + 1 < key_len; i++) { + if (key[i] == ':' && key[i + 1] == ':') { + sep = key + i; + break; + } + } + + if (ast->kind == ZEND_AST_STATIC_CALL) { + const zend_ast *class_ast = ast->child[0]; + zend_string *method = zend_ast_get_str(ast->child[1]); + zend_string *ref_name; + + if (!sep) { + return false; + } + + switch (class_ast->attr >> ZEND_CONST_EXPR_NEW_FETCH_TYPE_SHIFT) { + case ZEND_FETCH_CLASS_SELF: + ref_name = w->site_class->name; + break; + case ZEND_FETCH_CLASS_PARENT: + ref_name = w->site_class->parent ? w->site_class->parent->name : NULL; + break; + default: + ref_name = zend_ast_get_str((zend_ast *) class_ast); + break; + } + + return ref_name + && ZSTR_LEN(ref_name) == (size_t) (sep - key) + && zend_binary_strcasecmp(ZSTR_VAL(ref_name), ZSTR_LEN(ref_name), key, sep - key) == 0 + && ZSTR_LEN(method) == key_len - (sep - key) - 2 + && zend_binary_strcasecmp(ZSTR_VAL(method), ZSTR_LEN(method), sep + 2, ZSTR_LEN(method)) == 0; + } + + ZEND_ASSERT(ast->kind == ZEND_AST_CALL); + + if (sep) { + return false; + } + + zend_string *fname = zend_ast_get_str(ast->child[0]); + if (ZSTR_LEN(fname) == key_len + && zend_binary_strcasecmp(ZSTR_VAL(fname), ZSTR_LEN(fname), key, key_len) == 0) { + return true; + } + /* Non-fully-qualified names in namespaced code may resolve to the global + * function, whose name is what serialization emits as the key. */ + if (ast->child[0]->attr != ZEND_NAME_FQ) { + const char *backslash = zend_memrchr(ZSTR_VAL(fname), '\\', ZSTR_LEN(fname)); + if (backslash) { + size_t tail_len = ZSTR_LEN(fname) - (backslash + 1 - ZSTR_VAL(fname)); + return tail_len == key_len + && zend_binary_strcasecmp(backslash + 1, tail_len, key, key_len) == 0; + } + } + return false; +} + +static bool zend_constexpr_closure_visit_op_array(zend_constexpr_closure_walk *w, zend_ast *ast) +{ + zend_op_array *op_array = zend_ast_get_op_array(ast)->op_array; + uint32_t id = w->next_id++; + + if (w->by_id ? w->target_id == id : (w->opcodes && w->opcodes == op_array->opcodes)) { + w->found = ast; + w->found_id = id; + w->found_kind = w->cur_kind; + w->found_name = w->cur_name; + w->found_hook = w->cur_hook; + return true; + } + + /* The closure may itself declare closures in its attributes or in its + * parameter default values. */ + return zend_constexpr_closure_walk_op_array(w, op_array); +} + +static bool zend_constexpr_closure_walk_ast(zend_constexpr_closure_walk *w, zend_ast *ast) +{ + if (!ast) { + return false; + } + + switch (ast->kind) { + case ZEND_AST_OP_ARRAY: + return zend_constexpr_closure_visit_op_array(w, ast); + case ZEND_AST_ZVAL: + case ZEND_AST_CONSTANT: + return false; + case ZEND_AST_CALL: + case ZEND_AST_STATIC_CALL: { + zend_ast *args = ast->kind == ZEND_AST_CALL ? ast->child[1] : ast->child[2]; + + /* In constant expressions, calls only exist in their first-class + * callable form: each one is a closure declaration site, keyed by + * the name of its target. It does not consume a rank: adding or + * removing a callable reference never renumbers the anonymous + * closures of the element. */ + if (args && args->kind == ZEND_AST_CALLABLE_CONVERT) { + if (w->target_key && zend_constexpr_fcc_key_matches(w, ast)) { + w->found = ast; + w->found_kind = w->cur_kind; + w->found_name = w->cur_name; + w->found_hook = w->cur_hook; + return true; + } + return false; + } + break; + } + case ZEND_AST_CALLABLE_CONVERT: + /* Visited through its enclosing call node. */ + return false; + default: + break; + } + + if (zend_ast_is_list(ast)) { + zend_ast_list *list = zend_ast_get_list(ast); + for (uint32_t i = 0; i < list->children; i++) { + if (zend_constexpr_closure_walk_ast(w, list->child[i])) { + return true; + } + } + return false; + } + + if (zend_ast_is_decl(ast)) { + /* Closure declarations in constant expressions are compiled to + * ZEND_AST_OP_ARRAY nodes, so declarations cannot appear here. */ + ZEND_UNREACHABLE(); + return false; + } + + uint32_t children = zend_ast_get_num_children(ast); + for (uint32_t i = 0; i < children; i++) { + if (zend_constexpr_closure_walk_ast(w, ast->child[i])) { + return true; + } + } + return false; +} + +static bool zend_constexpr_closure_walk_zval(zend_constexpr_closure_walk *w, zval *zv) +{ + if (Z_TYPE_P(zv) == IS_CONSTANT_AST) { + return zend_constexpr_closure_walk_ast(w, Z_ASTVAL_P(zv)); + } + return false; +} + +static bool zend_constexpr_closure_walk_attributes(zend_constexpr_closure_walk *w, HashTable *attributes) +{ + if (!attributes) { + return false; + } + + /* Attribute lists are always packed (c.f. zend_get_attribute()). */ + ZEND_HASH_PACKED_FOREACH_PTR(attributes, zend_attribute *attr) { + for (uint32_t i = 0; i < attr->argc; i++) { + if (zend_constexpr_closure_walk_zval(w, &attr->args[i].value)) { + return true; + } + } + } ZEND_HASH_FOREACH_END(); + + return false; +} + +static bool zend_constexpr_closure_walk_op_array(zend_constexpr_closure_walk *w, zend_op_array *op_array) +{ + if (op_array->type != ZEND_USER_FUNCTION) { + return false; + } + + if (zend_constexpr_closure_walk_attributes(w, op_array->attributes)) { + return true; + } + + /* Parameter default values are the op2 of the RECV_INIT for each optional + * parameter, at the head of the op array in parameter order. */ + for (uint32_t i = 0; i < op_array->num_args; i++) { + const zend_op *recv = &op_array->opcodes[i]; + if (recv->opcode == ZEND_RECV_INIT + && zend_constexpr_closure_walk_zval(w, RT_CONSTANT(recv, recv->op2))) { + return true; + } + } + + for (uint32_t i = 0; i < op_array->num_dynamic_func_defs; i++) { + if (zend_constexpr_closure_walk_op_array(w, op_array->dynamic_func_defs[i])) { + return true; + } + } + + return false; +} + +/* Enter a reflection element: record its (kind, borrowed name, hook) descriptor + * and restart the local rank. No allocation for elements merely passed through; + * the "@" string is built once from found_* on a match. */ +#define WALK_ELEMENT(kind_, name_, hook_) \ + (w->cur_kind = (kind_), w->cur_name = (name_), w->cur_hook = (hook_), w->next_id = 0) + +static bool zend_constexpr_closure_walk_class(zend_constexpr_closure_walk *w, zend_class_entry *ce) +{ + zend_string *cname, *pname; + zend_class_constant *c; + zend_property_info *prop_info; + zend_function *func; + + if (ce->type != ZEND_USER_CLASS) { + return false; + } + + WALK_ELEMENT(ZEND_CEXPR_SITE_CLASS, NULL, 0); + if (zend_constexpr_closure_walk_attributes(w, ce->attributes)) { + return true; + } + + ZEND_HASH_MAP_FOREACH_STR_KEY_PTR(&ce->constants_table, cname, c) { + WALK_ELEMENT(ZEND_CEXPR_SITE_CONST, cname, 0); + if (zend_constexpr_closure_walk_attributes(w, c->attributes)) { + return true; + } + } ZEND_HASH_FOREACH_END(); + + ZEND_HASH_MAP_FOREACH_STR_KEY_PTR(&ce->properties_info, pname, prop_info) { + WALK_ELEMENT(ZEND_CEXPR_SITE_PROP, pname, 0); + if (zend_constexpr_closure_walk_attributes(w, prop_info->attributes)) { + return true; + } + if (prop_info->hooks) { + for (uint32_t i = 0; i < ZEND_PROPERTY_HOOK_COUNT; i++) { + if (prop_info->hooks[i]) { + WALK_ELEMENT(ZEND_CEXPR_SITE_HOOK, pname, (uint8_t) i); + if (zend_constexpr_closure_walk_op_array(w, &prop_info->hooks[i]->op_array)) { + return true; + } + } + } + } + } ZEND_HASH_FOREACH_END(); + + ZEND_HASH_MAP_FOREACH_PTR(&ce->function_table, func) { + WALK_ELEMENT(ZEND_CEXPR_SITE_METHOD, func->common.function_name, 0); + if (zend_constexpr_closure_walk_op_array(w, &func->op_array)) { + return true; + } + } ZEND_HASH_FOREACH_END(); + + return false; +} + +#undef WALK_ELEMENT + +/* Build the "" element descriptor for a found closure: the empty string + * for the class itself, a constant or enum-case name, "$prop" for a property, + * "$prop::get()" / "$prop::set()" for a property hook, "name()" for a method. */ +static zend_string *zend_constexpr_closure_build_site(const zend_constexpr_closure_walk *w) +{ + switch (w->found_kind) { + case ZEND_CEXPR_SITE_CLASS: + return ZSTR_EMPTY_ALLOC(); + case ZEND_CEXPR_SITE_CONST: + return zend_string_copy(w->found_name); + case ZEND_CEXPR_SITE_PROP: + return zend_strpprintf(0, "$%s", ZSTR_VAL(w->found_name)); + case ZEND_CEXPR_SITE_HOOK: + return zend_strpprintf(0, "$%s::%s()", ZSTR_VAL(w->found_name), + w->found_hook == ZEND_PROPERTY_HOOK_GET ? "get" : "set"); + case ZEND_CEXPR_SITE_METHOD: + return zend_strpprintf(0, "%s()", ZSTR_VAL(w->found_name)); + default: + ZEND_UNREACHABLE(); + return NULL; + } +} + +/* Resolve (element site, local rank) to a declaration-site AST: locate the one + * named element by a direct hash lookup, then walk only it. This is where + * element-scoping pays off on decode: instead of a class-global scan to the + * Nth site, resolution is bounded by a single element's const-expr surface. */ +static zend_ast *zend_constexpr_closure_site_by_element( + zend_class_entry *ce, const char *site, size_t site_len, + uint32_t rank, const char *key, size_t key_len) +{ + zend_constexpr_closure_walk w = {0}; + + if (ce->type != ZEND_USER_CLASS) { + return NULL; + } + + if (key) { + w.target_key = key; + w.target_key_len = key_len; + } else { + w.by_id = true; + w.target_id = rank; + } + w.site_class = ce; + + if (site_len == 0) { + /* class attributes */ + zend_constexpr_closure_walk_attributes(&w, ce->attributes); + return w.found; + } + + if (site[0] == '$') { + /* property "$name" or hook "$name::get()" / "$name::set()" */ + const char *sep = NULL; + for (size_t i = 1; i + 1 < site_len; i++) { + if (site[i] == ':' && site[i + 1] == ':') { + sep = site + i; + break; + } + } + size_t name_len = (sep ? (size_t) (sep - site) : site_len) - 1; + zend_property_info *prop = zend_hash_str_find_ptr(&ce->properties_info, site + 1, name_len); + if (!prop) { + return NULL; + } + if (!sep) { + zend_constexpr_closure_walk_attributes(&w, prop->attributes); + return w.found; + } + const char *spec = sep + 2; + size_t spec_len = site_len - (spec - site); + uint32_t hook_kind; + if (spec_len == 5 && memcmp(spec, "get()", 5) == 0) { + hook_kind = ZEND_PROPERTY_HOOK_GET; + } else if (spec_len == 5 && memcmp(spec, "set()", 5) == 0) { + hook_kind = ZEND_PROPERTY_HOOK_SET; + } else { + return NULL; + } + if (!prop->hooks || !prop->hooks[hook_kind] + || prop->hooks[hook_kind]->type != ZEND_USER_FUNCTION) { + return NULL; + } + zend_constexpr_closure_walk_op_array(&w, &prop->hooks[hook_kind]->op_array); + return w.found; + } + + if (site_len >= 2 && site[site_len - 2] == '(' && site[site_len - 1] == ')') { + /* method "name()" */ + zend_function *func = zend_hash_str_find_ptr_lc(&ce->function_table, site, site_len - 2); + if (!func || func->type != ZEND_USER_FUNCTION) { + return NULL; + } + zend_constexpr_closure_walk_op_array(&w, &func->op_array); + return w.found; + } + + /* class constant / enum case "NAME" */ + zend_class_constant *c = zend_hash_str_find_ptr(&ce->constants_table, site, site_len); + if (!c) { + return NULL; + } + zend_constexpr_closure_walk_attributes(&w, c->attributes); + return w.found; +} + +/* Instantiate the closure a declaration site declares, exactly as evaluating + * its constant expression would: an anonymous closure from its op_array, a + * first-class callable by re-evaluating the reference in the scope of the + * declaring class, re-running its visibility checks. */ +static zend_result zend_constexpr_closure_instantiate(zend_ast *site, zend_class_entry *ce, zval *result) +{ + if (site->kind == ZEND_AST_OP_ARRAY) { + zend_create_closure(result, (zend_function *) zend_ast_get_op_array(site)->op_array, ce, ce, NULL); + return SUCCESS; + } + return zend_ast_evaluate(result, site, ce); +} + +static zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, + zend_string **site, zval *key, uint32_t *hash) +{ + const zend_closure *closure = (const zend_closure *) closure_obj; + zend_constexpr_closure_walk w = {0}; + zend_class_entry *site_class; + zend_string *name = NULL; + + if (!(closure->func.common.fn_flags2 & ZEND_ACC2_CONSTEXPR_CLOSURE)) { + return FAILURE; + } + + if (!(closure->func.common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) { + /* Anonymous closure declared in a constant expression: identified by + * its op_array, found in the class it is scoped to. */ + if (closure->func.type != ZEND_USER_FUNCTION) { + return FAILURE; + } + site_class = closure->func.common.scope; + w.opcodes = closure->func.op_array.opcodes; + } else { + /* First-class callable evaluated in a constant expression: identified + * by the name key its reference serializes to, matched with the same + * comparison resolution uses, so the closure is serializable exactly + * when its reference resolves. */ + if (Z_TYPE(closure->this_ptr) != IS_UNDEF) { + return FAILURE; + } + site_class = closure->constexpr_site; + if (closure->func.common.scope && closure->called_scope) { + zend_string *called = closure->called_scope->name; + zend_string *method = closure->func.common.function_name; + name = zend_string_concat3( + ZSTR_VAL(called), ZSTR_LEN(called), "::", 2, ZSTR_VAL(method), ZSTR_LEN(method)); + } else if (!closure->func.common.scope) { + name = zend_string_copy(closure->func.common.function_name); + } else { + return FAILURE; + } + w.target_key = ZSTR_VAL(name); + w.target_key_len = ZSTR_LEN(name); + } + + w.site_class = site_class; + + if (!site_class || site_class->type != ZEND_USER_CLASS || (site_class->ce_flags & ZEND_ACC_ANON_CLASS) + || !zend_constexpr_closure_walk_class(&w, site_class)) { + if (name) { + zend_string_release_ex(name, 0); + } + return FAILURE; + } + + *ce = site_class; + /* Reference [class, site, key, hash]: names the declaring element; + * is the closure's local rank for an anonymous closure, with a + * non-zero hash of its code so a reordering of the element's declarations + * cannot make the rank name a different closure; or a callable name for a + * first-class callable, which cannot drift and so carries a zero hash. */ + *site = zend_constexpr_closure_build_site(&w); + if (w.found->kind == ZEND_AST_OP_ARRAY) { + ZVAL_LONG(key, w.found_id); + *hash = zend_ast_get_op_array(w.found)->code_hash; + } else { + ZVAL_STR(key, name); + name = NULL; + *hash = 0; + } + if (name) { + zend_string_release_ex(name, 0); + } + return SUCCESS; +} + +ZEND_API void zend_closure_mark_as_constexpr_fcc(zval *closure_zv, zend_class_entry *site_class) +{ + zend_closure *closure = (zend_closure *) Z_OBJ_P(closure_zv); + + ZEND_ASSERT(closure->func.common.fn_flags & ZEND_ACC_FAKE_CLOSURE); + closure->func.common.fn_flags2 |= ZEND_ACC2_CONSTEXPR_CLOSURE; + closure->constexpr_site = site_class; +} + +/* Serialize a closure as a reference to the declaration site of the + constant expression that declared it: either an anonymous closure + declaration, or a first-class callable reference */ +ZEND_METHOD(Closure, __serialize) +{ + zend_class_entry *ce; + zend_string *site; + zval key, payload, tagged; + uint32_t hash; + + ZEND_PARSE_PARAMETERS_NONE(); + + if (zend_constexpr_closure_ref(Z_OBJ_P(ZEND_THIS), &ce, &site, &key, &hash) == FAILURE) { + /* Only closures declared in a constant expression are serializable + * (handled above); every other closure stays non-serializable, with + * the same message as before this feature existed. */ + zend_throw_exception(NULL, "Serialization of 'Closure' is not allowed", 0); + RETURN_THROWS(); + } + + /* Tagged-union payload: [ , [ , ] ]. + * A Closure has no properties, so the first slot is an empty array; the + * tag names the reference kind so the format can grow new kinds without + * ambiguity. The "const-expr" reference is [class, site, key, hash]: key is + * an int rank (anonymous) or a callable name (first-class callable), and + * hash is the anonymous code hash, 0 when there is none. */ + array_init_size(&payload, 4); + add_next_index_str(&payload, zend_string_copy(ce->name)); + add_next_index_str(&payload, site); + add_next_index_zval(&payload, &key); + add_next_index_long(&payload, hash); + + array_init_size(&tagged, 2); + add_next_index_string(&tagged, "const-expr"); + add_next_index_zval(&tagged, &payload); + + array_init_size(return_value, 2); + add_next_index_array(return_value, zend_new_array(0)); + add_next_index_zval(return_value, &tagged); +} + +/* Build a "@" display of a reference, for error messages only. */ +static zend_string *zend_constexpr_closure_ref_display(zval *z_site, zval *z_key) +{ + if (Z_TYPE_P(z_key) == IS_LONG) { + return zend_strpprintf(0, "%s@" ZEND_LONG_FMT, Z_STRVAL_P(z_site), Z_LVAL_P(z_key)); + } + return zend_strpprintf(0, "%s@%s", Z_STRVAL_P(z_site), Z_STRVAL_P(z_key)); +} + +/* Recreate a closure from a reference to its declaration site */ +ZEND_METHOD(Closure, __unserialize) +{ + zend_closure *closure = (zend_closure *) Z_OBJ_P(ZEND_THIS); + HashTable *data; + zval *z_class; + zend_class_entry *ce; + zend_ast *site; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_ARRAY_HT(data) + ZEND_PARSE_PARAMETERS_END(); + + if (closure->func.type != ZEND_INTERNAL_FUNCTION + || closure->func.internal_function.handler != zend_closure_uninitialized_handler) { + zend_throw_exception(NULL, "Cannot unserialize an already initialized Closure", 0); + RETURN_THROWS(); + } + + /* Tagged-union payload: [ , [ , ] ]. + * Only the "const-expr" tag is known; an unknown tag from a future format + * is rejected cleanly rather than misread. */ + zval *z_props, *z_tagged, *z_tag, *z_payload; + HashTable *tagged, *payload; + + z_props = zend_hash_index_find(data, 0); + z_tagged = zend_hash_index_find(data, 1); + if (zend_hash_num_elements(data) != 2 || !z_props || !z_tagged) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + ZVAL_DEREF(z_tagged); + if (Z_TYPE_P(z_tagged) != IS_ARRAY) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + tagged = Z_ARRVAL_P(z_tagged); + z_tag = zend_hash_index_find(tagged, 0); + z_payload = zend_hash_index_find(tagged, 1); + if (zend_hash_num_elements(tagged) != 2 || !z_tag || !z_payload) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + ZVAL_DEREF(z_tag); + ZVAL_DEREF(z_payload); + if (Z_TYPE_P(z_tag) != IS_STRING || !zend_string_equals_literal(Z_STR_P(z_tag), "const-expr") + || Z_TYPE_P(z_payload) != IS_ARRAY) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + payload = Z_ARRVAL_P(z_payload); + + zval *z_site, *z_key, *z_hash; + z_class = zend_hash_index_find(payload, 0); + z_site = zend_hash_index_find(payload, 1); + z_key = zend_hash_index_find(payload, 2); + z_hash = zend_hash_index_find(payload, 3); + if (z_class) { ZVAL_DEREF(z_class); } + if (z_site) { ZVAL_DEREF(z_site); } + if (z_key) { ZVAL_DEREF(z_key); } + if (z_hash) { ZVAL_DEREF(z_hash); } + if (zend_hash_num_elements(payload) != 4 + || !z_class || Z_TYPE_P(z_class) != IS_STRING + || !z_site || Z_TYPE_P(z_site) != IS_STRING + || !z_key || (Z_TYPE_P(z_key) != IS_LONG && Z_TYPE_P(z_key) != IS_STRING) + || !z_hash || Z_TYPE_P(z_hash) != IS_LONG) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + + /* Honor unserialize()'s allowed_classes: resolving the reference autoloads + * the named class and pulls a closure out of it, so a class the filter + * excludes must not be reachable through a Closure payload. Outside + * unserialize() (a direct __unserialize() call) the hook allows everything. */ + if (zend_unserialize_class_allowed && !zend_unserialize_class_allowed(Z_STR_P(z_class))) { + zend_throw_exception_ex(NULL, 0, + "Invalid serialization data for Closure object (class \"%s\" is not allowed)", + Z_STRVAL_P(z_class)); + RETURN_THROWS(); + } + + ce = zend_lookup_class(Z_STR_P(z_class)); + if (!ce || ce->type != ZEND_USER_CLASS) { + if (!EG(exception)) { + zend_throw_exception_ex(NULL, 0, + "Invalid serialization data for Closure object (cannot load class \"%s\")", + Z_STRVAL_P(z_class)); + } + RETURN_THROWS(); + } + + /* Resolve [site, key] to a declaration-site AST. A LONG key is the local + * rank of an anonymous closure; a STRING key is a first-class callable name. + * A non-zero hash verifies the rank against the closure's code, so a + * reordering of the element's declarations cannot silently resolve to a + * different closure; a callable name cannot drift and carries no hash. A + * producer that cannot compute the hash passes 0, resolving the rank by + * position without verification. */ + uint32_t verify_hash = 0; + if (Z_TYPE_P(z_key) == IS_LONG) { + zend_long rank = Z_LVAL_P(z_key); + site = (rank >= 0 && rank <= UINT32_MAX) + ? zend_constexpr_closure_site_by_element(ce, Z_STRVAL_P(z_site), Z_STRLEN_P(z_site), (uint32_t) rank, NULL, 0) + : NULL; + verify_hash = (uint32_t) Z_LVAL_P(z_hash); + } else if (Z_LVAL_P(z_hash) == 0 && Z_STRLEN_P(z_key) != 0) { + site = zend_constexpr_closure_site_by_element(ce, Z_STRVAL_P(z_site), Z_STRLEN_P(z_site), 0, + Z_STRVAL_P(z_key), Z_STRLEN_P(z_key)); + } else { + site = NULL; + } + + if (!site) { + zend_string *disp = zend_constexpr_closure_ref_display(z_site, z_key); + zend_throw_exception_ex(NULL, 0, + "Invalid serialization data for Closure object (constant-expression closure \"%s\" of class %s not found)", + ZSTR_VAL(disp), ZSTR_VAL(ce->name)); + zend_string_release(disp); + RETURN_THROWS(); + } + + if (verify_hash && verify_hash != zend_ast_get_op_array(site)->code_hash) { + zend_string *disp = zend_constexpr_closure_ref_display(z_site, z_key); + zend_throw_exception_ex(NULL, 0, + "Invalid serialization data for Closure object (constant-expression closure \"%s\" of class %s changed)", + ZSTR_VAL(disp), ZSTR_VAL(ce->name)); + zend_string_release(disp); + RETURN_THROWS(); + } + + zval tmp; + zend_closure *tmp_closure; + + if (zend_constexpr_closure_instantiate(site, ce, &tmp) == FAILURE) { + if (!EG(exception)) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + } + RETURN_THROWS(); + } + + ZEND_ASSERT(Z_TYPE(tmp) == IS_OBJECT && Z_OBJCE(tmp) == zend_ce_closure); + tmp_closure = (zend_closure *) Z_OBJ(tmp); + + /* Move the resolved closure into the object being unserialized. */ + bool is_fake = (tmp_closure->func.common.fn_flags & ZEND_ACC_FAKE_CLOSURE) != 0; + zend_closure_init_ex(closure, &tmp_closure->func, + tmp_closure->func.common.scope, tmp_closure->called_scope, NULL, is_fake); + closure->constexpr_site = tmp_closure->constexpr_site; + if (is_fake) { + closure->func.common.fn_flags |= ZEND_ACC_FAKE_CLOSURE; + GC_ADD_FLAGS(&closure->std, GC_NOT_COLLECTABLE); + } + + zval_ptr_dtor(&tmp); +} + static ZEND_COLD zend_function *zend_closure_get_constructor(zend_object *object) /* {{{ */ { zend_throw_error(NULL, "Instantiation of class Closure is not allowed"); @@ -619,6 +1371,12 @@ static void zend_closure_free_storage(zend_object *object) /* {{{ */ } /* }}} */ +static ZEND_COLD ZEND_NAMED_FUNCTION(zend_closure_uninitialized_handler) /* {{{ */ +{ + zend_throw_error(NULL, "Cannot call an uninitialized Closure"); +} +/* }}} */ + static zend_object *zend_closure_new(zend_class_entry *class_type) /* {{{ */ { zend_closure *closure; @@ -628,6 +1386,17 @@ static zend_object *zend_closure_new(zend_class_entry *class_type) /* {{{ */ zend_object_std_init(&closure->std, class_type); + /* Initialize the function as a safe placeholder. Until the closure is + * actually initialized through zend_closure_init_ex() it may be observed + * by userland code (e.g. while delayed __unserialize() calls are still + * pending) and must not crash any object handler. The placeholder mimics + * a fake internal closure with a handler that always throws. */ + closure->func.type = ZEND_INTERNAL_FUNCTION; + closure->func.common.fn_flags = ZEND_ACC_PUBLIC | ZEND_ACC_CLOSURE | ZEND_ACC_FAKE_CLOSURE; + closure->func.common.function_name = ZSTR_EMPTY_ALLOC(); + closure->func.internal_function.handler = zend_closure_uninitialized_handler; + closure->orig_internal_handler = zend_closure_uninitialized_handler; + return (zend_object*)closure; } /* }}} */ @@ -640,6 +1409,7 @@ static zend_object *zend_closure_clone(zend_object *zobject) /* {{{ */ zend_create_closure_ex(&result, &closure->func, closure->func.common.scope, closure->called_scope, &closure->this_ptr, zend_closure_is_fake(closure), zend_closure_flags(closure)); + ((zend_closure *) Z_OBJ(result))->constexpr_site = closure->constexpr_site; return Z_OBJ(result); } /* }}} */ @@ -807,16 +1577,10 @@ static ZEND_NAMED_FUNCTION(zend_closure_internal_handler) /* {{{ */ } /* }}} */ -static void zend_create_closure_ex(zval *res, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr, bool is_fake, uint32_t flags) /* {{{ */ +static void zend_closure_init_ex(zend_closure *closure, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr, bool is_fake) /* {{{ */ { - zend_closure *closure; void *ptr; - object_init_ex(res, zend_ce_closure); - - closure = (zend_closure *)Z_OBJ_P(res); - closure->std.extra_flags = flags; - if ((scope == NULL) && this_ptr && (Z_TYPE_P(this_ptr) != IS_UNDEF)) { /* use dummy scope if we're binding an object without specifying a scope */ /* maybe it would be better to create one for this purpose */ @@ -910,6 +1674,16 @@ static void zend_create_closure_ex(zval *res, zend_function *func, zend_class_en } /* }}} */ +static void zend_create_closure_ex(zval *res, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr, bool is_fake, uint32_t flags) /* {{{ */ +{ + object_init_ex(res, zend_ce_closure); + + zend_closure *closure = (zend_closure *) Z_OBJ_P(res); + closure->std.extra_flags = flags; + zend_closure_init_ex(closure, func, scope, called_scope, this_ptr, is_fake); +} +/* }}} */ + ZEND_API void zend_create_closure(zval *res, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr) { zend_create_closure_ex(res, func, scope, called_scope, this_ptr, diff --git a/Zend/zend_closures.h b/Zend/zend_closures.h index 305d82e5015a..01eef135c2ab 100644 --- a/Zend/zend_closures.h +++ b/Zend/zend_closures.h @@ -41,6 +41,12 @@ ZEND_API zend_function *zend_get_closure_invoke_method(zend_object *obj); ZEND_API const zend_function *zend_get_closure_method_def(zend_object *obj); ZEND_API zval* zend_get_closure_this_ptr(zval *obj); +/* A first-class callable in a class constant expression is created without a + * link back to the declaring class (its scope points at the target). This + * records that class so Closure::__serialize() can reference the callable by + * its declaration; anonymous closures are found through their own scope. */ +ZEND_API void zend_closure_mark_as_constexpr_fcc(zval *closure_zv, zend_class_entry *site_class); + END_EXTERN_C() #endif diff --git a/Zend/zend_closures.stub.php b/Zend/zend_closures.stub.php index 46b51617eef9..f5b289c487b3 100644 --- a/Zend/zend_closures.stub.php +++ b/Zend/zend_closures.stub.php @@ -4,7 +4,6 @@ /** * @strict-properties - * @not-serializable */ final class Closure { @@ -23,4 +22,8 @@ public function call(object $newThis, mixed ...$args): mixed {} public static function fromCallable(callable $callback): Closure {} public static function getCurrent(): Closure {} + + public function __serialize(): array {} + + public function __unserialize(array $data): void {} } diff --git a/Zend/zend_closures_arginfo.h b/Zend/zend_closures_arginfo.h index 5bc983a97c2c..64a149339346 100644 --- a/Zend/zend_closures_arginfo.h +++ b/Zend/zend_closures_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_closures.stub.php instead. - * Stub hash: e0626e52adb2d38dad1140c1a28cc7774cc84500 */ + * Stub hash: 71e4941e3cd414871f5621f579c22b6fdaf12924 */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Closure___construct, 0, 0, 0) ZEND_END_ARG_INFO() @@ -27,12 +27,21 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Closure_getCurrent, 0, 0, Closure, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Closure___serialize, 0, 0, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Closure___unserialize, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, data, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + ZEND_METHOD(Closure, __construct); ZEND_METHOD(Closure, bind); ZEND_METHOD(Closure, bindTo); ZEND_METHOD(Closure, call); ZEND_METHOD(Closure, fromCallable); ZEND_METHOD(Closure, getCurrent); +ZEND_METHOD(Closure, __serialize); +ZEND_METHOD(Closure, __unserialize); static const zend_function_entry class_Closure_methods[] = { ZEND_ME(Closure, __construct, arginfo_class_Closure___construct, ZEND_ACC_PRIVATE) @@ -41,6 +50,8 @@ static const zend_function_entry class_Closure_methods[] = { ZEND_ME(Closure, call, arginfo_class_Closure_call, ZEND_ACC_PUBLIC) ZEND_ME(Closure, fromCallable, arginfo_class_Closure_fromCallable, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Closure, getCurrent, arginfo_class_Closure_getCurrent, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_ME(Closure, __serialize, arginfo_class_Closure___serialize, ZEND_ACC_PUBLIC) + ZEND_ME(Closure, __unserialize, arginfo_class_Closure___unserialize, ZEND_ACC_PUBLIC) ZEND_FE_END }; @@ -49,7 +60,7 @@ static zend_class_entry *register_class_Closure(void) zend_class_entry ce, *class_entry; INIT_CLASS_ENTRY(ce, "Closure", class_Closure_methods); - class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES); return class_entry; } diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 6a677e71a6b6..4d6ef072b392 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -12008,11 +12008,27 @@ static void zend_compile_const_expr_closure(zend_ast **ast_ptr) "Cannot use(...) variables in constant expression"); } + /* Serialization verifies a declaration-site reference against a hash of + * the closure's code, so that a reference cannot silently resolve to a + * different closure after the surrounding declarations were reordered. + * Hash the pretty-printed declaration (whitespace- and layout-insensitive) + * before it is compiled, while nested declarations are still source ASTs. + * 0 is reserved for "no hash" (a first-class callable reference carries + * none), so a zero result maps to 1. */ + zend_string *code = zend_ast_export("", (zend_ast *) closure_ast, ""); + uint32_t code_hash = (uint32_t) zend_inline_hash_func(ZSTR_VAL(code), ZSTR_LEN(code)); + zend_string_release_ex(code, 0); + if (code_hash == 0) { + code_hash = 1; + } + znode node; zend_op_array *op = zend_compile_func_decl(&node, (zend_ast*)closure_ast, FUNC_DECL_LEVEL_CONSTEXPR); + op->fn_flags2 |= ZEND_ACC2_CONSTEXPR_CLOSURE; zend_ast_destroy(*ast_ptr); *ast_ptr = zend_ast_create_op_array(op); + zend_ast_get_op_array(*ast_ptr)->code_hash = code_hash; } static void zend_compile_const_expr_fcc(zend_ast **ast_ptr) diff --git a/Zend/zend_compile.h b/Zend/zend_compile.h index 69d7aeb2f373..dd71ec9f2380 100644 --- a/Zend/zend_compile.h +++ b/Zend/zend_compile.h @@ -412,11 +412,18 @@ typedef struct _zend_oparray_context { /* op_array uses strict mode types | | | */ #define ZEND_ACC_STRICT_TYPES (1U << 31) /* | X | | */ /* | | | */ -/* Function Flags 2 (fn_flags2) (unused: 1-31) | | | */ +/* Function Flags 2 (fn_flags2) (unused: 3-31) | | | */ /* ============================ | | | */ /* | | | */ /* Function forbids dynamic calls | | | */ #define ZEND_ACC2_FORBID_DYN_CALLS (1 << 0) /* | X | | */ +/* | | | */ +/* Closure declared in a constant expression: either an | | | */ +/* anonymous closure (on its compiled op_array) or a fake | | | */ +/* closure from a first-class callable evaluated there | | | */ +/* (on the closure's own function copy). The two are told | | | */ +/* apart by ZEND_ACC_FAKE_CLOSURE. | | | */ +#define ZEND_ACC2_CONSTEXPR_CLOSURE (1 << 1) /* | X | | */ #define ZEND_ACC_PPP_MASK (ZEND_ACC_PUBLIC | ZEND_ACC_PROTECTED | ZEND_ACC_PRIVATE) #define ZEND_ACC_PPP_SET_MASK (ZEND_ACC_PUBLIC_SET | ZEND_ACC_PROTECTED_SET | ZEND_ACC_PRIVATE_SET) diff --git a/Zend/zend_execute.h b/Zend/zend_execute.h index eb73b3f3de9e..8c1a682fe283 100644 --- a/Zend/zend_execute.h +++ b/Zend/zend_execute.h @@ -36,6 +36,13 @@ ZEND_API extern void (*zend_execute_internal)(zend_execute_data *execute_data, z /* The lc_name may be stack allocated! */ ZEND_API extern zend_class_entry *(*zend_autoload)(zend_string *name, zend_string *lc_name); +/* Installed by ext/standard so that a magic __unserialize() which resolves a + * class by name (e.g. Closure) can honor unserialize()'s allowed_classes + * filter. Returns whether class_name may be resolved in the current context: + * always true outside unserialize(), or when no filter restricts classes. + * NULL when ext/standard is not present. */ +ZEND_API extern bool (*zend_unserialize_class_allowed)(zend_string *class_name); + void init_executor(void); void shutdown_executor(void); void shutdown_destructors(void); diff --git a/Zend/zend_execute_API.c b/Zend/zend_execute_API.c index 88b0a485750a..15a48c12666e 100644 --- a/Zend/zend_execute_API.c +++ b/Zend/zend_execute_API.c @@ -52,6 +52,7 @@ ZEND_API void (*zend_execute_ex)(zend_execute_data *execute_data); ZEND_API void (*zend_execute_internal)(zend_execute_data *execute_data, zval *return_value); ZEND_API zend_class_entry *(*zend_autoload)(zend_string *name, zend_string *lc_name); +ZEND_API bool (*zend_unserialize_class_allowed)(zend_string *class_name) = NULL; #ifdef ZEND_WIN32 ZEND_TLS HANDLE tq_timer = NULL; diff --git a/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c index a068f9f48ac6..4a8b02415880 100644 --- a/ext/reflection/php_reflection.c +++ b/ext/reflection/php_reflection.c @@ -1934,6 +1934,7 @@ ZEND_METHOD(ReflectionFunction, isAnonymous) } /* }}} */ + /* {{{ Returns whether this function has been disabled or not */ ZEND_METHOD(ReflectionFunction, isDisabled) { diff --git a/ext/standard/var.c b/ext/standard/var.c index f94c1cf09584..9656926074d7 100644 --- a/ext/standard/var.c +++ b/ext/standard/var.c @@ -25,6 +25,7 @@ #include "zend_smart_str.h" #include "basic_functions.h" #include "php_incomplete_class.h" +#include "zend_execute.h" #include "zend_enum.h" #include "zend_exceptions.h" #include "zend_types.h" @@ -1510,17 +1511,32 @@ PHPAPI void php_unserialize_with_options(zval *return_value, const char *buf, co } cleanup: - if (class_hash) { - zend_hash_destroy(class_hash); - FREE_HASHTABLE(class_hash); - } - - /* Reset to previous options in case this is a nested call */ - php_var_unserialize_set_allowed_classes(var_hash, prev_class_hash); + /* Reset to previous options in case this is a nested call. The + * allowed_classes filter is restored (and its table freed) only for a + * genuinely nested call that shares var_hash; for the call that owns + * var_hash it is kept installed across DESTROY, so that the delayed + * __unserialize() hooks run there still observe it (a Closure payload + * resolves its referenced class by name inside __unserialize()), then + * torn down together with var_hash below. */ php_var_unserialize_set_max_depth(var_hash, prev_max_depth); php_var_unserialize_set_cur_depth(var_hash, prev_cur_depth); + + bool owns_var_hash = BG(serialize_lock) || BG(unserialize).level == 1; + if (!owns_var_hash) { + php_var_unserialize_set_allowed_classes(var_hash, prev_class_hash); + if (class_hash) { + zend_hash_destroy(class_hash); + FREE_HASHTABLE(class_hash); + } + } + PHP_VAR_UNSERIALIZE_DESTROY(var_hash); + if (owns_var_hash && class_hash) { + zend_hash_destroy(class_hash); + FREE_HASHTABLE(class_hash); + } + /* Per calling convention we must not return a reference here, so unwrap. We're doing this at * the very end, because __wakeup() calls performed during UNSERIALIZE_DESTROY might affect * the value we unwrap here. This is compatible with behavior in PHP <=7.0. */ @@ -1585,8 +1601,34 @@ PHP_INI_BEGIN() STD_PHP_INI_ENTRY("unserialize_max_depth", "4096", PHP_INI_ALL, OnUpdateLong, unserialize_max_depth, php_basic_globals, basic_globals) PHP_INI_END() +/* Installed as zend_unserialize_class_allowed so the engine can apply the + * innermost active unserialize()'s allowed_classes filter to a class a magic + * __unserialize() resolves by name (e.g. Closure). Mirrors the object path's + * unserialize_allowed_class(): no active unserialize() or no filter allows + * everything; an empty filter allows nothing. */ +static bool php_unserialize_class_allowed(zend_string *class_name) +{ + if (!BG(unserialize).level) { + return true; + } + + HashTable *classes = php_var_unserialize_get_allowed_classes(BG(unserialize).data); + if (classes == NULL) { + return true; + } + if (!zend_hash_num_elements(classes)) { + return false; + } + + zend_string *lcname = zend_string_tolower(class_name); + bool allowed = zend_hash_exists(classes, lcname); + zend_string_release_ex(lcname, 0); + return allowed; +} + PHP_MINIT_FUNCTION(var) { REGISTER_INI_ENTRIES(); + zend_unserialize_class_allowed = php_unserialize_class_allowed; return SUCCESS; }