2021年10月,剑桥大学的两位研究员向19家科技公司发送了一份措辞谨慎的安全报告。报告中包含一段C代码,看起来简单到不值一提:检查用户是否为管理员,如果不是就什么都不做。然而,编译运行后,程序却打印出了"You are an admin."

这不是编译器的bug,也不是C语言的陷阱。这段代码里藏着几个不可见的Unicode字符——它们让编译器看到的逻辑和人类眼中的代码完全不同。

这场被称为"Trojan Source"的攻击,揭示了现代软件开发中一个被忽视的信任根基:我们假设编译器会忠实地实现我们在屏幕上看到的代码逻辑。这个假设在Unicode时代变得岌岌可危。

同一行代码,两个世界

问题的根源在于文本的"逻辑顺序"与"视觉顺序"之间的差异。

当我们写下一行代码时,字符在内存中的存储顺序叫做"逻辑顺序"。当这行代码被渲染到屏幕上时,字符的显示顺序叫做"视觉顺序"。对于纯英文文本,这两个顺序是一致的:从左到右。

但世界上有超过6亿人以从右到左的方式阅读——阿拉伯语、希伯来语、波斯语的使用者。当一行文本同时包含从左到右(LTR)和从右到左(RTL)的文字时,Unicode必须定义一套规则来决定显示顺序。

这套规则就是Unicode双向文本算法,简称Bidi算法。它的核心机制是:每个字符都有一个方向属性,算法根据这些属性和一组复杂的规则来计算最终显示顺序。

问题出在这里:编译器读取的是逻辑顺序,而代码审查者看到的是视觉顺序。如果有人能操控视觉顺序而不改变逻辑顺序,就能让编译器执行人类看不到的逻辑。

Bidi控制字符:看不见的方向开关

Unicode定义了一组特殊的控制字符来操控文本方向。它们在屏幕上不可见,却能改变后续字符的显示顺序。

字符 码点 名称 作用
LRE U+202A Left-to-Right Embedding 将后续文本视为从左到右
RLE U+202B Right-to-Left Embedding 将后续文本视为从右到左
LRO U+202D Left-to-Right Override 强制后续文本从左到右显示
RLO U+202E Right-to-Left Override 强制后续文本从右到左显示
LRI U+2066 Left-to-Right Isolate 将后续文本隔离为从左到右
RLI U+2067 Right-to-Left Isolate 将后续文本隔离为从右到左
FSI U+2068 First Strong Isolate 根据首个强类型字符决定方向
PDF U+202C Pop Directional Formatting 结束最近的LRE/RLE/LRO/RLO
PDI U+2069 Pop Directional Isolate 结束最近的LRI/RLI/FSI

理解这些字符如何工作,需要先理解Bidi算法的核心概念——嵌入等级(Embedding Level)

每个字符都有一个嵌入等级,从0开始。等级0表示从左到右的基础环境,等级1表示从右到左的嵌套环境,等级2表示在从右到左环境内部又嵌套了一个从左到右的环境,以此类推。

算法最终会根据嵌入等级的奇偶性决定显示顺序:偶数等级从左到右排列,奇数等级从右到左排列。

LRI和RLI字符(Unicode 6.3引入)比早期的LRE/RLE更安全,因为它们会"隔离"内部文本,防止方向设置泄漏到外部。这个设计初衷是为了避免"溢出效应"(spillover effect),但攻击者恰恰利用了隔离特性来实现更精细的字符重排。

隔离混洗:变魔术的数学

Trojan Source攻击的核心技术叫做"隔离混洗"(Isolate Shuffling)。

当多个LRI和RLI嵌套使用时,每个隔离块会被作为一个整体进行重排。攻击者可以通过精心设计嵌套结构,让字符以几乎任意顺序显示。

考虑这个简化示例:

RLI LRI a b c PDI LRI d e f PDI PDI

显示结果是:d e f a b c

原理是这样的:最外层的RLI让整个序列进入RTL模式。在这种模式下,两个内部隔离块(a b cd e f)会作为整体被交换位置。由于内部是LRI,每个块内部的字符保持LTR顺序。

通过多层嵌套,攻击者可以实现近似的任意排列——就像把一副牌洗成想要的顺序。

三种攻击模式

论文作者定义了三种主要的攻击模式,每种都利用注释和字符串作为Bidi控制字符的藏身之所。

早期返回攻击

把return语句伪装成注释的一部分,让函数提前返回。

def transfer(sender, receiver, amount):
    """Transfer funds between accounts.
    return
    """
    # 实际的转账逻辑

人眼看到的是一个普通的docstring,但编译器会执行其中的return语句。因为在逻辑顺序中,return在字符串之外——Bidi字符让它在视觉上移进了字符串。

注释隐藏攻击

让代码看起来存在,实际上被注释掉了。

bool isAdmin = false;
/* } if (isAdmin) begin admins only */
printf("You are an admin.\n");
/* end admins only { */
return 0;

这段代码看起来会检查isAdmin变量。但实际上,第一个注释里藏着RLO和LRI字符,把} if (isAdmin)这部分反转了方向。结果是:条件判断整段都在注释里,printf语句永远会执行。

字符串拉伸攻击

让字符串比较看起来正常,实际内容被篡改。

if (strcmp(access_level, "user // Check if admin ")) {
    printf("You are an admin.\n");
}

看起来是在检查access_level是否等于"user"。但由于字符串内部藏着Bidi字符,实际的比较字符串完全不同,导致条件判断失败,用户被判定为管理员。

攻击的广泛性

论文作者在12种编程语言中验证了攻击的有效性:C、C++、C#、JavaScript、Java、Rust、Go、Python、SQL、Bash、Assembly、Solidity。

这几乎覆盖了所有主流语言。原因很简单:几乎所有现代编译器都接受Unicode源代码,而Unicode规范中关于Bidi控制字符的处理与编程语言的词法分析规则存在根本性冲突。

更危险的是,Bidi控制字符在复制粘贴时会被保留。一个开发者在Stack Overflow上复制一段"有用"的代码片段,可能不知不觉就把恶意代码带进了自己的项目。

2021年的一项研究发现,大量开发者会从非官方来源复制代码到自己的项目中。Trojan Source让这种常见的开发行为变成了安全漏洞的传播渠道。

同形字攻击:另一个视角的欺骗

Trojan Source论文还讨论了另一种相关技术:同形字攻击(Homoglyph Attack)。

Unicode中存在大量视觉上相同但码点不同的字符。拉丁字母H(U+0048)和西里尔字母Н(U+041D)在大多数字体中看起来完全一样。

攻击者可以定义两个看起来名字相同的函数:

void sayHello() { printf("Hello, World!\n"); }
void sayНello() { printf("Goodbye, World!\n"); }

第二个函数名用的是西里尔字母Н。如果攻击者在库中注入这个恶意版本,并通过某种方式让目标程序调用它,就会执行恶意逻辑。

这种攻击被归类为CVE-2021-42694。Rust编译器对此有部分防御——它会发出"mixed_script_confusables"警告。但大多数编译器完全没有防护。

从石板到代码:RTL书写的历史根源

为什么阿拉伯语和希伯来语要从右向左书写?

这个问题的答案揭示了人类书写技术与方向的深层联系。学术界的一种主流解释是:RTL书写源于在石板上刻字的物理限制。右撇子刻字时,如果从左向右刻,手会挡住刚刻好的字。从右向左刻则避免了这个问题。

闪米特语族(包括阿拉伯语和希伯来语)继承了这种书写方向。它们的祖先——腓尼基字母和阿拉姆字母——就是RTL书写。

这种历史遗留问题在现代计算机系统中产生了复杂的技术挑战。当希伯来语开发者写代码时,他们的IDE需要同时处理RTL文本显示和LTR代码逻辑。Bidi算法正是为了解决这种冲突而诞生。

Unicode 6.3的关键改进

2013年发布的Unicode 6.3引入了隔离控制符(LRI、RLI、FSI、PDI),这是对早期嵌入控制符的重要改进。

早期LRE/RLE的问题是"溢出效应":嵌入的方向设置会影响后续文本。例如,在一个RTL环境中嵌入一段LTR文本后,如果忘记用PDF关闭,后续的中性字符(如数字、标点)会被错误地归类。

隔离控制符解决了这个问题:它们创建一个独立的"气泡",内部方向计算完全独立于外部环境。PDI会自动结束所有未关闭的隔离。

这个改进让Bidi文本的正确处理变得更容易,但讽刺的是,它也让Trojan Source攻击变得更加灵活。攻击者可以精确控制每个文本块的显示顺序,而不必担心副作用。

行业响应:一场不均衡的防御战

Trojan Source漏洞披露后,行业响应呈现出明显的不均衡。

编译器层面

  • GCC 12引入了-Wbidi-chars警告,默认开启。它能检测注释和字符串中不配对的Bidi控制字符,并在诊断输出中显示这些不可见字符的实际码点
  • Clang选择了在clang-tidy中实现检测(misc-misleading-bidirectional检查器),而不是编译器警告,理由是性能考量
  • Rust编译器增加了对非转义Bidi字符的错误报告
  • Java团队的态度是:这是编辑器应该解决的问题,不是语言层面的问题

代码仓库

  • GitHub在2021年10月31日部署了检测机制,当文件包含Bidi控制字符时会显示警告横幅,并以码点形式可视化这些字符
  • GitLab和Bitbucket也部署了类似的可视化功能

编辑器

  • VS Code现在默认可视化Bidi控制字符和潜在的同形字
  • Emacs使用启发式方法高亮疑似恶意的Bidi文本
  • Vim默认将Bidi控制字符显示为码点而非应用Bidi算法

这种不均衡的响应反映了一个根本分歧:谁应该为这个问题负责?是Unicode规范?编程语言规范?编译器?编辑器?还是代码仓库?

防御策略:纵深防御

面对Trojan Source,单一防御措施是不够的。有效的防御需要覆盖整个开发工具链。

编译器级别:最彻底的方案是在语言规范中禁止Bidi控制字符出现在标识符和关键位置,或者在编译器中默认拒绝包含此类字符的源代码。GCC的-Wbidi-chars=any选项会警告任何Bidi控制字符的出现,而-Wbidi-chars=unpaired(默认)只警告不配对的字符。

构建管道:在CI/CD流程中加入静态扫描,拒绝包含可疑Bidi字符的代码。Red Hat发布的Python脚本可以扫描整个代码库,适合集成到pre-commit钩子或构建流程中。

代码审查:使用能可视化不可见字符的编辑器和代码仓库界面。GitHub的警告横幅是一个开始,但审查者需要主动关注这些警告。

编辑器配置:启用VS Code的editor.unicodeHighlight.invisibleCharacters选项,安装能显示不可见字符的插件。Sublime Text的Gremlins插件是一个例子。

复制粘贴卫生:从外部来源复制代码时,先用十六进制编辑器或不可见字符检测工具检查一遍。这很繁琐,但Trojan Source的威胁让这种谨慎变得必要。

为什么CVSS评分存在争议

CVE-2021-42574被赋予CVSS v3评分8.3(高危)。但安全社区对此存在争议。

批评者指出,利用这个漏洞需要攻击者已经有源代码的写权限。如果攻击者能修改你的源代码,你面临的问题可能比Trojan Source大得多。

支持者则反驳:供应链攻击的现实中,攻击者可能只获得某个上游依赖的写权限。Trojan Source让攻击者可以在不引起怀疑的情况下注入恶意代码——代码审查通过,测试通过,但运行的是攻击者想要的逻辑。

更关键的是,这个漏洞挑战了编译器安全的基本假设。Ken Thompson在1984年的经典论文《Reflections on Trusting Trust》中指出,恶意编译器可以产生包含后门的二进制文件。Trojan Source则展示了:即使编译器本身是可信的,恶意编码的源代码也能欺骗整个信任链。

Unicode规范的立场

Unicode联盟的回应耐人寻味。他们没有把这当作一个需要修复的漏洞,而是将其归类为已知的安全考虑,在Unicode技术报告#36(Unicode Security Considerations)中已有描述。

报告指出,Bidi文本的显示行为是国际化的固有属性。应用程序需要主动实现缓解措施,而不是等待Unicode规范的改变。

这个立场有其道理:Bidi控制字符是支持全球数十亿RTL语言使用者的必要功能。禁用它们会造成更大的问题。问题在于编程语言和开发工具对Unicode的支持方式——它们继承了文本渲染的复杂性,却没有提供相应的安全防护。

静态分析的局限

检测Trojan Source并非易事。简单的正则匹配会产生大量误报——合法的RTL语言文本、本地化字符串、国际化配置文件都可能包含Bidi字符。

论文作者对GitHub上超过10亿条提交进行了扫描,发现7444条提交包含不配对的Bidi控制字符。其中98.8%是误报:文件路径中的LRE字符、RTL语言的正常文本、本地化格式字符串等。

但1.2%的结果确实显示了与Trojan Source类似的技术使用,包括JavaScript混淆工具利用Bidi字符来混淆代码。这说明相关技术在公开披露前就已经在灰色地带被使用。

有效的检测需要语义理解:Bidi字符出现在什么上下文中?它是否跨越了注释或字符串的边界?是否影响了代码结构?

未来展望

Trojan Source的披露触发了Unicode联盟内部关于源代码欺骗防护的新工作组提案。这可能在未来版本的Unicode规范中产生更明确的指导。

更根本的问题是:编程语言是否应该允许Unicode源代码?或者是否应该限制Unicode在特定上下文中的使用?

ASCII时代没有这个问题。但ASCII时代也没有全球化软件开发。阿拉伯语开发者用阿拉伯变量名、希伯来语开发者在注释中写希伯来语文档——这些都是合理的国际化需求。

最可能的演进方向是分层防护:语言规范定义安全的Unicode使用范围,编译器在边界处进行检测,编辑器和代码仓库提供可视化工具,开发流程中集成自动化检查。

一个隐藏的字符,一个破碎的信任链

Trojan Source攻击展示了一个令人不安的现实:我们信任的开发工具链中,存在着一层我们几乎看不见的编码复杂性。这层复杂性是为了支持全球数十亿人的书写需求而存在的,但它同时也创造了一个攻击面。

这个攻击面的本质是"信任的错位":我们信任眼睛看到的代码,但编译器执行的是内存中的字节序列。当视觉和逻辑产生分歧时,安全审查就会失效。

防御Trojan Source不需要放弃Unicode或国际化支持。它需要的是对这个盲点的认知——知道不可见字符的存在,知道它们能做什么,在关键环节部署检测工具。

毕竟,在软件供应链安全领域,最危险的不是看得见的威胁,而是看不见的那些。

参考资料

  1. Boucher, N., & Anderson, R. (2023). Trojan Source: Invisible Vulnerabilities. USENIX Security Symposium.
  2. Unicode Consortium. UAX #9: Unicode Bidirectional Algorithm. https://www.unicode.org/reports/tr9/
  3. CVE-2021-42574. NIST National Vulnerability Database. https://nvd.nist.gov/vuln/detail/CVE-2021-42574
  4. Red Hat. Prevent Trojan Source attacks with GCC 12. https://developers.redhat.com/articles/2022/01/12/prevent-trojan-source-attacks-gcc-12
  5. LLVM Blog. New passes in clang-tidy to detect (some) Trojan Source. https://blog.llvm.org/posts/2022-01-12-trojan-source/
  6. W3C. How to use Unicode controls for bidi text. https://www.w3.org/International/questions/qa-bidi-unicode-controls.en.html
  7. GitHub Changelog. Warning about bidirectional Unicode text. https://github.blog/changelog/2021-10-31-warning-about-bidirectional-unicode-text/
  8. Unicode Technical Report #36: Unicode Security Considerations. https://www.unicode.org/reports/tr36/