创建Linux上的“双面”Rust二进制程序
在本文中,我们将描述一种在Linux上轻松创建“双面”Rust二进制文件的技术:一个在大多数时候运行无害程序的可执行文件,但如果部署在特定的目标主机上,则会运行一段不同的隐藏代码。这种允许将二进制文件绑定到其环境的方法,可用于定向恶意软件载荷,或者更常见的是,用于许可证保护机制。
我们还将详细介绍如何使“隐藏”的二进制文件在内存中更难被检查。
目录
- 问题陈述
- 设计我们的“精神分裂”二进制文件
- 选择派生信息
- 构建时代码
- 从内存中运行
- 增加另一层乐趣
- 结果
- 结论
问题陈述
假设你想在特定的目标机器上运行一个恶意程序。一种方法是广泛分发该程序,并希望目标最终会运行它。具体的分发向量不在本文讨论范围之内,但你可以想象,例如一个预编译的二进制文件,开发者经常在他们喜欢的GitHub项目页面上下载。
然而,如果你想最大化到达目标的几率,你可能希望模仿一个无害程序的行为,并避免任何可能触发各种解决方案(沙箱、LSM、auditd等)检测的可疑行为(例如连接到C&C服务器)。
到目前为止,这听起来相当简单,那么让我们看看如何构建这个程序。
设计我们的“精神分裂”二进制文件
在本文的其余部分,我们将想要在目标主机上运行的程序称为“隐藏”程序,将在其他主机上运行的无害程序称为“正常”程序。
构建此类程序的一种简单方法是尽早决定实际运行什么代码,即:
|
|
就基本的运行时检测而言,这是可行的,但并不理想:
- 隐藏程序仍然会存在并可在内存中观察到
- 更糟糕的是,二进制文件可以被分析和反汇编,“隐藏”程序会被暴露
- 最糟糕的是,
is_running_on_target_host暴露了我们的目标是谁
如果我们想改进这一点呢?这里的根本问题是二进制文件暴露了我们想要隐藏的一切。那么,让我们隐藏这些数据,加密目标程序,甚至加密我们探测的主机数据,这样应该就能解决了吧?当然,这没那么简单,因为这些加密数据需要在运行时解密,所以密钥需要与加密数据一起嵌入到二进制文件中,这只是在之前的解决方案上增加了一层混淆。
但是,如果我们基于加密的想法,但不直接将密钥与加密程序一起存储,而是从我们目标机器的唯一主机数据中派生出来呢?
程序启动时的步骤将是:
- 从主机提取数据,以唯一标识目标(稍后详述)
- 使用先前的主机数据,通过HKDF派生嵌入在二进制文件中的密钥,生成一个新密钥
- 使用派生密钥解密“隐藏”的加密嵌入式二进制数据
- 如果解密成功,运行解密后的“隐藏”程序,否则运行“正常”程序
高级流程
现在,这开始变得有趣了。这样的二进制文件在构造上,如果不在目标主机上运行,就无法解密“隐藏”程序,因为提取的主机数据会不同,从而导致无效的解密密钥。
为此,我们将选择一种同时提供身份验证的基于块的对称加密算法,这样如果不在目标上运行,我们就能检测到无效的密钥,而不是运行一个垃圾程序。AES-GCM是实现这一点的常见算法选择。
选择派生信息
用于标识目标主机并如前所述派生密钥的数据需要仔细选择。
它需要:
- 足够唯一,否则我们的“隐藏”程序可能会在错误的目标上运行
- 随时间稳定,否则我们的“隐藏”程序可能永远不会运行,即使在正确的目标上
- 对于无法访问目标机器的人来说难以猜测,这样不知道目标系统的第三方就无法提取“隐藏”程序
请注意,这里的“难以猜测”不同于经典秘密(如密码)。例如,主板的序列号对我来说可能难以猜测,但它并不是真正的秘密,因为它可以很容易地从/sys/class/dmi/id/读取,或者可能在其包装上。
一些候选数据是:
- 用户UID:不够唯一,大多数工作站用户的值是1000,也严重缺乏熵
- WAN接口IPv6:可能不稳定,可能通过其他渠道被猜出
- 来自
/sys/class/dmi/id/的硬件序列号:需要root权限读取,可能并非所有设备都存在,熵不大 - 由
grep ^model /proc/cpuinfo显示的CPU型号:例如在虚拟机、公司笔记本电脑群中可能不够唯一 - 由
ls /dev/disk/by-uuid显示的磁盘分区UUID:在创建分区时生成的实际随机值,因此具有良好的熵和唯一性,这个符合我们所有需求!
构建时代码
为了便于开发人员使用,我们将所有这些逻辑集成到一个单一的twoface Rust crate中。幸运的是,除了作为现代系统级语言外,Rust对构建时代码也有很好的支持。我们的库将有两个主要部分,由功能标志启用:一个构建时部分,控制“隐藏”二进制文件的加密,并生成数据以供嵌入到第二部分(运行时部分),该部分将进行解密处理,并根据情况将执行分派给“正常”或“隐藏”二进制文件。
将我们的两个“正常”和“隐藏”二进制文件打包成一个新的“双面”二进制文件,包括所有加密和嵌入操作,都可以从一个build.rs文件中完成。最终的二进制代码只需要:
build.rs:
|
|
这里的HostPartitionUuids是一个泛型类型,用于自定义如何提取主机数据,它实现了HostData trait。
|
|
它的代码非常短,并且很容易自定义或实现其他数据源。
然后,我们可以编写一个JSON文件,其中包含我们期望在目标主机上匹配的数据,例如:
|
|
最终的代码还需要一些环境变量来构建,以传递两个二进制文件和之前的JSON路径:
|
|
在构建期间,这将:
- 加载“正常”可执行文件,并从中生成一个常量数组,供运行时代码使用
- 加载“隐藏”可执行文件,并压缩它
- 从
TWOFACE_HOST_INFO传递的文件加载主机数据 - 生成一个随机密钥,并从中生成一个常量数组,供运行时代码使用
- 用步骤3中的主机数据派生密钥
- 用派生密钥加密“隐藏”可执行文件的压缩数据,并生成一个常量数组,供运行时代码使用
然后在main.rs(运行时代码)中,我们只需要包含在构建时生成的.rs文件,并将生成的常量数组传递给run函数,该函数将运行“正常”或“隐藏”二进制文件:
main.rs:
|
|
从内存中运行
细心的读者可能已经注意到,我们在构建时以二进制ELF文件作为输入,并在运行时按原样启动它们,这在已经执行的ELF中可能比较棘手。一种可能的方法是将要执行的程序写入文件系统,然后对其运行exec系统调用。然而,对于“隐藏”程序,这将需要以易于隔离/观察的形式写入解密的二进制文件,而这正是我们想要避免的。其他可能的方法包括使用O_TMPFILE标志创建文件(对其他进程不可见),或者在内存中映射所有目标ELF页面(繁琐,并且需要映射可执行页面,这可能会触发运行时检测或加固问题)。
相反,我们选择memfd_create系统调用,它基本上创建一个不由文件支持的文件描述符。一旦目标二进制文件被写入其中,fexecve系统调用将用新的进程映像替换当前的进程映像,我们的工作就完成了。
增加另一层乐趣
现在我们有了一个很好的解决方案,可以在构建时将两个二进制文件打包成一个,在运行时提取主机数据以识别我们的目标,并根据结果从内存中运行我们的“正常”或“隐藏”二进制文件。
此时,解密的“隐藏”二进制文件从未作为一个整体存在于进程内存中,因为当我们解密AES块时,我们可以将它们即时写入我们将要执行的文件描述符。这是一个很好的特性,然而,写入操作是很容易观察到的,即使对于非特权用户也是如此。
如果我们以创建一个memfd并写入数据的一行Python程序为例,我们可以用strace轻松看到写入的数据:
|
|
每个解密的AES块都可以以同样的方式被观察到,并且我们完整的“隐藏”二进制文件可以被重建。当然,这需要在目标系统上运行分析,但如果我们能避免这一点就更好了。
为了改进这一点,我们将使用不同的方式将解密的“隐藏”程序ELF数据写入目标文件描述符,每种方式都有优缺点:
- 使用io_uring:不会发出
write系统调用,因此例如strace将看不到任何写入的数据,但系统可能不支持或禁用它 - 通过
mmap内存段:也没有write可追踪,但需要许多系统调用来映射/取消映射每个块(影响性能),因此在给定时间点,整个解密文件在内存中不可见 - 回退到经典的
write:完整的解密文件数据仍然不会在进程内存中,但write调用很容易被追踪
请注意,在任何情况下,这都无法抵抗来自特权用户的更高级运行时分析。虽然内存中的文件描述符数据没有映射到用户空间内存中,但可以从内核访问和提取它。
结果
完整的代码可以在 https://github.com/synacktiv/twoface 看到,包含一个示例“无害”/“正常”二进制文件,另一个“隐藏”/“邪恶”二进制文件,twoface库,以及一个将它们全部测试在一起的示例:
|
|
运行test-example将会:
- 构建“无害”二进制文件
- 构建“邪恶”二进制文件
- 从
example/host.json加载分区UUID - 构建一个包含“无害”和“邪恶”(加密)ELF的示例二进制文件
- 运行它,以便你可以看到实际运行的是哪一个
结论
这个概念验证展示了我们如何利用Rust的构建时代码设施来创建高级且对开发者友好的机制,并实现我们的“双面”二进制文件。
但这只是可能实现的功能的一瞥,为了进一步推进,我们可以:
- 添加构建时混淆,例如隐藏我们从
/dev/disk/by-uuids读取分区UUID的事实 - 添加运行时反调试技术
- 使用已在内存中的主机特定数据来派生密钥,例如通过哈希共享库页面
- 链接多个级别的加载器,每个加载器使用不同的派生数据源
- 使用例如
userfaultfd动态即时解密ELF内存页面
……这可能是另一篇文章的主题。