Commit 4561e92fa1e for php.net
commit 4561e92fa1e7b5515aad9d2f127e52d56e159928
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date: Sat Mar 14 20:00:04 2026 -0400
Fix ReflectionMethod::invoke() for first class callables (#21389)
* Fix ReflectionMethod::invoke() crash with internal closures
The closure identity check added in GH-21366 accessed op_array.opcodes
unconditionally, but internal closures (e.g. var_dump(...)) use
internal_function, not op_array. This caused undefined behavior when
comparing closures created via first-class callable syntax on internal
functions.
Check the function type first: compare op_array.opcodes for user
closures, compare the function pointer directly for internal closures.
* Fix internal closure comparison and expand test coverage
The previous comparison (orig_func == given_func) could never match for
internal closures since zend_get_closure_method_def() returns a pointer
to each closure's embedded copy. Compare function_name and scope instead.
Also handle the mixed user/internal type case explicitly.
Add tests for: userland first-class callables, cloned internal closures,
and cross-type (user vs internal) closure rejection.
* php_reflection: Simplify the Closure::__invoke() check
---------
Co-authored-by: Tim Düsterhus <tim@bastelstu.be>
diff --git a/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c
index 40a759043f0..ee49d2d90f5 100644
--- a/ext/reflection/php_reflection.c
+++ b/ext/reflection/php_reflection.c
@@ -3441,14 +3441,27 @@ static void reflection_method_invoke(INTERNAL_FUNCTION_PARAMETERS, int variadic)
}
/* For Closure::__invoke(), closures from different source locations have
- * different signatures, so we must reject those. However, closures created
- * from the same source (e.g. in a loop) share the same op_array and should
- * be allowed. Compare the underlying function pointer via op_array. */
+ * different signatures, so we must reject those. */
if (obj_ce == zend_ce_closure && !Z_ISUNDEF(intern->obj)
&& Z_OBJ_P(object) != Z_OBJ(intern->obj)) {
const zend_function *orig_func = zend_get_closure_method_def(Z_OBJ(intern->obj));
const zend_function *given_func = zend_get_closure_method_def(Z_OBJ_P(object));
- if (orig_func->op_array.opcodes != given_func->op_array.opcodes) {
+
+ bool same_closure = false;
+ /* Check if they are either both fake closures or they both are not. */
+ if ((orig_func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE) == (given_func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) {
+ if (orig_func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE) {
+ /* For fake closures, scope and name must match. */
+ same_closure = orig_func->common.scope == given_func->common.scope
+ && orig_func->common.function_name == given_func->common.function_name;
+ } else {
+ /* Otherwise the opcode structure must be identical. */
+ ZEND_ASSERT(orig_func->type == ZEND_USER_FUNCTION);
+ same_closure = orig_func->op_array.opcodes == given_func->op_array.opcodes;
+ }
+ }
+
+ if (!same_closure) {
if (!variadic) {
efree(params);
}
diff --git a/ext/reflection/tests/gh21362.phpt b/ext/reflection/tests/gh21362.phpt
index ce7840b494e..1f5619ce779 100644
--- a/ext/reflection/tests/gh21362.phpt
+++ b/ext/reflection/tests/gh21362.phpt
@@ -43,6 +43,60 @@
foreach ($closures as $closure) {
var_dump($m2->invoke($closure));
}
+
+// First-class callable of a userland function
+function my_func($x) { return "my_func: $x"; }
+function other_func($x) { return "other_func: $x"; }
+
+$mf = my_func(...);
+$mf2 = my_func(...);
+$of = other_func(...);
+
+$m3 = new ReflectionMethod($mf, '__invoke');
+var_dump($m3->invoke($mf, 'test'));
+var_dump($m3->invoke($mf2, 'test'));
+
+try {
+ $m3->invoke($of, 'test');
+ echo "No exception thrown\n";
+} catch (ReflectionException $e) {
+ echo $e->getMessage() . "\n";
+}
+
+// Internal closures (first-class callable syntax) should also be validated
+$vd = var_dump(...);
+$pr = print_r(...);
+
+$m4 = new ReflectionMethod($vd, '__invoke');
+$m4->invoke($vd, 'internal closure OK');
+
+// Cloned internal closure is a different object but same function - should work
+$vd2 = clone $vd;
+$m4->invoke($vd2, 'cloned internal closure OK');
+
+// Different internal closure should throw
+try {
+ $m4->invoke($pr, 'should not print');
+ echo "No exception thrown\n";
+} catch (ReflectionException $e) {
+ echo $e->getMessage() . "\n";
+}
+
+// Cross-type: userland Closure to internal Closure's invoke should throw
+try {
+ $m4->invoke($c1, 'should not print');
+ echo "No exception thrown\n";
+} catch (ReflectionException $e) {
+ echo $e->getMessage() . "\n";
+}
+
+// Cross-type: internal Closure to userland Closure's invoke should throw
+try {
+ $m->invoke($vd, 'should not print');
+ echo "No exception thrown\n";
+} catch (ReflectionException $e) {
+ echo $e->getMessage() . "\n";
+}
?>
--EXPECT--
c1: foo=FOO, bar=BAR
@@ -51,3 +105,11 @@
int(0)
int(1)
int(2)
+string(13) "my_func: test"
+string(13) "my_func: test"
+Given Closure is not the same as the reflected Closure
+string(19) "internal closure OK"
+string(26) "cloned internal closure OK"
+Given Closure is not the same as the reflected Closure
+Given Closure is not the same as the reflected Closure
+Given Closure is not the same as the reflected Closure