IT 论文精读 · PAPER 51
Ken Thompson · 贝尔实验室 · 1983 图灵奖演讲 / CACM 1984
1983 年,Unix 与 C 语言的共同缔造者 Ken Thompson 上台领图灵奖(计算机界的最高奖),只讲了一件让全场后背发凉的事:哪怕一个程序的源代码你逐行读干净了,它跑起来仍可能藏着后门。因为你读的是给人看的「菜谱」,机器真正吃的是「编译器」把菜谱翻译出来的「菜」——而那个翻译器本身,可能早被人动了手脚。
你想信任一家餐馆,于是把它的菜谱从头到尾读了一遍,一味毒都没有,干净。可你忘了:照菜谱做菜的是厨师。如果这个厨师被人收买过,他会照着菜谱做,却在你看不见的地方偷偷多加一味毒——你把菜谱翻烂也查不出来,因为毒不在菜谱里,在厨师的手上。程序的世界里,这个「厨师」就是编译器。
在这之前,安全界有个默认信条:「开源 = 能逐行审 = 可信」。Thompson 一句话戳穿它:你审的只是菜谱,从没审过厨师;而厨师也是别人「做」出来的,你信得过做厨师的那个厨师吗?做厨师的厨师又是谁做的?信任是一层套一层、往下没有底的。这是一个关于「信任到底能扎根在哪」的哲学炸弹。
他真造了这么个「坏厨师」,诀窍是两步套娃:
① 先让编译器留一手:每当它翻译「登录程序」时,就偷偷多塞一把万能钥匙——除了正常密码,还认 Thompson 自己的暗号。可这段坏心思写在编译器的菜谱里,审查者一眼能看见。
② 再让它学会隐身:更狠的一步——让编译器在翻译它自己的时候,把上面这套坏心思悄悄复制进新编译器里。然后 Thompson 把坏代码从编译器菜谱里全部删掉。从此菜谱干干净净,可用旧编译器编出的新编译器依然带毒,编出的登录程序依然有后门。毒只活在「翻译好的成品」里,任何一份源码里都找不到一个字。(这里藏着一个巧妙前提:程序可以「产出自己」——就像一句「请照抄本句」的话,能一遍遍把自己复制下去。)
这就是今天所说的「供应链攻击」的祖师爷——不去攻击你,而是攻击造你工具的工具。近年 SolarWinds、XZ Utils 后门这些真实大事件,思路的根都在这里。它逼所有人接受一个不舒服的事实:可信不能只靠读代码,最终得落到「你信不信造它的那个人」。好在这不是绝对无解——后来有人证明,用另一个独立来源的编译器重新编一遍、比对结果,能把这种毒揪出来;但前提是,你得先有一个信得过的「第二个厨师」。
你无法真正信任任何不是你亲手从头造出来的代码——因为翻译它的工具,可能早已被人教会撒谎,还教会了把自己的谎言藏进每一次翻译里、连源码都不留痕。
想看后门「自我繁殖」的机制图、三步构造和它如何绕过一切源码审查? → 切到精读版
Ken Thompson 在 1983 年图灵奖演讲里,构造了一个可自我繁殖、却在任何源代码里都查不到一个字的编译器(compiler)后门,用它论证了一条冷峻的结论:「你无法信任任何不是你自己完全亲手造出来的代码」——信任最终无法扎根于源码审查,只能落到「人」身上。这篇不到三页的短文,成了供应链安全(supply chain security)与「信任根(root of trust)」讨论的思想原点。
\n 表示换行。编译器需要「认识」每个转义符。作者 Ken Thompson,Unix 操作系统与 B/C 语言谱系的共同缔造者(与 Dennis Ritchie),供职于贝尔实验室(Bell Labs)。本文是他 1983 年图灵奖获奖演讲,1984 年 8 月刊于 Communications of the ACM,题为 "Reflections on Trusting Trust"。他自陈这个攻击构想最早见于 1974 年 Karger 与 Schell 对 Multics 的一份空军安全评估报告;而它下启的,是整个供应链安全、可复现构建(reproducible builds)与「可信计算基」的现代讨论。
那时(以及很大程度上今天),安全界有一条几乎不假思索的信条:只要能拿到源代码、把它逐行审查干净,就能信任这个程序。开源之所以被认为更安全,逻辑正在于此——「众目睽睽,无处藏奸」。
Thompson 要做的,是正面击穿这条信条。他要展示一种攻击:你把相关的源码全部读到烂穿、一个字都挑不出毛病,后门却真真切切地存在于正在运行的二进制里,而且你永远清除不掉。如果这种攻击存在,那么「读源码 = 可信」就是一个幻觉——问题不在你审得够不够仔细,而在你审的东西(源码)和你运行的东西(二进制)之间,隔着一个你并没有审过的翻译器。
整个论证分三步搭起来,一步比一步致命。
先建立一个关键事实:存在一种程序,运行后打印出的正好是它自己的完整源代码,一字不差。这叫自我繁殖程序(self-reproducing program,即 quine)。它乍看是悖论——要打印字符串、还要打印「打印这个字符串的代码」、还要打印「打印那段代码的代码」……但可以做到:诀窍是让同一份数据同时扮演两个角色——既当「要打印的内容」,又当「照着打印的指令」。这一步是全篇的钥匙:代码有能力凭空复制出自己,而不必依赖外部的源码。
C 编译器用 C 写成,它要处理转义符。设想给它新增一个转义符 \v(垂直制表符)。这里有个鸡生蛋的怪圈:一开始编译器还不认识 \v,所以你不能在编译器源码里直接写 \v——它会编译报错。
正确做法分两拍:先用 \v 对应的数字(它的字符编码值)把这个知识「教」给编译器,编译、装上这个新编译器;此后新编译器已经「认识」\v 了,你就能把源码里那个数字改回优雅的 \v,并把当初那段「教学代码」删掉。关键在于:删掉之后一切照常工作——因为这个知识已经烙进了编译器的二进制,不再需要出现在任何源码里。Thompson 把这叫编译器的「学习」:一段能力可以只存活于二进制中,而在源码里完全隐形。
现在把第 0 步的「自我繁殖」和第 1 步的「知识藏进二进制」合起来,就是致命一击。分两枪:
第一枪——可见的木马。修改编译器,让它在发现自己正在编译 login 程序时,偷偷往结果里多注入一段后门:除了正常密码,也放行 Thompson 预设的万能密码。这时后门虽已生效,但那段恶意代码明晃晃写在编译器源码里,任何审查者一读就能发现——还不够。
第二枪——让它隐形并自我繁殖。再加一段:让编译器在发现自己正在编译「C 编译器本身」时,自动把上面两段逻辑(login 后门 + 这段自我识别)一起注入到它编出的新编译器二进制里。然后关键操作来了——用这份恶意源码把编译器编译一次、装上这个已中毒的二进制;接着把全部恶意代码从编译器源码里删得干干净净。
此后世界变成这样:干净的编译器源码,被中毒的二进制一编译,又生出一个中毒的二进制(第 0 步的自我繁殖);干净的 login 源码,被这个中毒编译器一编译,就带上后门。后门永远存活于二进制里,却在编译器源码、login 源码里都找不到一个字。你把整套系统的源码审到天荒地老,全是干净的;你重新编译整个系统想「洗干净」它,也没用——因为传染发生在「编译」这个动作本身,而执行编译的正是那个已中毒的二进制。
为什么这样设计:借 quine 的自我繁殖,让后门脱离源码也能存活;借「编译器编译自己」这个自举环节,找到一个每次构建都必经的寄生宿主。一旦源码痕迹被抹去,后门就获得了近乎永生的隐蔽性。
这不是一篇拿数据集和指标说话的论文,而是一个思想实验 + 真实演示。Thompson 明确表示:他确实在贝尔实验室内部造出过这样一个 C 编译器(尽管从未放到外面去)。它的「证明」是逻辑上的不可反驳——无论你把源码读得多仔细,都无法排除这类攻击的存在。由此他给出那句被反复引用的结论:
"You can't trust code that you did not totally create yourself."(你无法信任任何不是你自己完全亲手造出来的代码。)
更彻底的是,攻击层级可以无限向下平移:就算你有本事验明编译器是干净的,它下面还有汇编器(assembler)、加载器(loader)、操作系统、CPU 微码(microcode)、乃至硬件电路——任何一层被做了手脚,上层的一切审查都可能被蒙蔽。你必须在某一层「选择信任」,而那个选择无法靠读代码得到保证。
这篇短文的影响远超它的篇幅:
① 一句话:用一个自我繁殖、源码里查不到的编译器后门,证明「你无法信任任何不是你亲手从头造出来的代码」。
② 痛点:安全界默认「读源码干净 = 可信」;但你审的是源码,运行的是二进制,中间隔着一个没被审过的编译器。
③ 第 0 步:程序能「产出自己」(quine)——后门可脱离源码而存活。
④ 第 1 步:编译器会「学习」,一段能力可只存于二进制、在源码里隐形(以新增转义符 \v 为例)。
⑤ 第 2 步:编译 login 时注入万能密码后门;编译「编译器自己」时把这套逻辑复制进新二进制;再把恶意代码从源码删净——后门永生于二进制、源码全干净。
⑥ 关键:传染发生在「编译」这个动作里,重编整套系统也洗不掉。
⑦ 结论:You can't trust code that you did not totally create yourself;且攻击可下移到汇编器、微码、硬件——信任无法靠读代码封顶。
⑧ 影响:供应链攻击的思想原型;催生可复现构建与多编译器交叉验证(DDC);SolarWinds、XZ 等事件的思想回声。
⑨ 局限:DDC 可在合理假设下检测;需一次性污染可信二进制为前提;现实攻击多走更省事的路;价值主要在思想。