From 79e4cd1efe9a6e17a3954d83a88d4d525d911e06 Mon Sep 17 00:00:00 2001 From: Georgij Tsarin Date: Fri, 28 Aug 2026 00:44:10 +0800 Subject: [PATCH 1/5] Fix read buffer compaction in stream filter flush (#23439) php_stream_filter_flush() compacts unread data before appending buckets produced by a read filter. The source and destination ranges may overlap, making memcpy() undefined behavior. Additionally, resetting readpos before subtracting it from writepos leaves the buffer size unadjusted and stale data visible. Use memmove() and adjust writepos before resetting readpos, matching the existing buffer compaction logic in php_stream_fill_read_buffer(). Closes #23439 --- NEWS | 1 + ...eam_filter_remove_compact_read_buffer.phpt | 31 +++++++++++++++++++ main/streams/filter.c | 4 +-- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 ext/standard/tests/filters/stream_filter_remove_compact_read_buffer.phpt diff --git a/NEWS b/NEWS index 59517a0dd355..1a9f52b2ab91 100644 --- a/NEWS +++ b/NEWS @@ -63,6 +63,7 @@ PHP NEWS empty Location header. (iliaal) . Fixed a memory leak in array_merge_recursive() when the recursive merge of an object converted to an array fails. (David Carlier) + . Fixed read buffer compaction in php_stream_filter_flush(). (crystarm) - Zip: . Fixed bug GH-23276 (ZipArchive subclass storing its own stream cannot be diff --git a/ext/standard/tests/filters/stream_filter_remove_compact_read_buffer.phpt b/ext/standard/tests/filters/stream_filter_remove_compact_read_buffer.phpt new file mode 100644 index 000000000000..7cfef971e7e7 --- /dev/null +++ b/ext/standard/tests/filters/stream_filter_remove_compact_read_buffer.phpt @@ -0,0 +1,31 @@ +--TEST-- +stream_filter_remove() compacts unread data before appending flushed data +--FILE-- +datalen; + stream_bucket_append($out, $bucket); + } + if ($closing) { + stream_bucket_append($out, stream_bucket_new($this->stream, 'END')); + } + return PSFS_PASS_ON; + } +} +stream_filter_register('closing-suffix', ClosingSuffixFilter::class); +$stream = fopen('php://memory', 'w+'); +fwrite($stream, 'abcdef'); +rewind($stream); +$filter = stream_filter_append($stream, 'closing-suffix', STREAM_FILTER_READ); +var_dump(fread($stream, 2)); +var_dump(stream_filter_remove($filter)); +var_dump(stream_get_contents($stream)); +?> +--EXPECT-- +string(2) "ab" +bool(true) +string(7) "cdefEND" diff --git a/main/streams/filter.c b/main/streams/filter.c index 967be5d7f724..edf0a01e46fc 100644 --- a/main/streams/filter.c +++ b/main/streams/filter.c @@ -459,9 +459,9 @@ PHPAPI int _php_stream_filter_flush(php_stream_filter *filter, int finish) /* Dump any newly flushed data to the read buffer */ if (stream->readpos > 0) { /* Back the buffer up */ - memcpy(stream->readbuf, stream->readbuf + stream->readpos, stream->writepos - stream->readpos); - stream->readpos = 0; + memmove(stream->readbuf, stream->readbuf + stream->readpos, stream->writepos - stream->readpos); stream->writepos -= stream->readpos; + stream->readpos = 0; } if (flushed_size > (stream->readbuflen - stream->writepos)) { /* Grow the buffer */ From a1b68afe0909e80e793bbca9e336269dc972408a Mon Sep 17 00:00:00 2001 From: Weilin Du Date: Fri, 28 Aug 2026 01:03:13 +0800 Subject: [PATCH 2/5] Fix GH-23477: Memory leak on duplicate native Phar manifest entries (#23479) Check the insertion result and release these allocations on failure. --- NEWS | 2 ++ ext/phar/phar.c | 5 ++++- ext/phar/tests/gh23477.phpt | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 ext/phar/tests/gh23477.phpt diff --git a/NEWS b/NEWS index 1a9f52b2ab91..31021de07015 100644 --- a/NEWS +++ b/NEWS @@ -57,6 +57,8 @@ PHP NEWS - Phar: . Fixed bug GH-23418 (Use-after-free when looking up mounted directories). (Weilin Du) + . Fixed bug GH-23477 (Memory leak on duplicate native Phar manifest entries). + (Weilin Du) - Standard: . Fixed an out-of-bounds read when following a redirect response with an diff --git a/ext/phar/phar.c b/ext/phar/phar.c index 7e74de782ccb..fc21692db2c1 100644 --- a/ext/phar/phar.c +++ b/ext/phar/phar.c @@ -1234,7 +1234,10 @@ static zend_result phar_parse_pharfile(php_stream *fp, char *fname, size_t fname } else { str = zend_string_init(entry.filename, entry.filename_len, 0); } - zend_hash_add_mem(&mydata->manifest, str, (void*)&entry, sizeof(phar_entry_info)); + if (!zend_hash_add_mem(&mydata->manifest, str, (void*)&entry, sizeof(phar_entry_info))) { + phar_metadata_tracker_free(&entry.metadata_tracker, entry.is_persistent); + pefree(entry.filename, entry.is_persistent); + } zend_string_release(str); } diff --git a/ext/phar/tests/gh23477.phpt b/ext/phar/tests/gh23477.phpt new file mode 100644 index 000000000000..cd015ddaa9b0 --- /dev/null +++ b/ext/phar/tests/gh23477.phpt @@ -0,0 +1,39 @@ +--TEST-- +GH-23477 (Memory leak on duplicate native Phar manifest entry) +--EXTENSIONS-- +phar +--INI-- +phar.require_hash=0 +--FILE-- +\r\n"; + +function u32($value) { + return pack('V', $value); +} + +function entry($name, $data, $metadata) { + $header = u32(strlen($name)) . $name + . u32(strlen($data)) . u32(0) . u32(strlen($data)) + . u32(crc32($data)) . u32(0) + . u32(strlen($metadata)) . $metadata; + return [$header, $data]; +} + +$first = entry('a.txt', 'hello', 'i:1;'); +$second = entry('a.txt', 'world', 'i:2;'); +$manifest = u32(2) . "\x11\x00" . u32(0) . u32(0) . u32(0) + . $first[0] . $second[0]; + +file_put_contents(__DIR__ . '/gh23477.phar', + $stub . u32(strlen($manifest)) . $manifest . $first[1] . $second[1]); + +$phar = new Phar(__DIR__ . '/gh23477.phar'); +echo iterator_count($phar), "\n"; +?> +--CLEAN-- + +--EXPECT-- +1 From 65ae1ad84719ac8343f4137f3138b80152d85be0 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Thu, 27 Aug 2026 21:05:19 +0100 Subject: [PATCH 3/5] ext/pgsql: stop advertising PGSQL_DML_ASYNC where it is refused pg_update() and pg_delete() list PGSQL_DML_ASYNC among the valid flags in the error they raise, although their masks reject it and their helpers assert it is never set. Conversely pg_insert() and pg_update() accept the whole PGSQL_CONV_OPTS set but only named PGSQL_CONV_FORCE_NULL, so PGSQL_CONV_IGNORE_DEFAULT and PGSQL_CONV_IGNORE_NOT_NULL are added. While there, widen php_pgsql_delete()'s assert to PGSQL_DML_NO_CONV, which pg_delete() accepts and the helper itself consults. Close GH-23480 --- NEWS | 4 ++ ext/pgsql/pgsql.c | 14 +++--- ext/pgsql/tests/pg_dml_option_flags.phpt | 64 ++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 ext/pgsql/tests/pg_dml_option_flags.phpt diff --git a/NEWS b/NEWS index 6ac1e8e329ac..871c1c9adde7 100644 --- a/NEWS +++ b/NEWS @@ -16,6 +16,10 @@ PHP NEWS . Added Pdo\Pgsql::ATTR_CHUNK_SIZE to fetch a result set in chunks of the given number of rows. (KentarouTakeda) +- PGSQL: + . Fixed the pg_insert(), pg_update() and pg_delete() flag error messages, + which did not name the set of flags actually accepted. (lacatoire) + - Phar: . Fixed bug GH-23418 (Use-after-free when looking up mounted directories). (Weilin Du) diff --git a/ext/pgsql/pgsql.c b/ext/pgsql/pgsql.c index 9f0ca2c1a5e6..791cbfd2c8fe 100644 --- a/ext/pgsql/pgsql.c +++ b/ext/pgsql/pgsql.c @@ -5740,8 +5740,9 @@ PHP_FUNCTION(pg_insert) } if (option & ~(PGSQL_CONV_OPTS|PGSQL_DML_NO_CONV|PGSQL_DML_EXEC|PGSQL_DML_ASYNC|PGSQL_DML_STRING|PGSQL_DML_ESCAPE)) { - zend_argument_value_error(4, "must be a valid bit mask of PGSQL_CONV_FORCE_NULL, PGSQL_DML_NO_CONV, " - "PGSQL_DML_ESCAPE, PGSQL_DML_EXEC, PGSQL_DML_ASYNC, and PGSQL_DML_STRING"); + zend_argument_value_error(4, "must be a valid bit mask of PGSQL_CONV_IGNORE_DEFAULT, PGSQL_CONV_FORCE_NULL, " + "PGSQL_CONV_IGNORE_NOT_NULL, PGSQL_DML_NO_CONV, PGSQL_DML_ESCAPE, PGSQL_DML_EXEC, PGSQL_DML_ASYNC, " + "and PGSQL_DML_STRING"); RETURN_THROWS(); } @@ -5972,8 +5973,9 @@ PHP_FUNCTION(pg_update) } if (option & ~(PGSQL_CONV_OPTS|PGSQL_DML_NO_CONV|PGSQL_DML_EXEC|PGSQL_DML_STRING|PGSQL_DML_ESCAPE)) { - zend_argument_value_error(5, "must be a valid bit mask of PGSQL_CONV_FORCE_NULL, PGSQL_DML_NO_CONV, " - "PGSQL_DML_ESCAPE, PGSQL_DML_EXEC, PGSQL_DML_ASYNC, and PGSQL_DML_STRING"); + zend_argument_value_error(5, "must be a valid bit mask of PGSQL_CONV_IGNORE_DEFAULT, PGSQL_CONV_FORCE_NULL, " + "PGSQL_CONV_IGNORE_NOT_NULL, PGSQL_DML_NO_CONV, PGSQL_DML_ESCAPE, PGSQL_DML_EXEC, " + "and PGSQL_DML_STRING"); RETURN_THROWS(); } @@ -6004,7 +6006,7 @@ PHP_PGSQL_API zend_result php_pgsql_delete(PGconn *pg_link, const zend_string *t ZEND_ASSERT(pg_link != NULL); ZEND_ASSERT(table != NULL); ZEND_ASSERT(Z_TYPE_P(ids_array) == IS_ARRAY); - ZEND_ASSERT(!(opt & ~(PGSQL_CONV_FORCE_NULL|PGSQL_DML_EXEC|PGSQL_DML_STRING|PGSQL_DML_ESCAPE))); + ZEND_ASSERT(!(opt & ~(PGSQL_CONV_FORCE_NULL|PGSQL_DML_NO_CONV|PGSQL_DML_EXEC|PGSQL_DML_STRING|PGSQL_DML_ESCAPE))); if (zend_hash_num_elements(Z_ARRVAL_P(ids_array)) == 0) { return FAILURE; @@ -6074,7 +6076,7 @@ PHP_FUNCTION(pg_delete) if (option & ~(PGSQL_CONV_FORCE_NULL|PGSQL_DML_NO_CONV|PGSQL_DML_EXEC|PGSQL_DML_STRING|PGSQL_DML_ESCAPE)) { zend_argument_value_error(4, "must be a valid bit mask of PGSQL_CONV_FORCE_NULL, PGSQL_DML_NO_CONV, " - "PGSQL_DML_ESCAPE, PGSQL_DML_EXEC, PGSQL_DML_ASYNC, and PGSQL_DML_STRING"); + "PGSQL_DML_ESCAPE, PGSQL_DML_EXEC, and PGSQL_DML_STRING"); RETURN_THROWS(); } diff --git a/ext/pgsql/tests/pg_dml_option_flags.phpt b/ext/pgsql/tests/pg_dml_option_flags.phpt new file mode 100644 index 000000000000..3f4b73671aa1 --- /dev/null +++ b/ext/pgsql/tests/pg_dml_option_flags.phpt @@ -0,0 +1,64 @@ +--TEST-- +pg_insert()/pg_update()/pg_delete(): the flags refused are the flags the message names +--EXTENSIONS-- +pgsql +--SKIPIF-- + +--FILE-- + 2], ['id' => 1], PGSQL_DML_ASYNC | PGSQL_DML_STRING); +} catch (ValueError $e) { + echo $e->getMessage(), "\n"; +} + +try { + pg_delete($conn, $table_name, ['id' => 1], PGSQL_DML_ASYNC | PGSQL_DML_STRING); +} catch (ValueError $e) { + echo $e->getMessage(), "\n"; +} + +/* 1 << 13 is not one of the flags at all */ +try { + pg_insert($conn, $table_name, ['id' => 1, 'id2' => 1], 1 << 13); +} catch (ValueError $e) { + echo $e->getMessage(), "\n"; +} + +/* but PGSQL_DML_ASYNC is accepted by pg_insert() and pg_select() */ +var_dump(is_string(pg_insert($conn, $table_name, ['id' => 1, 'id2' => 1], PGSQL_DML_ASYNC | PGSQL_DML_STRING))); +var_dump(is_string(pg_select($conn, $table_name, ['id' => 1], PGSQL_DML_ASYNC | PGSQL_DML_STRING))); + +/* every PGSQL_CONV_* flag the messages name is genuinely accepted */ +var_dump(is_string(pg_insert($conn, $table_name, ['id' => 1, 'id2' => 1], PGSQL_CONV_IGNORE_DEFAULT | PGSQL_DML_STRING))); +var_dump(is_string(pg_update($conn, $table_name, ['id2' => 2], ['id' => 1], PGSQL_CONV_IGNORE_NOT_NULL | PGSQL_DML_STRING))); + +/* PGSQL_DML_NO_CONV is accepted by pg_delete() and reaches its helper */ +var_dump(is_string(pg_delete($conn, $table_name, ['id' => 1], PGSQL_DML_NO_CONV | PGSQL_DML_STRING))); + +?> +--CLEAN-- + +--EXPECT-- +pg_update(): Argument #5 ($flags) must be a valid bit mask of PGSQL_CONV_IGNORE_DEFAULT, PGSQL_CONV_FORCE_NULL, PGSQL_CONV_IGNORE_NOT_NULL, PGSQL_DML_NO_CONV, PGSQL_DML_ESCAPE, PGSQL_DML_EXEC, and PGSQL_DML_STRING +pg_delete(): Argument #4 ($flags) must be a valid bit mask of PGSQL_CONV_FORCE_NULL, PGSQL_DML_NO_CONV, PGSQL_DML_ESCAPE, PGSQL_DML_EXEC, and PGSQL_DML_STRING +pg_insert(): Argument #4 ($flags) must be a valid bit mask of PGSQL_CONV_IGNORE_DEFAULT, PGSQL_CONV_FORCE_NULL, PGSQL_CONV_IGNORE_NOT_NULL, PGSQL_DML_NO_CONV, PGSQL_DML_ESCAPE, PGSQL_DML_EXEC, PGSQL_DML_ASYNC, and PGSQL_DML_STRING +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) From b3cbd55a69f957dc830b01141dafcef86205d493 Mon Sep 17 00:00:00 2001 From: Marc Bennewitz Date: Wed, 26 Aug 2026 09:01:50 +0200 Subject: [PATCH 4/5] ext/standard: Io\Poll\Context::wait() max events range check Reject a $maxEvents value greater than INT_MAX instead of truncating it, which prevents an integer overflow where SIZEOF_INT < SIZEOF_ZEND_LONG. Close GH-23468 --- NEWS | 2 ++ ext/standard/io_poll.c | 7 ++++-- ...ctx_wait.phpt => poll_ctx_wait_error.phpt} | 14 +++++++++++ .../tests/poll/poll_ctx_wait_error_int64.phpt | 23 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) rename ext/standard/tests/poll/{poll_ctx_wait.phpt => poll_ctx_wait_error.phpt} (58%) create mode 100644 ext/standard/tests/poll/poll_ctx_wait_error_int64.phpt diff --git a/NEWS b/NEWS index 871c1c9adde7..df78bf6abe8a 100644 --- a/NEWS +++ b/NEWS @@ -30,6 +30,8 @@ PHP NEWS . Fixed an out-of-bounds read when following a redirect response with an empty Location header. (iliaal) . 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) 27 Aug 2026, PHP 8.6.0beta2 diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c index f57813874d52..a8a0563627fa 100644 --- a/ext/standard/io_poll.c +++ b/ext/standard/io_poll.c @@ -804,12 +804,15 @@ PHP_METHOD(Io_Poll_Context, wait) if (max_events <= 0) { max_events = 64; } - } else if (max_events <= 0) { + } else if (UNEXPECTED(max_events <= 0)) { zend_argument_value_error(2, "must be greater than 0"); RETURN_THROWS(); + } else if (ZEND_LONG_INT_OVFL(max_events)) { + zend_argument_value_error(2, "must be less than or equal to %d", INT_MAX); + RETURN_THROWS(); } - php_poll_event *events = safe_emalloc(max_events, sizeof(*events), 0); + php_poll_event *events = safe_emalloc((size_t) max_events, sizeof(*events), 0); int num_events = php_poll_wait(intern->ctx, events, (int) max_events, timeout ? &timeout_ts : NULL); if (num_events < 0) { diff --git a/ext/standard/tests/poll/poll_ctx_wait.phpt b/ext/standard/tests/poll/poll_ctx_wait_error.phpt similarity index 58% rename from ext/standard/tests/poll/poll_ctx_wait.phpt rename to ext/standard/tests/poll/poll_ctx_wait_error.phpt index 5080c1421fdb..c514884c3c6f 100644 --- a/ext/standard/tests/poll/poll_ctx_wait.phpt +++ b/ext/standard/tests/poll/poll_ctx_wait_error.phpt @@ -12,13 +12,27 @@ try { echo $e::class, ': ', $e->getMessage(), PHP_EOL; } +try { + $poll_ctx->wait(maxEvents: PHP_INT_MIN); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + try { $poll_ctx->wait(maxEvents: -1); } catch (Throwable $e) { echo $e::class, ': ', $e->getMessage(), PHP_EOL; } +try { + $poll_ctx->wait(maxEvents: 0); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + ?> --EXPECT-- ValueError: Io\Poll\Context::wait(): Argument #1 ($timeout) must not be negative ValueError: Io\Poll\Context::wait(): Argument #2 ($maxEvents) must be greater than 0 +ValueError: Io\Poll\Context::wait(): Argument #2 ($maxEvents) must be greater than 0 +ValueError: Io\Poll\Context::wait(): Argument #2 ($maxEvents) must be greater than 0 diff --git a/ext/standard/tests/poll/poll_ctx_wait_error_int64.phpt b/ext/standard/tests/poll/poll_ctx_wait_error_int64.phpt new file mode 100644 index 000000000000..3d881050e655 --- /dev/null +++ b/ext/standard/tests/poll/poll_ctx_wait_error_int64.phpt @@ -0,0 +1,23 @@ +--TEST-- +Io\Poll\Context::wait(): Parameter validation upper limit +--SKIPIF-- + 32bit platforms only"); +} +?> +--FILE-- +wait(maxEvents: PHP_INT_MAX); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +ValueError: Io\Poll\Context::wait(): Argument #2 ($maxEvents) must be less than or equal to 2147483647 From 47bebf87a04485b3e7c5f22c84f5c6ae6004626d Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Mon, 24 Aug 2026 11:16:48 -0400 Subject: [PATCH 5/5] [standard] Fix crash when filter callback unsets StreamBucket::$data stream_bucket_prepend()/append() assumed a successful zend_read_property() of StreamBucket::$data always yields a string, but for an unset typed property it throws while returning &EG(uninitialized_zval), so Z_STRLEN_P() dereferenced a NULL string pointer and crashed when the brigade was consumed. Reject non-string reads up front (rethrowing any pending exception) so the bucket is never re-attached with undefined data; the sibling $bucket property path is already safe because zend_fetch_resource_ex() rejects non-resources. Closes GH-23466 --- NEWS | 2 ++ .../tests/filters/bucket_data_unset.phpt | 27 +++++++++++++++++++ ext/standard/user_filters.c | 4 +++ 3 files changed, 33 insertions(+) create mode 100644 ext/standard/tests/filters/bucket_data_unset.phpt diff --git a/NEWS b/NEWS index 31021de07015..cf28751877fa 100644 --- a/NEWS +++ b/NEWS @@ -61,6 +61,8 @@ PHP NEWS (Weilin Du) - Standard: + . Fixed a segfault when a stream filter callback unsets StreamBucket::$data + before re-attaching the bucket. (iliaal) . Fixed an out-of-bounds read when following a redirect response with an empty Location header. (iliaal) . Fixed a memory leak in array_merge_recursive() when the recursive merge of diff --git a/ext/standard/tests/filters/bucket_data_unset.phpt b/ext/standard/tests/filters/bucket_data_unset.phpt new file mode 100644 index 000000000000..043ea5953e92 --- /dev/null +++ b/ext/standard/tests/filters/bucket_data_unset.phpt @@ -0,0 +1,27 @@ +--TEST-- +unset(StreamBucket::$data) in filter callback must not crash when bucket is re-attached +--FILE-- +data); + stream_bucket_prepend($out, $bucket); + } + return PSFS_PASS_ON; + } +} +stream_filter_register("myfilter", "MyFilter"); +$fp = fopen("php://temp", "w+"); +fwrite($fp, str_repeat("A", 100)); +rewind($fp); +stream_filter_append($fp, "myfilter"); +try { + var_dump(stream_get_contents($fp)); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} +echo "DONE\n"; +--EXPECT-- +Error: Typed property StreamBucket::$data must not be accessed before initialization +DONE diff --git a/ext/standard/user_filters.c b/ext/standard/user_filters.c index 735dd8390de8..f5e58041ca80 100644 --- a/ext/standard/user_filters.c +++ b/ext/standard/user_filters.c @@ -423,7 +423,11 @@ static void php_stream_bucket_attach(int append, INTERNAL_FUNCTION_PARAMETERS) } if (NULL != (pzdata = zend_read_property(NULL, Z_OBJ_P(zobject), "data", sizeof("data")-1, false, &rv))) { + if (EG(exception)) { + RETURN_THROWS(); + } ZVAL_DEREF(pzdata); + ZEND_ASSERT(Z_TYPE_P(pzdata) == IS_STRING); if (!bucket->own_buf) { bucket = php_stream_bucket_make_writeable(bucket); }