Skip to content

Fix GH-21362: ReflectionMethod::invoke/invokeArgs() rejects different Closure instances#21366

Merged
DanielEScherzer merged 4 commits intophp:masterfrom
iliaal:fix/gh-21362-reflection-closure-invoke
Mar 8, 2026
Merged

Fix GH-21362: ReflectionMethod::invoke/invokeArgs() rejects different Closure instances#21366
DanielEScherzer merged 4 commits intophp:masterfrom
iliaal:fix/gh-21362-reflection-closure-invoke

Conversation

@iliaal
Copy link
Contributor

@iliaal iliaal commented Mar 6, 2026

Summary

Fixes #21362

ReflectionMethod::invokeArgs() (and invoke()) for Closure::__invoke() incorrectly accepted any Closure object, not just the one the ReflectionMethod was created from.

Root cause

All Closure objects share a single zend_ce_closure class entry. The instanceof_function() check in reflection_method_invoke() compares class entries, so it always sees zend_ce_closure == zend_ce_closure and passes -- it cannot distinguish between different Closure instances.

Fix

Two changes to ext/reflection/php_reflection.c:

  1. instantiate_reflection_method(): Store the original Closure object in intern->obj via ZVAL_OBJ_COPY() when reflecting Closure::__invoke(). Previously this was a no-op (/* do nothing, mptr already set */).

  2. reflection_method_invoke(): After the existing instanceof_function check passes for Closures, add an identity check (Z_OBJ_P(object) != Z_OBJ(intern->obj)) to reject different Closure instances.

Test

ext/reflection/tests/gh21362.phpt covers:

  • invokeArgs() with the correct Closure (should work)
  • invokeArgs() with a different Closure (should throw ReflectionException)
  • invoke() with a different Closure (should throw ReflectionException)

@TimWolla
Copy link
Member

TimWolla commented Mar 6, 2026

I did not test, but based on reading the diff I think this is not quite right, yet. Instead of comparing the objects, we should compare the inner op_array, because for:

$closures = [];

for ($i = 0; $i < 3; $i++) {
    $closures[] = function () use ($i) { return $i; };
}

$m = new ReflectionMethod(array_first($closures), '__invoke');
foreach ($closures as $closure) {
    var_dump($m->invoke($closure));
}

I expect this to successfully dump 0, 1, 2. All the closure instances are "of the same class". If that already works: Great, please just add that test case.

…ent Closure instances

ReflectionMethod::invokeArgs() (and invoke()) 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 phpGH-21362
@iliaal iliaal force-pushed the fix/gh-21362-reflection-closure-invoke branch from 1581e11 to 277d301 Compare March 6, 2026 22:36
@iliaal
Copy link
Contributor Author

iliaal commented Mar 6, 2026

Good catch -- the object identity check was too strict. Updated the comparison to use op_array.opcodes instead, so closures from the same source (e.g. created in a loop with different use bindings) are accepted, while closures from different source locations are still rejected.

Added the loop test case you suggested. All 515 reflection tests pass.

P.S. Thanks for the feedback, my php internals are a bit rusty 😆

…nvoke()

Use a Closure-specific message instead of the generic "not an instance
of the class" phrasing, since all Closures share the same class entry.
@TimWolla TimWolla requested a review from DanielEScherzer March 7, 2026 20:39
Change "not compatible with" to "not the same as" since closures
with different source locations aren't about compatibility.
Copy link
Member

@DanielEScherzer DanielEScherzer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks good now. Please add to NEWS/UPGRADING and I'll merge

@iliaal
Copy link
Contributor Author

iliaal commented Mar 8, 2026

Added NEWS entry, don't think UPGRADED needs updating

@iliaal iliaal requested a review from DanielEScherzer March 8, 2026 00:32
@DanielEScherzer DanielEScherzer merged commit 53e31d5 into php:master Mar 8, 2026
19 checks passed
@DanielEScherzer
Copy link
Member

Squashed and merged, thanks!

&& 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) {
Copy link
Member

@ndossche ndossche Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is wrong for internal closures because they don't have an op_array.
Intuitively, the code also seems a bit convoluted, it feels wrong to have to store the object.

Anyway, here is something that shouldn't be allowed most likely, and even if it were allowed then the access to op_array is still wrong (only allowed for userland):

<?php

$closure = var_dump(...);
$closure2 = print_r(...);

$rm = new ReflectionMethod($closure, '__invoke');
$rm->invoke($closure2, 1);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@iliaal thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a valid point, I made a follow-up PR #21389 to address this issue (along with corresponding test).

iliaal added a commit to iliaal/php-src that referenced this pull request Mar 8, 2026
The closure identity check added in phpGH-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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ReflectionMethod::invokeArgs() for Closure::__invoke() accepts objects from different Closures

4 participants