[php-src] Issue #23264: Streams: set_error_handler() callback frees the structured-handler context

[email protected] (edorian)
Newsgroups php.bugs
Message-ID <0C95DqNCCtO5IupdPxPCcyJfu2oksoOYtTNYIxQ2Y34@main.internal.php.net>
Issue: https://github.com/php/php-src/issues/23264
Author: edorian

### Description

## Description

Fuzzing on the new PHP 8.6 streams API, found by Ryan @ Calif.io.

---

The following code:

```php
<?php

class OversizedReadForLegacyHandler
{
    public $context;

    public function stream_open($path, $mode, $options, &$openedPath): bool
    {
        return true;
    }

    public function stream_read(int $count): string
    {
        return str_repeat('A', $count + 1);
    }

    public function stream_eof(): bool
    {
        return false;
    }

    public function stream_stat(): array
    {
        return [];
    }
}

stream_wrapper_register('legacy-handler-uaf', OversizedReadForLegacyHandler::class);

$context = stream_context_create(['stream' => ['error_mode' => StreamErrorMode::Error]]);
$stream = fopen('legacy-handler-uaf://input', 'r', false, $context);
unset($context);

set_error_handler(static function (int $severity, string $message) use (&$stream): bool {
    fclose($stream);
    return true;
});

var_dump(fread($stream, 1));
```

Resulted in this output:

```
=================================================================
==ERROR: AddressSanitizer: heap-use-after-free on address 0x... at pc 0x... bp 0x... sp 0x...
READ of size 8 at 0x... thread T0
    #0 0x... in php_stream_context_get_option main/streams/streams.c:2299
    #1 0x... in php_stream_report_errors main/streams/stream_errors.c:430
    #2 0x... in php_stream_error_operation_end main/streams/stream_errors.c:463
    #3 0x... in php_stream_error_operation_end_for_stream main/streams/stream_errors.c:555
    #4 0x... in zif_fread ext/standard/file.c:1623

freed by thread T0 here:
    #0 0x... in __interceptor_free
    #1 0x... in php_stream_context_free main/streams/streams.c:2267
    #2 0x... in file_context_dtor ext/standard/file.c:119
    #3 0x... in zend_resource_dtor Zend/zend_list.c:72
    #4 0x... in zend_list_delete Zend/zend_list.c:48
    #5 0x... in php_stream_free main/streams/streams.c:426
    #6 0x... in zif_fclose ext/standard/file.c:779
    #7 0x... in zend_error_zstr_at Zend/zend.c:1584
```

But I expected this output instead:

```
string(1) "A"
```

* * *

`php_stream_report_errors()` emits legacy warnings via `php_error_docref()` in a loop. If userland has `set_error_handler()` registered, that call re-enters user code once per recorded error. `fclose()`ing the stream drops the last reference to its `context`

Validated that the ASAN issues goes away with this patch, but not confident that this is the correct solution.

```diff
--- a/main/streams/stream_errors.c
+++ b/main/streams/stream_errors.c
@@ -404,6 +404,10 @@
 static void php_stream_report_errors(const php_stream_context *context, const php_stream_error_operation *op,
 		php_stream_error_mode error_mode, bool is_terminating)
 {
+	if (context) {
+		GC_ADDREF(context->res);
+	}
+
 	switch (error_mode) {
 		case PHP_STREAM_ERROR_MODE_ERROR: {
 			const php_stream_error_entry *entry = op->first_error;
@@ -428,13 +432,17 @@
 	const zval *handler
 			= context ? php_stream_context_get_option(context, "stream", "error_handler") : NULL;
 
 	if (handler) {
 		zval errors_array;
 		php_stream_error_create_array(&errors_array, op->first_error);
 
 		php_stream_call_error_handler(handler, &errors_array);
 
 		zval_ptr_dtor(&errors_array);
 	}
+
+	if (context) {
+		zend_list_delete(context->res);
+	}
 }
```

Rebuilt with this patch applied and re-ran the reproducer above: no crash, output matches the expected block exactly. Also re-ran with a variant that has the legacy handler additionally open a *replacement* `stream_context_create()` before the lookup runs — unpatched, this redirects the structured-handler dispatch to the replacement context; patched, it does not, confirming the fix closes both the crash and the cross-context redirection.

### PHP Version

```
PHP 8.6.0-dev (cli) (built: Aug 11 2026 17:26:03) (ZTS 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_

---

## Full test cases

<details>
<summary>Two full test cases</summary>

```php
<?php

class OversizedReadForLegacyHandler
{
    public $context;

    public function stream_open($path, $mode, $options, &$openedPath): bool
    {
        return true;
    }

    public function stream_read(int $count): string
    {
        return str_repeat('A', $count + 1);
    }

    public function stream_eof(): bool
    {
        return false;
    }

    public function stream_stat(): array
    {
        return [];
    }
}

stream_wrapper_register('legacy-handler-uaf', OversizedReadForLegacyHandler::class);

$context = stream_context_create(['stream' => ['error_mode' => StreamErrorMode::Error]]);
$stream = fopen('legacy-handler-uaf://input', 'r', false, $context);
unset($context);

$replacement = null;
set_error_handler(static function (int $severity, string $message) use (&$stream, &$replacement): bool {
    fclose($stream);
    $replacement = stream_context_create([
        'stream' => [
            'error_mode' => StreamErrorMode::Silent,
            'error_handler' => static function (array $errors): void {
                echo "replacement handler: {$errors[0]->code->name}\n";
            },
        ],
    ]);
    return true;
});

var_dump(fread($stream, 1));
```

</details>


### PHP Version

```plain
PHP 8.6.0-dev (cli) (built: Aug 11 2026 17:26:03) (ZTS 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.