Skip to content

Fix ReflectionMethod::invoke() for first class callables#21389

Open
iliaal wants to merge 3 commits intophp:masterfrom
iliaal:fix/gh-21362-internal-closure-invoke
Open

Fix ReflectionMethod::invoke() for first class callables#21389
iliaal wants to merge 3 commits intophp:masterfrom
iliaal:fix/gh-21362-internal-closure-invoke

Conversation

@iliaal
Copy link
Contributor

@iliaal iliaal commented Mar 8, 2026

Summary

Follow-up to GH-21366. The closure identity check accessed op_array.opcodes unconditionally, but internal closures created via first-class callable syntax (e.g. var_dump(...)) use internal_function, not op_array. This is undefined behavior and will crash under ASAN/debug builds.

  • Check func->type == ZEND_USER_FUNCTION before accessing op_array.opcodes
  • For internal closures, compare the zend_function pointer directly
  • Added test coverage for internal closure validation

Reported by @ndossche in #21366 (comment)

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.
@ndossche
Copy link
Member

ndossche commented Mar 8, 2026

On first sight this seems right. One other thing that I notice is that op_array.opcodes isn't necessarily a unique address unless the ZEND_ACC_IMMUTABLE flag is set. However, the current code is likely fine because you hold onto the object so that op_array.opcodes will not alias to another function.
I'll have another look tomorrow.

@ndossche ndossche requested a review from TimWolla March 8, 2026 22:20
@iliaal
Copy link
Contributor Author

iliaal commented Mar 8, 2026

I think it should be fine, but another pair of eyes never hurts :-) Thanks!

@iluuu1994
Copy link
Member

However, the current code is likely fine because you hold onto the object so that op_array.opcodes will not alias to another function.

I don't think that's possible for the reason you've mentioned. We're keeping the op_array alive through zend_op_array->refcount. zend_closure_free_storage() then calls destroy_op_array().

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.
@iliaal iliaal requested a review from TimWolla March 9, 2026 18:27
@TimWolla TimWolla changed the title Fix ReflectionMethod::invoke() with internal closures Fix ReflectionMethod::invoke() for first class callables Mar 9, 2026
@TimWolla
Copy link
Member

TimWolla commented Mar 9, 2026

@iliaal I've taken the liberty to push into your branch, since this was easier than explaining the change.

AFAIK the only way for a Closure to be an internal function is by using first class callables (“fake closure”) and this is the check we should make, since for userland fake Closures we should also compare scope and function name. The “proper closure” check can then remain the OPcode structure comparison.

@TimWolla TimWolla requested a review from ndossche March 9, 2026 19:50
@ndossche
Copy link
Member

ndossche commented Mar 9, 2026

This still looks wrong:

<?php

class Broken {
    function foo(){}
}

$func = (new Broken)->foo(...);

$rm = new ReflectionMethod(new Broken, 'foo');

$rm->invoke($func);

@iliaal
Copy link
Contributor Author

iliaal commented Mar 9, 2026

@ndossche After Tim's change it looks to ok as far as I can tell, the code throws "ReflectionException: Given object is not an instance of the class this method was declared in".

As far as I can tell that is correct behavior. intern->obj is only set when the ReflectionMethod is constructed for Closure::__invoke (the ce == zend_ce_closure guard in instantiate_reflection_method). For new ReflectionMethod(new Broken, 'foo'), intern->obj remains UNDEF, so the closure validation block is skipped entirely. The instanceof_function check then correctly rejects the Closure as not being an instance of Broken.

@TimWolla
Copy link
Member

TimWolla commented Mar 9, 2026

The instanceof_function check then correctly rejects the Closure as not being an instance of Broken.

Or to put it another way: That has always been rejected, as per https://3v4l.org/4UiEW. The original issue was the check not rejecting enough.

@iliaal
Copy link
Contributor Author

iliaal commented Mar 9, 2026

The instanceof_function check then correctly rejects the Closure as not being an instance of Broken.

Or to put it another way: That has always been rejected, as per https://3v4l.org/4UiEW. The original issue was the check not rejecting enough.

Thanks for the help on the patch BTW :)

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.

4 participants