Commit 956c177e725 for php
commit 956c177e7257d992721e6ffbfc8feae18c13d38c
Author: Jorg Adam Sowa <jorg.sowa@gmail.com>
Date: Wed Nov 11 16:24:39 2026 +0100
Fix stale PFA cache entries after the declaring site is freed (#25374)
The request PFA cache is keyed on the address of the declaring site. When
eval()'d code or a class constant AST was freed, a new PFA could reuse
that address and get the cached function for a different call.
diff --git a/NEWS b/NEWS
index 101e48c11fa..f702d1bb3d0 100644
--- a/NEWS
+++ b/NEWS
@@ -26,6 +26,9 @@ PHP NEWS
. Fixed use-after-free when the error handler releases a string while an
"Illegal string offset" warning is raised for its offset in a read, ?? or
nested isset()/empty(). (Jorg Sowa)
+ . Fixed partial function applications in eval'd or included code, or in
+ class constants, reusing a stale cached function with wrong bound
+ arguments. (Jorg Sowa)
. Fixed anonymous class names being truncated at the NUL byte in error
messages, the error log, and HTML and XML-RPC error output. (Jorg Sowa)
. Fixed AST printing dropping readonly on promoted constructor parameters.
@@ -34,6 +37,9 @@ PHP NEWS
- Curl:
. Fixed curl_reset() retaining CURLOPT_PRIVATE after reset. (lacatoire)
+- Date:
+ . Update timelib to 2026.03 (Derick)
+
- DBA:
. Fixed dba_close() not closing a database opened with dba_popen().
(Ilia Alshanetsky)
@@ -127,19 +133,6 @@ PHP NEWS
. Fix potential ABI incompatibility when adding fields in patches by adding
padding to globals. (ilutov)
-- Date:
- . Update timelib to 2026.03 (Derick)
-
-- Fileinfo:
- . Fixed bug GH-25051 (TrueType fonts detected as application/octet-stream
- since libmagic 5.48). (Weilin Du)
- . Fixed incorrect MIME type detection for HWPX files. (Weilin Du)
-
-- Streams:
- . Fixed use-after-free of the previous request's default stream context when
- a stream error is raised during request startup, such as when a POST body
- cannot be buffered to a temporary file. (Go Kudo)
-
05 Nov 2026, PHP 8.6.0RC5
- Core:
diff --git a/Zend/tests/partial_application/cache_freed_declaring_site_001.phpt b/Zend/tests/partial_application/cache_freed_declaring_site_001.phpt
new file mode 100644
index 00000000000..56b4edfc932
--- /dev/null
+++ b/Zend/tests/partial_application/cache_freed_declaring_site_001.phpt
@@ -0,0 +1,37 @@
+--TEST--
+PFA in class constant: request cache must not outlive the freed declaring AST
+--FILE--
+<?php
+function f($a = 'A', $b = 'B', $c = 'C', $d = 'D', $e = 'E', $f = 'F', $g = 'G') { return "$a|$b|$c|$d|$e|$f|$g"; }
+
+$n = 0;
+for ($round = 0; $round < 2; $round++) {
+ for ($argc = 1; $argc <= 7; $argc++) {
+ for ($mask = 1; $mask < (1 << $argc); $mask++) {
+ $args = [];
+ $expected = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
+ $placeholders = 0;
+ for ($i = 0; $i < $argc; $i++) {
+ if ($mask & (1 << $i)) {
+ $args[] = '?';
+ $expected[$i] = ++$placeholders;
+ } else {
+ $args[] = "'v$i'";
+ $expected[$i] = "v$i";
+ }
+ }
+ $shape = 'f(' . implode(', ', $args) . ')';
+ $n++;
+ eval("class C$n { const X = $shape; }");
+ $pfa = constant("C$n::X");
+ $result = $pfa(...range(1, $placeholders));
+ if ($result !== implode('|', $expected)) {
+ echo "$shape: got $result\n";
+ }
+ }
+ }
+}
+echo "Done\n";
+?>
+--EXPECT--
+Done
diff --git a/Zend/tests/partial_application/cache_freed_declaring_site_002.phpt b/Zend/tests/partial_application/cache_freed_declaring_site_002.phpt
new file mode 100644
index 00000000000..56959d460c3
--- /dev/null
+++ b/Zend/tests/partial_application/cache_freed_declaring_site_002.phpt
@@ -0,0 +1,36 @@
+--TEST--
+PFA in eval(): request cache must not outlive the freed declaring op_array
+--FILE--
+<?php
+function f($a = 'A', $b = 'B', $c = 'C', $d = 'D', $e = 'E', $f = 'F', $g = 'G') { return "$a|$b|$c|$d|$e|$f|$g"; }
+
+$n = 0;
+for ($round = 0; $round < 2; $round++) {
+ for ($argc = 1; $argc <= 7; $argc++) {
+ for ($mask = 1; $mask < (1 << $argc); $mask++) {
+ $args = [];
+ $expected = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
+ $placeholders = 0;
+ for ($i = 0; $i < $argc; $i++) {
+ if ($mask & (1 << $i)) {
+ $args[] = '?';
+ $expected[$i] = ++$placeholders;
+ } else {
+ $args[] = "'v$i'";
+ $expected[$i] = "v$i";
+ }
+ }
+ $shape = 'f(' . implode(', ', $args) . ')';
+ $n++;
+ $pfa = eval("return $shape;");
+ $result = $pfa(...range(1, $placeholders));
+ if ($result !== implode('|', $expected)) {
+ echo "$shape: got $result\n";
+ }
+ }
+ }
+}
+echo "Done\n";
+?>
+--EXPECT--
+Done
diff --git a/Zend/tests/partial_application/cache_freed_declaring_site_003.phpt b/Zend/tests/partial_application/cache_freed_declaring_site_003.phpt
new file mode 100644
index 00000000000..aa85ad6c948
--- /dev/null
+++ b/Zend/tests/partial_application/cache_freed_declaring_site_003.phpt
@@ -0,0 +1,37 @@
+--TEST--
+PFA in constant declared in eval(): request cache must not outlive the freed declaring AST
+--FILE--
+<?php
+function f($a = 'A', $b = 'B', $c = 'C', $d = 'D', $e = 'E', $f = 'F', $g = 'G') { return "$a|$b|$c|$d|$e|$f|$g"; }
+
+$n = 0;
+for ($round = 0; $round < 2; $round++) {
+ for ($argc = 1; $argc <= 7; $argc++) {
+ for ($mask = 1; $mask < (1 << $argc); $mask++) {
+ $args = [];
+ $expected = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
+ $placeholders = 0;
+ for ($i = 0; $i < $argc; $i++) {
+ if ($mask & (1 << $i)) {
+ $args[] = '?';
+ $expected[$i] = ++$placeholders;
+ } else {
+ $args[] = "'v$i'";
+ $expected[$i] = "v$i";
+ }
+ }
+ $shape = 'f(' . implode(', ', $args) . ')';
+ $n++;
+ eval("const K$n = $shape;");
+ $pfa = constant("K$n");
+ $result = $pfa(...range(1, $placeholders));
+ if ($result !== implode('|', $expected)) {
+ echo "$shape: got $result\n";
+ }
+ }
+ }
+}
+echo "Done\n";
+?>
+--EXPECT--
+Done
diff --git a/Zend/tests/partial_application/cache_freed_declaring_site_004.phpt b/Zend/tests/partial_application/cache_freed_declaring_site_004.phpt
new file mode 100644
index 00000000000..aaa63866aca
--- /dev/null
+++ b/Zend/tests/partial_application/cache_freed_declaring_site_004.phpt
@@ -0,0 +1,16 @@
+--TEST--
+PFAs in constants declared on the same line must not share a cache entry
+--FILE--
+<?php
+function f($a, $b) { return "$a|$b"; }
+
+class C { const X = f(?, 'x'), Y = f('y', ?); }
+const K1 = f(?, 'k1'), K2 = f('k2', ?);
+
+var_dump((C::X)(1), (C::Y)(2), (K1)(3), (K2)(4));
+?>
+--EXPECT--
+string(3) "1|x"
+string(3) "y|2"
+string(4) "3|k1"
+string(4) "k2|4"
diff --git a/Zend/tests/partial_application/cache_freed_declaring_site_005.phpt b/Zend/tests/partial_application/cache_freed_declaring_site_005.phpt
new file mode 100644
index 00000000000..37212bfd1f0
--- /dev/null
+++ b/Zend/tests/partial_application/cache_freed_declaring_site_005.phpt
@@ -0,0 +1,42 @@
+--TEST--
+PFA in included file: request cache must not outlive the freed declaring op_array
+--INI--
+opcache.enable_cli=0
+--FILE--
+<?php
+function f($a = 'A', $b = 'B', $c = 'C', $d = 'D') { return "$a|$b|$c|$d"; }
+
+$file = __DIR__ . '/cache_freed_declaring_site_005.inc';
+for ($round = 0; $round < 2; $round++) {
+ for ($argc = 1; $argc <= 4; $argc++) {
+ for ($mask = 1; $mask < (1 << $argc); $mask++) {
+ $args = [];
+ $expected = ['A', 'B', 'C', 'D'];
+ $placeholders = 0;
+ for ($i = 0; $i < $argc; $i++) {
+ if ($mask & (1 << $i)) {
+ $args[] = '?';
+ $expected[$i] = ++$placeholders;
+ } else {
+ $args[] = "'v$i'";
+ $expected[$i] = "v$i";
+ }
+ }
+ $shape = 'f(' . implode(', ', $args) . ')';
+ file_put_contents($file, "<?php return $shape;");
+ $pfa = include $file;
+ $result = $pfa(...range(1, $placeholders));
+ if ($result !== implode('|', $expected)) {
+ echo "$shape: got $result\n";
+ }
+ }
+ }
+}
+echo "Done\n";
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/cache_freed_declaring_site_005.inc');
+?>
+--EXPECT--
+Done
diff --git a/Zend/zend_ast.c b/Zend/zend_ast.c
index d7b1373b551..eea1ae07888 100644
--- a/Zend/zend_ast.c
+++ b/Zend/zend_ast.c
@@ -1361,10 +1361,16 @@ static zend_result ZEND_FASTCALL zend_ast_evaluate_inner(
if (uses_variadic_placeholder) {
flags |= ZEND_PARTIAL_USES_VARIADIC_PLACEHOLDER;
}
+ /* SHM names are shared by same-line PFAs, so they can't be a cache key */
+ ZEND_ASSERT((flags & ZEND_PARTIAL_CACHEABLE_IN_SHM)
+ || !(GC_FLAGS(fcc_ast->name) & IS_STR_PERMANENT));
+ /* A non-SHM AST may be freed, but the cached PFA keeps its name alive */
+ const uint32_t *cache_key = (flags & ZEND_PARTIAL_CACHEABLE_IN_SHM)
+ ? &ast->lineno : (const uint32_t *) fcc_ast->name;
zend_partial_create(result, scope, &frame->This, fptr,
ZEND_CALL_NUM_ARGS(frame), ZEND_CALL_ARG(frame, 1),
extra_named_params, named_positions,
- fcc_ast->filename, &ast->lineno,
+ fcc_ast->filename, cache_key, /* declaring_op_array */ NULL,
(void**)cache_slot, fcc_ast->name, flags, /* const_args */ 0);
if (named_positions) {
diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c
index 98732f06017..abed7636622 100644
--- a/Zend/zend_compile.c
+++ b/Zend/zend_compile.c
@@ -12292,6 +12292,7 @@ static void zend_compile_const_expr_fcc(zend_ast **ast_ptr)
if (!is_fcc) {
fcc->filename = zend_string_copy(CG(active_op_array)->filename);
+ /* Not unique by content, but its address is a cache key until persisted */
fcc->name = zend_compile_partial_name(CG(active_op_array), fcc->lineno);
}
diff --git a/Zend/zend_partial.c b/Zend/zend_partial.c
index 1108cff2301..b227c1f9e25 100644
--- a/Zend/zend_partial.c
+++ b/Zend/zend_partial.c
@@ -701,7 +701,8 @@ static zend_op_array *zp_compile(zval *this_ptr, zend_function *function,
uint32_t argc, zval *argv, zend_array *extra_named_params,
const zend_array *named_positions,
zend_string *declaring_filename,
- const uint32_t *declaring_lineno_ptr, void **cache_slot,
+ const uint32_t *declaring_lineno_ptr,
+ const zend_op_array *declaring_op_array, void **cache_slot,
zend_string *pfa_name, uint32_t flags, uint32_t const_args) {
zend_op_array *op_array = NULL;
@@ -1016,7 +1017,8 @@ static zend_op_array *zp_compile(zval *this_ptr, zend_function *function,
/* Takes ownership of closure_ast */
op_array = zend_accel_compile_pfa(closure_ast, declaring_filename,
- declaring_lineno_ptr, function, pfa_name, flags & ZEND_PARTIAL_CACHEABLE_IN_SHM);
+ declaring_lineno_ptr, declaring_op_array, function, pfa_name,
+ flags & ZEND_PARTIAL_CACHEABLE_IN_SHM);
clean:
zp_names_dtor(var_names, argc);
@@ -1040,7 +1042,8 @@ static const zend_op_array *zp_get_op_array(zval *this_ptr, zend_function *funct
uint32_t argc, zval *argv, zend_array *extra_named_params,
const zend_array *named_positions,
zend_string *declaring_filename,
- const uint32_t *declaring_lineno_ptr, void **cache_slot,
+ const uint32_t *declaring_lineno_ptr,
+ const zend_op_array *declaring_op_array, void **cache_slot,
zend_string *pfa_name, uint32_t flags, uint32_t const_args) {
if (EXPECTED(function->type == ZEND_INTERNAL_FUNCTION
@@ -1056,7 +1059,7 @@ static const zend_op_array *zp_get_op_array(zval *this_ptr, zend_function *funct
if (UNEXPECTED(!op_array)) {
op_array = zp_compile(this_ptr, function, argc, argv,
extra_named_params, named_positions, declaring_filename, declaring_lineno_ptr,
- cache_slot, pfa_name, flags, const_args);
+ declaring_op_array, cache_slot, pfa_name, flags, const_args);
}
if (EXPECTED(op_array) && !(function->common.fn_flags & ZEND_ACC_NEVER_CACHE)) {
@@ -1135,7 +1138,8 @@ void zend_partial_create(zval *result, zend_class_entry *scope, zval *this_ptr,
uint32_t argc, zval *argv, zend_array *extra_named_params,
const zend_array *named_positions,
zend_string *declaring_filename,
- const uint32_t *declaring_lineno_ptr, void **cache_slot,
+ const uint32_t *declaring_lineno_ptr,
+ const zend_op_array *declaring_op_array, void **cache_slot,
zend_string *pfa_name, uint32_t flags, uint32_t const_args) {
ZEND_ASSERT(pfa_name);
@@ -1143,7 +1147,7 @@ void zend_partial_create(zval *result, zend_class_entry *scope, zval *this_ptr,
const zend_op_array *op_array = zp_get_op_array(this_ptr, function, argc, argv,
extra_named_params, named_positions,
declaring_filename, declaring_lineno_ptr,
- cache_slot, pfa_name, flags, const_args);
+ declaring_op_array, cache_slot, pfa_name, flags, const_args);
if (UNEXPECTED(!op_array)) {
ZEND_ASSERT(EG(exception));
diff --git a/Zend/zend_partial.h b/Zend/zend_partial.h
index 285db1161e7..43b4c8ab561 100644
--- a/Zend/zend_partial.h
+++ b/Zend/zend_partial.h
@@ -30,12 +30,14 @@ BEGIN_EXTERN_C()
/* Create a partial application of 'function'
* 'declaring_lineno_ptr' should be a pointer the zend_op.lineno or
* zend_ast.lineno that declares the PFA. The address is used to build a cache
- * key. */
+ * key, so it must not be reused while cached. 'declaring_op_array' is retained
+ * for that purpose. */
void zend_partial_create(zval *result, zend_class_entry *scope, zval *this_ptr, zend_function *function,
uint32_t argc, zval *argv, zend_array *extra_named_params,
const zend_array *named_positions,
zend_string *declaring_filename,
- const uint32_t *declaring_lineno_ptr, void **cache_slot,
+ const uint32_t *declaring_lineno_ptr,
+ const zend_op_array *declaring_op_array, void **cache_slot,
zend_string *pfa_name, uint32_t flags, uint32_t const_args);
void zend_partial_op_array_dtor(zval *pDest);
diff --git a/Zend/zend_vm_def.h b/Zend/zend_vm_def.h
index 1596838195b..d10c891a730 100644
--- a/Zend/zend_vm_def.h
+++ b/Zend/zend_vm_def.h
@@ -9923,7 +9923,7 @@ ZEND_VM_HANDLER(212, ZEND_CALLABLE_CONVERT_PARTIAL, CONST, CONST|UNUSED, NUM)
(ZEND_CALL_INFO(call) & ZEND_CALL_HAS_EXTRA_NAMED_PARAMS) ?
call->extra_named_params : NULL,
OP2_TYPE == IS_CONST ? Z_ARRVAL_P(named_positions) : NULL,
- EX(func)->op_array.filename, &opline->lineno, cache_slot,
+ EX(func)->op_array.filename, &opline->lineno, &EX(func)->op_array, cache_slot,
pfa_name, opline->extended_value & ZEND_PARTIAL_FLAGS, const_args);
if (ZEND_CALL_INFO(call) & ZEND_CALL_HAS_EXTRA_NAMED_PARAMS) {
diff --git a/Zend/zend_vm_execute.h b/Zend/zend_vm_execute.h
index b196dcc01f0..8679330b2d0 100644
Binary files a/Zend/zend_vm_execute.h and b/Zend/zend_vm_execute.h differ
diff --git a/ext/opcache/ZendAccelerator.c b/ext/opcache/ZendAccelerator.c
index 9330a85a6ea..a5fa14a5fc7 100644
--- a/ext/opcache/ZendAccelerator.c
+++ b/ext/opcache/ZendAccelerator.c
@@ -2113,6 +2113,7 @@ const zend_op_array *zend_accel_pfa_cache_get(
zend_op_array *zend_accel_compile_pfa(zend_ast *ast,
zend_string *declaring_filename,
const uint32_t *declaring_lineno_ptr,
+ const zend_op_array *declaring_op_array,
const zend_function *called_function,
zend_string *pfa_func_name, bool cacheable_in_shm)
{
@@ -2169,21 +2170,33 @@ zend_op_array *zend_accel_compile_pfa(zend_ast *ast,
destroy_op_array(script_op_array);
efree(script_op_array);
+ const zend_op_array *retained[2];
+ uint32_t num_retained = 0;
if ((called_function->common.fn_flags & ZEND_ACC_CLOSURE)
&& called_function->type == ZEND_USER_FUNCTION
&& called_function->op_array.refcount) {
/* Extend the lifetime of the called opcodes if
* the called function is a closure.
* See comment in zend_accel_pfa_key(). */
- zend_op_array *copy = zend_arena_alloc(&CG(arena), sizeof(*copy));
- memcpy(copy, called_function, sizeof(*copy));
- function_add_ref((zend_function *) copy);
- /* Reference the copy in op_array->dynamic_func_defs so that it's
- * destroyed when op_array is destroyed. */
+ retained[num_retained++] = &called_function->op_array;
+ }
+ if (declaring_op_array && declaring_op_array->refcount) {
+ /* The key contains a declaring opline address, which eval() code may free and reuse. */
+ retained[num_retained++] = declaring_op_array;
+ }
+ if (num_retained) {
+ /* Copies in dynamic_func_defs are released with op_array. */
ZEND_ASSERT(!op_array->dynamic_func_defs && !op_array->num_dynamic_func_defs);
- op_array->dynamic_func_defs = safe_emalloc(1, sizeof(*op_array->dynamic_func_defs), 0);
- op_array->dynamic_func_defs[0] = copy;
- op_array->num_dynamic_func_defs = 1;
+ op_array->dynamic_func_defs = safe_emalloc(num_retained, sizeof(*op_array->dynamic_func_defs), 0);
+ for (uint32_t i = 0; i < num_retained; i++) {
+ zend_op_array *copy = zend_arena_alloc(&CG(arena), sizeof(*copy));
+ memcpy(copy, retained[i], sizeof(*copy));
+ /* function_add_ref() ignores ZEND_EVAL_CODE */
+ copy->type = ZEND_USER_FUNCTION;
+ function_add_ref((zend_function *) copy);
+ op_array->dynamic_func_defs[i] = copy;
+ }
+ op_array->num_dynamic_func_defs = num_retained;
}
zend_hash_add_new_ptr(&EG(partial_function_application_cache), key, op_array);
diff --git a/ext/opcache/ZendAccelerator.h b/ext/opcache/ZendAccelerator.h
index 5fd34441c40..5f8ebdd6f27 100644
--- a/ext/opcache/ZendAccelerator.h
+++ b/ext/opcache/ZendAccelerator.h
@@ -353,6 +353,7 @@ const zend_op_array *zend_accel_pfa_cache_get(
zend_op_array *zend_accel_compile_pfa(zend_ast *ast,
zend_string *declaring_filename,
const uint32_t *declaring_lineno_ptr,
+ const zend_op_array *declaring_op_array,
const zend_function *called_function,
zend_string *pfa_func_name, bool cacheable_in_shm);