[php-src] Issue #23262: stream_get_meta_data() uses the stream after error_handler closes it

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

### Description

## Description

Fuzzing on the new PHP 8.6 streams API:

---

The following code:

```php
<?php

class InvalidEofStream
{
    public $context;

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

    public function stream_eof()
    {
        return [];
    }

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

stream_wrapper_register('invalid-eof', InvalidEofStream::class);

$stream = null;
$context = stream_context_create([
    'stream' => [
        'error_mode' => StreamErrorMode::Silent,
        'error_handler' => static function (array $errors) use (&$stream): void {
            echo "handler: {$errors[0]->code->name}\n";
            fclose($stream);
        },
    ],
]);

$stream = fopen('invalid-eof://input', 'r', false, $context);
var_dump(stream_get_meta_data($stream));
```

Resulted in this output:

```
handler: UserspaceInvalidReturn
=================================================================
==ERROR: AddressSanitizer: heap-use-after-free on address 0x... at pc 0x... bp 0x... sp 0x...
READ of size 1 at 0x... thread T0
    #0 0x... in zval_get_type Zend/zend_types.h:685
    #1 0x... in zif_stream_get_meta_data ext/standard/streamsfuncs.c:568
    #2 0x... in ZEND_DO_ICALL_SPEC_RETVAL_USED_HANDLER Zend/zend_vm_execute.h:1393
    #3 0x... in execute_ex Zend/zend_vm_execute.h:110749

freed by thread T0 here:
    #0 0x... in __interceptor_free
    #1 0x... in __zend_free Zend/zend_alloc.c:3571
    #2 0x... in _efree Zend/zend_alloc.c:2788
    #3 0x... in php_stream_free main/streams/streams.c:422
    #4 0x... in zif_fclose ext/standard/file.c:779
    #5 0x... in php_stream_call_error_handler main/streams/stream_errors.c:375
    #6 0x... in php_stream_report_errors main/streams/stream_errors.c:436
    #7 0x... in php_stream_error_operation_end main/streams/stream_errors.c:463
    #8 0x... in php_stream_error_operation_end_for_stream main/streams/stream_errors.c:555
    #9 0x... in zif_stream_get_meta_data ext/standard/streamsfuncs.c:566
```

But I expected this output instead:

```
handler: UserspaceInvalidReturn
array(10) {
  ["timed_out"]=>
  bool(false)
  ["blocked"]=>
  bool(true)
  ["eof"]=>
  bool(true)
  ["wrapper_data"]=>
  object(InvalidEofStream)#3 (1) {
    ["context"]=>
    resource(5) of type (stream-context)
  }
  ["wrapper_type"]=>
  string(10) "user-space"
  ["stream_type"]=>
  string(10) "user-space"
  ["mode"]=>
  string(1) "r"
  ["unread_bytes"]=>
  int(0)
  ["seekable"]=>
  bool(true)
  ["uri"]=>
  string(19) "invalid-eof://input"
}
```

For validation, I used the following patch, but I haven't thought trough this, so it's just a bug report. Not a PR.

```diff
--- a/ext/standard/streamsfuncs.c
+++ b/ext/standard/streamsfuncs.c
@@ -560,7 +560,6 @@
 	php_stream_error_operation_begin();
 	if (!php_stream_populate_meta_data(stream, return_value)) {
 		add_assoc_bool(return_value, "timed_out", 0);
 		add_assoc_bool(return_value, "blocked", 1);
 		add_assoc_bool(return_value, "eof", php_stream_eof(stream));
 	}
-	php_stream_error_operation_end_for_stream(stream);

 	if (!Z_ISUNDEF(stream->wrapperdata)) {
@@ -594,5 +593,6 @@
 	add_assoc_bool(return_value, "seekable", (stream->ops->seek) && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0);
 	if (stream->orig_path) {
 		add_assoc_string(return_value, "uri", stream->orig_path);
 	}
+	php_stream_error_operation_end_for_stream(stream);
 }
```

---


## Additional test cases

<details><summary>
Expand</summary>


```php
<?php

class InvalidEofStream
{
    public $context;
    public function stream_open($path, $mode, $options, &$openedPath): bool { return true; }
    public function stream_eof() { return []; }
    public function stream_stat(): array { return []; }
}
stream_wrapper_register('invalid-eof', InvalidEofStream::class);

$stream = null;
$context = stream_context_create([
    'stream' => [
        'error_mode' => StreamErrorMode::Silent,
        'error_handler' => static function (array $errors) use (&$stream): void {
            echo "handler: {$errors[0]->code->name}\n";
            fclose($stream);
        },
    ],
]);
$stream = fopen('invalid-eof://input', 'r', false, $context);
var_dump(stream_get_meta_data($stream));

echo "--- case 2 ---\n";

class InvalidEofAliasStream
{
    public $context;
    public function stream_open($path, $mode, $options, &$openedPath): bool { return true; }
    public function stream_eof() { return []; }
    public function stream_stat(): array { return []; }
}
stream_wrapper_register('invalid-eof-alias', InvalidEofAliasStream::class);

$stream2 = null;
$replacement = null;
$context2 = stream_context_create([
    'stream' => [
        'error_mode' => StreamErrorMode::Silent,
        'error_handler' => static function (array $errors) use (&$stream2, &$replacement): void {
            echo "handler: {$errors[0]->code->name}\n";
            fclose($stream2);
            $replacement = fopen('php://memory', 'w+');
        },
    ],
]);
$stream2 = fopen('invalid-eof-alias://input', 'r', false, $context2);
var_export(stream_get_meta_data($stream2));
echo "\nreplacement_resource_is_stream=" . (get_resource_type($replacement) === 'stream' ? 'yes' : 'no') . "\n";

echo "--- case 3 ---\n";

class MissingEofControlledStream
{
    public $context;
    public function stream_open($path, $mode, $options, &$openedPath): bool { return true; }
    public function stream_stat(): array { return []; }
}
stream_wrapper_register('missing-eof-controlled', MissingEofControlledStream::class);

$stream3 = null;
$replacement3 = null;
$context3 = stream_context_create([
    'stream' => [
        'error_mode' => StreamErrorMode::Silent,
        'error_handler' => static function (array $errors) use (&$stream3, &$replacement3): void {
            fclose($stream3);
            $replacement3 = str_repeat("\0", 200);
            $chosen = pack('P', 0x4141414141414140);
            for ($i = 0; $i < 8; $i++) {
                $replacement3[56 + $i] = $chosen[$i];
            }
            $stringTypeInfo = pack('V', 0x106);
            for ($i = 0; $i < 4; $i++) {
                $replacement3[64 + $i] = $stringTypeInfo[$i];
            }
        },
    ],
]);
$stream3 = fopen('missing-eof-controlled://input', 'r', false, $context3);
stream_get_meta_data($stream3);
echo "case 3 completed without crash\n";
```

Expected (patched) output:

```
handler: UserspaceInvalidReturn
array(10) {
  ["timed_out"]=>
  bool(false)
  ["blocked"]=>
  bool(true)
  ["eof"]=>
  bool(true)
  ["wrapper_data"]=>
  object(InvalidEofStream)#3 (1) {
    ["context"]=>
    resource(5) of type (stream-context)
  }
  ["wrapper_type"]=>
  string(10) "user-space"
  ["stream_type"]=>
  string(10) "user-space"
  ["mode"]=>
  string(1) "r"
  ["unread_bytes"]=>
  int(0)
  ["seekable"]=>
  bool(true)
  ["uri"]=>
  string(19) "invalid-eof://input"
}
--- case 2 ---
handler: UserspaceInvalidReturn
array (
  'timed_out' => false,
  'blocked' => true,
  'eof' => true,
  'wrapper_data' =>
  \InvalidEofAliasStream::__set_state(array(
     'context' => NULL,
  )),
  'wrapper_type' => 'user-space',
  'stream_type' => 'user-space',
  'mode' => 'r',
  'unread_bytes' => 0,
  'seekable' => true,
  'uri' => 'invalid-eof-alias://input',
)
replacement_resource_is_stream=yes
--- case 3 ---
case 3 completed without crash
```

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