Skip to content

Use-after-free in stream_filter_remove() called from a stream callback - #24168

Open
EdmondDantes wants to merge 1 commit into
php:PHP-8.4from
true-async:stream-filter-remove-in-callback
Open

EdmondDantes wants to merge 1 commit into
php:PHP-8.4from
true-async:stream-filter-remove-in-callback

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

stream_filter_remove() flushes the filter, then unlinks and frees it. The flush can run PHP code: a user filter's filter() method, or a user wrapper's stream_write() for the flushed data. If that code removes the same filter, the outer call continues on freed memory. A user filter can also remove itself (or another filter of its stream) from filter(), or from other code while filter() is suspended in a Fiber; userfilter_filter() and the filter chain walks then use the freed filter after the callback returns. Valgrind shows invalid reads in userfilter_filter() and php_stream_filter_remove().

The fix:

  • userfilter_filter() sets a new stream flag, PHP_STREAM_FLAG_USER_FILTER_RUNNING, around the callback, next to the existing PHP_STREAM_FLAG_NO_FCLOSE. stream_filter_remove() refuses with a warning while it is set, as fclose() already does.
  • After the flush, stream_filter_remove() checks that the filter resource is still alive, for the case where a user wrapper removed it from stream_write().

Tests: ext/standard/tests/filters/stream_filter_remove_in_filter.phpt (removal from filter() and while filter() is suspended in a Fiber) and stream_filter_remove_during_flush.phpt (removal from a user wrapper's stream_write() during the flush). Both fail without the fix, with invalid reads under valgrind.

Known limits, left for separate changes: the flag is saved and restored per call, so Fibers resumed out of order can clear it early, as with PHP_STREAM_FLAG_NO_FCLOSE today; pclose() and closedir() free a stream without checking PHP_STREAM_FLAG_NO_FCLOSE.

@devnexen

devnexen commented Oct 6, 2026

Copy link
Copy Markdown
Member

Hi @EdmondDantes, can the following test be added please ?

--TEST--
stream_filter_remove() while user filters of the same stream are suspended in several Fibers
--FILE--
<?php
class Suspender extends php_user_filter {
    public function filter($in, $out, &$consumed, bool $closing): int {
        while ($bucket = stream_bucket_make_writeable($in)) {
            $consumed += $bucket->datalen;
            stream_bucket_append($out, $bucket);
        }

        if (Fiber::getCurrent()) {
            Fiber::suspend();
        }

        return PSFS_PASS_ON;
    }
}

stream_filter_register('suspender', 'Suspender');

$stream = fopen('php://memory', 'w+');
$filter = stream_filter_append($stream, 'suspender', STREAM_FILTER_WRITE);

$fiber1 = new Fiber(fn() => fwrite($stream, 'one'));
$fiber2 = new Fiber(fn() => fwrite($stream, 'two'));
$fiber1->start();
$fiber2->start();
$fiber1->resume();
var_dump($fiber1->getReturn());

var_dump(stream_filter_remove($filter));

$fiber2->resume();
var_dump($fiber2->getReturn());

var_dump(stream_filter_remove($filter));

rewind($stream);
var_dump(stream_get_contents($stream));
?>
--EXPECTF--
int(3)

Warning: stream_filter_remove(): Unable to remove filter while a user filter of the same stream is running in %s on line %d
bool(false)
int(3)
bool(true)
string(6) "onetwo"

EdmondDantes added a commit to true-async/php-src that referenced this pull request Oct 6, 2026
Add the test requested in php#24168. Mark it XFAIL because restoring a saved running flag permits removal while the second Fiber is still suspended; the existing PR explicitly leaves this limitation for a separate change.
@EdmondDantes

Copy link
Copy Markdown
Contributor Author

@devnexen
Oh... this isn’t the kind of issue that can be fixed easily...
And what should we do? Add a counter to the stream structure?

@EdmondDantes

Copy link
Copy Markdown
Contributor Author

The requested test is now added, currently with XFAIL. On this PR head, the first Fiber clears the running flag while the second is still suspended. Removal then succeeds prematurely; Valgrind with USE_ZEND_ALLOC=0 confirms use-after-free when the second Fiber resumes.

A possible follow-up is a per-stream counter, rather than saving/restoring a bit. Short sketch (not implemented or tested yet):

/* New field in php_stream, shared by both filter chains. */
uint32_t active_user_filters;

/* userfilter_filter(): replace the save/set of both flags,
 * before property access and the callback. */
stream->active_user_filters++;

/* On BOTH exits after entering the protected region:
 * after cleanup, including the property-update exception path. */
ZEND_ASSERT(stream->active_user_filters > 0);
stream->active_user_filters--;

/* stream_filter_remove(): replace the running-flag check. */
if (filter->chain && filter->chain->stream
        && filter->chain->stream->active_user_filters > 0) {
    php_error_docref(NULL, E_WARNING,
        "Unable to remove filter while a user filter of the same stream is running");
    RETURN_FALSE;
}

/* fclose(): retain the independent NO_FCLOSE protection. */
if ((stream->flags & PHP_STREAM_FLAG_NO_FCLOSE)
        || stream->active_user_filters > 0) {
    /* Existing warning and RETURN_FALSE. */
}

This removes all save/set/restore of NO_FCLOSE and USER_FILTER_RUNNING from userfilter_filter(); retaining the old NO_FCLOSE restoration would still leave its Fiber-order bug. Stream allocation already zeroes the structure. Cleanup remains protected until the decrement.

The counter handles nested callbacks and either Fiber completion order, but changes to the public php_stream structure require ABI review for PHP 8.4. It does not address pclose()/closedir() or make overlapping operations on a stream generally safe. Before replacing XFAIL with a passing regression, we should also check both completion orders, absence of stuck close protection, sibling/read-chain removal, and exception cleanup.

@devnexen

devnexen commented Oct 6, 2026

Copy link
Copy Markdown
Member

The counter is not a bad idea, might be tough for a stable branch however to update the struct. Another alternative would be, I think, the check should be per filter rather than per stream. The only unsafe case is removing the filter that is itself inside its callback.

@EdmondDantes
EdmondDantes force-pushed the stream-filter-remove-in-callback branch from 9c37309 to d67f39e Compare October 7, 2026 04:23
EdmondDantes added a commit to true-async/php-src that referenced this pull request Oct 7, 2026
…s in progress

Brings the fix of stream_filter_remove() in a stream callback to its
reviewed form (php#24168): a per-filter count of calls in
progress instead of a stream flag that overlapping Fibers clear early.
@EdmondDantes
EdmondDantes force-pushed the stream-filter-remove-in-callback branch from d67f39e to a7915f0 Compare October 7, 2026 08:56
…allback

stream_filter_remove() flushes the filter before it unlinks and frees it, and
the flush can run PHP code: a user filter's filter() method, or a user
wrapper's stream_write() for the flushed data. When that code removes the
same filter, the outer call continues on freed memory.

A user filter can also be removed from its own filter() method, or from
other code while filter() is suspended in a Fiber; userfilter_filter() and
the filter chain walks then use the freed filter after the callback returns.

php_stream_filter gets a running_calls field, which userfilter_filter()
increments for the duration of each call, a suspended Fiber included. The
field is appended at the end of the struct, which only the core allocates
(_php_stream_filter_alloc()), so extensions keep working. stream_filter_remove()
refuses a filter whose running_calls is not zero, before the flush and again after
it, since a Fiber can enter the filter from the flush, and it checks after
the flush that the filter is still there. Other filters of the stream can
still be removed from a callback.
@EdmondDantes
EdmondDantes force-pushed the stream-filter-remove-in-callback branch from a7915f0 to 6b5e85a Compare October 7, 2026 09:08
@EdmondDantes

Copy link
Copy Markdown
Contributor Author

The counter is not a bad idea, might be tough for a stable branch however to update the struct. Another alternative would be, I think, the check should be per filter rather than per stream. The only unsafe case is removing the filter that is itself inside its callback.

I decided to go with a counter.

EdmondDantes added a commit to true-async/true-async that referenced this pull request Oct 7, 2026
…run, php-src-fixes cfa0923ac31

async-core 6e43d6074e0 makes the GC coroutine take one threshold step
after its run instead of one per waiting coroutine, as TrueAsync's core
does. php-src-fixes cfa0923ac31 brings the explicit running_calls field
of php/php-src#24168 and #24177, so scope/075 passes and loses its
--XFAIL--. scope/058 keeps zend.enable_gc=0: a full root buffer parks
every coroutine that adds a root until the GC coroutine runs, and its
100 000 coroutines exhaust vm.max_map_count; the comment now says so.
@devnexen

devnexen commented Oct 7, 2026

Copy link
Copy Markdown
Member

It seems a working solution, but I prefer to defer the final decision on @bukka for this one.

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