引导种子里改一条 GNU strip,NixOS 图形安装器 3790 个二进制中招

Trusting-Trust Attack against an Entire Linux Distribution through Binary Manipulation

Julien Malka, Aman Sharma, Martin Monperrus, Stefano Zacchiroli, Théo Zimmermann

cs.CR, cs.SE

2026-07-27

Télécom Paris 与 KTH 在真实 NixOS 引导里证明:种子里一条被改的 GNU strip,用纯 ELF 改写即可自我繁殖;图形安装器 3791 个 CLI 二进制中 3790 个带后门,构建与功能测试零失败。

这篇在解决什么

开源社区常把「源码公开、眼睛够多」当成后门的克星。Ken Thompson 1984 年图灵奖演讲指出,这条信念有一个编译器级别的盲区:被篡改的编译器能在干净源码上继续给自己和敏感程序植入后门,源码里什么都看不到。这个构造后来被叫作 trusting-trust(信任信任)攻击。Thompson 自己做过原型,但公开文献里几乎没有人把它在生产级 Linux 发行版的完整引导链上跑通。一个实际障碍是,恶意编译器必须认出自己的源码才能续命,源码一改,后门就可能断代。

圈子里默认这是编译器的事。Télécom Paris 和 KTH 的这条工作把载体换成 GNU strip:binutils 里一个后期处理工具,既不读源码也不生成源码,只是从已经编好的 ELF 里丢掉符号。NixOS 的通用构建器在 fixup 阶段默认对安装产物跑 strip,所以它碰得到发行版里绝大部分可执行文件。他们要证明的是:只要引导种子里有一条被改过的 strip,载荷就能跨代活到最终标准环境,并写进几乎所有下游二进制。源码审计看不见,配方也干净。

方法

威胁模型故意收得很窄。攻击者只能替换引导种子里的一个可执行文件,不能改发行版配方,也不能改任何包的源码。这个能力对应种子在进发行版之前被污染:构建或发布种子的机器被拿下,或者一次被批准的种子更新。Nixpkgs 的种子大约二十个预编译程序,C 编译器、binutils、C 库、bash 和 coreutils 都在里面。标准环境(stdenv)要从这撮二进制里把自己重建出来,binutils 因而 strip 会从源码编不止一次。评估用的 revision 上,stage 1 和 stage 4 各编一次。

自繁殖要三条同时成立。C1:种子里已经信任这个工具。C2:旧代会改写新代的二进制表示。C3:改完的新代接着履行同一角色。strip 三条都占。论文把 C3 叫 successor edge,载荷沿这条边跨代,种子离开依赖闭包之后仍然活着。

植入全程是 ELF 级变换,三步都是加法,原程序字节一个不动:

载荷写成 freestanding 程序:不链 libc,不用宿主动态符号,直接打系统调用。原因很具体,同一段字节要打进依赖完全不同的可执行文件,宿主什么都不能指望。它跑在普通 start 之前,所以必须自己保存内核交给进程的寄存器,按 x86-64 调用约定对齐栈,干完再跳回宿主原来的入口。进程 PID、文件描述符、信号上下文保持原样。

运行时读环境变量 NIXBUILDTOP。在 Nix 构建沙箱里保持安静,避免功能测试被一行多余输出搞挂;出了沙箱才执行恶意动作。原型里这个动作是往标准输出写一行固定感染标记。论文写明,它可以换成后台偷凭证或 token。只有当前进程的 basename 真的是 strip 时,才对命令行里合格的 ELF 参数做同样植入。普通被感染程序只跑载荷、不继续扩散。

他们比较过另一种 wrapper 架构:种子里的 strip 整文件换成外壳,外壳内嵌一份真正的 strip,调用时先跑拷贝再改命令行文件。外壳能在 strip 前后任意插手,还能改环境,甚至把 strip 换成完全不相干的程序。最终没用它,理由是外形已经不像 strip,而且必须自己复现调用约定,一个偏差就可能毁掉某个包的构建。parasite 把控制权交回原程序,构建行为几乎原样继承。

结果

实验钉在 nixpkgs revision fef9403a3e4d,引导用 GNU binutils 2.44,架构 x8664-linux。目标包集合是图形安装 ISO,大约 2000 个包,用来代表发行版的宽度。

方法 / 范围指标结果
被篡改的种子构建失败0,完整图形 ISO 打出
引导后闭包包输出 / 用户可调用 ELF1,199 / 3,799
闭包体积磁盘 / 中位包6.16 GB / 0.39 MB
CLI 可调用二进制打印感染标记3,790 / 3,791
NixOS 图形镜像测试套件功能失败0

3,799 个用户可调用 ELF 里,3,791 个能当命令行程序跑。其中 3,790 个正常调用就会打出感染标记。唯一漏网的是 firefox 147.0.3,配方显式传了 --disable-strip --disable-install-strip,整个 strip 阶段被关掉,跟注入器失不失效无关。

语言覆盖按论文抽样:C/C++ 的 bash、git、sudo、curl、python3.13,Python 的 pydoc 和 idle,Rust 的 rsvg-convert,Go 的 captree,Lua 解释器,全部带标记。功能测试在完整 NixOS 虚拟机里跑图形工作流,包括从零安装。会话里的 gnome-shell、mutter、nautilus 和 GNOME 控制中心同样由被改过的 strip 构建,测试套件没有因此挂掉。

为什么重要

核心判断很短:信任信任攻击靠的是 successor edge,不是「这个工具会编译」。strip 处理的是编完的 ELF,覆盖面比 gcc 更宽,跟源语言无关。

对发行版和供应链安全,这意味着两件已经在用的防御对不上这个空档。Wheeler 的 diverse double-compiling(多样双重编译)用第二套独立编译器重编编译器并比对结果,diversify 的是编译器,两边共用的 strip 会把植入对消。可复现构建证明同一配方吐出同一比特;种子若同样被污染,独立重编会得到同一份带后门的二进制,检查照样绿灯。in-toto 一类步骤证明也看不见,因为每一步都按配方诚实执行了,只是配方信任的那个二进制从一开始就是坏的。Guix 把编译器种子缩到几百字节的 full-source bootstrap,如果后期工具没有一并从可审计源码重建,strip 这类载体仍然能续命。

论文还扫了通用构建器默认阶段按短名调用的大约四十个工具。同时满足 C1–C3 的,除了 strip 还有 patchelf,两者都在 fixup 阶段跑,包括跑到自己的重建。install 和 cp 走 make install 时也可能闭合 successor edge,但这条边取决于各包 Makefile,不算普遍。tar、sed、make 这类工具根本不写程序二进制,带不了这套载荷。

维护者能动手的位置很具体:种子的发布与钉死流程,以及 fixup 里所有会改写 ELF 的工具。只审计 gcc 源码,挡不住这条。

局限与存疑

作者列了注入器的硬假设:小端 ELF,至少一条闲置 PTNOTE,目标是 ETEXEC 或带解释器的 ETDYN。共享库、其他架构、没有解释器的 static PIE、没有合适 note 段的二进制,当前实现都排除。论文承认这不是原理上限,是演示刻意压复杂度。

实现绑在 NixOS 的引导图和「默认跑 strip」上。别的发行版引导过程不同,strip 不一定出现在默认构建路径里,不能原样搬。模式本身要求构建图里存在 successor edge,这条边不在,载荷会在种子离开闭包时消失。

原型故意好查。可见的感染标记、.payload 这个段名、按 basename 判断是不是 strip,都是实验埋点,不是繁殖模式的必要条件。评估把每个包都感染,是为了测覆盖上限。论文同时写明,真实攻击者可以让载荷长期休眠,只传染下一代 strip,几年后再打真正目标,检测会难得多。

实验只覆盖 x8664-linux 上一个 nixpkgs revision、一套图形安装器。3,799 个 ELF 里那 8 个「用户可调用但非 CLI」的感染情况没有单独报告。firefox 这条例外说明关掉 strip 就能出圈,发行版里还有多少配方这么做,论文没有普查。

术语

原文与代码

社区讨论

相关论文

全部论文解读