问题描述
假设你想在一个特定目标机器上运行一个恶意程序。一种方法是把程序广泛分发,希望目标最终会运行它。具体的分发向量超出本文范围,但你可以想象例如开发者常在其喜欢的项目的 GitHub 页面上下载的预编译二进制文件。
然而如果你想最大化到达目标的机会,你可能会想模仿一个无害程序的行为,避免任何可疑的动作(例如连接到 C&C 服务器),以免触发各种检测方案(沙箱、LSM、auditd 等)。
到目前为止,这听起来相当简单,我们来看看如何构建它。
“双重性”二进制
在本文其余部分,我们把想在目标主机上运行的程序称为“隐藏(hidden)”,把在其他机器上运行的无害程序称为“正常(normal)”。
构建此类程序的一种简单方法是在早期决定实际要运行的代码,例如:
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:实际上是在创建分区时生成的随机值,具有良好的熵和唯一性,满足我们所有需求!
构建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. 加载“正常”可执行文件,并为运行时代码生成一个 const数组; -
2. 加载“隐藏”可执行文件,并对其进行压缩; -
3. 从通过 TWOFACE_HOST_INFO传入的文件加载主机数据; -
4. 生成一个随机密钥,并为运行时代码生成一个 const数组; -
5. 使用第 3 步的主机数据派生密钥; -
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 内存页进行解密
声明:⽂中所涉及的技术、思路和⼯具仅供以安全为⽬的的学习交流使⽤,任何⼈不得将其⽤于⾮法⽤途以及盈利等⽬的,否则后果⾃⾏承担。所有渗透都需获取授权!
Crayon个人寒舍 本网站所有内容,包括但不限于文字、图片、音频、视频、软件、程序、以及网页版式设计等。
本网站部分内容转载自互联网,转载目的在于传递更多信息,并不代表本网站赞同其观点和对其真实性负责。如有侵权行为,请联系我们,我们将及时处理。
对于用户通过本网站上传、发布或传送的任何内容,用户应保证其为著作权人或已取得合法授权,并且该内容不会侵犯任何第三方的合法权益。如果第三方提出关于著作权的异议,本网站有权删除相关的内容并保留追究用户法律责任的权利。






暂无评论内容