在Linux上创建“双面”Rust二进制文件

本文详细介绍了一种在Linux系统上创建“双面”Rust二进制文件的技术。该技术通过主机唯一数据(如分区UUID)派生密钥,解密并运行隐藏的有效载荷,否则执行无害的正常程序,旨在实现针对性部署或软件许可保护。

在Linux上创建“双面”Rust二进制文件

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

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

目录

问题陈述

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

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

到目前为止,这听起来相当简单,那么让我们看看如何构建它。

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

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

构建此类程序的一种简单方法是尽早决定实际运行什么代码,例如:

1
2
3
4
5
if is_running_on_target_host() {
    hidden_program();
} else {
    normal_program();
}

就基本的运行时检测而言,这可以工作,但并不理想:

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

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

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

程序启动时的步骤将是:

  1. 从主机提取数据,这些数据能唯一标识目标(稍后详述)
  2. 使用先前的主机数据和嵌入二进制文件中的密钥,通过HKDF派生出一个新密钥
  3. 用派生出的密钥解密“隐藏”的加密嵌入式二进制数据
  4. 如果解密成功,运行解密后的“隐藏”程序;否则,运行“正常”程序

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

为此,我们将选择一种同样提供认证功能的对称分组加密算法,这样我们可以在不在目标上运行时检测到无效密钥,而不是运行一个垃圾程序。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:

1
2
3
4
5
use std::io;

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/// 系统分区UUID,如 `ls /dev/disk/by-uuid | LANG=C sort` 所示
#[derive(serde::Serialize, serde::Deserialize)]
pub struct HostPartitionUuids {
    part_uuids: Vec<String>,
}

impl HostData for HostPartitionUuids {
    fn from_host() -> io::Result<Self> {
        let mut part_uuids: Vec<_> = fs::read_dir("/dev/disk/by-uuid")?
            .filter_map(Result::ok)
            .filter_map(|e| e.file_name().into_string().ok())
            .collect();
        part_uuids.sort_unstable();
        Ok(Self { part_uuids })
    }
}

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

然后,我们可以编写一个JSON文件,包含我们希望目标主机匹配的数据,例如:

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

最终的代码还需要几个环境变量来构建,以传递两个二进制文件和之前的JSON路径:

1
2
3
4
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

在构建期间,这将:

  1. 加载“正常”可执行文件,并从中生成一个常量数组以供运行时代码使用
  2. 加载“隐藏”可执行文件,并压缩它
  3. 从通过TWOFACE_HOST_INFO传递的文件中加载主机数据
  4. 生成一个随机密钥,并从中生成一个常量数组以供运行时代码使用
  5. 用步骤3中的主机数据派生密钥
  6. 用派生密钥加密“隐藏”可执行文件的压缩数据,并生成一个常量数组以供运行时代码使用

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

main.rs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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块时,我们可以即时将它们写入我们将要执行的文件描述符。这是一个很好的特性,然而,写操作是显而易见的,即使对于非特权用户也是如此。

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

1
2
3
$ 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:不发出写系统调用,因此例如strace不会看到任何写入的数据,但是系统可能不支持或禁用了此功能
  • 通过mmap映射内存段:同样没有可追踪的写入,但需要许多系统调用来映射/取消映射每个数据块(性能影响),因此整个解密的文件在给定时间点不会在内存中可见
  • 回退到经典write:整个解密的文件数据仍然不会在进程内存中,但write调用很容易被追踪

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

结果

完整的代码可以在 https://github.com/synacktiv/twoface 查看,包含一个示例的“无害”/“正常”二进制文件,另一个“隐藏”/“邪恶”二进制文件,twoface库,以及一个将它们整合在一起的测试示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
test-example
harmless_binary
├── Cargo.toml
└── src
    └── main.rs
evil_binary
├── Cargo.toml
└── src
    └── main.rs
example
├── build.rs
├── Cargo.toml
├── host.json
└── src
    └── main.rs
twoface
├── Cargo.toml
└── src
    ├── build.rs
    ├── crypto
    │   ├── dec.rs
    │   ├── enc.rs
    │   └── mod.rs
    ├── exe_writer
    │   ├── io_uring.rs
    │   ├── mmap.rs
    │   └── mod.rs
    ├── host.rs
    ├── lib.rs
    └── run.rs

运行test-example将会:

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

结论

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

然而,这只是可以做的事情的一小部分,为了更进一步,我们可以:

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

……这些可能是另一篇文章的主题。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计