Skip to content

Commit ec348a1

Browse files
committed
Character indices used by mb_strpos and mb_substr have same meaning, even on invalid strings
Starting many years ago, libmbfl included a 'mblen_table' for selected text encodings. This table allows looking up the byte length of a (possibly multi-byte) character from the value of the first byte. libmbfl uses these tables to optimize certain operations; if a text-processing operation can be performed using an mblen_table, it may not be necessary to decode the text to codepoints. Since libmbfl's decoding filters are generally slow, this improves performance. Since mbstring is (or was) based on libmbfl, it has always used these mblen_tables to implement some functions. This design has a significant downside. Let me explain: While some mbstring functions are implemented by converting input text to codepoints and operating on the codepoints, others operate directly on the original input bytes (using an mblen_table to identify character boundaries). Both of these implementation styles, if correctly coded, yield equivalent results on valid strings. However, on strings which contain encoding errors, the results are often different. When decoding byte strings to codepoints using some text encoding, mbstring uses the non-existent codepoint 0xFFFFFFFF to represent a byte sequence which cannot be decoded. Then, when mbstring indexes into the resulting sequence of codepoints, the index of any particular character depends on the number of such 'error markers' which were produced during the decoding process. In contrast, when an mblen_table is used to split a byte sequence into characters, there is no question of counting encoding errors; rather, table lookups into the mblen_table are used to repeatedly 'bite off' some number of bytes (which are treated as one 'character'). In the presence of encoding errors, these two methods of mapping between byte indices and character indices are inherently different and will rarely agree. (For completeness, it must be said that some internal mbstring code which operates only on UTF-8 text uses a third method for mapping between byte indices and character indices, that is: counting non-continuation UTF-8 bytes, which are all bytes whose binary representation is NOT like 0b10xxxxxx. This method happens to agree with the method which involves decoding the input text to codepoints and then counting the codepoints.) I have been aware of this issue for years, but only recently became aware that in the case of mb_strstr, mb_strpos, and mb_substr, this issue can cause seriously unintuitive behavior (and even security vulnerabilities). This was reported by Stefan Schiller. Stefan Schiller shared the following example for mb_strstr: var_dump(mb_strstr("\xf0start", "start", false, "UTF-8")); // string(2) "rt" Similarly, when mb_strpos and mb_substr are used to identify and extract a substring from a string with encoding errors, Stefan Schiller pointed out that the extracted portion may be completely different than desired. This is because (for UTF-8 strings) mb_strpos works by counting non-continuation bytes, but mb_substr uses an mblen_table. Since some mbstring functions *cannot* be implemented using an mblen_table, as long as mblen_tables are used, similar inconsistencies cannot be totally avoided. But the mblen_tables are critical to mbstring's performance. Or are they? Benchmarking mb_substr on various UTF-8, SJIS, and EUC-JP strings revealed something interesting. On all SJIS and EUC-JP test cases, mb_substr was slightly faster when the mblen_table based code was deleted. For some UTF-8 test cases, the mblen_table-based code was a tiny bit faster, while for others the fallback code was a touch faster; in no case was the difference significant. Therefore, the simple fix is to delete the mblen_table-based implementation of mb_substr. Aside from making the function behave consistently with other mbstring functions on invalid strings, there is ONE case where behavior is now different on valid strings: that is, on SJIS-Mac (MacJapanese) strings which contain any of the following code units: 0x85AB-0x85AD, 0x85BF, 0x85C0, 0x85C1, 0x8645, 0x864B, 0x865D, 0x869E, 0x86CE, 0x86D3-0x86D5, 0x86D6, 0x8971, 0x8792, 0x879D, 0x87FB, 0x87FC, 0xEB41, 0xEB42, 0xEB50, 0xEB5B, 0xEB5D, 0xEB60-0xEB6E, and all from 0xEB81 and above. All of these SJIS-Mac code units share the (very unusual) property that they do not correspond to any one Unicode codepoint. When converting from SJIS-Mac to Unicode, these must be converted to 2, 3, 4, or 5 codepoints each. The previous, mblen_table-based implementation of mb_substr would treat all of these SJIS-Mac byte sequences as 'one character'. Now, they are treated as multiple characters (one for each of the Unicode codepoints which they decode to). The new behavior is more consistent with other mbstring functions. I don't know if SJIS-Mac users will like this change or not (probably most will never notice), but the BC break is justified by the very real security impact of the previous, inconsistent behavior. Finally, I should comment on whether similar changes are needed elsewhere. The remaining functions which use an mblen_table are: mb_str_split, mb_strcut, and various search functions (such as mb_strpos). The search functions are only affected now when they receive a positive 'offset' parameter specifying where to start searching from. The search functions should definitely be fixed so they do not use an mblen_table to implement the 'offset' parameter. I am not convinced that there is any good reason to change mb_str_split and mb_strcut.
1 parent 6aa70b5 commit ec348a1

File tree

3 files changed

+38
-56
lines changed

3 files changed

+38
-56
lines changed

ext/mbstring/mbstring.c

+5-41
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
#include "libmbfl/mbfl/mbfilter_wchar.h"
3939
#include "libmbfl/mbfl/eaw_table.h"
4040
#include "libmbfl/filters/mbfilter_base64.h"
41+
#include "libmbfl/filters/mbfilter_cjk.h"
4142
#include "libmbfl/filters/mbfilter_qprint.h"
4243
#include "libmbfl/filters/mbfilter_htmlent.h"
4344
#include "libmbfl/filters/mbfilter_uuencode.h"
@@ -2105,8 +2106,9 @@ static zend_string* mb_get_substr(zend_string *input, size_t from, size_t len, c
21052106
unsigned char *in = (unsigned char*)ZSTR_VAL(input);
21062107
size_t in_len = ZSTR_LEN(input);
21072108

2108-
if (from >= in_len) {
2109-
/* No supported text encoding decodes to more than one codepoint per byte
2109+
if (len == 0 || (from >= in_len && enc != &mbfl_encoding_sjis_mac)) {
2110+
/* Other than MacJapanese, no supported text encoding decodes to
2111+
* more than one codepoint per byte
21102112
* So if the number of codepoints to skip >= number of input bytes,
21112113
* then definitely the output should be empty */
21122114
return zend_empty_string;
@@ -2127,30 +2129,6 @@ static zend_string* mb_get_substr(zend_string *input, size_t from, size_t len, c
21272129
len = in_len;
21282130
}
21292131
return zend_string_init_fast((const char*)in, len);
2130-
} else if (enc->mblen_table) {
2131-
/* The use of the `mblen_table` means that for encodings like MacJapanese,
2132-
* we treat each character in its native charset as "1 character", even if it
2133-
* maps to a sequence of several codepoints */
2134-
const unsigned char *mbtab = enc->mblen_table;
2135-
unsigned char *limit = in + in_len;
2136-
while (from && in < limit) {
2137-
in += mbtab[*in];
2138-
from--;
2139-
}
2140-
if (in >= limit) {
2141-
return zend_empty_string;
2142-
} else if (len == MBFL_SUBSTR_UNTIL_END) {
2143-
return zend_string_init_fast((const char*)in, limit - in);
2144-
}
2145-
unsigned char *end = in;
2146-
while (len && end < limit) {
2147-
end += mbtab[*end];
2148-
len--;
2149-
}
2150-
if (end > limit) {
2151-
end = limit;
2152-
}
2153-
return zend_string_init_fast((const char*)in, end - in);
21542132
}
21552133

21562134
return mb_get_substr_slow(in, in_len, from, len, enc);
@@ -2343,21 +2321,7 @@ PHP_FUNCTION(mb_substr)
23432321

23442322
size_t mblen = 0;
23452323
if (from < 0 || (!len_is_null && len < 0)) {
2346-
if (enc->mblen_table) {
2347-
/* Because we use the `mblen_table` when iterating over the string and
2348-
* extracting the requested part, we also need to use it here for counting
2349-
* the "length" of the string
2350-
* Otherwise, we can get wrong results for text encodings like MacJapanese,
2351-
* where one native 'character' can map to a sequence of several codepoints */
2352-
const unsigned char *mbtab = enc->mblen_table;
2353-
unsigned char *p = (unsigned char*)ZSTR_VAL(str), *e = p + ZSTR_LEN(str);
2354-
while (p < e) {
2355-
p += mbtab[*p];
2356-
mblen++;
2357-
}
2358-
} else {
2359-
mblen = mb_get_strlen(str, enc);
2360-
}
2324+
mblen = mb_get_strlen(str, enc);
23612325
}
23622326

23632327
/* if "from" position is negative, count start position from the end

ext/mbstring/tests/mb_strstr.phpt

+8-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ var_dump(FROM_EUC_JP(mb_strstr(EUC_JP("あいうえおかきくけこ"), EUC_JP(
2626
var_dump(bin2hex(mb_strstr("\xdd\x00", "", false, 'UTF-8')));
2727
var_dump(bin2hex(mb_strstr("M\xff\xff\xff\x00", "\x00", false, "SJIS")));
2828

29+
// Test handling of invalid UTF-8 string
30+
// Thanks to Stefan Schiller
31+
var_dump(mb_strstr("\xf0start", "start", false, "UTF-8"));
32+
var_dump(mb_strstr("\xf0start", "start", true, "UTF-8"));
33+
2934
?>
3035
--EXPECT--
3136
string(18) "おかきくけこ"
@@ -36,5 +41,7 @@ string(12) "あいうえ"
3641
string(18) "おかきくけこ"
3742
string(18) "おかきくけこ"
3843
string(12) "あいうえ"
39-
string(4) "dd00"
44+
string(4) "3f00"
4045
string(2) "00"
46+
string(5) "start"
47+
string(1) "?"

ext/mbstring/tests/mb_substr.phpt

+25-14
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ print "3: " . mb_convert_encoding(mb_substr($utf7, -5, 3, 'UTF-7'), 'UTF-8', 'UT
118118
print "4: " . mb_convert_encoding(mb_substr($utf7, 1, null, 'UTF-7'), 'UTF-8', 'UTF-7') . "\n";
119119
print "5:" . mb_convert_encoding(mb_substr($utf7, 10, 0, 'UTF-7'), 'UTF-8', 'UTF-7') . "\n";
120120

121+
echo "Testing agreement with mb_strpos on invalid UTF-8 string:\n";
122+
/* Stefan Schiller pointed out that on invalid UTF-8 strings, character indices returned
123+
* by mb_strpos would not extract the desired part of the string when passed to mb_substr.
124+
* This is the test case which he provided: */
125+
$data = "\xF0AAA<b>";
126+
$pos = mb_strpos($data, "<", 0, "UTF-8");
127+
$out = mb_substr($data, 0, $pos, "UTF-8");
128+
print $out . "\n";
129+
121130
echo "Regression:\n";
122131
/* During development, one >= comparison in mb_get_substr was wrongly written as >
123132
* This was caught by libFuzzer */
@@ -138,30 +147,30 @@ SJIS:
138147
4: 967b8cea8365834c8358836782c582b781423031323334825482558256825782588142
139148
5:
140149
-- Testing illegal SJIS byte 0x80 --
141-
6380
142-
806162
150+
633f
151+
3f6162
143152
SJIS-2004:
144-
6380
145-
806162
153+
633f
154+
3f6162
146155
MacJapanese:
147156
6380
148157
806162
149158
SJIS-Mobile#DOCOMO:
150-
6380
151-
806162
159+
633f
160+
3f6162
152161
SJIS-Mobile#KDDI:
153-
6380
154-
806162
162+
633f
163+
3f6162
155164
SJIS-Mobile#SoftBank:
156-
6380
157-
806162
165+
633f
166+
3f6162
158167
-- Testing MacJapanese characters which map to 3-5 codepoints each --
159168
616263
160-
85ab85ac
161-
85ac
169+
3f3f
170+
58
162171
616263
163-
85bf85c0
164-
85c0
172+
3f3f
173+
78
165174
ISO-2022-JP:
166175
1: 1b2442212121721b284241
167176
2: 43
@@ -200,5 +209,7 @@ UTF-7:
200209
3: йте
201210
4: reek: Σὲ γνωρίζω ἀπὸ τὴν κόψη Russian: Зарегистрируйтесь
202211
5:
212+
Testing agreement with mb_strpos on invalid UTF-8 string:
213+
?AAA
203214
Regression:
204215
1b28493d3d3d3d3d3d3d3e3d3d3d1b28423f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f000000003f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f1b28493d3d3d3d3d3d3d3e1b2842013a4f1b28492a1b2842

0 commit comments

Comments
 (0)