原文地址:https://ring0.me/2014/11/insert-backdoor-into-compiler/
说起 Ken Thompson,我们首先想到的是他发明的 UNIX 操作系统。他因此获得 1984 年的图灵奖。在图灵奖演讲上,Ken Thompson 提出了一个深刻的问题:看到了软件的源码,就意味着没有后门吗?编译器是否可能存在能自我复制的后门?
这篇发表在《ACM 通讯》上的论文只有短短三页,省略了很多细节。原理上有点像输出自身代码的 C 程序,但又比它难很多。我追随先哲的脚步,给一个开源 C 编译器——tcc 插入了能自我复制的后门,这个插入了后门的编译器在编译 Linux 登录程序 sulogin 的源码时,会自动插入一个后门。
当文件系统挂载失败或者启动过程中出现其他故障时,Linux 往往会进入一个如下图所示的界面,要求输入 root 密码进入恢复控制台。这个请求输入 root 密码的程序就是 /sbin/sulogin
。
这个程序本身是以 root 身份运行的,去系统用户数据库检查用户输入的 root 密码是否正确,如果正确的话就进入一个 root shell。我使用了 sulogin 做例子,只是因为它比标准的命令行登录程序 /bin/login
代码行数更少。如果让 sulogin 程序在接受正确密码之余,还能够悄悄接受 bojieli 这个密码……
让我们从 sulogin 的源码开始。(sulogin 在 util-linux 这个软件包里,Debian 系可以用 dpkg -S
搜索到)其中负责验证密码的部分如下图所示。
只要增加一个条件判断,就可以畅通无阻啦!
当然,在 sulogin 中插入一段如此明显的后门代码,实在是太拙劣了。为何不让编译器完成这个光荣而伟大的使命?
简单来说,编译器的输入是程序源码,输出是二进制机器码。只要编译器发现正在编译的是 sulogin,就替换源码的特定部分,插入后门。
如何 “发现” 正在编译的是 sulogin 呢?编译器很复杂,在 AST(抽象代码树)层次上做替换,固然比较隐蔽,对 sulogin 代码修改的鲁棒性也比较强,但难度比较大。既然是演示,我们就做简单的源代码文本匹配和替换。
由于 gcc(GNU C compiler)太复杂了,编译一遍很耗时,就用小巧而简单的 tcc(tiny C compiler)编译器吧。我们从读取源代码的缓冲区下手,一旦读到的部分匹配上 sulogin 的比较密码部分,就替换成带有后门的源代码字符串。
上面的代码扼住了 tcc 读入源代码的 “咽喉”,当匹配到 login_pattern 时(红色箭头),就在它后面添加 login_append(绿色箭头),真是简单粗暴。这段代码里也有明显的 bug,当待匹配代码跨越了缓冲区边界时,就匹配不上了,不过不要在意这些细节……
加入后门的 C 编译器中有一段明显的后门代码,作为开源代码发布出去显然会被发现。我们要让编译器把后门 “隐藏” 起来。
在 gcc 的编译过程中,为了避免潜在的问题,需要用 gcc 编译 gcc 自身的源码得到一个可执行文件 A,再用 A 编译 gcc 源码得到可执行文件 B,只有 A 和 B 相同的时候才认为编译成功。也就是说,编译器必须能够编译自身。
我们的后门显然也要有自我复制的能力。有后门的 tcc 可执行文件在编译正常的 tcc 源代码时,生成的 tcc 可执行文件也要包含相同的后门。
初看,这个过程并不复杂。如下面的伪代码所述,当匹配到 sulogin 源码的时候就插入登录后门;当匹配到 tcc 自身的源码时就插入 tcc 后门。
实际实现的时候,却会发现一个困难:后门代码自身是一个字符串,它自己又要在 “tcc-backdoor” 部分出现……有点自我指涉的感觉了。
很多小伙伴也许听说过能输出自身代码的 C 程序。Google 一下也能找到,但程序作者往往把程序写得很短很精炼,因而不易看懂。事实上这并不是什么 rocket science。
如何输出自身呢?源代码一定要被放在二进制文件的数据段中。最简单的自我输出代码片段就像这样:
上面的 printf 代码重复了两次,第一次是作为字符串常量的一部分,第二次是作为源代码被编译。而这个字符串也被输出了两次,因为 printf 里有两个 %s。
给不熟悉 C 语言的朋友解释一下:char *s 定义了一个字符串,并以后面紫色和红色部分的字符串常量作为初值。其中 \ 是字符串常量 s 中的转义字符,表示紧随其后的引号或 \ 不是表示字符串结尾的引号,而是字符串中的一个普通字符。s 这段字符串与其后的完整代码完全相同。而其后的代码把 s 之前的代码抄过来,再输出两遍 s。
这段代码的强大之处在于:它可以包含任意的其他代码,因此任意程序都可以包装成自输出的形式。例如我们在程序最后增加一条输出 Hello World 的语句,只需要把它在字符串 s 中原样抄一遍(除了要注意转义字符)。
细心的读者也许已经发现了其中换行符、转义字符的细微区别,因此真正的自输出代码不能简单地把字符串输出两次,第一次输出的时候要添加上转义字符和每行末尾的字符串跨行连接符 \,而第二次输出就是原样输出了。各位看官不要着急 OCR,文末有代码的下载链接。
有了 “输出自身代码” 的理论基础,就可以把它应用于 tcc 了。输出两遍的代码字符串 s,在这里的名字是 tcc_replace。编译器读入一段代码后,一旦发现它是 tcc 的代码,就把这段代码替换成后门代码:转义后的 tcc_replace 连接上 tcc_replace。伪代码如下:
当然,C 语言是一门比较底层、表现力比较低的语言,因此实现出来的代码就很冗长了。
这个带有后门的编译器是这么玩的:
下载恶意版本 tcc 的用户,编译看起来正常的 tcc 源码,得到的仍然是恶意版本的 tcc,而且二进制文件完全相同。也就是除非反汇编二进制文件,是无法发现该 tcc 编译版本的恶意行为的。
当然,在被插入后门的编译器的数据段(.data section
)中,能够看到一大段源代码,这肯定是令人生疑的,用 strings 命令就能发现。应该用类似软件保护的方法,对这段数据进行加密,运行时再解密。此外,可以编写一个通用的框架来自动插入后门,免得手工构造 tcc_replace 这段字符串。本文只是给出了一个 proof of concept,后门要留得不露痕迹的话,还是要费很多心思的。
有人会说,使用其他编译器(如 gcc)编译干净的 tcc,得到的不就是不带后门的 tcc 了吗?可惜现在编译器越来越复杂,添加了各种独有的扩展语法,因此很多编译器只能用自己 bootstrap,例如 gcc 源码就只能用 gcc 编译。谁知道 gcc 数以百万行计的代码里,会不会隐藏着一个自我复制的后门呢?
Ken Thompson 说,如果被插入后门的不是编译器,而是汇编器、链接器,甚至硬件微码呢?层次越低,后门就越难被发现。
Ken Thompson 的预言应验了。Intel x86_64 SYSRET 本地提权漏洞就是一个臭名昭著的例子。这严格意义上应该算是 Intel 手册没写清楚。SYSRET 是 AMD 率先在 64 位系统上实现的,返回时如果 RIP 触发了通用保护错误,这个错误是触发在 ring 3。Intel x86_64 后来实现 SYSRET 指令时,RIP 触发通用保护错误却是在 ring 0,但手册里并没有指出这处不同。然后问题就来了。
早在 2006 年,Linux 社区就发现了这个漏洞(CVE-2006-0744)并 patch 上了,但这个问题并没有引起其他操作系统注意。直到 2012 年,Xen 又发现了这个问题(CVE-2012-0217),顺便发现了 FreeBSD、Windows 7 都有这个漏洞,但误以为 Linux 的问题已不存在了。
2014 年,Linux 社区发现 ptrace 仍然可以触发这个漏洞(CVE-2014-4699),也就是说除非禁用了 ptrace,几乎每台 Linux 机器都有这个漏洞。但 CVE 只是轻描淡写地说是 “linux ptrace bug”,Debian oldstable 至今仍未修复(stable 主线已经修复)。
安全算法中的后门是很底层的,也是非常可怕的。2013 年,NSA 被怀疑选用随机性有缺陷的 Dual_EC_DRBG 算法作为 RSA 公司一个加密库的默认算法,并促使该算法成为 ANSI 标准(丁老怪还拿这个做期末考试题)。2014 年 9 月刚爆出的 SSL 3.0 POODLE 漏洞,是 SSL 3.0 协议设计的问题(当然,这不一定是后门)。这不同于今年上半年的心脏出血等漏洞,不是软件实现的问题,而是协议本身就不安全,因此除了禁用协议之外没有好的解决方法。
在错综复杂的软件世界里,我们能够相信哪段源码呢?正如 Ken Thompson 所说,我们也许只能相信写代码的人了。
这是我大三《黑客反汇编入门》的课程作业,然后在 LUG 每周小聚上也讲过。
代码已经上传到 Github,戳这里。演示文稿 戳这里下载。