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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions