From 83a752634ba51906b9335d9e6432d42025cff586 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:12:34 +0000 Subject: [PATCH] Fix use-after-free when a stream is closed during stream_copy_to_stream() _php_stream_copy_to_stream_ex() writes to dest and reads from src in a loop. When either is a user wrapper, its stream_write() or stream_read() may fclose() the other stream, and the next turn of the loop uses the freed stream. Both streams now carry PHP_STREAM_FLAG_NO_FCLOSE for the duration of the copy, as a stream does while its user filter runs, so such an fclose() fails with a warning. The original flag of each stream is restored afterwards. --- ...ream_copy_to_stream_close_in_callback.phpt | 66 +++++++++++++++++++ main/streams/streams.c | 21 +++++- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 ext/standard/tests/streams/stream_copy_to_stream_close_in_callback.phpt diff --git a/ext/standard/tests/streams/stream_copy_to_stream_close_in_callback.phpt b/ext/standard/tests/streams/stream_copy_to_stream_close_in_callback.phpt new file mode 100644 index 000000000000..b63c19aeb793 --- /dev/null +++ b/ext/standard/tests/streams/stream_copy_to_stream_close_in_callback.phpt @@ -0,0 +1,66 @@ +--TEST-- +stream_copy_to_stream() when a user wrapper closes the other stream +--FILE-- +left-- > 0 ? 'abc' : ''; + } + + public function stream_write(string $data): int { + self::closeOther(); + + return strlen($data); + } + + public function stream_eof(): bool { + return $this->left <= 0; + } + + private static function closeOther(): void { + if (isset($GLOBALS['close'])) { + $stream = $GLOBALS['close']; + unset($GLOBALS['close']); + var_dump(fclose($stream)); + } + } +} + +stream_wrapper_register('test', 'Wrapper'); + +echo "The destination closes the source:\n"; +$src = fopen('php://memory', 'w+'); +fwrite($src, str_repeat('x', 100000)); +rewind($src); +$GLOBALS['close'] = $src; +var_dump(stream_copy_to_stream($src, fopen('test://', 'w'))); +var_dump(fclose($src)); + +echo "The source closes the destination:\n"; +$dest = fopen('php://memory', 'w+'); +$GLOBALS['close'] = $dest; +var_dump(stream_copy_to_stream(fopen('test://', 'r'), $dest)); +var_dump(fclose($dest)); +?> +--EXPECTF-- +The destination closes the source: + +Warning: fclose(): %d is not a valid stream resource in %s on line %d +bool(false) +int(100000) +bool(true) +The source closes the destination: + +Warning: fclose(): %d is not a valid stream resource in %s on line %d +bool(false) +int(9) +bool(true) diff --git a/main/streams/streams.c b/main/streams/streams.c index a37546a4ad2b..545c44520b90 100644 --- a/main/streams/streams.c +++ b/main/streams/streams.c @@ -1655,7 +1655,7 @@ PHPAPI zend_string *_php_stream_copy_to_mem(php_stream *src, size_t maxlen, int } /* Returns SUCCESS/FAILURE and sets *len to the number of bytes moved */ -PHPAPI zend_result _php_stream_copy_to_stream_ex(php_stream *src, php_stream *dest, size_t maxlen, size_t *len STREAMS_DC) +static zend_result php_stream_copy_to_stream_impl(php_stream *src, php_stream *dest, size_t maxlen, size_t *len) { char buf[CHUNK_SIZE]; size_t haveread = 0; @@ -1850,6 +1850,25 @@ PHPAPI zend_result _php_stream_copy_to_stream_ex(php_stream *src, php_stream *de return SUCCESS; } +PHPAPI zend_result _php_stream_copy_to_stream_ex(php_stream *src, php_stream *dest, size_t maxlen, size_t *len STREAMS_DC) +{ + /* Writing to dest or reading from src may run PHP code (a user wrapper or + * filter) that would otherwise close either stream under the copy. */ + uint32_t src_no_fclose = src->flags & PHP_STREAM_FLAG_NO_FCLOSE; + uint32_t dest_no_fclose = dest->flags & PHP_STREAM_FLAG_NO_FCLOSE; + src->flags |= PHP_STREAM_FLAG_NO_FCLOSE; + dest->flags |= PHP_STREAM_FLAG_NO_FCLOSE; + + zend_result ret = php_stream_copy_to_stream_impl(src, dest, maxlen, len); + + src->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE; + src->flags |= src_no_fclose; + dest->flags &= ~PHP_STREAM_FLAG_NO_FCLOSE; + dest->flags |= dest_no_fclose; + + return ret; +} + /* Returns the number of bytes moved. * Returns 1 when source len is 0. * Deprecated in favor of php_stream_copy_to_stream_ex() */