Commit 2a43bd1cce6 for php.net

commit 2a43bd1cce648961f0863dc32cd96730c08a49a1
Author: Nicolas Grekas <nicolas.grekas@gmail.com>
Date:   Sun Sep 20 16:54:58 2026 +0200

    ext/standard: Fix use-after-free when a polled stream is closed

    StreamPollHandle cached the php_stream pointer it was constructed with, while the
    reference it takes is on the resource. fclose() frees the stream and leaves the
    resource alive as a closed one, so every later use of the handle read freed memory:
    Context::add(), Watcher::modifyEvents() and Watcher::remove() crashed in
    php_stream_cast(), and isValid() returned true.

    The stream is now resolved from the resource on each use, which gives NULL once it
    is closed. The call sites already handle SOCK_ERR, so they report the invalid handle
    the way they were written to.

diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c
index 864a802bdad..5632fc270c5 100644
--- a/ext/standard/io_poll.c
+++ b/ext/standard/io_poll.c
@@ -67,7 +67,6 @@ struct php_io_poll_context_object {

 /* Stream poll handle specific data */
 typedef struct php_stream_poll_handle_data {
-	php_stream *stream;
 	zend_resource *res;
 } php_stream_poll_handle_data;

@@ -180,16 +179,29 @@ static const char *php_io_poll_backend_type_to_name(php_poll_backend_type type)

 /* Stream Poll Handle Implementation */

-static php_socket_t php_stream_poll_handle_get_fd(php_poll_handle_object *handle)
+/* The stream is resolved from the resource on every use: fclose() frees the stream
+ * while the resource, which the handle holds a reference to, stays as a closed one. */
+static php_stream *php_stream_poll_handle_get_stream(php_poll_handle_object *handle)
 {
 	php_stream_poll_handle_data *data = handle->handle_data;
+
+	if (!data) {
+		return NULL;
+	}
+
+	return zend_fetch_resource2(data->res, NULL, php_file_le_stream(), php_file_le_pstream());
+}
+
+static php_socket_t php_stream_poll_handle_get_fd(php_poll_handle_object *handle)
+{
+	php_stream *stream = php_stream_poll_handle_get_stream(handle);
 	php_socket_t fd;

-	if (!data || !data->stream) {
+	if (!stream) {
 		return SOCK_ERR;
 	}

-	if (php_stream_cast(data->stream, PHP_STREAM_AS_FD_FOR_SELECT | PHP_STREAM_CAST_INTERNAL,
+	if (php_stream_cast(stream, PHP_STREAM_AS_FD_FOR_SELECT | PHP_STREAM_CAST_INTERNAL,
 				(void *) &fd, 1)
 					!= SUCCESS
 			|| fd == -1) {
@@ -201,8 +213,8 @@ static php_socket_t php_stream_poll_handle_get_fd(php_poll_handle_object *handle

 static int php_stream_poll_handle_is_valid(php_poll_handle_object *handle)
 {
-	php_stream_poll_handle_data *data = handle->handle_data;
-	return data && data->stream && !php_stream_eof(data->stream);
+	php_stream *stream = php_stream_poll_handle_get_stream(handle);
+	return stream && !php_stream_eof(stream);
 }

 static void php_stream_poll_handle_cleanup(php_poll_handle_object *handle)
@@ -454,7 +466,6 @@ PHP_METHOD(StreamPollHandle, __construct)

 	/* Set up stream-specific data */
 	php_stream_poll_handle_data *data = emalloc(sizeof(php_stream_poll_handle_data));
-	data->stream = stream;
 	data->res = stream->res;
 	intern->handle_data = data;

@@ -469,12 +480,12 @@ PHP_METHOD(StreamPollHandle, getStream)
 	php_poll_handle_object *intern = PHP_POLL_HANDLE_OBJ_FROM_ZV(getThis());
 	php_stream_poll_handle_data *data = intern->handle_data;

-	if (!data || !data->stream) {
+	if (!data || !data->res) {
 		RETURN_NULL();
 	}

-	GC_ADDREF(data->stream->res);
-	php_stream_to_zval(data->stream, return_value);
+	GC_ADDREF(data->res);
+	ZVAL_RES(return_value, data->res);
 }

 PHP_METHOD(StreamPollHandle, isValid)
diff --git a/ext/standard/tests/poll/poll_stream_handle_closed_stream.phpt b/ext/standard/tests/poll/poll_stream_handle_closed_stream.phpt
new file mode 100644
index 00000000000..56b5c08fade
--- /dev/null
+++ b/ext/standard/tests/poll/poll_stream_handle_closed_stream.phpt
@@ -0,0 +1,43 @@
+--TEST--
+Io\Poll: handle operations after the stream has been closed
+--FILE--
+<?php
+require_once __DIR__ . '/poll.inc';
+
+list($r, $w) = pt_new_socket_pair();
+$poll_ctx = pt_new_stream_poll();
+
+$handle = new StreamPollHandle($r);
+$watcher = $poll_ctx->add($handle, [Io\Poll\Event::Read]);
+$not_added_yet = new StreamPollHandle($w);
+
+fclose($r);
+fclose($w);
+
+var_dump($handle->isValid());
+var_dump(get_debug_type($handle->getStream()));
+
+try {
+    $watcher->modifyEvents([Io\Poll\Event::Write]);
+} catch (Io\Poll\InvalidHandleException $e) {
+    echo $e->getMessage(), "\n";
+}
+
+try {
+    $poll_ctx->add($not_added_yet, [Io\Poll\Event::Read]);
+} catch (Io\Poll\InvalidHandleException $e) {
+    echo $e->getMessage(), "\n";
+}
+
+echo "Events count: ", count($poll_ctx->wait(Time\Duration::fromSeconds(0))), "\n";
+
+$watcher->remove();
+var_dump($watcher->isActive());
+?>
+--EXPECT--
+bool(false)
+string(17) "resource (closed)"
+Invalid handle for polling
+Invalid handle for polling
+Events count: 0
+bool(false)