Commit 75f4e8d2075 for php.net

commit 75f4e8d2075b51171a891ab1c2c0529e9d316d5f
Author: Yudai Takada <t.yudai92@gmail.com>
Date:   Tue Sep 8 00:07:16 2026 +0800

    Fix bounds check in multibyte UTF detection (#23527)

    zend_multibyte_detect_utf_encoding() previously advanced the search position
    by four bytes after each NUL while looking for UTF-32-specific byte orders.
    When fewer than three bytes remained, the search length underflowed and
    allowed memchr() to read past the script buffer.

    Track search positions as offsets and only call memchr() while at least
    three bytes remain. This also avoids advancing a pointer beyond the script
    buffer and prevents BOM-less UTF-16 input from being misdetected as UTF-32.

    Closes #23527

diff --git a/NEWS b/NEWS
index a8d2c0727be..056f0296b68 100644
--- a/NEWS
+++ b/NEWS
@@ -7,6 +7,8 @@ PHP                                                                        NEWS
     n_scale. (Ilia Alshanetsky)

 - Core:
+  . Fixed out-of-bounds reads during automatic UTF-16/32 encoding detection.
+    (Yudai Takada)
   . Fixed bug GH-15375 (Nested "yield from" skips items after a valid() or
     next() call on the inner generator). (iliaal)
   . Fixed bug GH-23232 (lone namespace separator asks the autoloader for an
diff --git a/Zend/tests/multibyte/multibyte_encoding_008.phpt b/Zend/tests/multibyte/multibyte_encoding_008.phpt
new file mode 100644
index 00000000000..69a2aa58369
--- /dev/null
+++ b/Zend/tests/multibyte/multibyte_encoding_008.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Zend Multibyte does not read past the script during UTF-16 detection
+--EXTENSIONS--
+mbstring
+--INI--
+zend.multibyte=1
+internal_encoding=UTF-8
+--FILE--
+<?php
+$filename = __DIR__ . '/multibyte_encoding_008.tmp.php';
+file_put_contents($filename, "<\0?\0p\0h\0p\0");
+include $filename;
+echo "Done\n";
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/multibyte_encoding_008.tmp.php');
+?>
+--EXPECT--
+Done
diff --git a/Zend/zend_language_scanner.l b/Zend/zend_language_scanner.l
index 0be49df0275..6672c4e2b99 100644
--- a/Zend/zend_language_scanner.l
+++ b/Zend/zend_language_scanner.l
@@ -327,14 +327,13 @@ ZEND_API zend_result zend_lex_tstring(zval *zv, unsigned char *ident)
 static const zend_encoding *zend_multibyte_detect_utf_encoding(const unsigned char *script, size_t script_size)
 {
 	const unsigned char *p;
+	size_t offset = 0;
 	int wchar_size = 2;
 	int le = 0;

 	/* utf-16 or utf-32? */
-	p = script;
-	assert(p >= script);
-	while ((size_t)(p-script) < script_size) {
-		p = memchr(p, 0, script_size-(p-script)-2);
+	while (offset < script_size && script_size - offset > 2) {
+		p = memchr(script + offset, 0, script_size - offset - 2);
 		if (!p) {
 			break;
 		}
@@ -344,13 +343,13 @@ static const zend_encoding *zend_multibyte_detect_utf_encoding(const unsigned ch
 		}

 		/* searching for UTF-32 specific byte orders, so this will do */
-		p += 4;
+		offset = p - script + 4;
 	}

 	/* BE or LE? */
-	p = script;
-	assert(p >= script);
-	while ((size_t)(p-script) < script_size) {
+	offset = 0;
+	while (script_size - offset >= (size_t) wchar_size) {
+		p = script + offset;
 		if (*p == '\0' && *(p+wchar_size-1) != '\0') {
 			/* BE */
 			le = 0;
@@ -360,7 +359,7 @@ static const zend_encoding *zend_multibyte_detect_utf_encoding(const unsigned ch
 			le = 1;
 			break;
 		}
-		p += wchar_size;
+		offset += wchar_size;
 	}

 	if (wchar_size == 2) {