From 8958d4b9ee47c75a1acb45ad9b5498c9ce79795d Mon Sep 17 00:00:00 2001 From: Enfilade Date: Thu, 29 Oct 2026 04:10:46 +0300 Subject: [PATCH] Fix GH-24661: Wrong function called or crash if the function table changed ZEND_INIT_FCALL is only emitted for a function that exists at compile time. Since da684582d7 its handler asserts that the function still exists, and since 980f7bd2c9 the ZEND_INIT_FCALL_OFFSET specialization takes an internal function from the offset it had in the function table at compile time. Opcache can run a script with a different function table: the file cache can hold a script compiled after dl(), and FPM pools apply their own disable_functions and extension after the master has started, so a pool can run scripts compiled by another pool or preloaded by the master. The offset can then point to another function, which is called instead, and a call to a function that doesn't exist anymore crashes. On a run-time cache miss, use the offset only if the function there has the expected name, otherwise look the function up by name, as before 980f7bd2c9. Throw "Call to undefined function" if the function doesn't exist, as before da684582d7, and also if a call compiled for an internal function (op2.extra holds its offset) finds a user function of that name, as its call frame was sized for the internal function. Co-Authored-By: Claude Opus 5.5 --- Zend/zend_vm_def.h | 24 ++++- Zend/zend_vm_execute.h | 24 ++++- ext/opcache/tests/gh24661.phpt | 101 ++++++++++++++++++ ...24661-opcache-pools-disable-functions.phpt | 77 +++++++++++++ ...661-opcache-preload-disable-functions.phpt | 61 +++++++++++ sapi/fpm/tests/gh24661-preload.inc | 12 +++ 6 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 ext/opcache/tests/gh24661.phpt create mode 100644 sapi/fpm/tests/gh24661-opcache-pools-disable-functions.phpt create mode 100644 sapi/fpm/tests/gh24661-opcache-preload-disable-functions.phpt create mode 100644 sapi/fpm/tests/gh24661-preload.inc diff --git a/Zend/zend_vm_def.h b/Zend/zend_vm_def.h index df255c5a868f..1bfb4986f7f5 100644 --- a/Zend/zend_vm_def.h +++ b/Zend/zend_vm_def.h @@ -4014,7 +4014,12 @@ ZEND_VM_HOT_HANDLER(61, ZEND_INIT_FCALL, NUM, CONST, NUM|CACHE_SLOT) if (UNEXPECTED(fbc == NULL)) { fname = (zval*)RT_CONSTANT(opline, opline->op2); func = zend_hash_find_known_hash(EG(function_table), Z_STR_P(fname)); - ZEND_ASSERT(func != NULL && "Function existence must be checked at compile time"); + /* The function table can differ from the one the script was compiled with. + * A call compiled for an internal function (op2.extra is its offset) can't go to a user function. */ + if (UNEXPECTED(func == NULL) + || (Z_EXTRA_P(fname) && UNEXPECTED(Z_FUNC_P(func)->type != ZEND_INTERNAL_FUNCTION))) { + ZEND_VM_DISPATCH_TO_HELPER(zend_undefined_function_helper); + } fbc = Z_FUNC_P(func); if (EXPECTED(fbc->type == ZEND_USER_FUNCTION) && UNEXPECTED(!RUN_TIME_CACHE(&fbc->op_array))) { init_func_run_time_cache(&fbc->op_array); @@ -4038,7 +4043,22 @@ ZEND_VM_HOT_TYPE_SPEC_HANDLER(ZEND_INIT_FCALL, Z_EXTRA_P(RT_CONSTANT(op, op->op2 zend_execute_data *call; fbc = CACHED_PTR(opline->result.num); if (UNEXPECTED(fbc == NULL)) { - fbc = Z_PTR(EG(function_table)->arData[Z_EXTRA_P(RT_CONSTANT(opline, opline->op2))].val); + zval *fname = (zval*)RT_CONSTANT(opline, opline->op2); + uint32_t offset = Z_EXTRA_P(fname); + Bucket *p = EG(function_table)->arData; + + /* The offset is stale if the function table differs from the one the script was compiled with. */ + if (EXPECTED(offset < EG(function_table)->nNumUsed) + && EXPECTED(p[offset].key && zend_string_equals(p[offset].key, Z_STR_P(fname)))) { + fbc = Z_PTR(p[offset].val); + } else { + zval *func = zend_hash_find_known_hash(EG(function_table), Z_STR_P(fname)); + fbc = func ? Z_FUNC_P(func) : NULL; + } + /* The call was compiled for an internal function and can't go to a user function. */ + if (UNEXPECTED(fbc == NULL) || UNEXPECTED(fbc->type != ZEND_INTERNAL_FUNCTION)) { + ZEND_VM_DISPATCH_TO_HELPER(zend_undefined_function_helper); + } CACHE_PTR(opline->result.num, fbc); } call = _zend_vm_stack_push_call_frame_ex( diff --git a/Zend/zend_vm_execute.h b/Zend/zend_vm_execute.h index 4b313f20cd39..aff93d3a31dc 100644 --- a/Zend/zend_vm_execute.h +++ b/Zend/zend_vm_execute.h @@ -4031,7 +4031,12 @@ static ZEND_VM_HOT ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_FCALL_SPEC_CO if (UNEXPECTED(fbc == NULL)) { fname = (zval*)RT_CONSTANT(opline, opline->op2); func = zend_hash_find_known_hash(EG(function_table), Z_STR_P(fname)); - ZEND_ASSERT(func != NULL && "Function existence must be checked at compile time"); + /* The function table can differ from the one the script was compiled with. + * A call compiled for an internal function (op2.extra is its offset) can't go to a user function. */ + if (UNEXPECTED(func == NULL) + || (Z_EXTRA_P(fname) && UNEXPECTED(Z_FUNC_P(func)->type != ZEND_INTERNAL_FUNCTION))) { + ZEND_VM_TAIL_CALL(zend_undefined_function_helper_SPEC(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU)); + } fbc = Z_FUNC_P(func); if (EXPECTED(fbc->type == ZEND_USER_FUNCTION) && UNEXPECTED(!RUN_TIME_CACHE(&fbc->op_array))) { init_func_run_time_cache(&fbc->op_array); @@ -4055,7 +4060,22 @@ static ZEND_VM_HOT ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_FCALL_OFFSET_ zend_execute_data *call; fbc = CACHED_PTR(opline->result.num); if (UNEXPECTED(fbc == NULL)) { - fbc = Z_PTR(EG(function_table)->arData[Z_EXTRA_P(RT_CONSTANT(opline, opline->op2))].val); + zval *fname = (zval*)RT_CONSTANT(opline, opline->op2); + uint32_t offset = Z_EXTRA_P(fname); + Bucket *p = EG(function_table)->arData; + + /* The offset is stale if the function table differs from the one the script was compiled with. */ + if (EXPECTED(offset < EG(function_table)->nNumUsed) + && EXPECTED(p[offset].key && zend_string_equals(p[offset].key, Z_STR_P(fname)))) { + fbc = Z_PTR(p[offset].val); + } else { + zval *func = zend_hash_find_known_hash(EG(function_table), Z_STR_P(fname)); + fbc = func ? Z_FUNC_P(func) : NULL; + } + /* The call was compiled for an internal function and can't go to a user function. */ + if (UNEXPECTED(fbc == NULL) || UNEXPECTED(fbc->type != ZEND_INTERNAL_FUNCTION)) { + ZEND_VM_TAIL_CALL(zend_undefined_function_helper_SPEC(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU)); + } CACHE_PTR(opline->result.num, fbc); } call = _zend_vm_stack_push_call_frame_ex( diff --git a/ext/opcache/tests/gh24661.phpt b/ext/opcache/tests/gh24661.phpt new file mode 100644 index 000000000000..97984fb378bf --- /dev/null +++ b/ext/opcache/tests/gh24661.phpt @@ -0,0 +1,101 @@ +--TEST-- +GH-24661 (Wrong function called or crash if the function table changed) +--EXTENSIONS-- +opcache +--SKIPIF-- + +--FILE-- +getMessage(), "\n"; +}); +function f() { + try { + return dl_test_test2('f'); + } catch (Error $e) { + return $e::class . ": " . $e->getMessage(); + } +} +echo f(), "\n"; +echo dl_test_test2('main'), "\n"; +PHP); + +function run(string $cache, array $args): void { + $ext = ini_get('extension_dir'); + $opcache = PHP_OS_FAMILY === 'Windows' ? 'php_opcache.dll' : 'opcache.so'; + $cmd = [PHP_BINARY, '-n', '-d', 'extension_dir="' . $ext . '"']; + if (file_exists($ext . DIRECTORY_SEPARATOR . $opcache)) { + array_push($cmd, '-d', 'zend_extension=opcache'); + } + array_push($cmd, + '-d', 'opcache.enable_cli=1', + '-d', 'opcache.file_cache="' . $cache . '"', + '-d', 'opcache.file_cache_only=1', + '-d', 'opcache.file_update_protection=0', + ...$args, + ); + $proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + echo stream_get_contents($pipes[1]); + stream_get_contents($pipes[2]); + proc_close($proc); +} + +echo "--- Compiled with dl_test loaded ---\n"; +run($cache, [$script, 'dl']); +echo "--- Cached, run without dl_test ---\n"; +run($cache, [$script]); +echo "--- Cached, run with a user function of the same name ---\n"; +run($cache, [$script, 'user']); +?> +--CLEAN-- +isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($cache); +} +?> +--EXPECT-- +--- Compiled with dl_test loaded --- +Hello f +Hello main +--- Cached, run without dl_test --- +Error: Call to undefined function dl_test_test2() +Error: Call to undefined function dl_test_test2() +--- Cached, run with a user function of the same name --- +Error: Call to undefined function dl_test_test2() +Error: Call to undefined function dl_test_test2() diff --git a/sapi/fpm/tests/gh24661-opcache-pools-disable-functions.phpt b/sapi/fpm/tests/gh24661-opcache-pools-disable-functions.phpt new file mode 100644 index 000000000000..c3321742282f --- /dev/null +++ b/sapi/fpm/tests/gh24661-opcache-pools-disable-functions.phpt @@ -0,0 +1,77 @@ +--TEST-- +FPM: GH-24661 - Script cached by a pool with other disable_functions calls the wrong function +--EXTENSIONS-- +opcache +--SKIPIF-- + +--FILE-- +getMessage(), "\n"; +}); +function f() { + try { + return gettype(getmypid()); + } catch (Error $e) { + return $e->getMessage(); + } +} +echo f(), "\n"; +echo var_export(true, true), "\n"; +echo gettype(getmypid()), "\n"; +EOT; + +$expected = [ + 'unrestricted' => ['integer', 'true', 'integer'], + 'restricted' => [ + 'Call to undefined function getmypid()', + 'true', + 'Call to undefined function getmypid()', + ], +]; + +$opcache = ini_get('extension_dir') . DIRECTORY_SEPARATOR . 'opcache.' . PHP_SHLIB_SUFFIX; +$extraArgs = is_file($opcache) ? ['-dzend_extension=' . $opcache] : []; + +foreach ([['unrestricted', 'restricted'], ['restricted', 'unrestricted']] as $pools) { + $tester = new FPM\Tester($cfg, $code); + $tester->start($extraArgs, iniEntries: ['opcache.file_update_protection' => '0']); + $tester->expectLogStartNotices(); + foreach ([...$pools, ...$pools] as $pool) { + $tester->request(address: "{{ADDR[$pool]}}")->expectBody($expected[$pool]); + } + $tester->terminate(); + $tester->expectLogTerminatingNotices(); + $tester->close(); +} + +?> +Done +--EXPECT-- +Done +--CLEAN-- + diff --git a/sapi/fpm/tests/gh24661-opcache-preload-disable-functions.phpt b/sapi/fpm/tests/gh24661-opcache-preload-disable-functions.phpt new file mode 100644 index 000000000000..20047d48310d --- /dev/null +++ b/sapi/fpm/tests/gh24661-opcache-preload-disable-functions.phpt @@ -0,0 +1,61 @@ +--TEST-- +FPM: GH-24661 - Preloaded script calls the wrong function in a pool with disable_functions +--EXTENSIONS-- +opcache +--SKIPIF-- + +--FILE-- +getMessage(), "\n"; +}); +echo preloaded_f(), "\n"; +preloaded_g(); +EOT; + +$opcache = ini_get('extension_dir') . DIRECTORY_SEPARATOR . 'opcache.' . PHP_SHLIB_SUFFIX; +$extraArgs = is_file($opcache) ? ['-dzend_extension=' . $opcache] : []; +$iniEntries = ['opcache.preload' => __DIR__ . '/gh24661-preload.inc']; +if (getenv('TEST_FPM_RUN_AS_ROOT')) { + $iniEntries['opcache.preload_user'] = 'root'; +} + +$tester = new FPM\Tester($cfg, $code); +$tester->start($extraArgs, iniEntries: $iniEntries); +$tester->expectLogStartNotices(); +$expected = [ + 'Call to undefined function getmypid()', + 'true', + 'Call to undefined function getmypid()', +]; +$tester->request()->expectBody($expected); +$tester->request()->expectBody($expected); +$tester->terminate(); +$tester->expectLogTerminatingNotices(); +$tester->close(); + +?> +Done +--EXPECT-- +Done +--CLEAN-- + diff --git a/sapi/fpm/tests/gh24661-preload.inc b/sapi/fpm/tests/gh24661-preload.inc new file mode 100644 index 000000000000..f979a8721db5 --- /dev/null +++ b/sapi/fpm/tests/gh24661-preload.inc @@ -0,0 +1,12 @@ +getMessage(); + } +} +function preloaded_g() { + echo var_export(true, true), "\n"; + echo gettype(getmypid()), "\n"; +}