Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ PHP NEWS
- Sockets:
. Fixed various memory related issues in ext/sockets. (David Carlier)

- Standard:
. Fixed bug GH-22845 (Use-after-free when a user filter stashes its bucket
brigade resource). (iliaal)

30 Jul 2026, PHP 8.4.24

- Calendar:
Expand Down
40 changes: 40 additions & 0 deletions ext/standard/tests/filters/gh22845.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
--TEST--
GH-22845 (Use-after-free when a user filter stashes its bucket brigade resource)
--FILE--
<?php
class stash_filter extends php_user_filter {
public function filter($in, $out, &$consumed, $closing): int {
$GLOBALS['stash_in'] = $in;
$GLOBALS['stash_out'] = $out;
while ($bucket = stream_bucket_make_writeable($in)) {
$consumed += strlen($bucket->data);
stream_bucket_append($out, $bucket);
}
return PSFS_PASS_ON;
}
}
stream_filter_register('stash', 'stash_filter');

$fp = fopen('php://memory', 'r+');
fwrite($fp, "hello world");
rewind($fp);
stream_filter_append($fp, 'stash', STREAM_FILTER_READ);
var_dump(fread($fp, 100));

// The brigades lived on the filter caller's stack. Both stashed resources must be
// closed now that the callback has returned, not left pointing at freed stack.
foreach (['stash_in', 'stash_out'] as $name) {
try {
stream_bucket_make_writeable($GLOBALS[$name]);
echo "$name: no error\n";
} catch (\Throwable $e) {
echo "$name: ", $e::class, "\n";
}
}
echo "done\n";
?>
--EXPECT--
string(11) "hello world"
stash_in: TypeError
stash_out: TypeError
done
39 changes: 39 additions & 0 deletions ext/standard/tests/filters/gh22845_bailout.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
--TEST--
GH-22845 (user filter bucket brigade resource is closed when the callback bails out)
--EXTENSIONS--
zend_test
--SKIPIF--
<?php
if (!function_exists('zend_trigger_bailout')) die("skip zend_trigger_bailout() not available before PHP 8.5");
?>
--FILE--
<?php
class bf extends php_user_filter {
public function filter($in, $out, &$consumed, $closing): int {
$GLOBALS['stash_in'] = $in;
while ($b = stream_bucket_make_writeable($in)) {
$consumed += strlen($b->data);
stream_bucket_append($out, $b);
}
register_shutdown_function(function() {
try {
stream_bucket_make_writeable($GLOBALS['stash_in']);
echo "shutdown: no error\n";
} catch (\Throwable $e) {
echo "shutdown: ", $e::class, "\n";
}
});
zend_trigger_bailout();
}
}
stream_filter_register('bf', 'bf');

$fp = fopen('php://memory', 'r+');
fwrite($fp, "hello world");
rewind($fp);
stream_filter_append($fp, 'bf', STREAM_FILTER_READ);
fread($fp, 100);
?>
--EXPECTF--
Fatal error: Bailout in %s on line %d
shutdown: TypeError
48 changes: 31 additions & 17 deletions ext/standard/user_filters.c
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ static php_stream_filter_status_t userfilter_filter(
zval func_name;
zval retval;
zval args[4];
int call_result;
int call_result = FAILURE;
bool bailout = false;

/* the userfilter object probably doesn't exist anymore */
if (CG(unclean_shutdown)) {
Expand Down Expand Up @@ -188,27 +189,33 @@ static php_stream_filter_status_t userfilter_filter(

ZVAL_BOOL(&args[3], flags & PSFS_FLAG_FLUSH_CLOSE);

call_result = call_user_function(NULL,
obj,
&func_name,
&retval,
4, args);
zend_try {
call_result = call_user_function(NULL,
obj,
&func_name,
&retval,
4, args);
} zend_catch {
bailout = true;
} zend_end_try();

zval_ptr_dtor(&func_name);

if (call_result == SUCCESS && Z_TYPE(retval) != IS_UNDEF) {
convert_to_long(&retval);
ret = (int)Z_LVAL(retval);
} else if (call_result == FAILURE) {
php_error_docref(NULL, E_WARNING, "Failed to call filter function");
}
if (!bailout) {
if (call_result == SUCCESS && Z_TYPE(retval) != IS_UNDEF) {
convert_to_long(&retval);
ret = (int)Z_LVAL(retval);
} else if (call_result == FAILURE) {
php_error_docref(NULL, E_WARNING, "Failed to call filter function");
}

if (bytes_consumed) {
*bytes_consumed = zval_get_long(&args[2]);
}
if (bytes_consumed) {
*bytes_consumed = zval_get_long(&args[2]);
}

if (buckets_in->head) {
php_error_docref(NULL, E_WARNING, "Unprocessed filter buckets remaining on input brigade");
if (buckets_in->head) {
php_error_docref(NULL, E_WARNING, "Unprocessed filter buckets remaining on input brigade");
}
}

/* filter resources are cleaned up by the stream destructor,
Expand All @@ -223,6 +230,9 @@ static php_stream_filter_status_t userfilter_filter(

zend_string_release(stream_name);

zend_list_close(Z_RES(args[1]));
zend_list_close(Z_RES(args[0]));

zval_ptr_dtor(&args[3]);
zval_ptr_dtor(&args[2]);
zval_ptr_dtor(&args[1]);
Expand All @@ -231,6 +241,10 @@ static php_stream_filter_status_t userfilter_filter(
stream->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE;
stream->flags |= orig_no_fclose;

if (bailout) {
zend_bailout();
}

return ret;
}

Expand Down
Loading