Skip to content

Fix GH-22063: stream filter chain UAF on self-removal during callback#22083

Open
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:fix/gh-22063-stream-filter-uaf
Open

Fix GH-22063: stream filter chain UAF on self-removal during callback#22083
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:fix/gh-22063-stream-filter-uaf

Conversation

@iliaal
Copy link
Copy Markdown
Contributor

@iliaal iliaal commented May 18, 2026

A php_user_filter that calls stream_filter_remove on its own resource from inside filter() frees the php_stream_filter struct while userfilter_filter still dereferences &thisfilter->abstract and while the chain walks in _php_stream_filter_flush, _php_stream_write_filtered, and _php_stream_fill_read_buffer still need current->next. Two new fields on php_stream_filter, in_callback and deferred_dtor, carry the deferral: php_stream_filter_remove(filter, true) unlinks and runs zend_list_delete immediately but defers the pefree.

A stream filter struct must stay live while a fops->filter() callback
or chain iteration holds it. A php_user_filter that removes its own
resource inside filter() frees the struct under userfilter_filter
(&thisfilter->abstract deref) and under the three chain-walk sites
(current->next read). Defer pefree via an in_callback counter until
every C-level frame holding the filter releases it.

Closes phpGH-22063
Copy link
Copy Markdown
Member

@arnaud-lb arnaud-lb left a comment

Choose a reason for hiding this comment

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

This looks right, but I'm wondering if it is possible to fix this in a way that doesn't force all filter iterators to pay attention to removal.

This affects only user filters, and only when removing themselves.

A related issue with user filters is that they were able to close the stream. This has been fixed by flagging the stream in userfilter_filter().

@iliaal
Copy link
Copy Markdown
Contributor Author

iliaal commented May 18, 2026

The iterators' current = current->next advance happens after userfilter_filter returns, so a flag scoped to userfilter_filter alone misses the second UAF site. Same issue with the closing-flush in zif_stream_filter_remove, where the outer flush returns and the caller re-derefs the freed pointer. The iterator boundary has to participate.

Closest alternative: stream-level deferred-free flag + list, each iterator brackets its loop with set/clear/drain. That moves per-iteration logic to per-loop boundaries, I can try that approach if you want?

@arnaud-lb
Copy link
Copy Markdown
Member

arnaud-lb commented May 19, 2026

Another approach would be to leverage refcounting: php_stream_filter.res is refcounted and should have the same lifetime as the php_stream_filter. It may be possible to free the php_stream_filter and .abstract only in the resource's dtor callback (removing the filter from the list before that should be valid?). userfilter_filter should increase the resource's refcount temporarily.

@iliaal
Copy link
Copy Markdown
Contributor Author

iliaal commented May 19, 2026

Tried the refcount version. Resource dtor on le_stream_filter does the free, userfilter_filter brackets the userland call with GC_ADDREF(thisfilter->res) / zend_list_delete, zif_stream_filter_remove does the same around the closing flush. gh22063.phpt passes under ASAN, upside, doesn't require new struct fields.

The blocker is request shutdown. zend_close_rsrc_list runs through EG(regular_list) in hash order, and zend_resource_dtor ignores refcount: the moment a filter resource's slot is visited, the new dtor frees the filter struct. If the owning stream resource is visited next, its stream_resource_regular_dtor_php_stream_free_php_stream_flush_php_stream_write_filtered walks stream->writefilters.head into freed memory. ASAN flags 17 new heap-use-after-free regressions across ext/standard/tests/filters/*.

Making refcount-only work without that means either the dtor stops being the owner, or filter-resource vs stream-resource teardown order needs coordination, which gives back the simplicity.

My failed attempt is here alt/gh-22063-refcount in iliaal/php-src if you want to look.

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