Skip to content

Fix Io\Poll memory-safety and error-handling issues#22316

Open
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:fix/io-poll-memory-safety
Open

Fix Io\Poll memory-safety and error-handling issues#22316
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:fix/io-poll-memory-safety

Conversation

@iliaal

@iliaal iliaal commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Fixes memory-safety and error-handling bugs in the new Io\Poll API, found by review and confirmed under valgrind.

  • Use-after-free: a Watcher kept a raw pointer to its Context's poll context with no reference, so dropping the Context while still holding a Watcher made remove()/modify() touch freed memory. The Context now clears its watchers (active=false, poll_ctx=NULL) before destruction, so those calls throw InactiveWatcherException.
  • Descriptor leak: StreamPollHandle referenced the stream resource in its constructor but never released it. Released in the handle cleanup.
  • Missing get_gc on Watcher and Context, so cycles through Watcher::$data leaked. Added for both.
  • clone of a Context/Watcher/StreamPollHandle went through the default handler, which copied the backing poll context and watcher map by pointer and double-freed them. All three are now uncloneable.
  • Calling __construct() twice on a Context or StreamPollHandle replaced the backing state without releasing the first, leaking it. Now throws if already constructed.
  • php_poll_set_error() dereferenced its ctx argument, but add()/modify()/remove()/wait() call it exactly when ctx is NULL. Guarded.
  • epoll and poll wait() returned -1 without recording the error, so the thrown exception reported a stale code. Now set from errno.
  • kqueue grouped-event path bounded the result buffer with only a ZEND_ASSERT, allowing an out-of-bounds write in release builds when more distinct descriptors were ready than maxEvents. The buffer write is now capped at runtime while the oneshot bookkeeping still runs so the backend's tracking stays in sync. The kqueue backend isn't compiled on Linux, so this one rides on macOS CI rather than local valgrind.

Tests cover the clone guard, the double-construct guard, the watcher-outlives-context UAF, the fd release, and the get_gc cycle.

Comment thread main/poll/poll_backend_kqueue.c Outdated
/* New FD, create new event */
ZEND_ASSERT(unique_events < max_events);
if (unique_events >= max_events) {
continue;

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.

it seems continue also skips the oneshot bookkeeping below it, so any oneshot fd dropped past the cap leaves the backend's tracking desynced. Gate only the buffer write on unique_events < max_events and still fall through to the tracking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Need to dig into it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, the continue also skipped the oneshot tracking. Gated only the buffer write now, so the bookkeeping runs regardless of the cap.

Comment thread main/poll/poll_backend_poll.c Outdated
php_poll_set_current_errno_error(ctx);
return -1;
}
if (nfds == 0) {

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.

nit: I guess you can just merge as if (...) else if (...) here wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

Several memory-safety and error-handling issues in the new Io\Poll API,
found by review and confirmed under valgrind:

- Watcher kept a raw pointer to its Context's php_poll_ctx with no
  reference, so dropping the Context while holding a Watcher left
  remove()/modify() dereferencing freed memory (use-after-free). The
  Context now neutralizes its watchers (active=false, poll_ctx=NULL)
  before it is destroyed, so those calls throw InactiveWatcherException.
- StreamPollHandle took a reference on the stream resource in the
  constructor but never released it, leaking the descriptor for the
  rest of the request. Store the zend_resource and release it in the
  handle cleanup; the php_stream may already be freed by then (e.g.
  the user closed it), so the cleanup must not dereference it.
- Watcher and Context had no get_gc handler, so reference cycles through
  Watcher::$data were uncollectable. Add get_gc for both.
- Context, Watcher and StreamPollHandle were cloneable through the
  default handler, which shallow-copied the backing php_poll_ctx and the
  watcher map by pointer and double-freed them on destruction. Mark all
  three uncloneable.
- Calling __construct() a second time on a Context or StreamPollHandle
  replaced the backing context or handle data without releasing the
  first, leaking it. Throw if the object is already constructed.
- php_poll_set_error() dereferenced its ctx argument, but the add(),
  modify(), remove() and wait() entry points call it precisely when ctx
  is NULL. Guard the dereference.
- The epoll and poll backends returned -1 from wait() without recording
  the error, so the thrown exception carried a stale code. Set it from
  errno, which maps EINTR to the interrupted error.
- The kqueue grouped-event path bounded the result buffer only with a
  ZEND_ASSERT, allowing an out-of-bounds write in release builds when
  more distinct descriptors were ready than the caller's maxEvents. Cap
  the buffer write at runtime while still running the oneshot bookkeeping
  so the backend's tracking stays in sync.
- php_poll_create_by_name() dereferenced a NULL backend name; guard it.
@iliaal iliaal force-pushed the fix/io-poll-memory-safety branch from ef34a6e to 71ddf7d Compare June 15, 2026 12:41
@iliaal

iliaal commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

@ndossche fixes pushed: clone now throws (Context/Watcher/StreamPollHandle marked uncloneable), double __construct throws instead of leaking, and php_poll_set_error() is NULL-safe so the add/modify/remove/wait guards no longer deref a NULL ctx. get_gc and the watcher-after-context-destroy UAF were already in the PR with tests (poll_watcher_gc_cycle, poll_watcher_outlives_context).

Couldn't reproduce the getAvailableBackends() foreach UAF under valgrind on Linux (poll and epoll), even mutating the array mid-iteration. Which platform/backend and build (ASAN?) did you hit it on?

@ndossche

Copy link
Copy Markdown
Member

Couldn't reproduce the getAvailableBackends() foreach UAF under valgrind on Linux (poll and epoll), even mutating the array mid-iteration. Which platform/backend and build (ASAN?) did you hit it on?

See Slack, I already fixed that yesterday ;)

I'll have a review look at this PR this evening.

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.

3 participants