Commit 3db04878609 for php.net
commit 3db04878609913866d791946d3e29036506fa7b3
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date: Sun Jun 28 13:35:50 2026 -0400
Don't expose a freed stream resource to user filters
When a stream is freed from its resource destructor, the on-close
write-filter flush runs the user filter callback while the stream's
resource is already dtor'd (type == -1) and about to be freed. Exposing
it through $this->stream let user code capture the dead resource in an
exception backtrace, a use-after-free. Assign null when the resource is
no longer live; the explicit fclose() flush still runs before the
resource is closed, so live streams are unaffected.
Fixes GH-15836
Closes GH-22503
diff --git a/NEWS b/NEWS
index e4667b1d3b9..0aa44c4d500 100644
--- a/NEWS
+++ b/NEWS
@@ -19,6 +19,10 @@ PHP NEWS
- Sockets:
. Fixed various memory related issues in ext/sockets. (David Carlier)
+- Streams:
+ . Fixed bug GH-15836 (Use-after-free when a user stream filter accesses
+ $this->stream during the close flush). (iliaal)
+
30 Jul 2026, PHP 8.4.24
- Calendar:
diff --git a/ext/standard/tests/filters/bug54350.phpt b/ext/standard/tests/filters/bug54350.phpt
index a017893eed7..704ca46ab53 100644
--- a/ext/standard/tests/filters/bug54350.phpt
+++ b/ext/standard/tests/filters/bug54350.phpt
@@ -23,4 +23,4 @@ function filter($in, $out, &$consumed, $closing): int {
?>
--EXPECTF--
Warning: fclose(): 5 is not a valid stream resource in %s on line %d
-fclose(): supplied resource is not a valid stream resource
+fclose(): Argument #1 ($stream) must be of type resource, null given
diff --git a/ext/standard/tests/filters/gh15836.phpt b/ext/standard/tests/filters/gh15836.phpt
new file mode 100644
index 00000000000..593b93dcdc7
--- /dev/null
+++ b/ext/standard/tests/filters/gh15836.phpt
@@ -0,0 +1,34 @@
+--TEST--
+GH-15836 (use-after-free when a user filter reads $this->stream during the close flush)
+--FILE--
+<?php
+class my_filter extends php_user_filter {
+ public static ?Throwable $e = null;
+ function filter($in, $out, &$consumed, $closing): int {
+ if ($closing) {
+ try {
+ stream_bucket_new($this->stream, "x");
+ } catch (TypeError $e) {
+ self::$e = $e;
+ }
+ }
+ return PSFS_PASS_ON;
+ }
+}
+var_dump(stream_filter_register("my_filter", "my_filter"));
+
+function run() {
+ $s = fopen("php://memory", "wb+");
+ stream_filter_append($s, "my_filter", STREAM_FILTER_WRITE);
+}
+run();
+
+echo my_filter::$e->getTraceAsString(), "\n";
+echo "done\n";
+?>
+--EXPECTF--
+bool(true)
+#0 %s(%d): stream_bucket_new(NULL, 'x')
+#1 %s(%d): my_filter->filter(Resource id #%d, Resource id #%d, 0, true)
+#2 {main}
+done
diff --git a/ext/standard/user_filters.c b/ext/standard/user_filters.c
index 83b1986b82a..735dd8390de 100644
--- a/ext/standard/user_filters.c
+++ b/ext/standard/user_filters.c
@@ -156,7 +156,11 @@ static php_stream_filter_status_t userfilter_filter(
bool stream_property_exists = Z_OBJ_HT_P(obj)->has_property(Z_OBJ_P(obj), stream_name, ZEND_PROPERTY_EXISTS, NULL);
if (stream_property_exists) {
zval stream_zval;
- php_stream_to_zval(stream, &stream_zval);
+ if (EXPECTED(stream->res && stream->res->type >= 0)) {
+ php_stream_to_zval(stream, &stream_zval);
+ } else {
+ ZVAL_NULL(&stream_zval);
+ }
zend_update_property_ex(Z_OBJCE_P(obj), Z_OBJ_P(obj), stream_name, &stream_zval);
/* If property update threw an exception, skip filter execution */
if (EG(exception)) {