方向控制字符:看不见的重排
一段文本在内存里的存储顺序,也就是 ===、正则与编译器逐字节看到的顺序,与它在屏幕上的显示顺序不一定相同。当从左到右的文字(拉丁、CJK)与从右到左的文字(希伯来、阿拉伯)混排时,浏览器按 Unicode 双向算法(UAX #9)重新排布,这是双向文字能正确显示的根基。而一小撮看不见的
\p{Cf} 控制字符能人为操纵这个重排:正经用途是修排版,恶意用途就是让源码「看到的和编译的不一样」。特殊用意的码点 与 受控输入 几页把 U+202E 当隐形字符顺带点过,本页把整套机制摊开。
1 · 逻辑序与视觉序
同一串文本有两种顺序。逻辑序按码点的存储顺序排开;视觉序则要实测——把每个字符渲染出来,用 getBoundingClientRect 读它在屏幕上的真实横坐标,再按从左到右重排。两行对不上的格子,就是被双向算法调换过位置的字符。
强类 L(拉丁、CJK)、R(希伯来)、AL(阿拉伯)决定方向;弱类 EN 与 AN(欧洲、阿拉伯数字)方向跟随上下文;中性的标点与空格被两侧夹着走。纯
L 文本逻辑序等于视觉序,没有重排;一旦出现连续的强右字符,这一段会被整体反转,而夹在中间的数字保持自己从左到右。
2 · 三代控制字符
操纵重排的字符都是看不见、不占宽的 \p{Cf},历史上分三代:mark 只给中性字符定个方向、不开范围;embedding 与 override 开一段范围;isolate 是 Unicode 6.3 新增的替代品。
建议 · 拼接不可信片段(用户名、文件名)时用 isolate 而非 embedding 或 override。后两者会让内部文本影响到范围之外的中性字符方向,嵌套时极易算错;isolate 把括起来的整段当成一个中性对象对外隔离,内外互不干扰。FSI 更省心:自动按片段里第一个强方向字符定方向。三代都靠
PDF 或 PDI 收尾。
3 · Trojan Source
把方向控制字符塞进源代码的注释或字符串里,人在 review 里读到的逻辑与编译器逐字节解析的逻辑就能南辕北辙。这就是 Trojan Source(CVE-2021-42574),影响几乎所有支持 Unicode 的语言。
警示 · 没有哪段正常源码需要在标识符或注释里混 bidi 控制符,所以最稳的策略是直接拒绝:CI 或 pre-commit 钩子里扫这批码点,命中即拒。rustc、gcc 与 clang 现在会对源码里的 bidi 控制符告警,GitHub 与 GitLab 的 diff 也会高亮提示。渲染不可信文本时用 isolate
包裹,或干脆把这些 \p{Cf} 过滤掉、可视化出来。
4 · 参考文献
- Unicode. UAX #9: Unicode Bidirectional Algorithm. 双向重排的源头标准:bidi class、embedding level、显式格式字符与 isolate 的完整规则。unicode.org
- Boucher & Anderson. Trojan Source: Invisible Vulnerabilities. CVE-2021-42574 的论文与示例仓库,本页第 3 节的样本取自此。trojansource.codes
-
MDN. unicode-bidi. CSS 侧的对应物:
unicode-bidi的六个取值(embed/isolate/bidi-override/isolate-override/plaintext)与direction的配合。developer.mozilla.org - W3C. Inline markup and bidirectional text in HTML. 何时该用控制字符、何时该用标记(
dir属性与<bdi>/<bdo>),以及拼接动态内容时如何避免泄漏与错排。w3.org