2024 年 3 月下旬,微软的 PostgreSQL 开发者安德烈斯·弗罗因德在自己那台 Debian sid 机器上跑数据库基准测试。他注意到一件不该发生的事:ssh 登录变慢了,而且慢在 CPU 上——即使用户名是错的、连接立刻被拒,sshd 进程也要多烧掉大约半秒的 CPU 时间。同时 Valgrind 在 sshd 里报出他看不懂的内存错误。
半秒。对绝大多数人来说,这个数字根本不构成"异常"。弗罗因德顺着它挖了下去,用 perf record -e intel_pt//ub 对比后门生效与不生效时执行路径在哪里分叉,再用 gdb 在分叉点前下断点。3 月 28 日,他把结论发到 oss-security 邮件列表:xz-utils 5.6.0 与 5.6.1 的发布包里藏着一个针对 OpenSSH 服务端的后门。CVE-2024-3094,CVSS 10.0。
这件事值得反复讲,不是因为技术手法多罕见——利用动态链接器做劫持的手法在恶意软件里并不新鲜——而是因为它把开源世界赖以运转的那套信任结构,一层一层地拆给了所有人看。攻击者花了将近三年,投入最多的地方不是写代码,是写邮件。
破除误解
误解一:这是 xz 里的一个安全漏洞,被人利用了。
不是。漏洞是无意写错的代码,后门是有意留下的入口,两者在治理上完全不同。更关键的是:恶意代码从来没有出现在 xz 的 git 源码树里。你在 2024 年 3 月 clone xz 仓库、./autogen.sh && ./configure && make,编译出来的 liblzma 是干净的。被投毒的是 make dist 产出的发布 tarball——发行版打包工,恰恰用的就是它。整整一个月里,git 上看到的和服务器上跑的不是同一份东西,而没有任何一道自动化流程去比对这两者。
误解二:sshd 依赖 xz,所以这是 OpenSSH 的问题。
上游 OpenSSH 从不链接 liblzma,一行都不。真实的链条是四跳:sshd → 发行版给 sshd 打的 systemd 就绪通知补丁 → libsystemd → liblzma(libsystemd 用它解压 journal 日志)。Debian、Ubuntu、Fedora 都打这个补丁,因为它们希望 systemctl start sshd 能准确知道守护进程什么时候真正开始监听。不打这个补丁的系统(比如默认配置的 Arch)即使装了 5.6.1 也不可利用。攻击者不是找到了 OpenSSH 的弱点,是找到了发行版集成层的弱点——一个上游没人负责、下游人人都打的补丁。
误解三:这么大的事,肯定是安全研究者或自动化扫描发现的。
一个都没发现。模糊测试没发现(后面会讲到它是怎么被合法地关掉的);CVE 扫描器没发现(版本号是新的,没有已知 CVE);SBOM 没发现(依赖清单如实写着 xz 5.6.1,一字不差);签名校验没发现(tarball 由项目正式维护者的密钥签的,签名完全有效)。发现它的是一个不接受"登录慢了半秒"这种事的数据库工程师。这不是防御体系的胜利,是运气。
误解四:代码审查本来能挡住。
审查的对象是 diff。而恶意载荷躺在两个二进制文件里:tests/files/bad-3-corrupt_lzma2.xz 和 tests/files/good-large_compressed.lzma——它们是压缩库测试套件里再正常不过的东西。压缩库必须测试"遇到损坏数据时不崩溃",所以仓库里本来就有一堆故意损坏的样本。审查工具对它们只会显示一行 Binary files differ。一个应该是乱码的文件,看起来是乱码,这本身构不成可疑。
三年:一个身份是怎么长出来的
时间线本身就是这次事件里信息量最大的部分。
| 日期 | 事件 |
|---|---|
| 2021-10-29 | "Jia Tan" 第一次向 xz-devel 邮件列表投稿,内容是加一个 .editorconfig 文件 |
| 2022-02-07 | 第一个以 jiat0218@gmail.com 为作者的提交进入仓库 |
| 2022-04-22 | 账号 "Jigar Kumar" 在列表施压:「补丁在这个列表上一躺就是几年。没理由指望很快会有什么进展。」 |
| 2022-05-19 | 维护者拉塞·科林回复:「Jia Tan 一直在列表之外帮我做 XZ Utils,将来他在 XZ Utils 里可能会有更大的角色。」 |
| 2022-06-07 | Jigar Kumar:「不换维护者就不会有进展。现在往这里发补丁没有意义。」 |
| 2022-06-21 | 账号 "Dennis Ens":「我对你的心理健康问题感到抱歉,但认清自己的极限很重要。」 |
| 2023-03-18 | Jia Tan 独立发布 xz 5.4.2——第一次由他签名的正式版本 |
| 2023-07-07 | Jia Tan 在 Google oss-fuzz 配置里关闭 ifunc 支持,理由是它和模糊测试工具有兼容问题 |
| 2024-02-23 | 两个"测试用"二进制文件进入仓库 |
| 2024-02-24 | 5.6.0 发布,tarball 内含后门 |
| 2024-02-28 | 一处极隐蔽的改动让 Landlock 沙箱的编译探测永远失败,xz 自身的沙箱静默失效 |
| 2024-03-09 | 5.6.1 发布,后门更新 |
| 2024-03-25 | 账号 "Hans Jansen" 提交 Debian bug #1067708,催促把 xz-utils 升到 5.6.1 |
| 2024-03-28 | 弗罗因德向 oss-security 披露 |
| 2024-05-29 | 5.6.2 发布,后门被移除 |
读这份时间线要抓住的不是"攻击者很有耐心",而是每一步在当时看都是合理的。加 .editorconfig 是好事。抱怨补丁堆积是社区的正常情绪。给一个持续贡献了几个月的人提交权,是每个缺人手的项目都在做的事。把 fuzz 里出问题的特性关掉,是每个被 CI 噪音折磨过的维护者都干过的事。攻击者没有绕过任何一道流程——他把每道流程都正常地走了一遍。
而被攻击的对象,从头到尾都不是代码。拉塞·科林一个人无偿维护 xz 十几年,这个库被几十亿台设备依赖。2022 年那几封邮件里,没有一封在讨论技术;它们讨论的是"你是不是拖累了大家"。当一个人已经在公开场合说过自己精力有限、有长期的心理健康困扰,几个陌生账号同时表达"我们理解,但社区需要更多"——这不是巧合,这是施压脚本。真正被利用的漏洞是一个人的时间与孤独。
藏在测试夹具里:第一级
bad-3-corrupt_lzma2.xz 这个文件名的意思是"第 3 号损坏的 lzma2 样本"。它确实无法被 xz -d 正常解开——但不是因为它是垃圾数据,而是因为它是一个合法的 xz 流被做了字节替换。发布 tarball 里被改过的 m4 宏,展开后大致等价于这样一条管线:
tr "\t \-_" " \t_\-" < tests/files/bad-3-corrupt_lzma2.xz | xz -d
```tr 在这里做的是一个四字符的替换密码:把制表符换成空格、空格换成制表符、连字符换成下划线、下划线换成连字符。换回来之后,它就是一个正常的 xz 压缩流,解开是一段 shell 脚本。这段脚本再去处理第二个文件 good-large_compressed.lzma,经过一轮更复杂的抽取,吐出一个预编译好的 ELF 目标文件 liblzma_la-crc64-fast.o,把它塞进 liblzma 的链接列表。
选择二进制测试夹具作为载体,是这次攻击里最漂亮也最阴险的一步。它的取舍是这样的:
- 得到:完全的审查豁免。没人 review 二进制,也没有工具能对"损坏样本"做语义检查。文件天然应该是不可读的。
- 付出:载荷体积受限,且必须放在一个可信项目里——所以前面三年的身份工程是必需的前置条件,不是可选项。
- 失效场景:只要项目要求"仓库里每个二进制夹具都必须能由一段可复现的脚本从文本描述生成",这条路就被堵死了。xz 事件之后,一些项目确实加了这条规矩,但它需要人力去执行,没有编译器能替你把关。
autoconf 的那条缝:git 树 ≠ 发布包
这是整件事的结构性缝隙,比载荷本身更值得理解。
用 autotools 的项目,源码里写的是 configure.ac(几百行 m4 宏),而用户拿到的 tarball 里是 configure——一个由 autoconf 展开生成的、动辄两三万行的 shell 脚本。生成过程需要 aclocal 把一堆 .m4 宏文件(有些来自 gnulib 等外部工程)收进 m4/ 目录。这些生成物按惯例不进 git,却必然进 tarball,因为发行版打包工不应该被迫安装特定版本的 autoconf。
于是就有了这个荒谬但完全符合传统的局面:分发出去的、真正被编译的那个东西,没有人读,也不在版本控制里。
攻击者在 tarball 的 m4/build-to-host.m4 里动了手脚。这个宏源自 gnulib,正常职责是处理构建机与目标机之间的路径转换,普通人一辈子不会打开它。git 里没有这个文件的踪迹,所以在 GitHub 上审查提交的人什么也看不到;而下载 tarball 的打包工只会核对签名——签名是有效的,因为签名的人就是攻击者本人,且他当时是项目的合法维护者。
这里可以看清一个常被混淆的区别:
| 机制 | 回答的问题 | 对 xz 这一击 |
|---|---|---|
| 制品签名(GPG / Sigstore) | 这个包是不是那个人发的 | 无效——就是他发的 |
| SBOM / 依赖清单 | 我用了哪些组件、什么版本 | 无效——清单完全正确 |
| CVE 扫描 | 这些版本有没有已知问题 | 无效——是零日,而且是后门 |
| 可复现构建 | 同样的源码是否得到同样的二进制 | 部分有效——能暴露 tarball 与 git 的差异,前提是有人真的去比 |
| 依赖最小化 | 这个进程为什么要加载这个库 | 有效——切断的正是 sshd → liblzma 这条链 |
"部分有效"这个措辞是认真的。可复现构建保证的是二进制忠实于源码,它不保证源码本身没有恶意。如果攻击者直接把后门写进 git(他有提交权,完全做得到),构建照样是可复现的,只是恶意代码会被更多人看见——所以他选择了不可复现的那条缝。可复现构建把攻击成本从"藏在生成物里"抬高到"藏在源码里",这是真实的收益,但它把问题转移给了代码审查,而不是解决了问题。
ifunc:RELRO 关门之前的那一小段窗口
现在讲运行时。这部分是整个后门的技术核心,也是最能说明"性能优化与攻击面是同一件事"的地方。
ifunc(indirect function,间接函数) 是 GNU 工具链的一个特性:一个函数的真实地址不在编译期定,而是在程序加载时由一个"解析器函数"现场决定。典型用途是 CPU 特性分发——同一份 memcpy,在支持 AVX2 的机器上返回 AVX2 版本,在老机器上返回 SSE2 版本。xz 用它来选 CRC32/CRC64 的实现,这是正当的优化,代码是 2023 年以 "Hans Jansen" 名义进来的(同一个账号,一年后去 Debian 催更新)。
关键在于解析器是什么时候被调用的。动态链接器 ld.so 在处理重定位时执行 ifunc 解析器;而 GNU_RELRO(把全局偏移表 GOT 设为只读的那步 mprotect)发生在重定位之后。也就是说,ifunc 解析器运行的那一瞬间,进程里所有库的 GOT 都还是可写的。
这是一个真实的设计取舍,代价现在很清楚:
- 得到:加载期就能选定最优实现,之后每次调用都是直接跳转,零分支开销;比在每个调用点做运行时判断快得多。
- 付出:允许任意用户代码在动态链接器最脆弱的时刻运行,此时安全加固(RELRO)尚未生效。
- 失效场景:只要进程里有任何一个被信任的共享库带 ifunc,攻击者就拿到了一个所有加固之前的执行窗口。
后门的解析器就在这个窗口里干活:它遍历链接映射(link map)找到动态链接器的内部结构,注册一个伪造的 rtld-audit 回调(la_symbind64 那套接口,本来是给性能分析工具用的符号绑定钩子)。之后每当加载器要绑定一个符号,钩子就先过一遍;当它看到 RSA_public_decrypt@got.plt 时,把 GOT 里的指针换成自己的实现。
于是运行时的路径变成这样:
- 攻击者向目标
sshd的 22 端口发起连接,出示一份特制的 SSH 证书。 sshd在认证之前验证证书签名,调用 OpenSSL 的RSA_public_decrypt——实际进的是后门。- 后门取出所出示公钥的模数
N,用内嵌的密钥对其最后 240 字节做 ChaCha20 解密。 - 用硬编码的 Ed448 公钥验证解密结果上的签名。私钥只有攻击者持有。
- 验签通过,则把解出的字符串交给
system()执行——以 root 身份,在任何认证发生之前。 - 验签不通过,就老老实实调用真正的
RSA_public_decrypt,一切正常。
第 4 步是这个后门最有"工程审美"的一处设计,也是它区别于普通漏洞的地方:它不是一个谁发现谁能用的洞,而是一把只有一个人有钥匙的门。 即使有人逆向出全部代码,没有那把 Ed448 私钥也用不了。代价是这条路径必须在每次连接时都跑一遍验签逻辑——而这,正是那半秒 CPU 的来源。
后门还做了一整套反分析:检查 argv[0] 是否为 /usr/sbin/sshd、TERM 是否未设置(确认不在交互终端里)、LD_DEBUG / LD_PROFILE 是否为空(确认没人在调试它);所有字符串不以明文存放,而是编码在一个查找结构里,strings liblzma.so 什么也看不出来。它甚至只在特定条件下才会被构建进去。
只在发行版的构建机上发火
激活条件是精确投放的教科书:
| 条件 | 为什么要这一条 |
|---|---|
| x86-64 Linux | 载荷是预编译的 ELF 目标文件,架构写死 |
| glibc | 需要 ifunc 与 rtld-audit,musl 没有这套 |
| GCC + GNU ld | 载荷依赖具体的链接行为 |
从 release tarball 构建(configure 存在) | git 检出走的是另一条路径,不触发 |
环境里有 dpkg-buildpackage 或 rpmbuild 的痕迹 | 只命中 .deb / .rpm 发行版的打包流水线 |
于是:xz 自己的 CI 不触发,开发者本地构建不触发,Gentoo 从源码编译不触发,oss-fuzz 不触发(更何况 ifunc 已在 2023 年 7 月被关掉)。只有发行版的官方构建机会产出带后门的二进制。缩小暴露面就是延长潜伏期——这是攻击者用"少感染"换"晚被发现"的取舍。
而它差一点就赢了。2024 年 2 月到 3 月,正是 Ubuntu 24.04 LTS 与 Fedora 40 的封版窗口。如果 5.6.x 挤进任何一个长期支持版本,它会在此后五年里躺在几百万台服务器上。"Hans Jansen" 那份 Debian bug 就是在推这最后一步。它失败的原因,和所有防御体系都没关系——它失败于攻击者在代码隐蔽性上做到了极致,却没有优化 CPU 周期。
三条药方,各自能治什么
事后的讨论集中在三个方向。诚实地说,它们的边界都比宣传的窄。
可复现构建与源码—制品对照。 能解决的:tarball 与 git 树不一致这条具体的缝。开源基础设施后来推动的"发布包必须能从 git 标签重新生成、逐字节比对"是直接对症的。不能解决的:源码里的恶意代码。它还有实打实的成本——需要固定工具链版本、消除时间戳与路径、处理并行编译的不确定性,很多老项目做不到,做到了也需要有人真的去跑那次比对,而"有人真的去看"恰恰是这次事件里最稀缺的资源。
依赖审计与依赖最小化。 这次真正切断链条的是后者。systemd 的 PR #31550 把 liblz4、libzstd、liblzma 改成按需 dlopen,2024 年 2 月 29 日开的,3 月 5 日合并——理由是给 initrd 镜像瘦身,和安全无关,而且早于后门被发现三周。OpenSSH 上游随后在 9.8(2024 年 7 月 1 日)里自己实现了 systemd 就绪通知协议,不再需要发行版打那个链接 libsystemd 的补丁。两处改动都不是"审计发现了风险",而是"这个依赖本来就不该在那里"。这提示了一件事:依赖审计的产出往往是一份没人读完的清单,而依赖删除的产出是攻击面真的变小了。 但删除依赖不能应对直接依赖被投毒——你不可能把 liblzma 从压缩工具里删掉。
维护者资助。 主权科技基金(Sovereign Tech Fund)这类机制给关键基础设施的维护者发钱,直接对应"精疲力竭的独立维护者"这个根因。它能降低的是移交压力——如果科林当年有薪水、有同事,那几封"社区需要更多"的邮件就没那么大杀伤力。它不能解决的也很清楚:钱不自动产生代码审查;一个有薪维护者同样可能被渗透,也同样可能给错误的人提交权;而且资金总是流向已经出名的项目,真正危险的是那些还没人听说过、直到出事才被发现有几十亿部署的库。
把三条放在一起:
| 方案 | 对 xz 这一击 | 治的是哪一环 | 治不了什么 |
|---|---|---|---|
| 可复现构建 | 部分有效 | 生成物与源码脱节 | 恶意源码本身 |
| SBOM / 依赖清单 | 无效 | 只回答"用了什么" | 不回答"它是不是坏的" |
| 制品签名 / 来源证明 | 无效 | 冒名分发 | 维护者本人就是攻击者 |
| 依赖最小化 | 有效 | 传递依赖链 | 直接依赖 |
| 二进制夹具可生成性要求 | 有效 | 不可审查的二进制入库 | 需要人力持续执行 |
| 维护者资助 | 不确定 | 移交压力与倦怠 | 审查密度、渗透、冷门项目 |
真正没有被任何一条覆盖的,是那个最贵的判断:该不该把提交权交给一个你只在邮件里认识的人。 开源协作的效率恰恰来自这条启发式——愿意信任持续贡献者,所以陌生人的补丁能进主线,所以分布式协作的成本才可控。取消这条信任,开源就不再是开源;保留它,就永远存在一个花三年时间伪造输入的攻击路径。xz 事件没有解掉这个矛盾,它只是把这个矛盾从"理论上存在"变成了"已经发生过一次,而且我们是靠运气躲过去的"。
跨域连接
- 市场失灵与公共品:xz 是典型的非竞争、非排他公共品——谁都能免费用,谁都没有独自付费维护的动机——公共品的经典结论是供给不足,而在软件里"供给不足"不表现为没人写,而表现为写的人只有一个:liblzma 被数十亿台设备依赖,年度维护预算却近乎为零,于是"让维护者精疲力竭"成了成本极低、收益极高的攻击路径。这解释了一个反直觉的事实——攻击者花在写邮件上的时间,远多于花在写代码上的时间。
- 公地治理:奥斯特罗姆研究的公共池塘资源之所以能存续,靠的是清晰边界、监督机制与分级惩罚,而不是使用者的善意——xz 恰好这三样都没有:谁能成为维护者没有明文边界,几个月的列表外协助就换来了提交权;发布制品没有第三方监督;出事时也没有分级的撤权手段,最后只能靠 GitHub 直接下线整个仓库这种最粗暴的方式收场。把"加一个共同维护者"当成技术决定而不是治理决定,是这次事件里最贵的一次省事。
- 认知偏差:Jigar Kumar 与 Dennis Ens 这些账号从不攻击代码,只攻击"你是不是拖累了社区"——互惠原则与承诺一致性让长期无偿付出的维护者更难拒绝"你已经尽力了,交给别人吧"这类话术,多个陌生账号同时抱怨又制造了社会认同的假象。关键在于这些偏差在正常协作里恰恰是黏合剂:正是"愿意相信持续贡献者"这条启发式让分布式开发的信任成本可控,攻击者做的只是把这条启发式的输入伪造了三年。
- 失效分析:这次的发现路径是教科书式的弱信号追踪——失效分析区分潜伏失效与显性失效,后门属于前者:功能层面一切正常,唯一泄露的是半秒 CPU 这种非功能指标;而所有针对显性失效设计的门禁(单元测试、模糊测试、CVE 扫描)在定义上都看不见它。这也是为什么根因分析必须回答"防御为什么没触发"而不是"漏洞在哪一行"——模糊测试早在 2023 年 7 月就被以"ifunc 兼容性有问题"为由合法地关掉了,防线是被一步步拆掉的,不是被突破的。
- 供应链:制造业早就知道风险不在一级供应商,而在你看不见的三级、四级——sshd 从不直接依赖 xz,链条是 sshd → 发行版的 systemd 通知补丁 → libsystemd → liblzma,四跳之后才碰到攻击面。供应链管理里的三个老问题在这里一一对应:单源依赖对应"只有一个维护者",来料检验对应"没人验二进制测试夹具",图纸与实物是否一致对应"git 树与发布 tarball 不同"。软件业花了很久才承认自己也在管一条供应链。
参考文献
- Andres Freund, backdoor in upstream xz/liblzma leading to ssh server compromise, oss-security 邮件列表, 2024 年 3 月 29 日
- Red Hat Product Security, CVE-2024-3094 安全公告, 2024 年
- Russ Cox, Timeline of the xz open source attack, research.swtch.com, 2024 年
- How the xz backdoor works, LWN.net, 2024 年 4 月
- Sam James, xz-utils backdoor situation (CVE-2024-3094) FAQ, GitHub Gist, 2024 年
- Open Source Security Foundation (OpenSSF), xz Backdoor CVE-2024-3094, 2024 年 3 月 30 日
延伸阅读
- Evan Boehs, Everything I Know About the xz Backdoor, boehs.org, 2024 年——按小时粒度还原社会工程部分的邮件往来
- Kaspersky Securelist, The XZ backdoor story(三篇系列:载荷分析、社会工程、OpenSSH 内的运行时行为), 2024 年
- Nadia Eghbal, Working in Public: The Making and Maintenance of Open Source Software, Stripe Press, 2020 年——xz 事件之前四年就写清了单人维护者的结构性处境