Rust OPSEC for Malware Development

How to be sneaky


OPSEC

OPSEC, or Operational Security, is a term that can broadly be described as staying hidden, unexposed, or untraceable to such a degree it would identify you, or some artifact of you. It isn’t about being invisible - that is impossible.

When we talk about OPSEC, most people associate it with things like “use Tor”, “use a VPN”, “don’t use social media”, etc. And sure, that would relate to your personal operational security - but we are talking about malware here.

So, what is malware OPSEC? Here, I am only going to talk about the ability to reduce the static finger-printable attack surface. This comes with other side benefits, such as making actor attribution harder and reduced opportunity for detection.

This isn’t a long form post about OPSEC, build processes etc, I just wanted to highlight some experience I have had with building malware in Rust, and particularly, some techniques to avoid leaving stupid strings in your binaries :).

To demonstrate the principals below, we will use a really simple example. I may add to this in time, but for now, I just wanted to focus on this small slice :).

String encryption

First and foremost, any string you have in your binary, if un-obfuscated, is really trivial to carve out. One good example of this would be a C2 address. Leaving this in your binary means: 1) The blue team will have an easier time finding your C2 if the binary is found; 2) Any EDR / sandbox which detects the string may perform some API analysis on it (VirusTotal, AlienVault, proprietary lists etc) - which may result in the binary being blocked.

One simple way to encrypt your strings, with a really easy interface, is via my Str Crypter macro! To use it, it is as simple as:

use str_crypter::{decrypt_string, sc};

fn main() {
    let encrypted_str: String = match sc!("Hello world!", 20) {
        Ok(s) => s,
        Err(e) => panic!("Decryption failed: {:?}", e),
    };

    println!("Decrypted string: {}", encrypted_str);
}

And this is fully Flare-FLOSS resistant :).

Avoid Serde

Serde is a great resource for serialising and deserialising data, useful mostly in C2 comms. However, this is problematic! Serde will encode your types into the binary. I haven’t found a complete way around this, you can rename fields but not the types.

Instead, write your own serialisation processes - for example, a simple way of sending a command from a C2 to your agent, rather than using a Command enum that gets serialised into Json via Serde, you can do something like this:

/// Commands supported by the implant and C2.
/// 
/// To convert an integer `u32` to a [`Command`], use [`Command::from_u32`].
/// 
/// # Safety
/// We are using 'C' style enums to avoid needing serde to ser/deser types through the network.
/// When interpreting a command integer, it **MUST** in all cases, be interpreted by [std::mem::transmute]
/// as a `u32`, otherwise you risk UB.
#[repr(u32)]
pub enum Command {
    Sleep = 1u32,
    ListProcesses,
    // .. etc
    Undefined,
}

impl Command {
    pub fn from_u32(id: u32) -> Self {
        // SAFETY: We have type safe signature ensuring that the input type is a u32 for the conversion
        unsafe {
            transmute(id)
        }
    }
}

In this snippet, we can send a command code from the C2, and convert it via transmute into a Command at runtime, assuming that the C2 sends the task as a code (u32).

Note at the end of the enum we have Undefined; if the u32 received is > than the max number of elements (indexed at 1), then the conversion will stop at the last enum, we can explicitly handle this with our own discriminant.

For more complex types, where you may want to use actual serialisation and deserialisation, you are best off writing your own.

Derive Debug

Okay - but what about printing the Command enum? Ordinarily you might want to whack a:

#[derive(Debug)]

On there and print a value you received, however, as you can see - doing so writes the enum into the strings of the binary (yes, this will put the strings in the binary, but keep reading for the solution to that!):

Rust Derive Debug strings in binary

How can we resolve this? Well, we can firstly remove #[derive(Debug)] from the enum, and implement Display ourselves:

impl Display for Command {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let choice = match self {
            Command::Sleep => "Sleep",
            Command::ListProcesses => "ListProcesses",
            Command::Undefined => "Undefined -> You received an invalid code.",
        };

        write!(f, "{}", choice)
    }
}

Conditional compilation

Finally, to take this one step further, we can add conditional compilation to ensure we do not include the above strings in the binary in release mode (the only mode we should be deploying agents in on real Red Team engagements), we can use the #[cfg(debug_assertions)] attribute.

Anything within a #[cfg(debug_assertions)] will not be compiled in Release mode. Meaning, we can do the following to ensure we can only print the struct data in Debug mode:

#[cfg(debug_assertions)]
impl Display for Command {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let choice = match self {
            Command::Sleep => "Sleep",
            Command::ListProcesses => "ListProcesses",
            Command::Undefined => "Undefined -> You received an invalid code.",
        };

        write!(f, "{}", choice)
    }
}

And then from your code using the enum:

let command = Command::from_u32(command);

// the println will not be compiled in release mode
#[cfg(debug_assertions)]
println!("Received command from C2: {}", command);

// Other code down here will be executed in release mode
// ..

What is nice about this design, if you forget to add #[cfg(debug_assertions)]) when printing:

println!("Received command from C2: {}", command);

Cargo / rustc will prevent you from compiling your binary as we marked this as needing debug assertions to compile:

#[cfg(debug_assertions)]
impl Display for Command {
    // ..
}

Removing strings via nightly flags

Finally for this post, if we want to ensure that rustc doesn’t compile in our file path names (including the file name itself!).

To use these features, you must also build with the nightly channel.

You will NOT want this on builds for your C2 infrastructure, especially the panic changes, you will want those to be as verbose as possible, this is strictly speaking for implants / agents that will be on disk.

Before:

Rust Derive Debug strings in binary

After:

Rust Derive Debug strings in binary

.cargo/config

[build]
rustflags = [
    "-Z", "location-detail=none",
    "-Z", "fmt-debug=none",
    "-C", "panic=abort",
]

[unstable]
build-std = ["std", "panic_abort"]
build-std-features = ["panic_immediate_abort"]

cargo.toml

[profile.release]
opt-level = "z" # size optimisations
codegen-units = 1 # size optimisations
lto = true # size optimisations
strip = true
panic = "abort"

rust-toolchain.toml

[toolchain]
channel = "nightly"

Nice!