Commit 46352f7eadd for php.net
commit 46352f7eadd0efd30d3eb3dcae17081d01ca0057
Author: David Carlier <devnexen@gmail.com>
Date: Mon Jul 20 21:23:43 2026 +0100
ext/standard: stream_filter_register() orphaned user_filter_map on shutdown re-registration.
Fix GH-22818
During request shutdown the user_filter_map is torn down by the
user_filters RSHUTDOWN before the streams referencing it are flushed. A
user filter whose filter() callback re-registers the filter recreated the
now-NULL map, then, since the volatile factory was still present in
FG(stream_filters), deleted the freshly added entry and left an empty
orphaned map behind. The following stream_filter_append() located the
factory but no matching fdat, tripping ZEND_ASSERT(fdat), and the
recreated map leaked.
Register the volatile factory first and only create and populate
user_filter_map on success, so a re-registration during the shutdown
window fails without recreating the map. The existing NULL-map guard in
user_filter_factory_create() then handles the append gracefully.
diff --git a/ext/standard/tests/gh22818.phpt b/ext/standard/tests/gh22818.phpt
new file mode 100644
index 00000000000..e6ebaeecce5
--- /dev/null
+++ b/ext/standard/tests/gh22818.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Bug GH-22818: user_filter_factory_create assertion failure on shutdown re-registration
+--FILE--
+<?php
+
+class rotate_filter_nw extends php_user_filter
+{
+ public function filter($in, $out, &$consumed, $closing): int
+ {
+ $stream = fopen('php://memory', 'w+');
+ stream_filter_register("rotator_notWorking", rotate_filter_nw::class);
+ stream_filter_append($stream, "rotator_notWorking");
+
+ return PSFS_PASS_ON;
+ }
+}
+
+stream_filter_register("rotator_notWorking", rotate_filter_nw::class);
+
+$stream = fopen('php://memory', 'w+');
+stream_filter_append($stream, "rotator_notWorking");
+
+echo "done\n";
+?>
+--EXPECTF--
+done
+
+Warning: stream_filter_append(): Unable to create or locate filter "rotator_notWorking" in %s on line %d
diff --git a/ext/standard/user_filters.c b/ext/standard/user_filters.c
index 9711ad1f83f..d69af26912c 100644
--- a/ext/standard/user_filters.c
+++ b/ext/standard/user_filters.c
@@ -601,20 +601,21 @@ PHP_FUNCTION(stream_filter_register)
RETURN_THROWS();
}
+ /* Register the factory first; if that fails, don't (re)create the map,
+ * which would leak during shutdown re-registration. */
+ if (php_stream_filter_register_factory_volatile(filtername, &user_filter_factory) == FAILURE) {
+ RETURN_FALSE;
+ }
+
if (!BG(user_filter_map)) {
BG(user_filter_map) = (HashTable*) emalloc(sizeof(HashTable));
/* We don't need a destructor as we are only storing a CE which should be never modified */
zend_hash_init(BG(user_filter_map), 8, NULL, NULL, 0);
}
- if (zend_hash_add_ptr(BG(user_filter_map), filtername, ce) != NULL) {
- if (php_stream_filter_register_factory_volatile(filtername, &user_filter_factory) == SUCCESS) {
- RETURN_TRUE;
- }
-
- zend_hash_del(BG(user_filter_map), filtername);
- }
+ /* The factory has just been (re)registered, so keep the map in sync. */
+ zend_hash_update_ptr(BG(user_filter_map), filtername, ce);
- RETURN_FALSE;
+ RETURN_TRUE;
}
/* }}} */