在Linux上构建双面Rust二进制

admin 2026-05-01 05:55:21 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文档介绍了一种利用Rust构建时特性创建双面二进制文件的技术,通过主机分区UUID派生密钥加密隐藏程序,确保仅在目标环境解密执行。关键发现包括使用AES-GCM加密、memfdcreate内存运行机制以及iouring等反检测技术,提供完整代码库和可扩展的HostDatatrait实现方案。 综合评分: 85 文章分类: 恶意软件,二进制安全,红队,内网渗透,安全工具


cover_image

在 Linux 上构建双面 Rust 二进制

Maxime Desbrus Maxime Desbrus

securitainment

2026年4月30日 12:54 中国香港

在小说阅读器读本章

去阅读

| 原文链接 | 作者 | | — | — | | https://www.synacktiv.com/en/publications/creating-a-two-face-rust-binary-on-linux | Maxime Desbrus |

问题陈述

假设需要在某台特定目标机器上运行恶意程序。一种思路是将程序大范围分发,期望目标最终会执行它。具体的分发途径不在本文讨论范围内,可以设想为预编译的二进制文件,开发者经常从常用的 GitHub 项目页面下载此类文件。

为最大化触达目标的概率,程序需要模仿无害程序的行为,避免任何可疑操作(例如连接到 C&C 服务器),这类操作会被各类检测方案(sandbox、LSM、auditd 等)识别。

目前为止这听起来相当简单,具体构建方式见下文。

设计 Two-Face 二进制文件

下文将在目标主机上运行的程序称为 “hidden”,将在其他主机上运行的无害程序称为 “normal”。

构建此类程序的朴素做法是在早期就决定实际运行哪段代码,即:

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

这种方式在基础运行时检测层面可行,但存在以下问题:

  • “hidden” 程序仍存在于内存中,可被观测
  • 更糟的是,二进制文件可被分析和反汇编,从而暴露 “hidden” 程序
  • 更严重的是,is_running_on_target_host会暴露目标对象身份

如何改进?根本问题在于二进制文件暴露了所有需要隐藏的内容。隐藏这些数据,并加密目标程序以及被探测的主机数据,看似可以解决问题。实际情况更复杂:加密数据需要在运行时解密,密钥必须与加密数据一并嵌入二进制文件中,仅在原方案之上增加了一层混淆。

可以在加密思路上继续推进:将密钥从目标机器的唯一主机数据中派生,替代直接与加密程序一并存储的做法。

程序启动时的步骤如下:

  1. 从主机提取可唯一标识目标的数据(详见后文)
  2. 使用 HKDF,将嵌入二进制文件的密钥与上述主机数据派生出新密钥
  3. 使用派生密钥解密嵌入二进制中的 “hidden” 加密数据
  4. 解密成功则运行解密后的 “hidden” 程序,否则运行 “normal” 程序

整体流程

该方案由设计本身保证:二进制文件在非目标主机上无法解密 “hidden” 程序,因为提取到的主机数据不同,派生出的解密密钥无效。

为此选择一种同时提供认证的对称分组加密算法,使得在非目标主机上运行时可检测到无效密钥,避免将解密产生的垃圾数据作为程序执行。AES-GCM 是常见的算法选择。

选择派生信息

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

需满足以下条件:

  • 足够独特,否则 “hidden” 程序可能在错误的目标上运行
  • 随时间稳定,否则 “hidden” 程序可能永远无法运行,即使在正确的目标上也是如此
  • 对无权访问目标机器的人难以猜测,使不了解目标系统的第三方无法提取 “hidden” 程序

此处”难以猜测”的含义有别于经典密钥(例如密码)。例如主板序列号对外人确实难以猜测,但它算不上真正的秘密,因为可以轻易从 /sys/class/dmi/id/读取,或从包装上读取。

候选项包括:

  • 用户 UID: 独特性不足,大多数工作站用户的值为 1000,且熵严重不足
  • WAN 接口 IPv6: 可能不稳定,且可能通过其他渠道被猜测
  • 来自 /sys/class/dmi/id/的硬件序列号:读取需要 root权限,并非所有设备都具备,熵也有限
  • 由 grep ^model /proc/cpuinfo显示的 CPU 型号:独特性可能不足,例如在虚拟机或公司笔记本机群中
  • 由 ls /dev/disk/by-uuid显示的磁盘分区 UUID: 该值在分区创建时随机生成,熵和独特性俱佳,满足全部需求

构建时代码

为了便于开发者使用,我们将所有逻辑集成到一个 twofaceRust crate 中。Rust 作为现代系统级语言,对构建时代码也有出色的支持。该库通过特性标志启用两个主要部分:构建时部分控制 “hidden” 二进制的加密,并生成嵌入数据供第二个运行时部分使用;运行时部分执行解密,并将执行分派至 “normal” 或 “hidden” 二进制。

将 “normal” 与 “hidden” 两个二进制打包为一个新的 “Two-Face” 二进制,包含所有加密与嵌入操作,全部可在一个 build.rs文件中完成,最终二进制代码只需:

build.rs:

use std::io;

fnmain() -> io::Result<()> {
&nbsp; &nbsp; twoface::build::build::<twoface::host::HostPartitionUuids>()
}

这里的 HostPartitionUuids是一个泛型类型,用于定制主机数据的提取方式,实现了 HostDatatrait。

/// System partition UUIDs, as shown in `ls /dev/disk/by-uuid | LANG=C sort`
#[derive(serde::Serialize, serde::Deserialize)]
pubstructHostPartitionUuids&nbsp;{
&nbsp; &nbsp; part_uuids:&nbsp;Vec<String>,
}

implHostDataforHostPartitionUuids&nbsp;{
fnfrom_host() -> io::Result<Self> {
letmut&nbsp;part_uuids:&nbsp;Vec<_>=&nbsp;fs::read_dir("/dev/disk/by-uuid")?
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .filter_map(Result::ok)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .filter_map(|e|&nbsp;e.file_name().into_string().ok())
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .collect();
&nbsp; &nbsp; &nbsp; &nbsp; part_uuids.sort_unstable();
Ok(Self&nbsp;{ part_uuids })
&nbsp; &nbsp; }
}

代码非常简短,定制或实现其他数据源都很容易。

随后可编写一个 JSON 文件,包含期望在目标主机上匹配的数据,例如:

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

最终代码在构建时还需要几个环境变量,用于传递两个二进制文件以及上述 JSON 的路径:

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

在构建时执行以下步骤:

  1. 读取 “normal” 可执行文件,并据此生成一个 const数组供运行时代码使用
  2. 读取 “hidden” 可执行文件并对其压缩
  3. 从 TWOFACE_HOST_INFO指向的文件读取主机数据
  4. 生成一个随机密钥,并据此生成一个 const数组供运行时代码使用
  5. 使用第 3 步的主机数据派生该密钥
  6. 使用派生出的密钥加密 “hidden” 可执行文件的压缩数据,并生成一个 const数组供运行时代码使用

接着在 main.rs(运行时代码)中,仅需 include 构建时生成的 .rs文件,并将生成的 const数组传递给 run函数,由其运行 “normal” 或 “hidden” 二进制:

use&nbsp;std::io;

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

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

从内存运行

细心的读者可能已经注意到,构建时以二进制 ELF 文件作为输入,运行时按原样启动,这在已执行的 ELF 中难以实现。一种可能方式是将待执行的程序写入文件系统,再对其调用 exec系统调用。对于 “hidden” 程序,这要求以容易被隔离或观察的形式写入解密后的二进制文件,恰恰是要规避的情形。其他可选方法包括使用 O_TMPFILE标志创建文件(该文件对其他进程不可见),或将目标 ELF 的所有页映射到内存(操作繁琐,且需要映射可执行页,可能触发运行时检测或加固问题)。

最终选择 memfd_create系统调用,它创建一个不由文件支持的文件描述符。目标二进制文件写入其中后,fexecve系统调用以新的进程映像替换当前进程映像,任务即告完成。

增加一层趣味

至此已有一套可行方案:构建时将两个二进制文件打包为一个,运行时读取主机数据以识别目标,并根据结果从内存中运行 “normal” 或 “hidden” 二进制文件。

此时,解密后的 “hidden” 二进制文件不会作为整体出现在进程内存中,因为 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) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; = 11
+++ exited with 0 +++

每个解密的 AES 块都可被同样观察到,从而重建出完整的 “hidden” 二进制文件。当然,这需要在目标系统上执行分析,但若能规避此情形则更佳。

为此,将采用不同方式将解密后的 “hidden” 程序 ELF 数据写入目标文件描述符,各方式各有优劣:

  • 使用 io_uring: 不发出 write系统调用,strace等工具看不到写入的数据,然而该机制在系统上可能不受支持或被禁用
  • 通过 mmap 映射内存段:同样无可追踪的写入,但需要大量系统调用来映射或取消映射每个数据块(影响性能),使完整的解密文件在任一时刻都不在内存中可见
  • 回退到传统 write: 完整的解密文件数据仍不出现在进程内存中,但 write调用容易被追踪

注意,无论采用哪种方式,这都无法抵御来自特权用户的高级运行时分析。内存中文件描述符的数据虽未映射到用户空间内存,但可从内核中访问并提取。

结果

完整代码位于 https://github.com/synacktiv/twoface,包含一个示例 “harmless”/”normal” 二进制、另一个 “hidden”/”evil” 二进制、twoface库,以及一个用于整体测试的示例:

test-example
harmless_binary
├── Cargo.toml
└── src
&nbsp; &nbsp; └──&nbsp;main.rs
evil_binary
├── Cargo.toml
└── src
&nbsp; &nbsp; └──&nbsp;main.rs
example
├── build.rs
├── Cargo.toml
├── host.json
└── src
&nbsp; &nbsp; └──&nbsp;main.rs
twoface
├── Cargo.toml
└── src
&nbsp; &nbsp; ├── build.rs
&nbsp; &nbsp; ├── crypto
&nbsp; &nbsp; │ &nbsp; ├── dec.rs
&nbsp; &nbsp; │ &nbsp; ├── enc.rs
&nbsp; &nbsp; │ &nbsp; └── mod.rs
&nbsp; &nbsp; ├── exe_writer
&nbsp; &nbsp; │ &nbsp; ├── io_uring.rs
&nbsp; &nbsp; │ &nbsp; ├── mmap.rs
&nbsp; &nbsp; │ &nbsp; └── mod.rs
&nbsp; &nbsp; ├── host.rs
&nbsp; &nbsp; ├── lib.rs
&nbsp; &nbsp; └── run.rs

执行 test-example会:

  • 构建 “harmless” 二进制
  • 构建 “evil” 二进制
  • 从 example/host.json加载分区 UUID
  • 构建一个 example二进制,将 “harmless” 与已加密的 “evil” ELF 一并打包
  • 运行该二进制,观察实际执行的是哪一个

结论

这个概念验证展示了如何利用 Rust 的构建时代码能力,构建出既高级又对开发者友好的机制,并实现 “Two-Face” 二进制。

这只是冰山一角,进一步可以:

  • 加入构建时混淆,例如隐藏从 /dev/disk/by-uuids读取分区 UUID 的行为
  • 加入运行时反调试技术
  • 利用已在内存中的主机特定数据派生密钥,例如对共享库页面进行哈希
  • 串联多层加载器,每层使用不同来源的派生数据
  • 动态解密 ELF 内存页,例如借助 userfaultfd

……这些可能成为后续文章的主题。


免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:securitainment Maxime Desbrus Maxime Desbrus《在 Linux 上构建双面 Rust 二进制》

评论:0   参与:  0