Skip to content

stream_get_meta_data() uses the stream after error_handler closes it #23262

Description

@edorian

Description

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


The following code:

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

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

Expand
<?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

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions