在本文中,我们将描述一种在Linux上轻松创建“双面”Rust二进制文件的技术:该可执行文件在大多数情况下运行无害程序,但如果部署在特定目标主机上,则会运行一个不同的隐藏代码。这种方法可以将二进制文件与其环境绑定,可用于定向恶意软件载荷,或者更常见的是用于许可证保护机制。
我们还将详细介绍如何使“隐藏”二进制文件在内存中更难以被检查。
问题陈述
假设你想在特定目标机器上运行一个恶意程序。一种方法是广泛分发该程序,并希望目标最终会运行它。具体分发向量不在本文讨论范围内,但你可以想象一个预编译的二进制文件,就像开发者经常在他们喜欢的项目GitHub页面上下载的那样。
然而,为了最大化接触目标的机会,你可能希望模仿无害程序的行为,并避免任何可能触发各种解决方案(沙箱、LSM、auditd等)检测的可疑行为(例如连接到C&C服务器)。
设计我们的“分裂”二进制文件
在本文的其余部分,我们将希望在目标主机上运行的程序称为“隐藏”程序,而在其他主机上运行的无害程序称为“正常”程序。
一种天真的构建方式是尽早决定实际运行哪个代码:
if is_running_on_target_host() {
hidden_program();
} else {
normal_program();
}
这在基本运行时检测方面是可行的,但效果不佳:
- 隐藏程序仍将存在于内存中并可被观察
- 更糟的是,二进制文件可以被分析和反汇编,从而暴露“隐藏”程序
- 最糟的是,
is_running_on_target_host暴露了我们的目标是谁
如果我们想改进呢?这里根本问题是二进制文件暴露了我们想要隐藏的一切。所以让我们隐藏这些数据,加密目标程序甚至我们正在探测的主机数据,这样就能解决问题,对吧?当然这没那么简单,因为这些加密数据需要在运行时解密,因此密钥需要与加密数据一起嵌入二进制文件中,这只是在之前的解决方案上增加了一层混淆。
然而,如果我们基于加密思想,但不直接将密钥与加密程序存储在一起,而是从目标机器的唯一主机数据中派生出来呢?
程序启动时的步骤将是:
- 从主机提取唯一标识目标的数据(稍后会详细介绍)
- 使用HKDF,结合之前的宿主数据从嵌入二进制文件的密钥中派生出一个新密钥
- 使用派生出的密钥解密嵌入的加密“隐藏”二进制数据
- 如果解密成功,则运行解密后的“隐藏”程序,否则运行“正常”程序
现在,这开始变得有趣了。这种二进制文件如果不在目标主机上运行,由于其构造方式将无法解密“隐藏”程序,因为提取的主机数据会不同,导致解密密钥无效。
为此,我们将选择一种基于对称块且提供认证的加密算法,以便在非目标主机上运行时检测到无效密钥,而不是运行垃圾程序。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