[BUG] bionic:wcscspn()/wcspbrk() 将所有 wchar_t >= 0x100 当作匹配项(Android 17,CP3A.260905.009)
描述
在 2026 年 9 月构建的 Android 17 版本上,当拒绝集合包含两个或更多全部小于 U+0100 的字符时,wcscspn() 与 wcspbrk() 会停在字符串中第一个大于等于 U+0100 的字符处,并将它当作匹配项,即使它并不在该集合中。wcsspn()、wcschr()、wcsrchr()、wmemchr() 与 wcsstr() 的行为是正确的。
任何用宽字符串扫描 ASCII 分隔符(例如换行符或通配符)的代码,只要文本中包含表情符号、CJK(中日韩统一表意文字)、西里尔字母、希腊字母或拉丁扩展字符,就会被破坏。
受影响 / 未受影响
| 设备 | 构建版本 | 结果 |
|---|---|---|
| Pixel 7 | google/panther/panther:17/CP3A.260905.009/16091614:user/release-keys | 异常 |
| Pixel 8a | google/akita/akita:17/CP3A.260905.009/16091614:user/release-keys(2026-09-05 补丁) | 异常 |
| Nothing Phone 2 | Nothing/PongEEA/Pong:16/BQ2A.250721.001-BP2A.250605.031.A3(2026-08-01 补丁) | 正常 |
公开的 android17-release 源码(android-17.0.0_r1,2026 年 6 月)从 external/llvm-libc 构建 wcscspn/wcspbrk,这是对 wchar_t 的一个普通嵌套循环,行为正确。9 月构建中的代码不同(参见"机制"部分),因此该回归问题是在 6 月与 9 月两次发布之间引入的。
复现
使用 NDK(aarch64-linux-android30-clang)构建,推送到 /data/local/tmp,通过 adb shell 运行:
#include <stdio.h>
#include <wchar.h>
int main(void)
{
wchar_t s[] = {'a', 0x1F680, 0x100, 'b', 'x', '\n', 0};
printf("wcscspn(s, L\"x\\n\") = %zu (expected 4)\n", wcscspn(s, L"x\n"));
printf("wcspbrk(s, L\"x\\n\") = index %ld (expected 4)\n", (long)(wcspbrk(s, L"x\n") - s));
printf("wcsspn(s, L\"a\\U0001F680\") = %zu (expected 2)\n", wcsspn(s, L"a\U0001F680"));
return 0;
}
CP3A.260905.009 上的输出:
wcscspn(s, L"x\n") = 1 (expected 4)
wcspbrk(s, L"x\n") = index 1 (expected 4)
wcsspn(s, L"a\U0001F680") = 2 (expected 2)
使用 wcscspn(L"\n", L"x\n") 遍历从 U+0001 到 U+10FFFF 的每一个码点(跳过代理区码点和那两个集合成员),结果对 1,111,808 个码点都返回 0 而不是 1:所有从 U+0100 到 U+10FFFF 的码点。U+0001 到 U+00FF 的结果是正确的。
哪些集合形式会触发该问题(全部在 Pixel 8a 上测得):
| 拒绝集合 | 结果 |
|---|---|
| 一个字符 | 正常 |
两个或以上字符,全部 < U+0100(例如 L"x\n"、L"\xe9\n") |
异常 |
| 两个或以上字符,至少有一个 >= U+0100 | 正常 |
| haystack(待扫描字符串)限制在 U+0080..U+00FF 范围内 | 正常 |
机制(取自已发布的 libc.so)
对 CP3A.260905.009 中 /apex/com.android.runtime/lib64/bionic/libc.so 里 wcscspn 的反汇编显示,代码根据集合选中三条路径之一:
- 一个字符:普通的逐位比较循环。结果正确。
- 两个或以上字符:在栈上分配一个 256 字节的
bool表,由集合填充(前提是集合中的字符都在 1..0xFF 范围内)。如果看到某个集合字符 >= 0x100,函数会回退到对每个 haystack 字符调用wcschr()。结果正确。 - 其他情况下,haystack 针对该表进行扫描:
a2564: ldrb w8, [x9, x8] ; table[c]
a2568: tbnz w8, #0, a2578 ; 在集合中 -> 停止(正确)
a256c: ldr w8, [x22, #0x4]! ; 下一个字符
a2570: cmp w8, #0x100
a2574: b.lo a2564 ; c < 0x100 -> 继续扫描
a2578: ... ; c >= 0x100 -> 停止,并将其当作匹配项
对于所有成员都 < 0x100 的集合而言,>= 0x100 的字符不可能属于该集合,因此对 wcscspn 来说扫描必须越过它继续进行。但代码却直接停了下来。wcsspn 拥有相同的结构,在那种情况下停下来是正确的答案(该字符不属于保留集合),这也解释了为什么只有补集函数(即 wcscspn/wcspbrk)出错。wcspbrk 是基于该扫描实现的,因此继承了这个缺陷。
现有的 bionic 测试 TEST(wchar, wcspbrk)(位于 tests/wchar_test.cpp)只使用 ASCII 输入,所以它捕获不到这个缺陷。一条像 EXPECT_EQ(3u, wcscspn(L"aĀb\n", L"x\n")) 这样的测试就可以。
实际影响
Android 上的 Visual Pinball(libwinevbs,是 Wine 的 vbscript 引擎的 Unix 构建)在 9 月更新后无法加载任何 VBScript 源码里注释中包含表情符号的桌台:词法分析器使用 wcspbrk(ptr, L"\n\r") 来跳过注释,结果得到的不是行尾位置,因此报告"无效字符"。FileSystem.MoveFolder 同样通过 wcspbrk(src, L"*?><") 把任何路径中包含 >= U+0100 字符的路径当作通配符模式处理。
下游已经通过将这些调用替换为普通循环来绕过该问题:https://github.com/vpinball/libwinevbs/pull/41 (随 https://github.com/vpinball/vpinball/commit/e7837322f4 一起发布)。
受影响函数的完整反汇编以及探测源码可按需索取。
我正在使用受支持的 NDK
- [x] 我已确认我正在使用的 NDK 当前受支持
受影响版本
r29