From 1e4375d0de5af2fb6b646cb88f1ede9733839aac Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 24 Jul 2026 13:53:48 -0400 Subject: [PATCH] Fix GH-22878: Use-after-free of callable via autoloader Validating an array or string callable runs user code before its borrowed method name and object are used: a string class name can trigger an autoloader, and a compound "Class::method" name emits an E_DEPRECATED that reaches a user error handler. Either can free or mutate the callable, leaving the method string and $this dangling. Copy the method string before the reentrant lookup, and hold the callable array across INIT_USER_CALL's validation and frame build so the object survives to the call. This also covers call_user_func_array(), $cb(), and referenced or reference-wrapped array members. Fixes GH-22878 --- NEWS | 3 +++ Zend/tests/gh22878.phpt | 41 +++++++++++++++++++++++++++++++++ Zend/zend_API.c | 17 +++++++++++++- Zend/zend_execute.c | 10 ++++++--- Zend/zend_vm_def.h | 16 +++++++++++++ Zend/zend_vm_execute.h | 50 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 Zend/tests/gh22878.phpt diff --git a/NEWS b/NEWS index 0aa44c4d5006..1601c05c8acb 100644 --- a/NEWS +++ b/NEWS @@ -2,6 +2,9 @@ PHP NEWS ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| ?? ??? ????, PHP 8.4.24 +- Core: + . Fixed bug GH-22878 (Use-after-free of callable via autoloader). (iliaal) + - Date: . Fixed leak on double DatePeriod::__construct() call. (ilutov) diff --git a/Zend/tests/gh22878.phpt b/Zend/tests/gh22878.phpt new file mode 100644 index 000000000000..c69127f392ae --- /dev/null +++ b/Zend/tests/gh22878.phpt @@ -0,0 +1,41 @@ +--TEST-- +GH-22878 (Use-after-free of callable via autoloader) +--FILE-- + +--EXPECT-- +256 +256 +256 +256 +13 +done diff --git a/Zend/zend_API.c b/Zend/zend_API.c index 3c9891a00e92..8f63a4c8e393 100644 --- a/Zend/zend_API.c +++ b/Zend/zend_API.c @@ -4179,6 +4179,9 @@ ZEND_API bool zend_is_callable_at_frame( bool ret; zend_fcall_info_cache fcc_local; bool strict_class = 0; + zval callable_copy; + + ZVAL_UNDEF(&callable_copy); if (fcc == NULL) { fcc = &fcc_local; @@ -4206,11 +4209,17 @@ ZEND_API bool zend_is_callable_at_frame( return 1; } + ZVAL_COPY(&callable_copy, callable); + callable = &callable_copy; + check_func: ret = zend_is_callable_check_func(callable, frame, fcc, strict_class, error, check_flags & IS_CALLABLE_SUPPRESS_DEPRECATIONS); if (fcc == &fcc_local) { zend_release_fcall_info_cache(fcc); } + if (!Z_ISUNDEF(callable_copy)) { + zval_ptr_dtor(&callable_copy); + } return ret; case IS_ARRAY: @@ -4244,7 +4253,11 @@ ZEND_API bool zend_is_callable_at_frame( return 1; } + ZVAL_COPY(&callable_copy, method); + callable = &callable_copy; + if (!zend_is_callable_check_class(Z_STR_P(obj), get_scope(frame), frame, fcc, &strict_class, error, check_flags & IS_CALLABLE_SUPPRESS_DEPRECATIONS)) { + zval_ptr_dtor(&callable_copy); return 0; } } else { @@ -4256,9 +4269,11 @@ ZEND_API bool zend_is_callable_at_frame( fcc->called_scope = fcc->calling_scope; return 1; } + + ZVAL_COPY(&callable_copy, method); + callable = &callable_copy; } - callable = method; goto check_func; } return 0; diff --git a/Zend/zend_execute.c b/Zend/zend_execute.c index ea5f55cf6ef7..f20e1933a343 100644 --- a/Zend/zend_execute.c +++ b/Zend/zend_execute.c @@ -5008,23 +5008,27 @@ static zend_never_inline zend_execute_data *zend_init_dynamic_call_array(zend_ar } if (Z_TYPE_P(obj) == IS_STRING) { + zend_string *method_name = zend_string_copy(Z_STR_P(method)); zend_class_entry *called_scope = zend_fetch_class_by_name(Z_STR_P(obj), NULL, ZEND_FETCH_CLASS_DEFAULT | ZEND_FETCH_CLASS_EXCEPTION); if (UNEXPECTED(called_scope == NULL)) { + zend_string_release(method_name); return NULL; } if (called_scope->get_static_method) { - fbc = called_scope->get_static_method(called_scope, Z_STR_P(method)); + fbc = called_scope->get_static_method(called_scope, method_name); } else { - fbc = zend_std_get_static_method(called_scope, Z_STR_P(method), NULL); + fbc = zend_std_get_static_method(called_scope, method_name, NULL); } if (UNEXPECTED(fbc == NULL)) { if (EXPECTED(!EG(exception))) { - zend_undefined_method(called_scope, Z_STR_P(method)); + zend_undefined_method(called_scope, method_name); } + zend_string_release(method_name); return NULL; } + zend_string_release(method_name); if (!(fbc->common.fn_flags & ZEND_ACC_STATIC)) { zend_non_static_method_call(fbc); if (fbc->common.fn_flags & ZEND_ACC_CALL_VIA_TRAMPOLINE) { diff --git a/Zend/zend_vm_def.h b/Zend/zend_vm_def.h index c46b17101546..90ff579809e1 100644 --- a/Zend/zend_vm_def.h +++ b/Zend/zend_vm_def.h @@ -3906,9 +3906,16 @@ ZEND_VM_HANDLER(118, ZEND_INIT_USER_CALL, CONST, CONST|TMPVAR|CV, NUM) void *object_or_called_scope; zend_execute_data *call; uint32_t call_info = ZEND_CALL_NESTED_FUNCTION | ZEND_CALL_DYNAMIC; + zval held_callable; SAVE_OPLINE(); function_name = GET_OP2_ZVAL_PTR(BP_VAR_R); + ZVAL_UNDEF(&held_callable); + ZVAL_DEREF(function_name); + if (UNEXPECTED(Z_TYPE_P(function_name) == IS_ARRAY)) { + ZVAL_COPY(&held_callable, function_name); + function_name = &held_callable; + } if (zend_is_callable_ex(function_name, NULL, 0, NULL, &fcc, &error)) { ZEND_ASSERT(!error); @@ -3917,6 +3924,9 @@ ZEND_VM_HANDLER(118, ZEND_INIT_USER_CALL, CONST, CONST|TMPVAR|CV, NUM) * For the CONST and CV case we reuse the same exception block below * to make sure we don't increase VM size too much. */ if (!(OP2_TYPE & (IS_TMP_VAR|IS_VAR)) && UNEXPECTED(EG(exception))) { + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } FREE_OP2(); HANDLE_EXCEPTION(); } @@ -3940,6 +3950,9 @@ ZEND_VM_HANDLER(118, ZEND_INIT_USER_CALL, CONST, CONST|TMPVAR|CV, NUM) call_info |= ZEND_CALL_RELEASE_THIS | ZEND_CALL_HAS_THIS; } + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } FREE_OP2(); if ((OP2_TYPE & (IS_TMP_VAR|IS_VAR)) && UNEXPECTED(EG(exception))) { if (call_info & ZEND_CALL_CLOSURE) { @@ -3954,6 +3967,9 @@ ZEND_VM_HANDLER(118, ZEND_INIT_USER_CALL, CONST, CONST|TMPVAR|CV, NUM) init_func_run_time_cache(&func->op_array); } } else { + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } zend_type_error("%s(): Argument #1 ($callback) must be a valid callback, %s", Z_STRVAL_P(RT_CONSTANT(opline, opline->op1)), error); efree(error); FREE_OP2(); diff --git a/Zend/zend_vm_execute.h b/Zend/zend_vm_execute.h index f4f35ce2a8a9..71e675298926 100644 --- a/Zend/zend_vm_execute.h +++ b/Zend/zend_vm_execute.h @@ -7408,9 +7408,16 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_CONS void *object_or_called_scope; zend_execute_data *call; uint32_t call_info = ZEND_CALL_NESTED_FUNCTION | ZEND_CALL_DYNAMIC; + zval held_callable; SAVE_OPLINE(); function_name = RT_CONSTANT(opline, opline->op2); + ZVAL_UNDEF(&held_callable); + ZVAL_DEREF(function_name); + if (UNEXPECTED(Z_TYPE_P(function_name) == IS_ARRAY)) { + ZVAL_COPY(&held_callable, function_name); + function_name = &held_callable; + } if (zend_is_callable_ex(function_name, NULL, 0, NULL, &fcc, &error)) { ZEND_ASSERT(!error); @@ -7419,6 +7426,9 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_CONS * For the CONST and CV case we reuse the same exception block below * to make sure we don't increase VM size too much. */ if (!(IS_CONST & (IS_TMP_VAR|IS_VAR)) && UNEXPECTED(EG(exception))) { + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } HANDLE_EXCEPTION(); } @@ -7442,6 +7452,10 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_CONS call_info |= ZEND_CALL_RELEASE_THIS | ZEND_CALL_HAS_THIS; } + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } + if ((IS_CONST & (IS_TMP_VAR|IS_VAR)) && UNEXPECTED(EG(exception))) { if (call_info & ZEND_CALL_CLOSURE) { zend_object_release(ZEND_CLOSURE_OBJECT(func)); @@ -7455,6 +7469,9 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_CONS init_func_run_time_cache(&func->op_array); } } else { + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } zend_type_error("%s(): Argument #1 ($callback) must be a valid callback, %s", Z_STRVAL_P(RT_CONSTANT(opline, opline->op1)), error); efree(error); @@ -9997,9 +10014,16 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_TMPV void *object_or_called_scope; zend_execute_data *call; uint32_t call_info = ZEND_CALL_NESTED_FUNCTION | ZEND_CALL_DYNAMIC; + zval held_callable; SAVE_OPLINE(); function_name = _get_zval_ptr_var(opline->op2.var EXECUTE_DATA_CC); + ZVAL_UNDEF(&held_callable); + ZVAL_DEREF(function_name); + if (UNEXPECTED(Z_TYPE_P(function_name) == IS_ARRAY)) { + ZVAL_COPY(&held_callable, function_name); + function_name = &held_callable; + } if (zend_is_callable_ex(function_name, NULL, 0, NULL, &fcc, &error)) { ZEND_ASSERT(!error); @@ -10008,6 +10032,9 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_TMPV * For the CONST and CV case we reuse the same exception block below * to make sure we don't increase VM size too much. */ if (!((IS_TMP_VAR|IS_VAR) & (IS_TMP_VAR|IS_VAR)) && UNEXPECTED(EG(exception))) { + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } zval_ptr_dtor_nogc(EX_VAR(opline->op2.var)); HANDLE_EXCEPTION(); } @@ -10031,6 +10058,9 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_TMPV call_info |= ZEND_CALL_RELEASE_THIS | ZEND_CALL_HAS_THIS; } + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } zval_ptr_dtor_nogc(EX_VAR(opline->op2.var)); if (((IS_TMP_VAR|IS_VAR) & (IS_TMP_VAR|IS_VAR)) && UNEXPECTED(EG(exception))) { if (call_info & ZEND_CALL_CLOSURE) { @@ -10045,6 +10075,9 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_TMPV init_func_run_time_cache(&func->op_array); } } else { + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } zend_type_error("%s(): Argument #1 ($callback) must be a valid callback, %s", Z_STRVAL_P(RT_CONSTANT(opline, opline->op1)), error); efree(error); zval_ptr_dtor_nogc(EX_VAR(opline->op2.var)); @@ -12496,9 +12529,16 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_CV_H void *object_or_called_scope; zend_execute_data *call; uint32_t call_info = ZEND_CALL_NESTED_FUNCTION | ZEND_CALL_DYNAMIC; + zval held_callable; SAVE_OPLINE(); function_name = _get_zval_ptr_cv_BP_VAR_R(opline->op2.var EXECUTE_DATA_CC); + ZVAL_UNDEF(&held_callable); + ZVAL_DEREF(function_name); + if (UNEXPECTED(Z_TYPE_P(function_name) == IS_ARRAY)) { + ZVAL_COPY(&held_callable, function_name); + function_name = &held_callable; + } if (zend_is_callable_ex(function_name, NULL, 0, NULL, &fcc, &error)) { ZEND_ASSERT(!error); @@ -12507,6 +12547,9 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_CV_H * For the CONST and CV case we reuse the same exception block below * to make sure we don't increase VM size too much. */ if (!(IS_CV & (IS_TMP_VAR|IS_VAR)) && UNEXPECTED(EG(exception))) { + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } HANDLE_EXCEPTION(); } @@ -12530,6 +12573,10 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_CV_H call_info |= ZEND_CALL_RELEASE_THIS | ZEND_CALL_HAS_THIS; } + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } + if ((IS_CV & (IS_TMP_VAR|IS_VAR)) && UNEXPECTED(EG(exception))) { if (call_info & ZEND_CALL_CLOSURE) { zend_object_release(ZEND_CLOSURE_OBJECT(func)); @@ -12543,6 +12590,9 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_USER_CALL_SPEC_CONST_CV_H init_func_run_time_cache(&func->op_array); } } else { + if (UNEXPECTED(!Z_ISUNDEF(held_callable))) { + zval_ptr_dtor(&held_callable); + } zend_type_error("%s(): Argument #1 ($callback) must be a valid callback, %s", Z_STRVAL_P(RT_CONSTANT(opline, opline->op1)), error); efree(error);