Commit 53e31d58834 for php.net

commit 53e31d58834ef04375dae7bdc549d73dae371c5a
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Sat Mar 7 20:19:33 2026 -0500

    Fix GH-21362: ReflectionMethod::invoke() allows different Closures (#21366)

    ReflectionMethod::invoke() (and invokeArgs()) for Closure::__invoke()
    incorrectly accepted any Closure object, not just the one the
    ReflectionMethod was created from. This happened because all Closures
    share a single zend_ce_closure class entry, so the instanceof_function()
    check always passed.

    Fix: store the original Closure object in intern->obj during
    ReflectionMethod construction, then compare object identity in
    reflection_method_invoke() to reject different Closure instances.

    Closes GH-21362

diff --git a/NEWS b/NEWS
index 4e1196fb094..482dfcb0913 100644
--- a/NEWS
+++ b/NEWS
@@ -90,6 +90,8 @@ PHP                                                                        NEWS
   . Added ReflectionConstant::inNamespace(). (Khaled Alam)
   . Added ReflectionProperty::isReadable() and ReflectionProperty::isWritable().
     (ilutov)
+  . Fixed bug GH-21362 (ReflectionMethod::invoke/invokeArgs() did not verify
+    Closure instance identity for Closure::__invoke()). (Ilia Alshanetsky)

 - Session:
   . Fixed bug 71162 (updateTimestamp never called when session data is empty).
diff --git a/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c
index 587bca11522..2692e192806 100644
--- a/ext/reflection/php_reflection.c
+++ b/ext/reflection/php_reflection.c
@@ -3306,7 +3306,9 @@ static void instantiate_reflection_method(INTERNAL_FUNCTION_PARAMETERS, bool is_
 		&& memcmp(lcname, ZEND_INVOKE_FUNC_NAME, sizeof(ZEND_INVOKE_FUNC_NAME)-1) == 0
 		&& (mptr = zend_get_closure_invoke_method(orig_obj)) != NULL)
 	{
-		/* do nothing, mptr already set */
+		/* Store the original closure object so we can validate it in invoke/invokeArgs.
+		 * Each closure has a unique __invoke signature, so we must reject different closures. */
+		ZVAL_OBJ_COPY(&intern->obj, orig_obj);
 	} else if ((mptr = zend_hash_str_find_ptr(&ce->function_table, lcname, method_name_len)) == NULL) {
 		efree(lcname);
 		zend_throw_exception_ex(reflection_exception_ptr, 0,
@@ -3441,6 +3443,23 @@ static void reflection_method_invoke(INTERNAL_FUNCTION_PARAMETERS, int variadic)
 			_DO_THROW("Given object is not an instance of the class this method was declared in");
 			RETURN_THROWS();
 		}
+
+		/* 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. */
+		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) {
+				if (!variadic) {
+					efree(params);
+				}
+				_DO_THROW("Given Closure is not the same as the reflected Closure");
+				RETURN_THROWS();
+			}
+		}
 	}
 	/* Copy the zend_function when calling via handler (e.g. Closure::__invoke()) */
 	callback = _copy_function(mptr);
diff --git a/ext/reflection/tests/gh21362.phpt b/ext/reflection/tests/gh21362.phpt
new file mode 100644
index 00000000000..ce7840b494e
--- /dev/null
+++ b/ext/reflection/tests/gh21362.phpt
@@ -0,0 +1,53 @@
+--TEST--
+GH-21362 (ReflectionMethod::invokeArgs() for Closure::__invoke() accepts objects from different Closures)
+--FILE--
+<?php
+
+$c1 = function ($foo, $bar) {
+    echo "c1: foo={$foo}, bar={$bar}\n";
+};
+
+$c2 = function ($bar, $foo) {
+    echo "c2: foo={$foo}, bar={$bar}\n";
+};
+
+$m = new ReflectionMethod($c1, '__invoke');
+
+// invokeArgs with the correct Closure should work
+$m->invokeArgs($c1, ['foo' => 'FOO', 'bar' => 'BAR']);
+
+// invokeArgs with a different Closure should throw
+try {
+    $m->invokeArgs($c2, ['foo' => 'FOO', 'bar' => 'BAR']);
+    echo "No exception thrown\n";
+} catch (ReflectionException $e) {
+    echo $e->getMessage() . "\n";
+}
+
+// invoke with a different Closure should also throw
+try {
+    $m->invoke($c2, 'FOO', 'BAR');
+    echo "No exception thrown\n";
+} catch (ReflectionException $e) {
+    echo $e->getMessage() . "\n";
+}
+
+// Closures from the same source (e.g. loop) share the same op_array
+// and should be allowed to invoke each other's ReflectionMethod
+$closures = [];
+for ($i = 0; $i < 3; $i++) {
+    $closures[] = function () use ($i) { return $i; };
+}
+
+$m2 = new ReflectionMethod($closures[0], '__invoke');
+foreach ($closures as $closure) {
+    var_dump($m2->invoke($closure));
+}
+?>
+--EXPECT--
+c1: foo=FOO, bar=BAR
+Given Closure is not the same as the reflected Closure
+Given Closure is not the same as the reflected Closure
+int(0)
+int(1)
+int(2)