算法与数据结构 / regex · Unicode 字符模型与一个自研引擎 / 方向控制字符:看不见的重排 待审核 26 / 36
Bidi · 方向控制与显示重排

方向控制字符:看不见的重排

一段文本在内存里的存储顺序,也就是 ===、正则与编译器逐字节看到的顺序,与它在屏幕上的显示顺序不一定相同。当从左到右的文字(拉丁、CJK)与从右到左的文字(希伯来、阿拉伯)混排时,浏览器按 Unicode 双向算法(UAX #9)重新排布,这是双向文字能正确显示的根基。而一小撮看不见的 \p{Cf} 控制字符能人为操纵这个重排:正经用途是修排版,恶意用途就是让源码「看到的和编译的不一样」。特殊用意的码点受控输入 几页把 U+202E 当隐形字符顺带点过,本页把整套机制摊开。

1 · 逻辑序与视觉序

同一串文本有两种顺序。逻辑序按码点的存储顺序排开;视觉序则要实测——把每个字符渲染出来,用 getBoundingClientRect 读它在屏幕上的真实横坐标,再按从左到右重排。两行对不上的格子,就是被双向算法调换过位置的字符。

强类 L(拉丁、CJK)、R(希伯来)、AL(阿拉伯)决定方向;弱类 ENAN(欧洲、阿拉伯数字)方向跟随上下文;中性的标点与空格被两侧夹着走。纯 L 文本逻辑序等于视觉序,没有重排;一旦出现连续的强右字符,这一段会被整体反转,而夹在中间的数字保持自己从左到右。

图 1-1 · 一段混排文本的逻辑序与实测视觉序对照,被调换位置的格子高亮,每格按 bidi class 上色。可改文本、切换段落基方向或点预设。

2 · 三代控制字符

操纵重排的字符都是看不见、不占宽的 \p{Cf},历史上分三代:mark 只给中性字符定个方向、不开范围;embedding 与 override 开一段范围;isolate 是 Unicode 6.3 新增的替代品。

图 2-1 · 十二个方向控制字符按三代分组,逐个给出码点、作用与注入按钮,注入即写进图 1-1 的输入框。

建议 · 拼接不可信片段(用户名、文件名)时用 isolate 而非 embedding 或 override。后两者会让内部文本影响到范围之外的中性字符方向,嵌套时极易算错;isolate 把括起来的整段当成一个中性对象对外隔离,内外互不干扰。FSI 更省心:自动按片段里第一个强方向字符定方向。三代都靠 PDFPDI 收尾。

3 · Trojan Source

把方向控制字符塞进源代码的注释或字符串里,人在 review 里读到的逻辑与编译器逐字节解析的逻辑就能南辕北辙。这就是 Trojan Source(CVE-2021-42574),影响几乎所有支持 Unicode 的语言。

图 3-1 · 两个攻击样本的三视图:逻辑字节序、屏幕渲染与控制符扫描结果。可切换样本。

警示 · 没有哪段正常源码需要在标识符或注释里混 bidi 控制符,所以最稳的策略是直接拒绝:CI 或 pre-commit 钩子里扫这批码点,命中即拒。rustcgccclang 现在会对源码里的 bidi 控制符告警,GitHub 与 GitLab 的 diff 也会高亮提示。渲染不可信文本时用 isolate 包裹,或干脆把这些 \p{Cf} 过滤掉、可视化出来。

4 · 参考文献

  1. Unicode. UAX #9: Unicode Bidirectional Algorithm. 双向重排的源头标准:bidi class、embedding level、显式格式字符与 isolate 的完整规则。unicode.org
  2. Boucher & Anderson. Trojan Source: Invisible Vulnerabilities. CVE-2021-42574 的论文与示例仓库,本页第 3 节的样本取自此。trojansource.codes
  3. MDN. unicode-bidi. CSS 侧的对应物:unicode-bidi 的六个取值(embed / isolate / bidi-override / isolate-override / plaintext)与 direction 的配合。developer.mozilla.org
  4. W3C. Inline markup and bidirectional text in HTML. 何时该用控制字符、何时该用标记(dir 属性与 <bdi> / <bdo>),以及拼接动态内容时如何避免泄漏与错排。w3.org