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)) {