Skip to content

Fix use-after-free in StreamPollHandle when its stream is closed - #23804

Closed
nicolas-grekas wants to merge 1 commit into
php:masterfrom
nicolas-grekas:io-poll-handle-uaf
Closed

nicolas-grekas wants to merge 1 commit into
php:masterfrom
nicolas-grekas:io-poll-handle-uaf

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Contributor

StreamPollHandle takes a reference on the resource, but it caches the php_stream * it was given at construction. fclose() frees that stream while the resource stays alive, so anything that reaches the handle afterwards works on freed memory:

$ctx = new Io\Poll\Context();
[$a, $b] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
$w = $ctx->add(new StreamPollHandle($a), [Io\Poll\Event::Read]);
fclose($a);
$w->remove(); // segfault, same with modifyEvents() and isValid()

Resolving the stream from the resource is enough: res->ptr is NULL once it is closed, and the SOCK_ERR paths that are already there then do the right thing - remove() skips the poll context, modifyEvents() throws InvalidHandleException, isValid() returns false, and wait() ignores the watcher. getStream() returns the closed resource, which is what the polyfill does too.

I hit this while writing an event loop driver on top of the API. Closing a socket before cancelling its watcher is a user mistake, but it should not crash.

BTW StreamPollHandle::getFileDescriptor() is implemented but not in the stub, so it is dead code at the moment - remove it or expose it?

/cc @bukka @iluuu1994

StreamPollHandle takes a reference on the resource but caches the php_stream
pointer it was given at construction. fclose() frees that stream while the
resource stays alive, so Watcher::remove(), Watcher::modifyEvents() and
StreamPollHandle::isValid() dereference freed memory.

Resolve the stream from the resource instead: res->ptr is NULL once the stream
is closed, which the existing SOCK_ERR paths already handle.
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Duplicate of #23791, which came first and resolves the stream through zend_fetch_resource2() rather than reading res->ptr directly, so it validates the resource type and covers persistent streams too. I moved over the one assertion this had that it did not, that wait() reports nothing for a watcher whose stream was closed.

@nicolas-grekas
nicolas-grekas deleted the io-poll-handle-uaf branch September 21, 2026 10:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants