> "你无法信任不是自己写的代码,但你也无法信任自己写的编译器。" > —— Ken Thompson, 1984
---
开场:那99行代码
1984年,ACM图灵奖演讲的现场。
Ken Thompson站在台上,穿着那身标志性的休闲装,手里没有PPT,只有一张嘴和一段他称之为"Reflections on Trusting Trust"的故事。
他讲了一个程序。很短的程序。99行C代码。
"这是我的得意之作,"他说,"我从来没写过比这更可爱的代码。"
可爱?一个编译器后门程序,用"可爱"来形容?
但当你真正理解这99行代码在做什么,你会明白Thompson为什么用这个词。它不是暴力的、笨拙的——它是优雅的,像一把看不见的手术刀。
这就是我们今天要讲的故事:一个关于信任的悖论。
---
第一部分:俄罗斯套娃——Thompson的三阶段攻击
1.1 从一个问题开始
想象一下这个场景:你写了一个登录程序。
它检查用户名和密码,如果匹配,就让你进入系统。你觉得自己很安全,因为你审查了每一行代码。没有后门,没有漏洞。
但你用的是什么编译器?
如果编译器被人动过手脚呢?它可以在你不知情的情况下,在你的登录程序里插入一段代码——比如,当用户名是某个特定字符串时,直接放行。
"这不可能,"你说,"我会检查编译器的源代码。"
很好。那你用什么编译那个编译器?
1.2 第一只套娃:Quine——自我复制的艺术
Thompson的攻击从理解一个古老的概念开始:quine(奎因)。
什么是quine?它是一个程序,它的输出就是它自己的源代码。
听起来像魔法?看这段C代码:
char s[] = {
'\t', '0', '0', '0', ' ', '{', '\n',
/* ... 这里是它自己的源代码 ... */
'}', ';', '\n', '\0'
};
main() {
printf("char s[] = {\n");
// 打印s数组的内容
for (int i = 0; s[i]; i++) {
printf("%d, ", s[i]);
}
printf("};\n");
printf("%s", s); // 再打印s本身
}
这个程序运行时,会打印出它自己的源代码。就像一条蛇吞吃自己的尾巴——不是毁灭,而是完整的自我复制。
Thompson说,理解quine是理解整个攻击的第一步。一个程序可以知道自己,可以打印自己,可以把自己植入到另一个程序中。
这就像俄罗斯套娃。最小的那个娃娃里,藏着制造所有娃娃的图纸。
1.3 第二只套娃:编译器学习新"语法"
现在,Thompson做了更狡猾的事。
他修改了C编译器,让它"学习"一个新的语法规则:
// 当编译器看到这段特定模式时...
if (match_login_pattern(code)) {
// ...悄悄插入后门代码
inject_backdoor(output);
}
具体来说,他让编译器识别一个特定的模式:编译login.c时,自动注入一个后门——如果用户名是"ken",不需要密码就能登录。
但这里有个问题:如果有人检查编译器源代码,他们会发现这个恶意逻辑。
1.4 第三只套娃:自我传播的幽灵
这就是Thompson的天才之处。
他修改编译器,让它在编译任何C编译器时,自动把同样的后门逻辑插入到新生成的编译器中。
代码结构大概是这样的:
// 编译器源代码中的后门(伪代码)
void compile(char *source, char *output) {
// 正常的编译逻辑
normal_compile(source, output);
// 阶段1: 如果正在编译login.c,注入登录后门
if (is_login_source(source)) {
inject_login_backdoor(output);
}
// 阶段2: 如果正在编译编译器自身,传播后门
if (is_compiler_source(source)) {
// 把这段后门代码本身插入到新编译器中!
inject_self_replicating_backdoor(output);
}
}
现在,关键的一步来了:
1. Thompson用干净的编译器A编译这段带后门的编译器源代码 → 得到编译器B(包含后门,但B的源代码看起来是干净的!)
2. 删除所有痕迹,包括编译器A
3. 现在全世界都用的编译器B,它会在编译login.c时注入后门
4. 而且,如果任何人用编译器B去编译一个新的编译器,后门会自动传承下去
源代码里没有恶意代码。二进制里也没有明显的恶意代码。但恶意代码就在那里。
这就像你把一份合同的原件烧掉,只留下复印件。复印件上有个签名,看起来和原件一样。但没有人知道,签名是复印时自动加上去的。
Thompson用"最可爱"来形容这段代码,是因为它做到了三件事:
- 隐形:源代码审查无法发现
- 持久:自我复制,永远传播
- 优雅:用最少的代码实现最大的效果
第二部分:当幽灵走出实验室
Thompson的演讲是1984年。那时候,很多人觉得这是理论上的威胁——"谁会真的这么做?"
答案是:很多人会。
2.1 XcodeGhost:692个App的噩梦
2015年9月。
中国互联网上突然爆出消息:多个知名iOS应用被感染了恶意代码。微信、网易云音乐、滴滴出行——这些每天被数亿人使用的App,全都在名单上。
总计692个应用。
攻击者做了什么?
他们创建了一个假冒的Xcode下载站。Xcode是苹果的官方开发工具,体积很大,从苹果官方下载很慢。很多中国开发者为了省事,从第三方网站下载了"加速版"。
这个"加速版"Xcode被植入了恶意代码。当开发者用它编译自己的App时,恶意代码自动被注入到最终的应用中。
// 被感染的Xcode会在编译时注入类似这样的代码
- (void)injected_backdoor {
// 收集设备信息
NSString *deviceInfo = [self collectDeviceInfo];
// 发送到攻击者服务器
[self sendToC2Server:deviceInfo];
// 甚至能弹出钓鱼对话框窃取密码
[self showFakePasswordDialog];
}
最讽刺的是什么?
这些App的源代码都是干净的。开发者们在自己的代码里没有写任何恶意逻辑。但在用户手机上运行的二进制文件,却包含了后门。
这就是Trusting Trust攻击的现实版本。只不过Thompson用了自我传播的编译器,而XcodeGhost用了社会工程学——让开发者主动下载被感染的工具。
2.2 W32/Induc.A:编译器感染了编译器
2009年。
一种名为W32/Induc.A的病毒开始在全球范围内传播。它感染了使用Delphi编译器开发的程序。
Delphi是一个流行的Pascal语言IDE。病毒做了什么?
它修改了系统上的Delphi编译器文件(SysConst.dcu和SysInit.dcu)。从此以后,任何使用这台机器上Delphi编译的程序,都会自动携带病毒。
// 被感染的SysInit单元会在每个编译的程序中注入代码
initialization
// 正常的初始化
// ...
// 病毒注入的代码
if IsNewDelphiInstallation then
InfectCompiler; // 感染新安装的Delphi
if IsExecutable then
InfectOtherFiles; // 感染其他可执行文件
这几乎就是Thompson攻击的完全复现——只不过是用病毒的形式。
攻击者不需要知道你的源代码是什么。他们只需要感染你的工具。然后你的工具会帮他们完成剩下的工作。
2.3 tcc后门:一个概念验证
2014年,安全研究员Bojie Li做了一个实验。
他针对Tiny C Compiler(tcc)实现了一个后门。目标是什么?Linux系统的sulogin程序。
sulogin是单用户模式下的登录程序,用于系统维护。它有最高权限。
Li的后门代码不到100行:
// 修改tcc的代码生成部分
void gen_function(char *func_name) {
if (strcmp(func_name, "main") == 0 &&
is_sulogin_binary(output_file)) {
// 在main函数开头注入后门
// 如果检测到特定信号,直接获得root shell
emit_backdoor_check();
}
// 正常的代码生成
normal_gen_function(func_name);
}
他用tcc编译了tcc自身(自我复制),然后用被感染的tcc编译sulogin。
结果?一个看起来完全正常的sulogin二进制文件,但包含了最高权限的后门。
这个实验证明了:Thompson的攻击不只是理论。它可以在现代系统上实现,而且很难检测。
---
第三部分:我们能做什么?
好了,现在你知道了问题有多严重。
源代码不可信——因为编译器可能被动手脚。 编译器不可信——因为它可能自我感染。 二进制不可信——因为你不知道它是用什么编译的。
我们能信任什么?
3.1 多样化双重编译(DDC)
2009年,计算机科学家David A. Wheeler提出了一个解决方案:Diverse Double-Compiling(DDC)。
核心思想很简单:用不同的编译器交叉验证。
步骤是这样的:
1. 你有一个编译器源代码S 2. 用编译器A编译S,得到二进制B1 3. 用编译器B(完全不同的实现)编译S,得到二进制B2 4. 如果B1和B2完全一样,说明S是可信的(假设A和B不会同时被感染) 5. 如果B1和B2不同,至少有一个有问题
这个方法基于一个关键假设:攻击者很难同时感染两个完全不同来源的编译器。
比如,用GCC和Clang互相验证。它们是完全独立的实现,一个被感染不会导致另一个也被感染。
# DDC示例流程
# 步骤1: 用GCC编译编译器源代码
gcc compiler.c -o compiler_from_gcc
# 步骤2: 用Clang编译同样的源代码
clang compiler.c -o compiler_from_clang
# 步骤3: 比较两个输出
diff compiler_from_gcc compiler_from_clang
# 如果输出相同,置信度提高
Wheeler用这个方法验证了GCC本身。结果?GCC通过了测试。我们至少可以相信,主流编译器目前没有Thompson式的自我复制后门。
但这还不够。因为XcodeGhost告诉我们——问题不只是编译器,还有整个工具链。
3.2 可重现构建(Reproducible Builds)
下一个解决方案是可重现构建。
正常情况下,两次编译同一个程序,产生的二进制文件会略有不同——时间戳、随机数、文件路径等信息会嵌入到输出中。
可重现构建的目标是:给定相同的源代码和构建环境,任何人都能生成完全相同的二进制文件。
为什么这重要?
因为如果构建是可重现的,我就可以: 1. 下载你的软件 2. 获取你的源代码 3. 在我自己的机器上编译 4. 比较我编译的结果和你发布的二进制文件
如果完全一样,说明你发布的二进制确实来自你声称的源代码,没有被篡改。
Debian、Tor、Bitcoin Core等项目都在推进可重现构建。这是一个缓慢但重要的工作。
3.3 XZ事件的启示:供应链的薄弱环节
2024年3月。
一个差点成为历史上最严重的供应链攻击被发现。
XZ Utils——几乎所有Linux发行版都使用的压缩库——被植入了一个极其复杂的后门。
攻击者"JiaT75"花了两年时间建立信誉,成为项目的维护者。然后,他在测试文件中隐藏了恶意代码,通过复杂的间接层,最终在sshd中创建了一个远程代码执行后门。
这个后门不是通过编译器注入的,但它揭示了一个更深层的真相:供应链的每个环节都可能是脆弱的。
# XZ后门的影响范围估算
# - 几乎所有主流Linux发行版都包含XZ
# - 包括Debian、Fedora、Arch、openSUSE
# - 影响的系统数量:数亿台服务器
# - 被发现时,后门已进入beta版本,即将进入稳定版
幸运的是,一名微软工程师在调查性能问题时偶然发现了异常。如果后门再隐藏几个月进入稳定版……后果无法想象。
XZ事件教会我们几件事:
1. 信任是个错误的前提:我们不能假设维护者是善意的 2. 复杂性是敌人:XZ后门的隐藏利用了测试文件和二进制 blobs,这些复杂层让人难以审查 3. 多样性拯救了我们:这次是一个细心的工程师在调查无关问题时发现。如果所有人都用同样的工具、同样的流程,后门可能永远不会被发现
3.4 竹子机场:货物崇拜的防御
说到防御,我想讲一个Thompson可能喜欢的故事——竹子机场。
二战期间,美军在南太平洋岛屿建立基地。岛民看到飞机降落在跑道上,带来物资。战争结束后,美军离开了。
岛民想:如果我们建一个机场,飞机就会回来。
于是他们建了跑道——用竹子做控制塔,用椰子壳做耳机,用树枝拼成雷达。
一切看起来完全正确。但飞机不会来。
这就是货物崇拜(Cargo Cult)——模仿形式,但缺少实质。
在软件安全领域,我们有多少"竹子机场"?
- 我们写了安全策略文档,但没有人真正执行
- 我们做了代码审查,但只是走过场
- 我们用了加密,但密钥管理一团糟
- 我们信任"开源就是安全的",但从不验证
防御这种攻击,不能靠更多的流程、更多的文档、更多的"最佳实践"。
它需要的是:怀疑的习惯。
3.5 怀疑清单
这里有一个你可以立刻使用的怀疑清单:
对于开发者:
- 你的构建工具从哪里来?是你自己编译的,还是从第三方下载的?
- 你的依赖库,你审查过源代码吗?还是只是
npm install或pip install? - 你的CI/CD流水线,如果它被入侵,会产生什么后果?
- 你运行的软件,你能验证它的来源吗?
- 你知道你的操作系统是用什么编译的吗?
- 如果某个关键开发者被收买或胁迫,你会知道吗?
---
结尾:我们能信任什么?
让我们回到1984年的那个演讲厅。
Thompson讲完他的故事,台下一片沉默。然后有人问了一个问题:"那我们该怎么办?"
Thompson的回答是什么?
他说,唯一的防御是源代码级别的验证。但即使这样,你还是要信任你的编译器。
"这是一个递归问题,"他说,"你永远无法完全解决它。"
40年过去了,这个问题依然存在。
但我们学到了一些东西:
多样性是信任的基础:不要依赖单一的工具链。用GCC和Clang互相验证。用不同的操作系统。用不同的包管理器。
透明是安全的代价:开源不是银弹,但闭源是银弹的反面——它保证了你永远无法验证。
怀疑是责任:不是怀疑一切,而是知道你在信任什么——并且准备好,当信任被打破时,你有退路。
Thompson在演讲结尾说了一句话,很多人忽略了:
> "你不能信任不是自己写的代码。但你也不能信任你自己写的代码——因为你不知道你用的工具是什么。"
这不是虚无主义。这是清醒。
清醒的意思是:知道边界在哪里。
我们可以写更安全的代码。我们可以建立更透明的供应链。我们可以培养怀疑的习惯。
但我们永远无法达到"绝对安全"。这不是失败,这是现实。
就像费曼说的——如果你不能承认你不知道,你就永远无法真正学习。
编译器中的幽灵不会消失。但我们可以学会和它共处——不是通过恐惧,而是通过理解。
理解它的机制。理解它的边界。理解我们在信任什么,以及为什么。
这才是Thompson那99行代码真正的遗产。
不是为了让我们害怕——而是为了让我们睁开眼睛。
---
延伸阅读
- Ken Thompson, "Reflections on Trusting Trust" (1984 ACM Turing Award Lecture)
- David A. Wheeler, "Fully Countering Trusting Trust through Diverse Double-Compiling" (2009)
- XcodeGhost事件分析报告 (2015)
- XZ Utils后门事件时间线 (2024)
- reproducible-builds.org
*"The code is the truth. But only if you know how to read it."*
#编译器安全 #供应链攻击 #TrustingTrust #后门 #小凯