From df3278d2b00ea2db4e60d3e26a28e78a485dd939 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Tue, 18 Aug 2026 07:44:48 +0200 Subject: [PATCH 1/3] Fix fseek() accepting $whence values that do not fit in an int $whence is parsed as a zend_long but cast to a C int when handed to php_stream_seek(). Values whose low 32 bits alias onto a valid seek constant were accepted and acted upon: on a 64-bit build, SEEK_CUR + 2**32 seeks relative to the current position, SEEK_END + 2**32 relative to the end, and PHP_INT_MIN relative to the start, each returning 0 for success. Reject values outside the int range before the cast and return -1, which is already what fseek() returns for an invalid $whence that does fit in an int. Platform constants such as SEEK_DATA and SEEK_HOLE fit in an int and keep working. --- NEWS | 2 + ext/standard/file.c | 4 ++ .../tests/file/fseek_whence_overflow.phpt | 46 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 ext/standard/tests/file/fseek_whence_overflow.phpt diff --git a/NEWS b/NEWS index 500f75cc050d..ad55023bc255 100644 --- a/NEWS +++ b/NEWS @@ -34,6 +34,8 @@ PHP NEWS . Fixed read buffer compaction in php_stream_filter_flush(). (crystarm) . Io\Poll\Context::wait() now rejects a $maxEvents value greater than INT_MAX instead of truncating it. (marc-mabe) + . Fixed fseek() accepting $whence values that do not fit in an int, which + were silently truncated onto a valid seek constant. (lacatoire) 27 Aug 2026, PHP 8.6.0beta2 diff --git a/ext/standard/file.c b/ext/standard/file.c index d6a8b9f1d0ea..c2b395b858de 100644 --- a/ext/standard/file.c +++ b/ext/standard/file.c @@ -1104,6 +1104,10 @@ PHPAPI PHP_FUNCTION(fseek) Z_PARAM_LONG(whence) ZEND_PARSE_PARAMETERS_END(); + if (whence < INT_MIN || whence > INT_MAX) { + RETURN_LONG(-1); + } + php_stream_error_operation_begin(); RETVAL_LONG(php_stream_seek(stream, offset, (int) whence)); php_stream_error_operation_end_for_stream(stream); diff --git a/ext/standard/tests/file/fseek_whence_overflow.phpt b/ext/standard/tests/file/fseek_whence_overflow.phpt new file mode 100644 index 000000000000..760659dd9ee0 --- /dev/null +++ b/ext/standard/tests/file/fseek_whence_overflow.phpt @@ -0,0 +1,46 @@ +--TEST-- +fseek(): $whence values that overflow int must return -1, not alias onto a valid constant +--SKIPIF-- + +--FILE-- + +--EXPECT-- +int(-1) +int(4) +int(-1) +int(4) +int(-1) +int(4) +int(0) +int(7) From 62d5985a6e34b94075295594c773efd2511acf8f Mon Sep 17 00:00:00 2001 From: Louis-Arnaud Catoire Date: Fri, 28 Aug 2026 14:44:49 +0200 Subject: [PATCH 2/3] Do not discard the read buffer when a seek fails php_stream_seek() invalidated the read buffer after calling ops->seek() even when that call reported a failure. On a plain file, an invalid $whence makes lseek() fail with EINVAL without moving the descriptor, so the buffered data was still valid for the reported position; dropping it desynchronized the stream: $h = fopen($f, 'r'); // 10-byte file fread($h, 4); // "0123" fseek($h, 3, 99); // -1, position left at 4 fread($h, 6); // "" instead of "456789" The buffer and the filter state are now left alone when ops->seek() fails without moving the stream. Implementations that do move on failure, such as php_stream_memory_seek() resetting fpos, still invalidate it. --- NEWS | 2 + .../file/fseek_whence_invalid_inrange.phpt | 40 +++++++++++++++++++ main/streams/streams.c | 8 ++++ 3 files changed, 50 insertions(+) create mode 100644 ext/standard/tests/file/fseek_whence_invalid_inrange.phpt diff --git a/NEWS b/NEWS index ad55023bc255..d7c0e9a1a2bf 100644 --- a/NEWS +++ b/NEWS @@ -36,6 +36,8 @@ PHP NEWS INT_MAX instead of truncating it. (marc-mabe) . Fixed fseek() accepting $whence values that do not fit in an int, which were silently truncated onto a valid seek constant. (lacatoire) + . Fixed a failed seek discarding the read buffer, which desynchronized the + stream from its reported position. (lacatoire) 27 Aug 2026, PHP 8.6.0beta2 diff --git a/ext/standard/tests/file/fseek_whence_invalid_inrange.phpt b/ext/standard/tests/file/fseek_whence_invalid_inrange.phpt new file mode 100644 index 000000000000..f0aba239b5f8 --- /dev/null +++ b/ext/standard/tests/file/fseek_whence_invalid_inrange.phpt @@ -0,0 +1,40 @@ +--TEST-- +fseek(): an invalid $whence that fits in an int must not desynchronize the stream +--FILE-- + +--CLEAN-- + +--EXPECT-- +whence=99 +string(4) "0123" +int(-1) +int(4) +string(6) "456789" + +whence=-2147483648 +string(4) "0123" +int(-1) +int(4) +string(6) "456789" + +whence=2147483647 +string(4) "0123" +int(-1) +int(4) +string(6) "456789" diff --git a/main/streams/streams.c b/main/streams/streams.c index a09a2180921d..bbc5047ab357 100644 --- a/main/streams/streams.c +++ b/main/streams/streams.c @@ -1367,6 +1367,7 @@ PHPAPI int php_stream_seek(php_stream *stream, zend_off_t offset, int whence) if (stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0) { + zend_off_t old_position = stream->position; int ret; switch(whence) { case SEEK_CUR: @@ -1386,6 +1387,13 @@ PHPAPI int php_stream_seek(php_stream *stream, zend_off_t offset, int whence) ret = stream->ops->seek(stream, offset, whence, &stream->position); if (((stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0) || ret == 0) { + if (ret != 0 && stream->position == old_position) { + /* the seek failed without moving the stream, so the buffered + * data and the filter state still describe the current + * position and must be left alone */ + return ret; + } + if (ret == 0) { stream->eof = 0; stream->fatal_error = 0; From dd943cce2bf3f620905fb2ef034a5bc7f1f06600 Mon Sep 17 00:00:00 2001 From: Louis-Arnaud Catoire Date: Sat, 29 Aug 2026 23:43:40 +0200 Subject: [PATCH 3/3] Address review: use ZEND_LONG_EXCEEDS_INT, cover memory streams, add CLEAN sections --- ext/standard/file.c | 2 +- .../file/fseek_whence_invalid_inrange.phpt | 39 ++++++++++++++++--- .../tests/file/fseek_whence_overflow.phpt | 7 +++- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/ext/standard/file.c b/ext/standard/file.c index c2b395b858de..7088d8c1cbd6 100644 --- a/ext/standard/file.c +++ b/ext/standard/file.c @@ -1104,7 +1104,7 @@ PHPAPI PHP_FUNCTION(fseek) Z_PARAM_LONG(whence) ZEND_PARSE_PARAMETERS_END(); - if (whence < INT_MIN || whence > INT_MAX) { + if (ZEND_LONG_EXCEEDS_INT(whence)) { RETURN_LONG(-1); } diff --git a/ext/standard/tests/file/fseek_whence_invalid_inrange.phpt b/ext/standard/tests/file/fseek_whence_invalid_inrange.phpt index f0aba239b5f8..c10fbcfbeb73 100644 --- a/ext/standard/tests/file/fseek_whence_invalid_inrange.phpt +++ b/ext/standard/tests/file/fseek_whence_invalid_inrange.phpt @@ -7,12 +7,21 @@ file_put_contents($tmp, "0123456789"); foreach ([99, -2147483648, 2147483647] as $whence) { echo 'whence=', $whence, PHP_EOL; - $h = fopen($tmp, 'r'); - var_dump(fread($h, 4)); - var_dump(fseek($h, 3, $whence)); - var_dump(ftell($h)); - var_dump(fread($h, 6)); - fclose($h); + + foreach (['file' => $tmp, 'memory' => 'php://memory'] as $label => $target) { + echo $label, PHP_EOL; + $h = fopen($target, $label === 'file' ? 'r' : 'w+'); + if ($label === 'memory') { + fwrite($h, "0123456789"); + rewind($h); + } + var_dump(fread($h, 4)); + var_dump(fseek($h, 3, $whence)); + var_dump(ftell($h)); + var_dump(fread($h, 6)); + fclose($h); + } + echo PHP_EOL; } ?> @@ -22,18 +31,36 @@ foreach ([99, -2147483648, 2147483647] as $whence) { ?> --EXPECT-- whence=99 +file +string(4) "0123" +int(-1) +int(4) +string(6) "456789" +memory string(4) "0123" int(-1) int(4) string(6) "456789" whence=-2147483648 +file +string(4) "0123" +int(-1) +int(4) +string(6) "456789" +memory string(4) "0123" int(-1) int(4) string(6) "456789" whence=2147483647 +file +string(4) "0123" +int(-1) +int(4) +string(6) "456789" +memory string(4) "0123" int(-1) int(4) diff --git a/ext/standard/tests/file/fseek_whence_overflow.phpt b/ext/standard/tests/file/fseek_whence_overflow.phpt index 760659dd9ee0..46c5a0292ef1 100644 --- a/ext/standard/tests/file/fseek_whence_overflow.phpt +++ b/ext/standard/tests/file/fseek_whence_overflow.phpt @@ -6,7 +6,7 @@ if (PHP_INT_SIZE < 8) die("skip 64-bit only"); ?> --FILE-- +--CLEAN-- + --EXPECT-- int(-1)