Commit 4f35c154c74 for php.net
commit 4f35c154c74f9dc54f7167e5e927179862a81978
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date: Sun Jun 14 12:01:11 2026 -0400
Fix SplObjectStorage getHash() guard leaking across a bailout
The getHash() recursion guard increments a request-persistent counter
around the userland getHash() call but decrements it only on the normal
return path. A bailout inside an overridden getHash() (out-of-memory,
timeout, or any fatal) skips the decrement, and the counter is never
reset per request, so on a persistent SAPI every later request on the
same worker wrongly throws "Modification of SplObjectStorage during
getHash() is prohibited". Reset the counter in the SPL request init so
each request starts at zero regardless of how the previous one exited.
Closes GH-22308
diff --git a/ext/spl/php_spl.c b/ext/spl/php_spl.c
index 2477b371ea4..0610e79196f 100644
--- a/ext/spl/php_spl.c
+++ b/ext/spl/php_spl.c
@@ -563,6 +563,7 @@ PHP_MINIT_FUNCTION(spl)
PHP_RINIT_FUNCTION(spl) /* {{{ */
{
spl_autoload_extensions = NULL;
+ spl_object_storage_reset_get_hash_depth();
return SUCCESS;
} /* }}} */
diff --git a/ext/spl/spl_observer.c b/ext/spl/spl_observer.c
index f897ab1350c..613bf5384dc 100644
--- a/ext/spl/spl_observer.c
+++ b/ext/spl/spl_observer.c
@@ -47,6 +47,11 @@ static zend_object_handlers spl_handler_MultipleIterator;
ZEND_TLS uint32_t spl_object_storage_get_hash_depth;
+void spl_object_storage_reset_get_hash_depth(void)
+{
+ spl_object_storage_get_hash_depth = 0;
+}
+
typedef struct _spl_SplObjectStorage { /* {{{ */
HashTable storage;
zend_long index;
diff --git a/ext/spl/spl_observer.h b/ext/spl/spl_observer.h
index 08d3126d9c8..bbb3ed656cc 100644
--- a/ext/spl/spl_observer.h
+++ b/ext/spl/spl_observer.h
@@ -31,4 +31,6 @@ extern PHPAPI zend_class_entry *spl_ce_MultipleIterator;
PHP_MINIT_FUNCTION(spl_observer);
+void spl_object_storage_reset_get_hash_depth(void);
+
#endif /* SPL_OBSERVER_H */
diff --git a/ext/spl/tests/spl_object_storage_gethash_bailout.phpt b/ext/spl/tests/spl_object_storage_gethash_bailout.phpt
new file mode 100644
index 00000000000..a6327bd6209
--- /dev/null
+++ b/ext/spl/tests/spl_object_storage_gethash_bailout.phpt
@@ -0,0 +1,44 @@
+--TEST--
+SplObjectStorage getHash() depth counter is reset after a bailout in a user getHash()
+--SKIPIF--
+<?php
+if (!file_exists(__DIR__ . "/../../../sapi/cli/tests/php_cli_server.inc")) {
+ die("skip sapi/cli/tests/php_cli_server.inc required but not found");
+}
+?>
+--INI--
+allow_url_fopen=1
+--FILE--
+<?php
+include __DIR__ . "/../../../sapi/cli/tests/php_cli_server.inc";
+
+$code = <<<'PHP'
+if ($_SERVER["REQUEST_URI"] === "/poison") {
+ class Poison extends SplObjectStorage {
+ public function getHash($o): string {
+ ini_set("memory_limit", "2M");
+ str_repeat("a", 100 * 1024 * 1024);
+ return "x";
+ }
+ }
+ (new Poison())->offsetSet(new stdClass());
+ echo "poison";
+} else {
+ $s = new SplObjectStorage();
+ $s->offsetSet(new stdClass());
+ echo "check-ok count=", count($s);
+}
+PHP;
+
+php_cli_server_start($code, 'router.php');
+
+$base = 'http://' . PHP_CLI_SERVER_ADDRESS;
+// Request 1 bails out (OOM) inside the overridden getHash() mid-offsetSet.
+@file_get_contents($base . '/poison');
+// A later request on the same worker must not be poisoned by a stuck counter.
+echo @file_get_contents($base . '/check'), "\n";
+echo @file_get_contents($base . '/check'), "\n";
+?>
+--EXPECT--
+check-ok count=1
+check-ok count=1