Malware development on Rust: stealth agent for red team, bypass EDR and comparison with C++

Depov

Activist
ULTIMATE
SUPREME
PREMIUM
MEMBER
Joined
Feb 18, 2025
Messages
126
Reaction score
115
Deposit
0$
A functionally identical plaid loader weighs 71.7 KB on C and 151.5 KB on Rust - the binary doubled. At the same time, according to a study by the Rochester Institute of Technology (2023, according to the Bishop Fox blog), automated means of analysis give much more false negatives on Rust-binaries, and Ghidra and IDA Free will decompile them in frank porridge compared to analogues in C/C++. For the past two years, I have been rewriting loaders and C2-agents on Rust and see this picture in every project: the size is growing, but the detect falls. Not the magic of the language - the specific properties of the compiler and rantime break the usual patterns of static analysis. Below is a full pipelline analysis: from the OPSEC configuration of the project to specific restrictions against production-EDR.
The place in the chain of attack: why red team builds custom agents on Rust
Kastomy malware agent is not an end in itself, but a link in the chain: initial access -> loader -> implant -> post-exploitation -> exfiltration. In terms of MITRE AT&CK, the development of an agent is Malware (T1587.001, Resource Development): preparation of the toolkit before the start of the operation. Agent when performing pulls Native API (T1106Execution) to call Windows API, and in advanced scenarios - Process Hollowing (T1055.012, Defense Evasion / Privilege Escalation) for injection into legitimate processes.
Why not take a ready-made framework? Sliver, Havoc, Mythic are working solutions, but for each of them there are public signatures. CrowdStrike Falcon and SentinelOne update the detects to public C2 within days of release. On the internal pentest against the mature infrastructure with EDR coverage, a public agent is an alter to SOC at the initial access stage.

The Kastomic agent solves a specific problem: his binary does not coincide with any known signature, and the behavioral profile can be adjusted to the target environment. Rust here gives three advantages over C/C++, and they are confirmed by data:

The first is the absence of binding to MSVC CRT. Rust statically links the addictions through LLVM, the import tables characteristic of C/C++ patterns disappear (msvcrt.dll, ucrtbase.dll) The trivial classification of binary by import is becoming more complicated.

The second is the complexity of decompilation. Ownership model, pattern matching, monadic processing Result<T, E> generate code that Ghidra turns into an unreadable mess. Analytics in SOC will take noticeably more time on triage. According to analysts of PT Expert Security Center (Habr, 2025), the complexity of the reverse of the Rust binary is due to aggressive inline, the system of ownership and abstractions (traits, macros, changing).
Inline is an optimization in which a compiler replaces the call of the function directly with its body.

The third is smaller overlap with well-known families. The vast majority of malware is still written in C/C++. The Rust binary does not get into the same clusters during machine learning, which reduces the signature detect.

Context of application: internal pentest or red team engagement against infrastructure with EDR (CrowdStrike Falcon, SentinelOne, Elastic 8.x+). On an external pentest without EDR control overengineering with a custom Rust agent - shooting from a gun on sparrows.
Rust vs C++ for malware: trade-off table
Before writing the code is a sober assessment. Rust offensive security is not a silver bullet, and in a number of C++ scenarios remains the preferred choice.
1780598817096.png

When Rust is better: custom C2 agent from scratch, loader of the first stage (stager), tools for initial access - everything that is critically minimizes a static detect and complicate triage. Applicable: internal pentest, infrastructure with EDR-coating.

When C++ is better: sleep obfuscation level Ekko/Foliage, working with ROP-chains, integration with existing C2 framework on C (Cobalt Strike BOF). In these scenarios, Rust creates additional friction without winning evasion.

When language doesn’t matter: if EDR uses kernel-level telemetry (ETW-TI in Elastic 8.x+, kernel callbacks at SentinelOne) - a behavioral detective is the same for any language. At least write on the assembly.
OPSEC configuration: from toolchain to pure binary
Adjustments to the environment
• OS: Windows 10/11 or GNU/Linux (cross-compilation through x86_64-pc-windows-gnu)
• Rust: nightly channel (required for flags -Z) Installation: rustup default nightly
• Target: rustup target add x86_64-pc-windows-msvc(or -gnu)
• RAM: at least 4 GB (Rust-compiler with LTO eats significantly more than gcc/clang), recommended 8 GB
• Dependence: the key windowsfor WinAPI, optionally ntapifor direct syscals
• Working hours: offline-assembly is possible after the first cargo buildwith cached registry
Binary lines: encryption and bypass FLOSS
After cleaning the panic strings remain user: URL C2 servers, paths, HTTP headers. Mandiant FLARE-FLOSS pulls them out in seconds. Solution - Encryption of lines at the compilation stage. Craith str_crypter encrypts the line in compile-time and decrypts when calling: let url = sc!("https://c2.example.com", 20).unwrap() - in the binary of the line will be XOR-encrypted with the key 20. According to the author of the key, the result is resistant to FLARE-FLOSS.

A separate headache is serialization. Craith serde, standard for JSON in Rust, records the names of the structures in the section .rdata. If the C2 protocol is described as struct Task { command: String }, line command will remain in the binary in what the mother gave birth. Alternative - C-style enums with manual degeneration through transmute:
Code:

#[repr(u32)]
pub enum Command {
Sleep = 1u32,
Shell,
Upload,
Undefined, // catch-all category for invalid codes
}
impl Command {
pub fn from_u32(id: u32) -> Self {
// SAFETY: u32 is guaranteed by the function’s signature
unsafe { std::mem::transmute(id) }
}
}
C2 sends u32- command code, agent converts through transmute - not a single line of literature in a release-binary. For debugging: #[cfg(debug_assertions)] impl Display for Command { ... } - Display-sexing is compiled only in debug-assembly. Attempt to call println!("{}", command) in revo without this attribute will lead to a compilation error. The compiler itself becomes insurance from OPSEC-bags - and this is what I consider one of the most underappreable properties of Rust in the offensive development.
Rust shellcode loader: a minimum example with analysis
The basic sheller loading pattern in memory does not depend on the language: highlight RW memory, copy payload, change the rights to RWX, transfer control. On Rust through the key windows (example adapted from Bishop Fox, 2025):
Code:
unsafe {
let addr = VirtualAlloc(Some(ptr::null_mut()), buf.len(),
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
ptr::copy(buf.as_ptr(), addr as *mut u8, buf.len());
VirtualProtect(addr, buf.len(), PAGE_EXECUTE_READWRITE,
&mut PAGE_PROTECTION_FLAGS(0));
let h = CreateThread(Some(ptr::null()), 0,
Some(std::mem::transmute(addr)),
Some(ptr::null()), THREAD_CREATION_FLAGS(0), Some(ptr::null_mut()));
WaitForSingleObject(h.unwrap(), INFINITE);
}
What the EDR sees at every step:
1. VirtualAllocc PAGE_READWRITE- allocation of RW-memory. Nothing suspicious: apps regularly ostrotheted memory.
2. ptr::copy- record shallcode in your own process. For the user-mode, the hooks is invisible: there is no inter-process interaction.
3. VirtualProtect -> PAGE_EXECUTE_READWRITE- this is where the most interesting thing begins. CrowdStrike Falcon intercepts NtProtectVirtualMemorythrough the user-mode and marks the region. SentinelOne additionally monitors through kernel callbacks.
4. CreateThreadwith address in allocated memory - the second trigger. EDR correlates NtCreateThreadExwith an address that does not belong to the loaded module.
The size of the compiled binary with a configuration from the previous section: ~155 KB. C-equivalent - ~ 72 KB. According to Bishop Fox, after decompilation in the Ghidra main function, the Rust-loader feature contains dozens of variables and challenges rust_dealloc / unwrap_failed, whereas the C-version is decompiled into almost readable code.

Predictions: The technique works without modifications on Windows 10/11 without EDR. With active CrowdStrike Falcon (≥6.x) or SentinelOne binary will be detected in the behavioral ligament of VirtualProtect + CreateTread. Bypass, indirect syscals or unhooking threadll.
Where the Rust agent will light up: restrictions on vendors EDR
Rust does not protect against behavioral detect. The language changes the static of the binary, but not the sequence of API-chickers. Let’s look at specific products – because the generalization “works against EDR” without vendor-specific is, to put it mildly, self-deception.

CrowdStrike Falcon (user-mode hooks + kernel callbacks). Falcon intercepts functions ntdll.dll through inline-hot. Chapel VirtualAlloc -> VirtualProtect(RWX) -> CreateThread Triggerite practical methods regardless of language. Bypass: straight or indirect syscals through the key ntapi, bypassed hunchback functions. On Rust implementation of direct syscals via macro asm! less trivial than through SysWhispers3 to C, but functionally equivalent.

SentinelOne (combined approach). In addition to the user-mode, hooks uses kernel-level monitoring through minifilter-drivers. Direct syscals bypass the user-mode layer, but kernel callbacks NtCreateThreadEx and NtMapViewOfSection remain. Rust vs C++ here - no matter.

Elastic 8.x+ (kernel ETW-TI). Elastic Endpoint Relies on Event Tracing for Windows - Threat Intelligence provider at the core level. Even straight syscals are detectable because telemetry comes from the nucleus, not from the huckled DLL. Against ETW-TI, neither Rust nor C++ give the advantages - you need Disable or Modify Tools (T1685): ETW provider patch or BYOVD.

Kaspersky EDR Expert. Your own kernel driver with callbacks for basic operations. Detects shellcode injection at the kernel level. Kaspersky’s Static analysis of Rust binaries is less mature than for C/C++, but the behavioral module is equally effective.

General pattern: Rust reduces the detects at the stage of static analysis (Masquerading, T1036 - binary is not similar to famous families) and complicates the manual reverse (Debugger Evasion, T1622 - analytics need more time to find out). But at the stage of behavioral analysis - runtime-monitoring - the language does not play a role: EDR sees APIs, not the source code.
1780598780333.png

Around Rust in the offensive community there was a kind of cult: we transplant loaders from C to Rust and wait for the kit to disappear. In practice, I see the opposite – teams spend weeks fighting a borrow checker instead of spending this time studying detect mechanics specific EDR in the target infrastructure.

Rust gives a real win exactly in two points: a decrease in signature coincidence and a complication of triage. The rest is direct syscals, sleep obfuscation, ETW patching - is sold in any language with inline assembly. If the agent is detected by behavior, rewriting on Rust will not change anything: VirtualProtect with RWX-right triggerite the same altrate to C, on Rust and on assembly.

The real value of Rust is the ability to write an agent with pure static in a reasonable time without drowning in buffer overflow of its own code. Memory safety is not about protecting the victim, it's about the stability of your implant for the third week of surgery. The agent, who crashes due to use-after-free at four in the morning, costs more than a couple of days for the lifetime development of annotations.

In the meantime, the community is arguing about language selection, EDR vendors are building up kernel-level telemetry. And no compiler can save it. If you want to work out this bundle with your hands - WAPT disassemble the chain from loader to bypass the EDR in several modules with labs.
 
Top Bottom