Commit 90569082bbb for php.net
commit 90569082bbb446ab60e03541f3487731edb84342
Author: Levi Morrison <levi.morrison@datadoghq.com>
Date: Mon Jun 15 08:17:38 2026 -0600
fix GH-20469: unsafe inheritance cache replay with reentrant autoloading (#22221)
Inheritance cache dependencies are collected while a class is being
linked. During delayed variance resolution, autoloading can re-enter
class linking and use the current class while it is only nearly linked.
If that class is persisted in the inheritance cache, a later request
can replay dependencies in a different order and observe an incomplete
hierarchy.
When delayed autoloading causes the class to be used through the
unlinked/nearly-linked lookup path, mark it as non-cacheable after
load_delayed_classes() returns. This also catches cases where the
class's variance obligations were resolved reentrantly, before the
direct resolve_delayed_variance_obligations() call would run.
If dependency tracking already allocated a temporary dependency table,
free it when cache insertion is skipped. Restrict this cleanup to
classes that entered inheritance-cache construction, because otherwise
inheritance_cache is not a dependency table and may contain unrelated
or uninitialized data. This preserves inheritance-cache use for
delayed-variance classes that did not participate in this reentrant
cycle.
With the invalid cache entry prevented, unlinked_instanceof() can keep
using instanceof_function() for linked classes.
diff --git a/NEWS b/NEWS
index 3ac671dda65..e0375b21d4f 100644
--- a/NEWS
+++ b/NEWS
@@ -44,6 +44,10 @@ PHP NEWS
. Fix stmt->query leak in mysqli_execute_query() validation errors.
(David Carlier)
+- Opcache:
+ . Fixed bug GH-20469 (Unsafe inheritance cache replay with reentrant
+ autoloading). (Levi Morrison)
+
- Phar:
. Fixed a bypass of the magic ".phar" directory protection in
Phar::addEmptyDir() for paths starting with "/.phar", while allowing
diff --git a/Zend/zend_inheritance.c b/Zend/zend_inheritance.c
index eba21dd8e82..a4dd5f1893d 100644
--- a/Zend/zend_inheritance.c
+++ b/Zend/zend_inheritance.c
@@ -3735,6 +3735,11 @@ ZEND_API zend_class_entry *zend_do_link_class(zend_class_entry *ce, zend_string
if (ce->ce_flags & ZEND_ACC_UNRESOLVED_VARIANCE) {
resolve_delayed_variance_obligations(ce);
}
+ /* Delayed variance resolution can re-enter linking before the full
+ * hierarchy is linked. See ext/opcache/tests/gh20469*.phpt. */
+ if (CG(unlinked_uses) && zend_hash_index_exists(CG(unlinked_uses), (zend_long)(uintptr_t) ce)) {
+ ce->ce_flags &= ~ZEND_ACC_CACHEABLE;
+ }
if (ce->ce_flags & ZEND_ACC_CACHEABLE) {
ce->ce_flags &= ~ZEND_ACC_CACHEABLE;
} else {
@@ -3742,6 +3747,7 @@ ZEND_API zend_class_entry *zend_do_link_class(zend_class_entry *ce, zend_string
}
}
+ bool was_cacheable = is_cacheable;
if (!CG(current_linking_class)) {
is_cacheable = 0;
}
@@ -3762,6 +3768,13 @@ ZEND_API zend_class_entry *zend_do_link_class(zend_class_entry *ce, zend_string
zend_hash_destroy(ht);
FREE_HASHTABLE(ht);
}
+ } else if (was_cacheable && ce->inheritance_cache) {
+ /* Cacheability can be disabled after dependency tracking prepared
+ * an inheritance-cache dependency table. Discard it here. */
+ HashTable *ht = (HashTable*)ce->inheritance_cache;
+ ce->inheritance_cache = NULL;
+ zend_hash_destroy(ht);
+ FREE_HASHTABLE(ht);
}
if (!orig_record_errors) {
diff --git a/ext/opcache/tests/gh20469.phpt b/ext/opcache/tests/gh20469.phpt
new file mode 100644
index 00000000000..1cd826c177e
--- /dev/null
+++ b/ext/opcache/tests/gh20469.phpt
@@ -0,0 +1,134 @@
+--TEST--
+GH-20469: Inheritance cache with reentrant autoloading must not crash
+--EXTENSIONS--
+opcache
+--CONFLICTS--
+server
+--FILE--
+<?php
+$dir = __DIR__ . '/gh20469';
+@mkdir($dir . '/classes', 0777, true);
+
+file_put_contents($dir . '/autoload.php', <<<'PHP'
+<?php
+spl_autoload_register(function ($class) {
+ $prefix = 'APP\\';
+ if (strncmp($class, $prefix, strlen($prefix)) === 0) {
+ require __DIR__ . '/classes/' . substr($class, strlen($prefix)) . '.php';
+ }
+});
+PHP);
+
+/* The dependency cycle is:
+ * ChildOfParentBeingLinked -> ParentBeingLinked -> CovariantReturnWithTrait
+ * -> RequiresRootReturnTrait -> ChildOfParentBeingLinked.
+ */
+file_put_contents($dir . '/test1.php', <<<'PHP'
+<?php
+require __DIR__ . '/autoload.php';
+echo \APP\ChildOfParentBeingLinked::SOME_CONSTANT;
+PHP);
+
+file_put_contents($dir . '/test2.php', <<<'PHP'
+<?php
+require __DIR__ . '/autoload.php';
+echo \APP\ParentBeingLinked::SOME_CONSTANT;
+PHP);
+
+file_put_contents($dir . '/classes/RootForTraitReturn.php', <<<'PHP'
+<?php
+namespace APP;
+
+class RootForTraitReturn
+{
+ function createResult(): BaseCovariantReturn
+ {
+ }
+}
+PHP);
+
+file_put_contents($dir . '/classes/ParentBeingLinked.php', <<<'PHP'
+<?php
+namespace APP;
+
+class ParentBeingLinked extends RootForTraitReturn
+{
+ public const SOME_CONSTANT = 3;
+
+ function createResult(): CovariantReturnWithTrait
+ {
+ }
+}
+PHP);
+
+file_put_contents($dir . '/classes/ChildOfParentBeingLinked.php', <<<'PHP'
+<?php
+namespace APP;
+
+class ChildOfParentBeingLinked extends ParentBeingLinked
+{
+}
+PHP);
+
+file_put_contents($dir . '/classes/BaseCovariantReturn.php', <<<'PHP'
+<?php
+namespace APP;
+
+abstract class BaseCovariantReturn
+{
+}
+PHP);
+
+file_put_contents($dir . '/classes/RequiresRootReturnTrait.php', <<<'PHP'
+<?php
+namespace APP;
+
+trait RequiresRootReturnTrait
+{
+ abstract function build(): RootForTraitReturn;
+}
+PHP);
+
+file_put_contents($dir . '/classes/CovariantReturnWithTrait.php', <<<'PHP'
+<?php
+namespace APP;
+
+class CovariantReturnWithTrait extends BaseCovariantReturn
+{
+ use RequiresRootReturnTrait;
+
+ function build(): ChildOfParentBeingLinked
+ {
+ }
+}
+PHP);
+
+include 'php_cli_server.inc';
+$ini = trim((string) getenv('TEST_PHP_EXTRA_ARGS'));
+$ini .= ($ini !== '' ? ' ' : '') . '-d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.file_update_protection=0';
+php_cli_server_start($ini);
+
+echo file_get_contents('http://' . PHP_CLI_SERVER_ADDRESS . '/gh20469/test1.php'), "\n";
+echo file_get_contents('http://' . PHP_CLI_SERVER_ADDRESS . '/gh20469/test2.php'), "\n";
+?>
+--CLEAN--
+<?php
+$dir = __DIR__ . '/gh20469';
+if (is_dir($dir)) {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ($iterator as $file) {
+ if ($file->isDir()) {
+ rmdir($file->getPathname());
+ } else {
+ unlink($file->getPathname());
+ }
+ }
+ rmdir($dir);
+}
+?>
+--EXPECT--
+3
+3
diff --git a/ext/opcache/tests/gh20469_child_variance_resolves_parent.phpt b/ext/opcache/tests/gh20469_child_variance_resolves_parent.phpt
new file mode 100644
index 00000000000..4a66c3b0513
--- /dev/null
+++ b/ext/opcache/tests/gh20469_child_variance_resolves_parent.phpt
@@ -0,0 +1,181 @@
+--TEST--
+GH-20469: Child delayed variance can resolve parent before direct delayed resolution
+--DESCRIPTION--
+This variant ensures the cacheability check after load_delayed_classes() is
+needed. Loading the delayed child resolves the parent class's variance
+obligations reentrantly, so the parent no longer has ZEND_ACC_UNRESOLVED_VARIANCE
+when control returns from load_delayed_classes(). The parent was still used while
+nearly linked, and must not be inserted into the inheritance cache.
+--EXTENSIONS--
+opcache
+--CONFLICTS--
+server
+--FILE--
+<?php
+$dir = __DIR__ . '/gh20469_child_variance_resolves_parent';
+@mkdir($dir . '/classes', 0777, true);
+
+file_put_contents($dir . '/autoload.php', <<<'PHP'
+<?php
+spl_autoload_register(function ($class) {
+ $prefix = 'APP\\';
+ if (strncmp($class, $prefix, strlen($prefix)) === 0) {
+ require __DIR__ . '/classes/' . substr($class, strlen($prefix)) . '.php';
+ }
+});
+PHP);
+
+/* The dependency cycle is:
+ * ParentBeingLinked -> CovariantReturnWithTrait -> RequiresRootReturnTrait
+ * -> ChildOfParentBeingLinked -> ParentBeingLinked.
+ *
+ * ChildOfParentBeingLinked also has delayed variance, so resolving the child's
+ * dependency on ParentBeingLinked can resolve ParentBeingLinked before it
+ * reaches its direct resolve_delayed_variance_obligations() call.
+ */
+file_put_contents($dir . '/test1.php', <<<'PHP'
+<?php
+require __DIR__ . '/autoload.php';
+// Prime the inheritance cache with the full dependency cycle.
+echo \APP\ChildOfParentBeingLinked::SOME_CONSTANT;
+PHP);
+
+file_put_contents($dir . '/test2.php', <<<'PHP'
+<?php
+require __DIR__ . '/autoload.php';
+// Link ParentBeingLinked first. During load_delayed_classes(), loading the
+// delayed child resolves ParentBeingLinked's variance obligations reentrantly.
+echo \APP\ParentBeingLinked::SOME_CONSTANT;
+$i = new \APP\ChildOfParentBeingLinked();
+var_dump($i->test());
+PHP);
+
+file_put_contents($dir . '/test3.php', <<<'PHP'
+<?php
+require __DIR__ . '/autoload.php';
+// Replay the cache state created by test2. If ParentBeingLinked was cached even
+// though it was used while nearly linked, this request fails before test() runs.
+echo \APP\ParentBeingLinked::SOME_CONSTANT;
+$i = new \APP\ChildOfParentBeingLinked();
+var_dump($i->test());
+PHP);
+
+file_put_contents($dir . '/classes/RootForTraitReturn.php', <<<'PHP'
+<?php
+namespace APP;
+
+class RootForTraitReturn
+{
+ function createResult(): BaseCovariantReturn
+ {
+ }
+
+ function test() {}
+}
+PHP);
+
+file_put_contents($dir . '/classes/ParentBeingLinked.php', <<<'PHP'
+<?php
+namespace APP;
+
+class ParentBeingLinked extends RootForTraitReturn
+{
+ public const SOME_CONSTANT = 3;
+
+ // CovariantReturnWithTrait is unavailable when this method is checked,
+ // putting ParentBeingLinked into delayed variance resolution.
+ function createResult(): CovariantReturnWithTrait
+ {
+ }
+}
+PHP);
+
+file_put_contents($dir . '/classes/ChildOfParentBeingLinked.php', <<<'PHP'
+<?php
+namespace APP;
+
+class ChildOfParentBeingLinked extends ParentBeingLinked
+{
+ // MoreSpecificReturn is also unavailable when the child is linked. Resolving
+ // this child's delayed variance obligation recursively resolves the parent.
+ function createResult(): MoreSpecificReturn
+ {
+ }
+}
+PHP);
+
+file_put_contents($dir . '/classes/BaseCovariantReturn.php', <<<'PHP'
+<?php
+namespace APP;
+
+abstract class BaseCovariantReturn
+{
+}
+PHP);
+
+file_put_contents($dir . '/classes/RequiresRootReturnTrait.php', <<<'PHP'
+<?php
+namespace APP;
+
+trait RequiresRootReturnTrait
+{
+ abstract function build(): RootForTraitReturn;
+}
+PHP);
+
+file_put_contents($dir . '/classes/CovariantReturnWithTrait.php', <<<'PHP'
+<?php
+namespace APP;
+
+class CovariantReturnWithTrait extends BaseCovariantReturn
+{
+ use RequiresRootReturnTrait;
+
+ // This pulls ChildOfParentBeingLinked into the delayed autoload queue while
+ // ParentBeingLinked is nearly linked.
+ function build(): ChildOfParentBeingLinked
+ {
+ }
+}
+PHP);
+
+file_put_contents($dir . '/classes/MoreSpecificReturn.php', <<<'PHP'
+<?php
+namespace APP;
+
+class MoreSpecificReturn extends CovariantReturnWithTrait
+{
+}
+PHP);
+
+include 'php_cli_server.inc';
+$ini = trim((string) getenv('TEST_PHP_EXTRA_ARGS'));
+$ini .= ($ini !== '' ? ' ' : '') . '-d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.file_update_protection=0';
+php_cli_server_start($ini);
+
+echo rtrim(file_get_contents('http://' . PHP_CLI_SERVER_ADDRESS . '/gh20469_child_variance_resolves_parent/test1.php'), "\n"), "\n";
+echo rtrim(file_get_contents('http://' . PHP_CLI_SERVER_ADDRESS . '/gh20469_child_variance_resolves_parent/test2.php'), "\n"), "\n";
+echo rtrim(file_get_contents('http://' . PHP_CLI_SERVER_ADDRESS . '/gh20469_child_variance_resolves_parent/test3.php'), "\n"), "\n";
+?>
+--CLEAN--
+<?php
+$dir = __DIR__ . '/gh20469_child_variance_resolves_parent';
+if (is_dir($dir)) {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ($iterator as $file) {
+ if ($file->isDir()) {
+ rmdir($file->getPathname());
+ } else {
+ unlink($file->getPathname());
+ }
+ }
+ rmdir($dir);
+}
+?>
+--EXPECT--
+3
+3NULL
+3NULL
diff --git a/ext/opcache/tests/gh20469_inheritance_cache_cleanup.phpt b/ext/opcache/tests/gh20469_inheritance_cache_cleanup.phpt
new file mode 100644
index 00000000000..aabbc398cbc
--- /dev/null
+++ b/ext/opcache/tests/gh20469_inheritance_cache_cleanup.phpt
@@ -0,0 +1,22 @@
+--TEST--
+GH-20469: Skipped inheritance cache cleanup must ignore non-cacheable classes
+--DESCRIPTION--
+Autoloading the parent makes the child use the runtime class-linking path, but
+the child does not enter inheritance-cache construction. Under ASAN, the
+uninitialized inheritance_cache field is filled with non-zero bytes. Skipped
+cache insertion must not treat that value as a temporary dependency table.
+--EXTENSIONS--
+opcache
+--FILE--
+<?php
+spl_autoload_register(function ($class) {
+ if ($class === 'ParentForSkippedInheritanceCacheCleanup') {
+ eval('class ParentForSkippedInheritanceCacheCleanup {}');
+ }
+});
+
+eval('class ChildForSkippedInheritanceCacheCleanup extends ParentForSkippedInheritanceCacheCleanup {}');
+echo "ok\n";
+?>
+--EXPECT--
+ok
diff --git a/ext/opcache/tests/gh20469_inherited_method.phpt b/ext/opcache/tests/gh20469_inherited_method.phpt
new file mode 100644
index 00000000000..f3c038bdc33
--- /dev/null
+++ b/ext/opcache/tests/gh20469_inherited_method.phpt
@@ -0,0 +1,138 @@
+--TEST--
+GH-20469: Inheritance cache with reentrant autoloading must preserve inherited methods
+--EXTENSIONS--
+opcache
+--CONFLICTS--
+server
+--FILE--
+<?php
+$dir = __DIR__ . '/gh20469_inherited_method';
+@mkdir($dir . '/classes', 0777, true);
+
+file_put_contents($dir . '/autoload.php', <<<'PHP'
+<?php
+spl_autoload_register(function ($class) {
+ $prefix = 'APP\\';
+ if (strncmp($class, $prefix, strlen($prefix)) === 0) {
+ require __DIR__ . '/classes/' . substr($class, strlen($prefix)) . '.php';
+ }
+});
+PHP);
+
+/* The dependency cycle is:
+ * ChildOfParentBeingLinked -> ParentBeingLinked -> CovariantReturnWithTrait
+ * -> RequiresRootReturnTrait -> ChildOfParentBeingLinked.
+ */
+file_put_contents($dir . '/test1.php', <<<'PHP'
+<?php
+require __DIR__ . '/autoload.php';
+echo \APP\ChildOfParentBeingLinked::SOME_CONSTANT;
+PHP);
+
+file_put_contents($dir . '/test2.php', <<<'PHP'
+<?php
+require __DIR__ . '/autoload.php';
+echo \APP\ParentBeingLinked::SOME_CONSTANT;
+$i = new \APP\ChildOfParentBeingLinked();
+var_dump($i->test());
+PHP);
+
+file_put_contents($dir . '/classes/RootForTraitReturn.php', <<<'PHP'
+<?php
+namespace APP;
+
+class RootForTraitReturn
+{
+ function createResult(): BaseCovariantReturn
+ {
+ }
+
+ function test() {}
+}
+PHP);
+
+file_put_contents($dir . '/classes/ParentBeingLinked.php', <<<'PHP'
+<?php
+namespace APP;
+
+class ParentBeingLinked extends RootForTraitReturn
+{
+ public const SOME_CONSTANT = 3;
+
+ function createResult(): CovariantReturnWithTrait
+ {
+ }
+}
+PHP);
+
+file_put_contents($dir . '/classes/ChildOfParentBeingLinked.php', <<<'PHP'
+<?php
+namespace APP;
+
+class ChildOfParentBeingLinked extends ParentBeingLinked
+{
+}
+PHP);
+
+file_put_contents($dir . '/classes/BaseCovariantReturn.php', <<<'PHP'
+<?php
+namespace APP;
+
+abstract class BaseCovariantReturn
+{
+}
+PHP);
+
+file_put_contents($dir . '/classes/RequiresRootReturnTrait.php', <<<'PHP'
+<?php
+namespace APP;
+
+trait RequiresRootReturnTrait
+{
+ abstract function build(): RootForTraitReturn;
+}
+PHP);
+
+file_put_contents($dir . '/classes/CovariantReturnWithTrait.php', <<<'PHP'
+<?php
+namespace APP;
+
+class CovariantReturnWithTrait extends BaseCovariantReturn
+{
+ use RequiresRootReturnTrait;
+
+ function build(): ChildOfParentBeingLinked
+ {
+ }
+}
+PHP);
+
+include 'php_cli_server.inc';
+$ini = trim((string) getenv('TEST_PHP_EXTRA_ARGS'));
+$ini .= ($ini !== '' ? ' ' : '') . '-d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.file_update_protection=0';
+php_cli_server_start($ini);
+
+echo file_get_contents('http://' . PHP_CLI_SERVER_ADDRESS . '/gh20469_inherited_method/test1.php'), "\n";
+echo file_get_contents('http://' . PHP_CLI_SERVER_ADDRESS . '/gh20469_inherited_method/test2.php'), "\n";
+?>
+--CLEAN--
+<?php
+$dir = __DIR__ . '/gh20469_inherited_method';
+if (is_dir($dir)) {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ($iterator as $file) {
+ if ($file->isDir()) {
+ rmdir($file->getPathname());
+ } else {
+ unlink($file->getPathname());
+ }
+ }
+ rmdir($dir);
+}
+?>
+--EXPECT--
+3
+3NULL