3 月底的邮件列表上,一个看起来再普通不过的问题帖引起了我的注意。发帖者说自己在升级了某个系统包之后,通过 SSH 登录服务器的耗时从几十毫秒涨到了五百毫秒以上,同时 sshd 的 CPU 占用明显偏高。他试着回退了 XZ Utils 的版本,问题立刻消失。当时谁也没想到,这个藏在压缩库里的异常,会在几天后被正式编号为 CVE-2024-3094,并成为 2024 年最典型的 XZ Utils 后门事件,攻击面直指 sshd 的远程代码执行。
这篇文章不打算复述一遍新闻稿,而是把整个事件按下述逻辑拆开:先复盘漏洞是怎么被发现的,再看恶意代码藏在哪里、利用了哪些底层机制,然后讲清楚攻击链如何从“加载一个库”变成“远程执行代码”,最后我会附上自己在测试机上实际跑过的排查步骤和修复操作。内容主要面向 Linux 系统运维、DevOps、容器平台维护者,以及想搞明白“供应链攻击到底是怎么回事”的开发者。
1. 事件复盘:一条“SSH 登录变慢”的消息如何牵出顶级供应链后门
1.1 轻度异常背后的重度信号:CPU 飙升与登录延迟
先说一个我自己的观点:这类事件能被发现,最核心的运气成分不在于分析工具多强,而在于有人对“轻度异常”保持了敏感。登录延迟增加几百毫秒,在大多数运维体系里根本不会被注意到,甚至会被归因于网络抖动、DNS 解析超时、磁盘 IO 抖动。但这次发帖的人做了两件很关键的事:一是确认了延迟和 CPU 占用同时出现,并且与 SSH 登录行为强相关;二是通过二分回退,把嫌疑范围缩小到了最近一次升级引入的 XZ Utils 包。
后来社区分析者用 valgrind、gdb 对 liblzma 做追踪时发现,库内部出现了大量与压缩解压无关的操作。正常的 liblzma 在 SSH 登录这个场景里应该只是被动的、几乎没有运行路径的依赖库,但在登录触发的某条调用链上,它却执行了一堆看起来像“初始化环境、判断调用者身份、校验运行条件”的指令。更关键的是,当系统回退到 5.4.x 版本后,这些问题全部消失。到这里,基本可以断定恶意代码是随着某个版本的 liblzma 被打进系统的。
给个实操层面的建议:如果你在生产环境也碰到过这类“说不清道不明”的异常,不要急着忽略它。可以把最近的包变更记录调出来,同时记录异常发生的时间窗口,形成一个“变更 + 异常”的对照表。很多时候,安全攻击在早期留下的线索就是这种很不起眼的性能回退或资源占用异常。
1.2 从 liblzma 到 sshd 的“隐藏链路”
很多人第一次看 CVE-2024-3094 时,会有一个困惑:sshd 不是直接链接 liblzma 的,XZ Utils 为什么会影响 SSH 服务?要理解这一点,必须看清现代 Linux 系统里那条很长的运行时依赖链。
在 systemd 化的发行版上,sshd 通常由 systemd 管理,而 systemd 的若干组件(比如 libsystemd)会链接 liblzma。也就是说,攻击者不需要让 sshd 直接依赖 xz,只要让“sshd 进程在启动时通过某个中间库间接加载 liblzma”,恶意代码就获得了在 sshd 进程内存空间中运行的机会。实际受影响的主要发行版路径大致是:sshd → libsystemd → liblzma。
这条链路的选择非常聪明。攻击者没有直接去污染 OpenSSH 源码,因为 OpenSSH 的审计强度极高,提交量巨大,任何可疑改动都容易被盯上。而 XZ Utils 是一个底层压缩库,包维护者多、更新频繁、受关注度低,大部分开发者根本不知道它是谁在维护。把一个后门放到这里,等于是在一栋大楼的消防管道里埋了一根监听线,平时没人会去检查消防管道,但它通向大楼的每一个房间。
2. 后门的藏匿手段:为什么它能在开源项目里潜伏这么长时间
2.1 多层混淆的构建脚本:平时看不见,发布时才出现
这个后门让我最觉得“值得复盘”的地方,不是某个具体的漏洞利用代码,而是它的藏匿方式。恶意代码并没有直接出现在源码仓库里。从公开分析来看,攻击者是在 xz 的构建系统里动了手脚,主要涉及 build-to-host.m4、configure.ac 和 CMakeLists.txt 这几个文件,同时在 tests 目录下放了一些伪装成测试数据的预编译文件。
实际执行过程大致是:正常的 git 源码树看起来是干净的,但当你用特定方式生成发布包(比如 make dist)时,构建脚本会从 tests 目录下那几个“测试数据”文件里解出隐藏的恶意对象,并在编译 liblzma 时把它打进去。也就是说,你在 GitHub 上看到的源码和最终安装到机器上的二进制,根本不是同一个东西。这种“源码与产物不一致”的攻击手法,比直接在源码里写一段恶意逻辑难发现得多。
对安全审计者来说,这是一个很痛苦的现实:即使你把仓库里的 C 代码全部读一遍,也找不到问题,因为恶意负载是在构建阶段由 shell 脚本动态解出来的,而且只在满足特定环境条件时才会展开。攻击者还刻意做了很多环境探测,比如确认目标架构、编译器、链接器特征是否符合预期,不满足条件时就让恶意代码段静默消失,从而避免被安全研究人员的沙箱环境抓到样本。这也是为什么后门能在多个版本中存在却没有被更早发现的原因之一。
2.2 IFUNC 与符号重定向:让后门在运行时“顶替”正常函数
藏匿只是第一步,真正让后门起作用的是它对动态链接机制的深度利用。这里要简单解释一下 ELF 中的 IFUNC 机制。正常情况下,动态库里的函数地址在加载时由动态链接器解析,而 IFUNC(indirect function)允许一个函数在运行时通过一个 resolver 函数来决定最终采用哪个实现。在 liblzma 里,crc32 和 crc64 的计算就用了 IFUNC,以便根据 CPU 特性选择最优的 SIMD 实现。
攻击者做的,就是劫持 glibc 里 get_cpuid 这类符号的解析过程。后门代码会先于正常的 resolver 执行,在 sshd 进程里检查调用者身份和运行条件;如果判断当前环境不是攻击目标,就会把执行权交还给正常的 CRC 函数,整个过程看起来毫无异常。可以这样理解:你拨了一个总机号码,总机先转接给一个“改号通知”,再接原来的分机。电话号码没变,但接电话的人已经被替换过了。
这个手法非常隐蔽,因为它没有修改任何函数的逻辑,只是改变了函数指针的解析路径。常规的源码审计和二进制 diff 如果只比对函数内部的指令,很容易忽略这种“中间人式”的重定向。更麻烦的是,由于它运行在系统库层面,安全监控工具看到的只是程序在调用 crc64,而不知道这个调用在进入正常实现之前已经做了一系列恶意操作。
2.3 心理诱导式反审计:故意留下“正确 CRC”的假信号
另一个值得单独拿出来讲的设计,是后门对审计工具的反制。部分样本分析显示,后门代码会计算并提供正确的 CRC 校验结果,让那些试图通过校验源码包完整性、或者检查共享库 hash 的自动化工具得到“一切正常”的反馈。也就是说,它主动给审计人员递上一份伪造的“体检合格报告”。
再加上前面提到的环境探测,后门的整体行为模式是:在非目标环境下保持静默,在被分析时呈现正常假象,在真实攻击场景里才释放恶意行为。这种“平时正常,用时异常”的设计,恰恰是供应链攻击里最可怕的一点。传统恶意软件倾向于快速传播、快速变现,而这种定向植入型后门追求的是长期潜伏、精准触发,它会想尽一切办法避免在实验室环境里暴露自己。
3. 漏洞利用链拆解:从库函数劫持到远程代码执行
3.1 触发路径:谁加载了 liblzma,谁就成了攻击面
现在把前面的链路串起来,完整地看一次从远程到本地的利用流程。攻击者通过 SSH 客户端向目标服务器发起连接,目标服务器上的 sshd 进程开始处理握手请求。此时,sshd 进程已经因为 systemd 的依赖关系,在内存中加载了被植入后门的 liblzma。后门代码会在 SSH 握手过程的某个阶段被触发,进而接管执行流。
从公开技术分析来看,后门在运行时会检查当前进程是否符合预期条件(比如是否确实是 sshd),然后对攻击者发送的特殊数据做出反应。一旦条件满足,攻击者就获得了在 sshd 进程上下文中执行任意代码的能力。由于 sshd 通常以 root 身份运行,这意味着攻击者可以进一步获取系统权限,而不是停留在普通用户层面。整个过程不需要攻击者预先拥有任何系统账号,也不需要用户交互。
这里必须强调一点:虽然最终漏洞被定级为远程代码执行,但公开渠道的分析更多是确认了“后门具备代码执行能力”,正常情况下我们很难在测试环境里完整复现出一次“无痕拿 root”的利用过程。但这不妨碍我们理解它的危险等级——攻击者一旦掌握这种能力,完全可以在认证前后动手,把整台服务器变成后续横向渗透的跳板。
3.2 为什么它是 10.0 分的远程代码执行
CVE-2024-3094 的 CVSS 分数被定为 10.0,也就是最高严重等级。很多人看到这个分数会问:它到底比普通 Web 漏洞高在哪?我通常用三个维度来解释:
第一,从网络层面看,它是远程可利用的。攻击者不需要物理接触设备,不需要提前在目标机器上放置文件,只需要能够向 SSH 服务发起网络连接。第二,从权限要求看,它不需要任何权限和账号,更不需要受害者执行某个文件或点击某个链接,这就让传统的“钓鱼”“诱导执行”等前置条件全部失效。第三,从影响范围看,它位于系统最底层,一旦成功就是完全控制,对机密性、完整性和可用性的影响都是全部。
拿它和常见的命令注入、文件上传漏洞对比一下就会明白差距:Web 漏洞往往需要前提条件(比如找到上传点、绕过认证、猜出路径);而这个漏洞是直接埋在系统库里,只要服务启动,恶意代码就在内存里等着你。这也是为什么供应链攻击一旦成行,造成的破坏往往是指数级的。
3.3 对我方环境的实际威胁评估
在动手处置之前,先冷静评估一下自己到底处于什么风险等级。我按场景整理了一张表,你在排查时可以直接对照:
| 环境类型 | 风险等级 | 典型特征 | 处理优先级 |
|---|---|---|---|
| 使用滚动更新源或测试版源的机器 | 高 | 包管理器可能在窗口期内拉取过 5.6.0 / 5.6.1 | 立即排查并处置 |
| 稳定发行版的长期支持版本 | 低 | 默认源未推送恶意版本 | 仍需确认是否手动安装过 xz |
| 手动编译 / 第三方源安装 | 高 | 版本不受发行版保护,可能直接用了 5.6.x | 立即核对版本与来源 |
| 容器镜像与 CI 环境 | 中到高 | 基础镜像如果上游受影响,构建产物也会连带受影响 | 重新构建并核对镜像层 |
| 离线内网且版本受控 | 低 | 无外部变更通道,但仍需确认历史包 | 按资产管理流程核验 |
从我个人的经验看,最容易被遗漏的是“容器镜像 + CI 构建产物”。很多时候运维只检查了宿主机上的 liblzma,却忘了镜像仓库里的旧镜像还在,一旦被回拉或复用,恶意版本就又回来了。所以评估风险时一定要把镜像仓库、缓存目录、备份目录都纳入范围。
4. 应急排查清单:我在测试机上的完整检测过程
4.1 版本、库依赖和符号特征:三步快速定位
事件爆出后,我在一台装了受影响版本的测试机上做了一轮排查,步骤基本可以概括为三步。第一步是确认 xz 版本。这条命令能直接看到当前安装的 XZ Utils 版本,如果命中 5.6.0 或 5.6.1,就要立即进入处置流程。
xz --version第二步是确认 liblzma 是否确实进入了 sshd 的运行时依赖。这里要注意,直接用 ldd 查看 sshd 可能看不到 liblzma,因为路径是经过 systemd 间接引入的。我在测试机上分别看了 sshd 和 systemd 的依赖,才确认完整链路:
ldd /usr/sbin/sshd | grep -E 'lzma|systemd' ldd /usr/lib/systemd/systemd | grep -E 'lzma'第三步是检查 liblzma 动态库里的可疑符号。受污染版本的 crc64_resolve 函数大小和干净版本有明显差异,在动态符号表里很容易暴露。可以用 objdump 拉出与 CRC 相关的函数,观察长度和指令特征:
objdump -T /usr/lib/x86_64-linux-gnu/liblzma.so.5 | grep -E 'crc32_resolve|crc64_resolve'正常版本里这些函数通常只有非常短的几条指令,恶意版本里则可能出现明显偏长的实现、穿插着大量与 CRC 计算无关的汇编。操作时要注意把库路径替换成你系统里的实际路径,不同架构、不同发行版的路径并不一致。
4.2 一个可复现的检测脚本思路
为了在更多机器上快速过一遍,我参考社区的分析思路写了一个简单的排查脚本。它不是官方发布工具,只是一个帮你快速建立初步判断的检查器,核心逻辑还是“版本命中 + 可疑符号特征”的组合判断,你可以根据自己的环境调整:
#!/usr/bin/env bash # CVE-2024-3094 快速排查脚本示例,思路参考社区公开分析,请按实际情况调整 set -euo pipefail xz_version="$(xz --version | head -n1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)" echo "[-] 当前 xz 版本: ${xz_version:-unknown}" case "${xz_version}" in 5.6.0|5.6.1) echo "[!] 命中已知恶意版本区间,需要立即处理" ;; *) echo "[*] 未命中已知恶意版本区间,仍需结合符号特征判断" ;; esac lib_path="$(ldconfig -p | grep 'liblzma.so.5' | awk 'NR==1{print $NF}')" if [ -n "$lib_path" ]; then echo "[-] 定位到 liblzma 路径: $lib_path" objdump -T "$lib_path" | grep -E 'crc32_resolve|crc64_resolve' || echo "[*] 未发现相关符号" else echo "[!] 系统内未找到可加载的 liblzma.so.5" fi脚本的逻辑很简单:先看版本是否命中恶意区间,再定位动态库并列出 CRC 相关符号。但我要提醒一句:这种方式只能作为快速筛选,不能作为最终结论。因为后门设计得非常谨慎,静态特征可能被调整,而系统里也可能存在多个 liblzma 副本。最终确认建议还是把库文件 hash 与上游干净版本做对比,或者直接用发行版官方提供的更新包覆盖后复查。
4.3 修复与清理操作:从降级到重建
如果确认系统存在风险,或者无法完全排除风险,我建议按下面的顺序处理。第一步,把 xz 降级到确认干净的版本,绝大多数发行版在事件公开后很快就推送了修复包,对应的安全版本一般是 5.4.x 系列。注意降级后要锁定包版本,避免下一次 yum、apt 升级又把它带回 5.6.x。
第二步,排查系统里所有可能残留恶意库的位置。不要只看 /usr/lib,还要看容器镜像层、chroot 环境、应用程序内置的第三方库目录。尤其要注意那些把 liblzma 静态链接进可执行文件的程序,这种程序光升级系统包是不够的,必须重新构建。
第三步,重建受影响的容器镜像和 CI 基础镜像。我见过不少团队只修了宿主机,镜像仓库里还躺着旧镜像,结果下一次部署又把恶意版本带上线。比较稳妥的做法是:把所有基于受影响基础镜像构建的镜像全部标记为过期,重新从干净基础层构建一遍,并更新镜像摘要。
第四步,审查时间窗口内的日志和审计记录。后门可能已经存在了相当长一段时间,你无法假设系统一定没有被入侵过。至少要做一次针对 sshd 日志、认证记录和异常网络连接的回顾性检查,确认没有可疑的登录来源或异常行为。
5. 复盘后的几条硬教训:供应链安全的工程化反思
5.1 依赖收窄与构建可复现性
这个事件给我自己的工程实践带来了很大冲击。以前我做依赖管理时,更多关注的是“这个库有没有漏洞”“版本是不是太旧”,很少去思考“这个库到底为什么被依赖”。XZ Utils 后门让我意识到,一个库在系统里的影响范围,往往由它所在的依赖图决定,而不是由它的功能大小决定。liblzma 本身只是个压缩库,但因为它被 systemd 间接依赖,最终影响了 sshd,这就是依赖图放大效应的典型案例。
所以我现在会在项目里做两件事。第一,尽量收窄依赖,凡是能用系统自带能力替代的第三方库就不要再引入,尤其是那些“被间接依赖”的底层库,要建立明确的依赖白名单。第二,对关键构建产物做可复现构建验证。如果同一个源码树在不同时间、不同机器上构建出来的产物 hash 不一致,那说明构建链路里很可能存在不可控因素。完整的可复现构建不是所有项目都能做到,但至少可以先从记录构建产物的 SHA256 开始,形成自己的“最低配 SBOM”。
5.2 维护者输入的信任模型调整
CVE-2024-3094 给我们提出了一个很难回答的问题:开源项目应该如何信任长期贡献者?从公开记录看,恶意代码贡献者在项目里投入了相当长的时间,提交了大量看似正常的改进,逐步获得维护者信任,直到最终接触到构建脚本和发布流程。这个过程说明,仅靠“认识这个人”“他做了很多贡献”这类感觉是不够的。
我的建议是,对关键路径上的代码和脚本设立更高的审查门槛。具体来说,configure.ac、Makefile.am、build-to-host.m4、打包脚本、CI 发布流程这类文件,凡是涉及生成发布产物或修改构建参数的变更,尽量要求双人评审,并且发布时要在独立的干净环境里重新构建一次,对比产物 hash。如果能做到发布包强制签名,并且由多个维护者交叉验证,那供应链攻击者想混入恶意代码的成本会高很多。
5.3 最后还想提的一点
我在测试机上进行完整排查那几天,最深的体会是:安全工作的价值其实不在于“事后能多快修复”,而在于“平时有没有积累足够多的检查基线和异常感知能力”。如果你对每一台服务器的关键库版本都有记录,对每次升级后的产物 hash 都有留存,那么类似事件发生时,你判断自己是否受影响只需要几分钟;反过来,如果什么都没有,你就只能靠运气。
所以我建议每一位负责系统运维的朋友,从今天开始做一台“最干净的参考机”:把这台机器上所有关键动态库的文件名、版本、SHA256 记录下来,之后每次升级都做一次对比。这不需要昂贵的商业工具,几个 shell 脚本就能实现。依赖关系是看不见的,但风险是真实的;只有把看不见的关系和看得见的变更管理起来,供应链攻击才没有可乘之机。