在Linux上创建“双面”Rust二进制文件
在这篇文章中,我们将描述一种在Linux上轻松创建“双面”Rust二进制文件的技术:一个大多数时间运行无害程序的可执行文件,但如果部署在特定的目标主机上,则会运行一段不同的、隐藏的代码。这种方法允许将二进制文件与其运行环境绑定,可用于针对性的恶意软件载荷,或者更常见的软件许可保护机制。
我们还将详细介绍如何使“隐藏”的二进制文件在内存中更难被检测。
目录
问题陈述
假设你想在特定的目标机器上运行一个恶意程序。一种方法是广泛分发该程序,并希望目标最终会运行它。具体的分发方式不在本文讨论范围之内,但你可以想象,例如一个预编译的二进制文件,就像开发者经常从他们喜爱的GitHub项目页面上下载的那样。
然而,如果你想最大化命中目标的几率,你可能希望模仿无害程序的行为,并避免任何可能触发各种解决方案(沙箱、LSM、auditd等)检测的可疑行为(例如连接到C&C服务器)。
到目前为止,这听起来相当简单,那么让我们看看如何构建它。
设计我们的“精神分裂”二进制文件
在本文的其余部分,我们将要运行在目标主机上的程序称为“隐藏”程序,将在其他主机上运行的无害程序称为“正常”程序。
构建此类程序的一种简单方法是尽早决定实际运行什么代码,例如:
|
|
就基本的运行时检测而言,这可以工作,但并不理想:
- 隐藏程序仍然会存在于内存中并可能被观察到
- 更糟糕的是,二进制文件可以被分析和反汇编,从而暴露“隐藏”程序
- 更糟糕的是,
is_running_on_target_host暴露了我们的目标是谁
如果我们想改进这一点呢?这里的基本问题是二进制文件暴露了我们想要隐藏的一切。那么,让我们隐藏这些数据并加密目标程序,甚至我们探测的主机数据,这应该能解决问题,对吧?当然,事情没那么简单,因为加密数据需要在运行时解密,所以密钥需要与加密数据一起嵌入二进制文件中,这只是在我们的解决方案之上增加了一层混淆。
然而,如果我们基于加密的想法,但不直接将密钥与加密的程序一起存储,而是从我们目标机器的唯一主机数据中派生出来,会怎样?
程序启动时的步骤将是:
- 从主机提取数据,这些数据能唯一标识目标(稍后详述)
- 使用先前的主机数据和嵌入二进制文件中的密钥,通过HKDF派生出一个新密钥
- 用派生出的密钥解密“隐藏”的加密嵌入式二进制数据
- 如果解密成功,运行解密后的“隐藏”程序;否则,运行“正常”程序
现在,这开始变得有趣了。这样的二进制文件在构造上,如果不在目标主机上运行,将无法解密“隐藏”程序,因为提取的主机数据会不同,从而导致解密密钥无效。
为此,我们将选择一种同样提供认证功能的对称分组加密算法,这样我们可以在不在目标上运行时检测到无效密钥,而不是运行一个垃圾程序。AES-GCM是实现此目的的常见算法选择。
选择派生信息
用于识别目标主机并如前所述派生密钥的数据需要仔细选择。
它需要满足:
- 足够独特,否则我们的“隐藏”程序可能会在错误的目标上运行
- 随时间稳定,否则我们的“隐藏”程序即使在正确的目标上也可能永远无法运行
- 对于无法访问目标机器的第三方来说难以猜测,这样不知道目标系统的人就无法提取“隐藏”程序
请注意,这里的“难以猜测”不同于经典秘密(如密码)。例如,你的主板序列号可能对我来说很难猜测,但它并不是一个真正的秘密,因为它可以很容易地从/sys/class/dmi/id/读取,或者可能在其包装上。
一些候选数据是:
- 用户UID:不够唯一,大多数工作站用户的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块时,我们可以即时将它们写入我们将要执行的文件描述符。这是一个很好的特性,然而,写操作是显而易见的,即使对于非特权用户也是如此。
如果我们以一个单行Python程序为例,它创建一个memfd并向其写入数据,我们可以用strace轻松看到写入的数据:
|
|
每个解密的AES块都可以以同样的方式被观察到,从而重建我们完整的“隐藏”二进制文件。当然,这需要在目标系统上进行分析,但如果我们可以避免这一点就更好了。
为了改进这一点,我们将使用不同的方式来将解密的“隐藏”程序ELF数据写入目标文件描述符,每种方式都有其优缺点:
- 使用io_uring:不发出写系统调用,因此例如
strace不会看到任何写入的数据,但是系统可能不支持或禁用了此功能 - 通过mmap映射内存段:同样没有可追踪的写入,但需要许多系统调用来映射/取消映射每个数据块(性能影响),因此整个解密的文件在给定时间点不会在内存中可见
- 回退到经典write:整个解密的文件数据仍然不会在进程内存中,但write调用很容易被追踪
请注意,在任何情况下,这都无法抵抗来自特权用户的更高级运行时分析。虽然内存中的文件描述符数据未映射在用户空间内存中,但可以从内核中访问和提取。
结果
完整的代码可以在 https://github.com/synacktiv/twoface 查看,包含一个示例的“无害”/“正常”二进制文件,另一个“隐藏”/“邪恶”二进制文件,twoface库,以及一个将它们整合在一起的测试示例:
|
|
运行test-example将会:
- 构建“无害”二进制文件
- 构建“邪恶”二进制文件
- 从
example/host.json加载分区UUID - 构建一个打包了“无害”和“邪恶”(加密的)ELF的示例二进制文件
- 运行它,以便你可以看到实际运行的是哪一个
结论
这个概念验证展示了我们如何利用Rust的构建时代码功能来创建先进且对开发者友好的机制,并实现我们的“双面”二进制文件。
然而,这只是可以做的事情的一小部分,为了更进一步,我们可以:
- 添加构建时混淆,例如隐藏我们从
/dev/disk/by-uuids读取分区UUID的行为 - 添加运行时反调试技术
- 使用已经在内存中的主机特定数据来派生密钥,例如通过哈希共享库页面
- 链接多个级别的加载器,每个使用不同的派生数据源
- 使用例如
userfaultfd动态解密ELF内存页面
……这些可能是另一篇文章的主题。