红队安全研究之构建”双面”Rust 二进制文件

问题描述

假设你想在一个特定目标机器上运行一个恶意程序。一种方法是把程序广泛分发,希望目标最终会运行它。具体的分发向量超出本文范围,但你可以想象例如开发者常在其喜欢的项目的 GitHub 页面上下载的预编译二进制文件。

然而如果你想最大化到达目标的机会,你可能会想模仿一个无害程序的行为,避免任何可疑的动作(例如连接到 C&C 服务器),以免触发各种检测方案(沙箱、LSM、auditd 等)。

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

“双重性”二进制

在本文其余部分,我们把想在目标主机上运行的程序称为“隐藏(hidden)”,把在其他机器上运行的无害程序称为“正常(normal)”。

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

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

这在基本运行时检测方面是可行的,但并不理想:

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

如果我们想改进该方法呢?根本问题在于二进制暴露了我们想要隐藏的一切。那就把那些数据隐藏起来并加密目标程序,甚至加密我们正在探测的主机数据,这样应该就解决了,对吧?当然事情没那么简单,因为那些加密数据需要在运行时解密,因此密钥需要和加密数据一起嵌入二进制文件,这只是为之前的方案增加了一层混淆。

但是如果我们在加密思路上改进一下,不是直接把密钥和加密程序一起存放,而是从我们要针对的机器的唯一主机数据派生出密钥呢?

程序启动时的步骤将是:

  1. 1. 从主机提取能唯一识别目标的数据(稍后详细说明);
  2. 2. 使用 HKDF 将嵌入二进制的某个值与上述主机数据派生出一个新密钥;
  3. 3. 使用派生密钥解密嵌入的“隐藏”加密二进制数据;
  4. 4. 如果解密成功,运行解密后的“隐藏”程序,否则运行“正常”程序。

    image

     

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

为此,我们会选择一种基于分组的对称加密算法并提供认证,以便在不在目标主机上运行时能够检测到密钥无效,而不是运行一段垃圾程序。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:实际上是在创建分区时生成的随机值,具有良好的熵和唯一性,满足我们所有需求!

构建time code

为了便于开发者使用,我们将把所有逻辑集成到一个名为 twoface 的 Rust crate 中。值得庆幸的是,Rust 对构建时代码有很好的支持,并且是一门现代的系统级语言。我们的库将有两部分,通过 feature flags 启用:一部分是构建时逻辑,负责对“隐藏”二进制进行加密并生成要嵌入的用于运行时的数据显示;另一部分是运行时逻辑,负责解密处理并根据结果调度执行“正常”或“隐藏”二进制。

将我们的“正常”和“隐藏”两个二进制打包成一个新的 “Two-Face” 可执行文件,包括所有加密和嵌入操作,可以在 build.rs 文件中完成,最终的二进制代码仅需要:

build.rs:

use std::io;

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

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

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

implHostDataforHostPartitionUuids {
    fnfrom_host() -> io::Result<Self> {
        letmut 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 文件,包含我们期望在目标主机上匹配的数据,例如:

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

最终代码在构建时还需要一些环境变量,以传递两个二进制以及上面提到的 JSON 路径:

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. 1. 加载“正常”可执行文件,并为运行时代码生成一个 const 数组;
  2. 2. 加载“隐藏”可执行文件,并对其进行压缩;
  3. 3. 从通过 TWOFACE_HOST_INFO 传入的文件加载主机数据;
  4. 4. 生成一个随机密钥,并为运行时代码生成一个 const 数组;
  5. 5. 使用第 3 步的主机数据派生密钥;
  6. 6. 使用派生密钥加密压缩后的“隐藏”可执行数据,并为运行时代码生成一个 const 数组。

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

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 系统调用(https://man7.org/linux/man-pages/man2/memfd_create.2.html),它本质上创建了一个不对应文件的文件描述符。一旦目标二进制被写入其中,fexecve 系统调用(https://man7.org/linux/man-pages/man3/fexecve.3.html)将用新映像替换当前进程映像,我们的工作就完成了。

再加一层花样

现在我们有了一个很好的解决方案:在构建时将两个二进制打包到一起,在运行时提取主机数据以识别目标,并根据结果从内存中运行“正常”或“隐藏”二进制。

此时解密后的“隐藏”二进制不会作为整体出现在进程内存中,因为当我们解密 AES 块时,可以将它们即时写入随后将要执行的文件描述符。这是一个很好的特性,但写入操作本身很容易被观察到,即使是非特权用户也能监视到。

举例来说,如果我们用一行 Python 程序创建一个 memfd 并写入它,用 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)             = 11
+++ exited with 0 +++

每个解密的 AES 块都可以用同样的方式被观察到,从而重构出完整的“隐藏”二进制。当然这需要在目标系统上运行分析,但如果能避免这一点那就更好了。

为改进这一点,我们将使用不同的方法把解密后的“隐藏” ELF 数据写入目标文件描述符,每种方法都有优缺点:

  • • 使用 io_uring:不会发出 write 系统调用,因此例如 strace 不会看到任何写入数据,然而系统可能不支持或已禁用它;
  • • 通过 mmap映射内存段:也不会有可追踪的写入,但需要多次映射/解除映射每个块(性能影响),因此在任意时刻整个解密后的文件不会全部出现在内存中;
  • • 回退到经典的 write:完整的解密文件数据仍不会出现在进程空间内存中,但 write 调用可以被轻松追踪。

注意,在任一情况下,这都无法抵抗特权用户进行的一些更高级的运行时分析。尽管内存中文件描述符的数据未映射到用户空间内存,但内核中仍可以访问并提取这些数据。

结果

完整代码见 https://github.com/synacktiv/twoface,仓库包含一个示例的“无害”/“正常”二进制、另一个“隐藏”/“恶意”二进制、twoface 库,以及一个用于整体测试的示例:

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 将会:
    • • 构建“无害”二进制;
    • • 构建“恶意”二进制;
    • • 从 example/host.json 加载分区 UUID;
    • • 构建一个 example 二进制,将“无害”和“恶意”(已加密)的 ELF 打包在一起;
    • • 运行它,以便你能看到实际上运行的是哪一个。

结论

该概念验证展示了我们如何利用 Rust 的构建时代码能力来创建高级且对开发者友好的机制,并实现我们的 “Two-Face” 二进制。

不过这只是可以做的很小一部分;若要进一步推进,我们可以:

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

 

声明:⽂中所涉及的技术、思路和⼯具仅供以安全为⽬的的学习交流使⽤,任何⼈不得将其⽤于⾮法⽤途以及盈利等⽬的,否则后果⾃⾏承担。所有渗透都需获取授权

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容