Skip to content

Fix GH-22844: StreamPollHandle use-after-free after fclose() - #22848

Closed
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:fix/gh-22844-streampollhandle-uaf
Closed

iliaal wants to merge 1 commit into
php:masterfrom
iliaal:fix/gh-22844-streampollhandle-uaf

Conversation

@iliaal

@iliaal iliaal commented Jul 21, 2026

Copy link
Copy Markdown
Member

StreamPollHandle stored the raw php_stream * but referenced only the resource container, so fclose() freed the stream while the handle kept a dangling pointer. isValid(), getStream() and the internal get_fd (reached via Context::add()) then dereferenced freed memory. The accessors now re-derive the stream from the held resource and report a closed resource as absent: getStream() returns null, isValid() returns false, and the stub return becomes resource|null.

A watcher also outlived the stream it polled: once the stream closed its backend registration could no longer be removed by fd, so wait() could dereference the freed watcher. On epoll the interest even survives fclose() when the fd is duplicated, so removing it lazily is not enough. Watchers are now unregistered from php_stream_free(), while the fd is still valid, via a poll_watchers back-reference on the stream. Reproduced under ASAN.

Fixes #22844

@arnaud-lb arnaud-lb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not cache the fd as it becomes unrelated to the original stream after fclose(). At this point fd may be invalid (best case) or may point to another file, so calling php_poll_remove() may remove an unrelated watch:

use Io\Poll\{Context, Event};

$context = new Context();

list($r0, $w0) = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);

$watcher0 = $context->add(new StreamPollHandle($r0), [Event::Read]);

fclose($r0);

// Likely reuses $r0's fd number
list($r1, $w1) = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);

$watcher1 = $context->add(new StreamPollHandle($r1), [Event::Read]);

// Likely removes r1
$watcher0->remove();

fwrite($w1, 'hello');

// Should return [$watcher1], but will likely return []
var_dump($context->wait(0));

But if we don't remove fd, we may run into epoll's edge cases with duplicated fds (I don't know about other backends).

We should probably add a callback from php_stream_free to notify Io\Poll when we close a stream, so that Io\Poll gets an opportunity to remove a fd just before it's closed. I did that in arnaud-lb@ecc3309 to support weak Io\Poll handles.

Comment thread ext/standard/io_poll.c Outdated
StreamPollHandle referenced only the resource container but dereferenced
a raw php_stream pointer, so fclose() freed the stream while the
accessors kept dangling. Re-derive the stream from the held resource each
time and report a closed resource as absent.

A watcher also outlived the stream it polled: after the stream closed its
backend registration could not be removed by fd, so wait() dereferenced
the freed watcher. Unregister watchers from php_stream_free(), while the
fd is still valid, through a poll_watchers back-reference on the stream.

Fixes phpGH-22844
@iliaal
iliaal force-pushed the fix/gh-22844-streampollhandle-uaf branch from d02c172 to 614c39f Compare July 22, 2026 13:05
@iliaal

iliaal commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

You're right, dropped the fd cache and added the php_stream_free callback you suggested: a watched stream unregisters its watchers from every context at close time, while the fd is still valid, so there's no stale registration for a reused fd to hit. This also covers the case the fd cache couldn't, where a duplicated fd keeps the epoll interest alive past fclose(). StreamPollHandle isn't a singleton like your weak handle, so the back-reference is a per-stream watcher set (stream->poll_watchers) plus a cached fd per watcher. One caveat it shares with #32: if something closes the fd before php_stream_free (e.g. bzopen() on a watched socket), the close-time removal can't fire. If you'd rather own this in #32, I'll rebase onto it.

@arnaud-lb

Copy link
Copy Markdown
Member

One caveat it shares with #32: if something closes the fd before php_stream_free (e.g. bzopen() on a watched socket), the close-time removal can't fire

Good point. User stream wrappers would be an issue as well: not only the stream returned by stream_cast() may be closed later, but it can also return a completely different stream on subsequent calls.

We need to handle these otherwise the fix is incomplete.

For bzopen() and similar cases, we could generalize the php_stream_free callback so that the bz stream can use it to be notified when the inner stream is closed, and in turn notify its watcher or handle.

For user stream wrappers, maybe StreamPollHandle should call stream_cast() once and store the result during instantiation.

This is starting to get complicated, but it may be worth it for ergonomics, if that's doable.

An alternative may be to not do anything (besides fixing the UAF) and just document the behavior. Users would be recommended to remove watchers before closing the related stream, otherwise phantom events can be triggered.

Another alternative is that StreamPollHandle dup() the fd, so that it can not be closed by someone else, but I don't think that's a good solution.

Maybe wait for @bukka's input before spending too much time on this.

@DanielEScherzer DanielEScherzer added this to the PHP 8.6 milestone Sep 2, 2026
bukka added a commit that referenced this pull request Sep 21, 2026
…3822)

A StreamPollHandle keeps a reference to the stream resource, but an
explicit fclose() still closes the stream underneath the watcher. The
watcher could then no longer resolve its fd, so its backend registration
was left behind: a recycled fd number confused the registry and the poll
backend, and on epoll a duplicated fd could keep the interest alive and
hand a freed watcher back from wait().

Streams now keep a list of their watchers and notify them from
php_stream_free() before the fd is closed, so every watcher is
unregistered while the fd is still valid. The Context registry is keyed
by the registered fd, which the watcher caches, so remove() no longer
depends on the stream and an fd number reused after a close is detected
on add(). A watcher retired by the close reports inactive and its
remove() becomes a no-op. modifyEvents() re-adds a fired one-shot
registration that the backend dropped, and the kqueue backend drops its
tracking entry when there is nothing left to delete.

This follows the approach of GH-22848 by Ilia Alshanetsky.
@bukka

bukka commented Sep 21, 2026

Copy link
Copy Markdown
Member

I tried to just map it internally in fdtable but that just had some limitations and his callback will be needed for weakref so it's actually a good approach. We should not null the stream and there were few more issues so the full fix in #23822 .

I think that bzopen() and similar should never close it unless they duplicate it. I looked to bz2 and it actually had a double close bug (first through BZ2_bzclose and then doing php_stream_free which closes it too) without doing any dup. Fix in #23824 . I also checked zlib and it uses dup so it should be fine.

@bukka bukka closed this Sep 21, 2026
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.

StreamPollHandle UAF

4 participants