在Linux上创建“双面”Rust二进制文件:环境绑定的隐藏代码执行技术

1 阅读10分钟

在本文中,我们将描述一种在Linux上轻松创建“双面”Rust二进制文件的技术:该可执行文件在大多数情况下运行无害程序,但如果部署在特定目标主机上,则会运行一个不同的隐藏代码。这种方法可以将二进制文件与其环境绑定,可用于定向恶意软件载荷,或者更常见的是用于许可证保护机制。

我们还将详细介绍如何使“隐藏”二进制文件在内存中更难以被检查。

问题陈述

假设你想在特定目标机器上运行一个恶意程序。一种方法是广泛分发该程序,并希望目标最终会运行它。具体分发向量不在本文讨论范围内,但你可以想象一个预编译的二进制文件,就像开发者经常在他们喜欢的项目GitHub页面上下载的那样。

然而,为了最大化接触目标的机会,你可能希望模仿无害程序的行为,并避免任何可能触发各种解决方案(沙箱、LSM、auditd等)检测的可疑行为(例如连接到C&C服务器)。

设计我们的“分裂”二进制文件

在本文的其余部分,我们将希望在目标主机上运行的程序称为“隐藏”程序,而在其他主机上运行的无害程序称为“正常”程序。

一种天真的构建方式是尽早决定实际运行哪个代码:

if is_running_on_target_host() {
    hidden_program();
} else {
    normal_program();
}

这在基本运行时检测方面是可行的,但效果不佳:

  • 隐藏程序仍将存在于内存中并可被观察
  • 更糟的是,二进制文件可以被分析和反汇编,从而暴露“隐藏”程序
  • 最糟的是,is_running_on_target_host 暴露了我们的目标是谁

如果我们想改进呢?这里根本问题是二进制文件暴露了我们想要隐藏的一切。所以让我们隐藏这些数据,加密目标程序甚至我们正在探测的主机数据,这样就能解决问题,对吧?当然这没那么简单,因为这些加密数据需要在运行时解密,因此密钥需要与加密数据一起嵌入二进制文件中,这只是在之前的解决方案上增加了一层混淆。

然而,如果我们基于加密思想,但不直接将密钥与加密程序存储在一起,而是从目标机器的唯一主机数据中派生出来呢?

程序启动时的步骤将是:

  1. 从主机提取唯一标识目标的数据(稍后会详细介绍)
  2. 使用HKDF,结合之前的宿主数据从嵌入二进制文件的密钥中派生出一个新密钥
  3. 使用派生出的密钥解密嵌入的加密“隐藏”二进制数据
  4. 如果解密成功,则运行解密后的“隐藏”程序,否则运行“正常”程序

现在,这开始变得有趣了。这种二进制文件如果不在目标主机上运行,由于其构造方式将无法解密“隐藏”程序,因为提取的主机数据会不同,导致解密密钥无效。

为此,我们将选择一种基于对称块且提供认证的加密算法,以便在非目标主机上运行时检测到无效密钥,而不是运行垃圾程序。AES-GCM 是一个常见的算法选择。

选择派生信息

用于识别目标主机并派生密钥的数据需要谨慎选择。

它需要:

  • 足够唯一,否则我们的“隐藏”程序可能会在错误的目标上运行
  • 随时间稳定,否则即使在正确的目标上,“隐藏”程序也可能永远不会运行
  • 难以猜测,对于无法访问目标机器的人来说,这样第三方在不知道目标系统的情况下就无法提取“隐藏”程序

一些候选数据包括:

  • 用户UID:不够唯一,大多数工作站用户的值为1000,而且严重缺乏熵
  • WAN接口IPv6:可能不稳定,可能通过其他渠道被猜到
  • /sys/class/dmi/id/ 下的硬件序列号:需要root权限才能读取,可能不在所有设备上存在,熵值不高
  • CPU型号:在虚拟机或公司笔记本群中可能不够唯一
  • 磁盘分区UUID:实际上是在创建分区时生成的随机值,具有良好的熵和唯一性,符合我们所有需求!

构建时代码

为了便于开发者使用,我们将所有这些逻辑集成到一个单一的 twoface Rust crate 中。幸运的是,Rust 除了是一种现代系统级语言外,还对构建时代码有很好的支持。我们的库将有两个主要部分,通过 feature flags 启用:一个构建时部分,负责加密“隐藏”二进制文件并生成数据以供嵌入;一个运行时部分,负责解密处理并将执行分派给“正常”或“隐藏”二进制文件。

将“正常”和“隐藏”两个二进制文件打包成一个新的“双面”二进制文件,包括所有加密和嵌入操作,可以通过 build.rs 文件完成。最终的二进制代码只需要:

// build.rs
use std::io;

fn main() -> io::Result<()> {
    twoface::build::build::<twoface::host::HostPartitionUuids>()
}

这里 HostPartitionUuids 是一个通用类型,用于自定义如何提取主机数据,它实现了 HostData trait。

然后我们编写一个 JSON 文件,其中包含我们在目标主机上期望匹配的数据,例如:

{
    "part_uuids": [
        "02e989c5-32dc-45ad-98f8-f284e9ac23c0",
        "0e2fcda2-5ca1-4e38-841d-68e5d3a46f93",
        "f99b45d8-d76d-48a3-94a2-3b0c6316d899"
    ]
}

最终代码还需要一些环境变量来构建,以传递两个二进制文件和上述 JSON 路径:

export TWOFACE_HOST_INFO="/path/to/host_partition_uuids.json"
export TWOFACE_NORMAL_EXE="/path/to/normal_exe"
export TWOFACE_HIDDEN_EXE="/path/to/hidden_exe"
cargo build

在构建时,这将:

  • 加载“正常”可执行文件,并从中生成一个 const 数组供运行时代码使用
  • 加载“隐藏”可执行文件,并压缩它
  • TWOFACE_HOST_INFO 传递的文件中加载主机数据
  • 生成一个随机密钥,并从中生成一个 const 数组供运行时代码使用
  • 使用步骤3的主机数据派生密钥
  • 使用派生密钥加密压缩后的“隐藏”可执行文件数据,并生成一个 const 数组供运行时代码使用

然后在 main.rs(运行时代码)中,我们只需包含构建时生成的 .rs 文件,并将生成的 const 数组传递给 run 函数,该函数将运行“正常”或“隐藏”二进制文件:

use std::io;

include!(concat!(env!("OUT_DIR"), "/target_exe.rs"));

fn main() -> io::Result<!> {
    twoface::run::run::<twoface::host::HostPartitionUuids>(
        NORMAL_EXE,
        HIDDEN_EXE_BLACK,
        HIDDEN_EXE_KEY,
        &HIDDEN_EXE_DERIVATION_SALT,
    )
}

从内存运行

细心的读者可能已经注意到,我们在构建时接收 ELF 二进制文件作为输入,并在运行时原样启动它们,这对于一个已经执行的 ELF 来说可能有些棘手。一种可能的方法是将要执行的程序写入文件系统,然后对其执行 exec 系统调用。然而对于“隐藏”程序,这需要将解密后的二进制文件以一种易于隔离/观察的形式写入,这是我们想要避免的。其他可能的方法包括使用 O_TMPFILE 标志创建文件(其他进程不可见),或者将目标 ELF 的所有页面映射到内存中(繁琐,并且需要映射可执行页面,可能触发运行时检测或加固问题)。

相反,我们选择 memfd_create 系统调用,它基本上创建一个没有文件备份的文件描述符。一旦目标二进制文件被写入其中,fexecve 系统调用将用新的进程映像替换当前进程映像,我们的工作就完成了。

增加另一层乐趣

现在我们有了一个很好的解决方案:在构建时将两个二进制文件打包成一个,在运行时提取主机数据以识别目标,并根据结果从内存中运行“正常”或“隐藏”二进制文件。

此时,解密的“隐藏”二进制文件永远不会作为整体存在于进程内存中,因为当我们解密 AES 块时,我们可以实时地将它们写入稍后将执行的文件描述符。这是一个很好的特性,但写入操作很容易被观察到,即使是非特权用户。

例如,一个创建 memfd 并写入数据的一行 Python 程序,我们可以轻松地用 strace 看到写入的数据:

$ strace -e write python3 -c 'import os; fd = os.memfd_create(""); f = open(fd, "wb"); f.write(b"secret data")'
write(3, "secret data", 11)             = 11
+++ exited with 0 +++

每个解密的 AES 块都可以以同样的方式被观察到,我们的完整“隐藏”二进制文件可以被重建。当然,这需要在目标系统上运行分析,但如果能避免这种情况就更好了。

为了改进这一点,我们将使用不同的方式将解密后的“隐藏”程序 ELF 数据写入目标文件描述符,每种方式各有优缺点:

  • 使用 io_uring:不发出 write 系统调用,因此例如 strace 不会看到任何写入的数据,但系统可能不支持或禁用了 io_uring
  • 通过 mmap 内存段:同样没有写入痕迹,但需要许多系统调用来映射/取消映射每个块(性能影响),这样解密后的完整文件在任何时刻都不会在内存中完全可见
  • 回退到经典 write:完整的解密文件数据仍然不会出现在进程内存中,但 write 调用很容易被追踪

请注意,无论如何,这都无法抵抗特权用户的更高级运行时分析。虽然内存中的文件描述符数据并未映射到用户空间内存,但可以从内核中访问和提取。

结果

完整代码可以在 github.com/synacktiv/t… 查看,其中包含一个示例“无害”/“正常”二进制文件、另一个“隐藏”/“邪恶”二进制文件、twoface 库,以及一个将所有内容测试在一起的示例。

运行测试示例将:

  • 构建“无害”二进制文件
  • 构建“邪恶”二进制文件
  • example/host.json 加载分区 UUID
  • 构建一个打包了“无害”和“邪恶”(加密)ELF 的示例二进制文件
  • 运行它,以便你可以看到实际运行的是哪一个

结论

这个概念验证展示了如何利用 Rust 的构建时代码功能来创建先进且对开发者友好的机制,并实现我们的“双面”二进制文件。

这仅仅是可能实现的功能的一个 glimpse,为了进一步推进,我们可以:

  • 添加构建时混淆,例如隐藏我们从 /dev/disk/by-uuid 读取分区 UUID 的行为
  • 添加运行时反调试技术
  • 使用已在内存中的主机特定数据来派生密钥,例如通过对共享库页面进行哈希
  • 链式使用多个加载器级别,每个级别使用不同的派生数据源
  • 使用例如 userfaultfd 动态解密 ELF 内存页面

……这可能是另一篇文章的主题。 KDDkXb0q5e0VUs5UVHFxotCxcebu7GRgXkZhrIkvuqqVDSiwhKCgBLCB61aG7D1WMgqLd4HrBKHQjvIeJhUl2tK2DV7LWBm/EdoaVMk6tsspBjLLAc8yX/MH51HYQwAH