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)