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