创建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:不够唯一,大多数工作站用户的值是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块时,我们可以将它们即时写入我们将要执行的文件描述符。这是一个很好的特性,然而,写入操作是很容易观察到的,即使对于非特权用户也是如此。

如果我们以创建一个memfd并写入数据的一行Python程序为例,我们可以用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:不会发出write系统调用,因此例如strace将看不到任何写入的数据,但系统可能不支持或禁用它
  • 通过mmap内存段:也没有write可追踪,但需要许多系统调用来映射/取消映射每个块(影响性能),因此在给定时间点,整个解密文件在内存中不可见
  • 回退到经典的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 设计