[php-src] Issue #24769: ext/mbstring: mb_regex_substitute() reads past the replacement string's end on a truncated trailing multibyte lead byte
| From: | kunwaratulhax0r | Date: | Tue, 27 Oct 2026 03:02:36 +0000 |
| Subject: | [php-src] Issue #24769: ext/mbstring: mb_regex_substitute() reads past the replacement string's end on a truncated trailing multibyte lead byte | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-253228@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24769
Author: kunwaratulhax0r
### Description
509e27bae1 (GH-23499, fixed August 2026) corrected two places in
mb_regex_substitute() (ext/mbstring/php_mbregex.c, the replacement-string
interpolation behind mb_ereg_replace()/mb_eregi_replace()) where
php_mb_mbchar_bytes()'s claimed character width was used to advance a pointer
without checking it against the buffer's actual remaining length -- both were clamped to
MIN(p + clen, eos). f6852befa3 (GH-24498, fixed October 2026) separately
fixed the same bug class in a different file, php_mb_rfc1867_getword().
Neither fix touched the *first* php_mb_mbchar_bytes() call in
mb_regex_substitute()'s main loop -- the check that runs on every character,
before any backslash/backref handling, to decide whether it's an ordinary character or the
start of a backreference:
```c
while (p < eos) {
...
clen = (int) php_mb_mbchar_bytes(p, enc);
if (clen != 1 || p == eos || p[0] != '\\') {
/* skip anything that's not an ascii backslash */
smart_str_appendl(pbuf, p, clen);
p += clen;
continue;
}
```
If the character at p is not a backslash, and its lead byte (looked up in the current
mbregex encoding's mblen_table) claims a width of 2-4 bytes but fewer than
clen - 1 bytes actually remain before eos, smart_str_appendl(pbuf,
p, clen) copies clen bytes starting at p regardless -- reading past
the replacement string's real content. This is the identical shape as the two already-fixed
call sites a few lines later in the same function, which use MIN(p + clen, eos)
instead.
Minimal reproducer:
```php
<?php
mb_regex_encoding('UTF-8');
$replacement = "x" . "\xF0"; // ends in a UTF-8 4-byte lead byte with 0
continuation bytes present
var_dump(mb_ereg_replace('a', $replacement, 'a'));
```
Resulted in this output:
```
string(5) "x..."
```
(5 bytes: the input is only 2 bytes, but php_mb_mbchar_bytes() claims the trailing
\xF0 byte is a 4-byte character, so 4 bytes get copied starting from it -- 3 of them
past the replacement string's actual end.)
But I expected this output instead: a 2-byte result ("x\xF0"), i.e. the same
MIN(p + clen, eos)-style clamping already applied to the other two call sites in this
function.
This was repeatable across 20 differently-sized/content inputs ending the same way -- always exactly
claimed_width - 1 extra bytes beyond the logical input length. In shorter variants the
extra bytes were non-zero, unpredictable content rather than a fixed pattern, consistent with
genuine adjacent heap memory being copied into the return value.
**Suggested fix:** apply the same clamp already used nearby in the same function:
```c
clen = (int) php_mb_mbchar_bytes(p, enc);
if (clen != 1 || p == eos || p[0] != '\\') {
size_t avail = MIN((size_t) clen, (size_t) (eos - p));
smart_str_appendl(pbuf, p, avail);
p += avail;
continue;
}
```
Confirmed present, unmodified, on PHP-8.3, PHP-8.4, and PHP-8.5, and master.
### PHP Version
```
PHP 8.7.0-dev (cli) (built: Oct 26 2026 09:57:01) (NTS DEBUG)
Copyright © The PHP Group and Contributors
Zend Engine v4.7.0-dev, Copyright © Zend by Perforce
with Zend OPcache v8.7.0-dev, Copyright ©, by Zend by Perforce
```
Also confirmed present on the PHP-8.3, PHP-8.4, and PHP-8.5 branches via direct content check.
### Operating System
Ubuntu 24.04 (Docker), but this is pure string-handling logic, not OS-specific.