fix(compiler): prevent static method call cache pollution with dynamic trampolines

- Add test case for repeated dynamic static calls not reusing released trampoline
- Add test case for repeated static calls to runtime-defined magic class
- Throw LogicException in getFuncPtr when class method name is detected
- Update getFuncPtr to reject class method names with proper error message
- Replace getFuncPtr with getMethodPtr for static method calls in method call trait
- Update Closure::fromCallable handling to use getMethodPtr instead of getFuncPtr
- Fix Python module trait to use getMethodPtr for PyCore methods
- Update translator to clear function and class maps on every request shutdown
- Refactor property access resolver to use compiled class declaration for parent lookup
- Add test to verify subclass lookup uses compiled class declaration when parent index is missing
- Update request shutdown test to run in all build modes with proper assertions
- Add specific test for function pointer cache rejecting class method names
pull/48/head
韩天峰 2 weeks ago
parent e07b97d6cf
commit 18dd9a8a32
  1. 56
      phpunit/src/CompilerBaseApiTest.php
  2. 3
      phpunit/src/Python/PythonModuleTest.php
  3. 32
      phpunit/src/Resolver/PropertyAccessResolverTest.php
  4. 3
      src/CompilerBase.php
  5. 2
      src/Generator/PlaceHolderGenerator.php
  6. 19
      src/Parser/MethodCallTrait.php
  7. 12
      src/Python/PythonModuleTrait.php
  8. 15
      src/Resolver/PropertyAccessResolver.php
  9. 13
      src/Translator.php
  10. 23
      tests/compiler/dynamic_call/call-static-trampoline-repeat.phpt
  11. 25
      tests/compiler/dynamic_call/static-call-runtime-magic-repeat.phpt

@ -916,30 +916,44 @@ YAML);
$this->assertContains('/usr/local/lib', $libraryPaths);
}
public function testExtensionCleanClearsRuntimeMapsWithValidCpp(): void
public function testRequestShutdownClearsRuntimeMapsInEveryBuildMode(): void
{
global $translator;
$compiler = CompilerTest::create(ROOT_PATH);
$translator = $compiler;
$ref = new \ReflectionClass($compiler);
$buildMode = $ref->getProperty('buildMode');
$buildMode->setAccessible(true);
$buildMode->setValue($compiler, CompilerBase::BUILD_MODE_EXT);
foreach ([CompilerBase::BUILD_MODE_BIN, CompilerBase::BUILD_MODE_LIB, CompilerBase::BUILD_MODE_EXT] as $mode) {
$compiler = CompilerTest::create(ROOT_PATH);
$translator = $compiler;
$compiler->setBuildMode($mode);
$testFile = ROOT_PATH . '/phpunit/code/compiler_api/extension_clean_maps.php';
$compiler->addFiles([$testFile]);
$compiler->prepareFile($testFile);
$compiler->convertFile($testFile);
$code = file_get_contents($compiler->genExtension());
$this->assertStringContainsString('#include <cstring>', $code, $mode);
$this->assertStringContainsString(
'std::memset(php_func_map, 0, sizeof(php_func_map));',
$code,
$mode,
);
$this->assertStringContainsString(
'std::memset(php_class_map, 0, sizeof(php_class_map));',
$code,
$mode,
);
$this->assertStringNotContainsString('php_property_map', $code, $mode);
$this->assertStringNotContainsString('func_map = {}', $code, $mode);
$this->assertStringNotContainsString('class_map = {}', $code, $mode);
$this->assertStringNotContainsString('property_map = {}', $code, $mode);
}
}
$testFile = ROOT_PATH . '/phpunit/code/compiler_api/extension_clean_maps.php';
$compiler->addFiles([$testFile]);
$compiler->prepareFile($testFile);
$compiler->convertFile($testFile);
$extensionFile = $compiler->genExtension();
$code = file_get_contents($extensionFile);
$this->assertStringContainsString('#include <cstring>', $code);
$this->assertStringContainsString('std::memset(php_func_map, 0, sizeof(php_func_map));', $code);
$this->assertStringContainsString('std::memset(php_class_map, 0, sizeof(php_class_map));', $code);
$this->assertStringNotContainsString('php_property_map', $code);
$this->assertStringNotContainsString('func_map = {}', $code);
$this->assertStringNotContainsString('class_map = {}', $code);
$this->assertStringNotContainsString('property_map = {}', $code);
public function testFunctionPointerCacheRejectsClassMethodNames(): void
{
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('Class methods must be resolved through getMethodPtr()');
$this->invokeMethod('getFuncPtr', 'RuntimeClass::method');
}
public function testPersistentSymbolCachesAreLazyAndZtsSafe(): void

@ -216,7 +216,8 @@ final class PythonModuleTest extends TestCase
$this->assertStringContainsString('PyDict', $extension);
$this->assertStringContainsString('THREAD_LOCAL zval php_python_module_map[1]', $extension);
$this->assertStringContainsString('builtins', $extension);
$this->assertStringContainsString('PyCore::setOptions', $extension);
$this->assertStringContainsString('php_get_persistent_method(', $extension);
$this->assertStringContainsString('setOptions', $extension);
$this->assertStringContainsString('return_as_object', $extension);
$this->assertStringContainsString('php_python_runtime_configured = false;', $extension);
$this->assertStringNotContainsString('python\\\\list', $extension);

@ -41,4 +41,36 @@ final class PropertyAccessResolverTest extends TestCase
$this->assertTrue($resolver->isSameOrSubclassOf('App\\ChildException', 'runtimeexception'));
$this->assertTrue($resolver->canAccessProtectedProperty('App\\ChildException', 'RuntimeException'));
}
public function testSubclassLookupUsesCompiledClassDeclarationWhenParentIndexIsMissing(): void
{
$child = new ClassDef('ChildException', 0, 'App');
$child->extends = 'RuntimeException';
$context = new class($child) implements PropertyAccessContext {
public function __construct(private ClassDef $child)
{
}
public function getClassDef(string $name): ?ClassDef
{
return strtolower(ltrim($name, '\\')) === 'app\\childexception' ? $this->child : null;
}
public function getParentClass(string $class): string
{
return '';
}
public function fatalError(NodeAbstract $node, string $msg): never
{
throw new \LogicException($msg);
}
};
$resolver = new PropertyAccessResolver($context);
$this->assertTrue($resolver->isSameOrSubclassOf('App\\ChildException', 'runtimeexception'));
$this->assertTrue($resolver->canAccessProtectedProperty('App\\ChildException', 'RuntimeException'));
}
}

@ -1334,6 +1334,9 @@ class CompilerBase implements PropertyAccessContext
protected function getFuncPtr(string $funcName): string
{
if (str_contains($funcName, '::')) {
throw new \LogicException('Class methods must be resolved through getMethodPtr()');
}
$id = $this->getFuncId($funcName);
$helper = isset($this->persistentFuncMap[$funcName]) ? 'php_get_persistent_func' : 'php_get_func';
return $helper . '(' . $id . ', ' . $this->getLiteralString($funcName) . ')';

@ -17,7 +17,7 @@ trait PlaceHolderGenerator
}
$ce = $this->getClassEntryPtr(\Closure::class);
$fn = $ce . ', ' . $this->getFuncPtr('Closure::fromCallable');
$fn = $ce . ', ' . $this->getMethodPtr('Closure', 'fromCallable');
return 'php::call(' . $fn . ', {' . $callable . '})';
}
}

@ -683,7 +683,6 @@ trait MethodCallTrait
$method = $this->parseIdentifier($expr->name);
$rtFunc = $method;
$rtClass = $class;
$dynamicCall = false;
$this->context->beforeStmtLines[] = $this->formatCppLineComment(
'Static Method Call: ',
$class . '::' . $method . '()'
@ -695,16 +694,11 @@ trait MethodCallTrait
if ($callScope) {
$nativeFunc = $this->getNativeMethod($expr, $class, $method);
// 存在 Native 类,但是没有找到方法,可能是动态调用
if (!$nativeFunc and $this->hasClass($class) and $this->getNativeMethod($expr, $class, '__callStatic', false)) {
$dynamicCall = true;
}
if ($nativeFunc) {
try {
if ($this->shouldUseDynamicCallForNativeArgs($nativeFunc, $expr->args)) {
return $this->genRuntimeFunctionCall(
$this->getClassEntryPtr($class) . ', ' . $this->getFuncPtr($class . '::' . $method),
$this->getClassEntryPtr($class) . ', ' . $this->getMethodPtr($class, $method),
$expr->args,
$method,
$class
@ -729,12 +723,11 @@ trait MethodCallTrait
}
}
if ($dynamicCall) {
$fn = $this->getLiteralString($class . '::' . $method);
} else {
$ce = $this->getClassEntryPtr($class);
$fn = $ce . ', ' . $this->getFuncPtr($class . '::' . $method);
}
// Reaching this fallback means no concrete native method was
// proven above. Keep the callable dynamic so Zend can resolve a
// runtime-defined method or __callStatic(). PHPX only caches real,
// reusable handlers and never stores transient trampolines.
$fn = $this->getLiteralString($class . '::' . $method);
$placeHolder = $this->genArray($callScope);
} else {
$fn = 'php::concat({' . $this->identifierToStr($expr->class) . ', "::", ' . $this->identifierToStr($expr->name) . '})';

@ -140,7 +140,7 @@ trait PythonModuleTrait
{
$this->markPythonRuntimeUsed();
$scalar = 'php::call(' . $this->getClassEntryPtr('PyCore') . ', '
. $this->getFuncPtr('PyCore::scalar') . ', php::ArgList{' . $expression . '})';
. $this->getMethodPtr('PyCore', 'scalar') . ', php::ArgList{' . $expression . '})';
return 'php::toBool(' . $scalar . ')';
}
@ -330,7 +330,7 @@ trait PythonModuleTrait
$this->markPythonRuntimeUsed();
$this->getFuncId('PyCore::import');
$this->getLiteralString('PyCore::import');
$this->getLiteralString('import');
$this->getLiteralString($module);
return $id;
@ -348,7 +348,7 @@ trait PythonModuleTrait
$this->getClassId('PyCore');
$this->getFuncId('PyCore::setOptions');
$this->getLiteralString('PyCore');
$this->getLiteralString('PyCore::setOptions');
$this->getLiteralString('setOptions');
$this->getLiteralString('return_as_object');
}
@ -444,7 +444,7 @@ trait PythonModuleTrait
// or (`scalar`) explicitly leave Python's object-preserving rules.
if (isset(self::PYTHON_CORE_FUNCTIONS[$builtin])) {
$callable = $this->getClassEntryPtr('PyCore') . ', '
. $this->getFuncPtr('PyCore::' . $builtin);
. $this->getMethodPtr('PyCore', $builtin);
if ($expr->args === []) {
return $this->withPythonRuntimeConfigured('php::call(' . $callable . ')');
}
@ -648,7 +648,7 @@ trait PythonModuleTrait
}
$pyCoreClass = $this->getClassEntryPtr('PyCore');
$setOptionsFunction = $this->getFuncPtr('PyCore::setOptions');
$setOptionsFunction = $this->getMethodPtr('PyCore', 'setOptions');
$returnAsObject = $this->getLiteralString('return_as_object');
$code = 'void ' . self::PREFIX . 'configure_python_runtime() {' . PHP_EOL
@ -665,7 +665,7 @@ trait PythonModuleTrait
return $code;
}
$importFunction = $this->getFuncPtr('PyCore::import');
$importFunction = $this->getMethodPtr('PyCore', 'import');
return $code
. 'php::Object ' . self::PREFIX

@ -35,9 +35,15 @@ final class PropertyAccessResolver
return true;
}
// The conversion snapshot may not retain every user-to-internal
// edge in the parent index. A compiled class still carries its
// declared parent, so prefer that value when it is available.
$classDef = $this->compiler->getClassDef($class);
$next = $classDef?->extends ?: $this->compiler->getParentClass($class);
// Parent names retain declaration casing. Normalize every hop,
// not only the initial class name.
$class = strtolower(ltrim($this->compiler->getParentClass($class), '\\'));
$class = strtolower(ltrim($next, '\\'));
}
return false;
}
@ -189,7 +195,12 @@ final class PropertyAccessResolver
$declaringClass = $propRef->getDeclaringClass()->getName();
if ($propRef->isProtected()) {
if (!$this->canAccessProtectedProperty($scope, $declaringClass)) {
// Reaching an internal declaration by walking requestedClass's
// compiled parent chain already proves that requestedClass may
// access the inherited protected slot. Avoid rebuilding that
// relationship from a conversion-time parent index here.
if (!$this->isSameClassName($scope, $requestedClass)
&& !$this->canAccessProtectedProperty($scope, $declaringClass)) {
$displayClass = ltrim($requestedClass, '\\');
$this->fatal($expr, "Cannot access protected property `{$property}` of class `{$displayClass}`");
}

@ -1116,13 +1116,12 @@ CODE;
}
}
// 扩展模式,需要在 RSHUTDOWN 阶段中清理用户定义的函数、类缓存;
// 内置/编译产物符号在 persistent_*_map 中,跨请求存活,无需清理;
// 无动态 propMap(属性 offset 缓存仅含进程级稳定的声明属性),无需清理
if ($this->isBuildModeExt()) {
$code .= 'std::memset(' . self::PREFIX . self::FUNC_MAP . ', 0, sizeof(' . self::PREFIX . self::FUNC_MAP . '));' . PHP_EOL;
$code .= 'std::memset(' . self::PREFIX . self::CLASS_MAP . ', 0, sizeof(' . self::PREFIX . self::CLASS_MAP . '));' . PHP_EOL;
}
// User-code symbols have request lifetime regardless of the build mode.
// Embedded/library hosts may start more than one Zend request in the
// same process, so never let these pointers survive RSHUTDOWN.
// Internal/compiled symbols remain in the module-lifetime persistent maps.
$code .= 'std::memset(' . self::PREFIX . self::FUNC_MAP . ', 0, sizeof(' . self::PREFIX . self::FUNC_MAP . '));' . PHP_EOL;
$code .= 'std::memset(' . self::PREFIX . self::CLASS_MAP . ', 0, sizeof(' . self::PREFIX . self::CLASS_MAP . '));' . PHP_EOL;
$code .= '}' . PHP_EOL . PHP_EOL;
// php_app_clean end

@ -0,0 +1,23 @@
--TEST--
Repeated dynamic static calls do not reuse a released Zend trampoline
--FILE--
<?php
class RepeatedMagicStaticCall
{
public static function __callStatic(string $name, array $arguments): string
{
return $name . ':' . $arguments[0];
}
}
function main(): void
{
for ($i = 0; $i < 3; $i++) {
var_dump(RepeatedMagicStaticCall::missing($i));
}
}
?>
--EXPECT--
string(9) "missing:0"
string(9) "missing:1"
string(9) "missing:2"

@ -0,0 +1,25 @@
--TEST--
Repeated static calls to a runtime-defined magic class use transient callables safely
--FILE--
<?php
function main(): void
{
eval(<<<'PHP'
class RuntimeMagicStaticCall
{
public static function __callStatic(string $name, array $arguments): string
{
return $name . ':' . $arguments[0];
}
}
PHP);
for ($i = 0; $i < 3; $i++) {
var_dump(RuntimeMagicStaticCall::missing($i));
}
}
?>
--EXPECT--
string(9) "missing:0"
string(9) "missing:1"
string(9) "missing:2"
Loading…
Cancel
Save