Locating Modules Via The PEB (Process Environment Block)

Index of content

Introduction

In order to find information about a specific module or DLL (such as its base address), code may call functions like GetModuleHandle() or NtQuerySystemInformation(). However, this is not always desirable, or even possible. Many malicious samples wish to conceal behavior or reduce detectability by avoiding calls to these functions, which may be monitored. In the case of shellcode, the code may not know the system memory layout ahead of time, therefore has no idea where any API functions are location in memory.

A very common technique utilized by malicious code to resolve module addresses is via the Process Environment Block. Within the PEB is the PEB_LDR_DATA structure, which has a list of every module loaded into the process (including the process itself). This is especially useful for shellcode, which needs to locate modules before it can make calls to them.

Process Environment Block (PEB) Basics

The Process Environment Block is a memory structure loaded into every process. Inside the PEB is various information about the current process and system. What makes the PEB extremely useful is the fact its address can be obtained purely by reading a static memory offset. This means that shellcode and malware can obtain the PEB address without knowing anything about the system memory layout.

Within a 64-bit Windows process (x64) the TEB (Thread Environment Block) is pointed to by the GS segment. The TEB holds the address of the PEB at offset 0x60. Thus, the PEB address can be obtained by simply reading the 64-bit value stored at GS:0x60.

In Assembly code this can be done with a single instruction:

Retrieving The PEB Address

mov rax, gs:[0x60]    ; rax = address of PEB

In C, however, the method of obtaining the PEB can vary based on compiler. Visual Studio doesn’t allow inline Assembly when compiling for 64-bit, but does provide some alternative macros. GCC, on the other hand, requires the use of inline Assembly.

// Get PEB address via Visual Studio's macro
#include <Windows.h>

DWORD64 peb_address = __readgsqword(0x60);
// Get PEB address via GCC inline assembly
#include <stdint.h>

uint64_t peb_address;
__asm__ __volatile__(
    "movq %%gs:0x60, %0"
    : "=r" (peb_address)
);

With some SDKs, pseudo-functions such as NtCurrentTeb() or NtCurrentPeb() may be defined. In this case, a call to NtCurrentPeb() or NtCurrentTeb()->ProcessEnvironmentBlock can be used instead. These pseudo-functions will be translated directly to Assembly, rather than actual function calls.

Defining the PEB structure

While the entire PEB structure is extremely large, we’re only interested in the first few entries. For our purpose, we can omit everything after the Ldr field.

typedef struct _PEB
{
    UCHAR InheritedAddressSpace;            // +0x00
    UCHAR ReadImageFileExecOptions;         // +0x01
    UCHAR BeingDebugged;                    // +0x02
    UCHAR BitField;                         // +0x03
    ULONG ImageUsesLargePages : 1;          // +0x03
    ULONG IsProtectedProcess : 1;           // +0x03
    ULONG IsLegacyProcess : 1;              // +0x03
    ULONG IsImageDynamicallyRelocated : 1;  // +0x03
    ULONG SpareBits : 4;                    // +0x03
    PVOID Mutant;                           // +0x08
    PVOID ImageBaseAddress;                 // +0x10
    PVOID Ldr;                              // +0x18 (This is what we're interested in)

    // Snipped (we don't need the rest of the PEB defined)

} PEB, *PPEB;

Loader Data Table (PEB_LDR_DATA) Basics

The PEB Loader Data table or PEB_LDR_DATA is a small structure which defines three separate module lists. These are InLoadOrderModuleList, InMemoryOrderModuleList and InInitializationOrderModuleList.

Each module list is a doubly linked list (LIST_ENTRY) of LDR_DATA_TABLE_ENTRY structures.

Defining the PEB_LDR_DATA structure

typedef struct _PEB_LDR_DATA
{
    ULONG Length;                                // +0x00
    UCHAR Initialized;                           // +0x04
    PVOID SsHandle;                              // +0x08
    LIST_ENTRY InLoadOrderModuleList;            // +0x10
    LIST_ENTRY InMemoryOrderModuleList;          // +0x20
    LIST_ENTRY InInitializationOrderModuleList;  // +0x30
} PEB_LDR_DATA, *PPEB_LDR_DATA;

Different Module List Orders

The three module lists, although similar, are in slightly different orders. When using code which iterates through the module list by name, it’s not really required to understand the difference between these lists. However, some malicious code such as shellcode may omit code for validating module names due to size constraints or other limitations. In this case, the code has to rely on assuming the module order, which requires understanding the specific list used.

InLoadOrderModuleList

The module list sorted by the order in which modules were loaded.

InMemoryOrderModuleList

This list is often claimed to sort modules by module base address, though I’ve not found any system from Windows XP to Windows 11 where this is the case. In reality, the order appears to be identical to InLoadOrderModuleList, and even maintained by the same function.

InInitializationOrderModuleList

The module list sorted by the order in which modules were initialized. This differs from the previous two based on the fact that modules can have dependencies. If a.dll requires b.dll, when the application loads a.dll, the loader will automatically load it’s dependency (b.dll). Since the dependency would be loaded after a.dll begins loading, but before a.dll finishes loading (is initialized), the load order would list a.dll before b.dll, but the initialization order would be b.dll before a.dll, because a.dll requires b.dll to be initialized before a.dll can be.

Assuming Module Orders

Typically, malware tends to make use of either InLoadOrderModuleList or InMemoryOrderModuleList.

In almost every native process these lists will be ordered as so:

  1. application.exe (application name)
  2. ntdll.dll
  3. kernel32.dll
  4. kernelbase.dll

However, non-native processes may load other dlls prior to kernel32. For example, .NET processes load mscoree.dll after ntdll.dll, in which case the order goes as follows:

  1. application.exe (application name)
  2. ntdll.dll
  3. mscoree.dll
  4. kernel32.dll
  5. kernelbase.dll

Since the order of the first three modules is predictable, code need not check the module name. It’s common to see malicious code just grab the first, second, or third entry without validating the names.

EDR Considerations

Some security products may abuse the fact that many shellcode examples assume module order. Typically, most shellcode wants to find ntdll and kernel32, which are the 2nd and 3rd modules in the list. Since legitimate method of resolving modules do it via name or hash, they are not affected by the order of the PEB_LDR_DATA lists.

By replacing the first few module entries with fake modules, EDRs can detect when shellcode is being executed. Instead of having module 2 and 3 be ntdll and kernel32, they can swap them with entries for honeypot modules. The behavior of these honeypot modules can be as simple as just triggering and EDR detection when accessed.

Shellcode relying on module entry 2 or 3 will result in it trigger an EDR detection; meanwhile, any legitimate code looking for ntdll or kernel32 by name or hash will skip those entries entirely, as the name or hash does not match that of the module it’s looking for.

Getting The PEB_LDR_DATA Table Address

To get the address of the Loader Data Table, all we need to do is read the Ldr offset from the PEB structure.

C example:

#include <Windows.h>
#include <Stdio.h>

/* Insert PEB and PEB_LDR_DATA definition here */

int main()
{
    PEB* peb = (PEB*)__readgsqword(0x60);
    PEB_LDR_DATA* ldr = (PEB_LDR_DATA*)peb->Ldr;

    printf("PEB address: 0x%p\n", peb);
    printf("Ldr address: 0x%p\n", ldr);
}

Equivalent assembly code:

mov     rax, gs:60h     ; rax = address of PEB
mov     rbx, [rax+18h]  ; rbx = address of Ldr (PEB_LDR_DATA)

Loader Data Table Entry (LDR_DATA_TABLE_ENTRY) Basics

The LDR_DATA_TABLE_ENTRY is the actual structure which describes each module in any of the modules lists. It contains the following fields:

  • InLoadOrderLinks - same as InLoadOrderModuleList of PEB_LDR_DATA
  • InMemoryOrderLinks - same as InMemoryOrderModuleList of PEB_LDR_DATA
  • InInitializationOrderLinks - same as InInitializationOrderModuleList of PEB_LDR_DATA
  • DllBase - the address the module is loaded at in memory
  • EntryPoint - the address of the module’s entrypoint
  • SizeOfImage - the size of the loaded module in memory
  • FullDllName - the full path to the module file on disk (ex: C:\Windows\System32\ntdll.dll)
  • BaseDllName - just the file name portion of the module (i.e. ntdll.dll)
  • Flags - a bit array describing various attributes about the module used by the loader
  • LoadCount - a reference counter which prevents the module from being unloaded while still in use
  • HashTable - pointer to the ntdll!LdrpHashTable entry for the current module (used for fast module lookup)
  • TimeDateStamp - the timestamp embedded in the PE file’s header (typically it’s compile date)

While the LDR_DATA_TABLE_ENTRY structure does have other fields, they change from Windows version to version.

Defining the LDR_DATA_TABLE_ENTRY structure

typedef struct _UNICODE_STRING {
    USHORT Length;                             // +0x00
    USHORT MaximumLength;                      // +0x02
    PWSTR  Buffer;                             // +0x08
} UNICODE_STRING, *PUNICODE_STRING;

typedef struct _LDR_DATA_TABLE_ENTRY
{
    LIST_ENTRY InLoadOrderLinks;               // +0x00
    LIST_ENTRY InMemoryOrderLinks;             // +0x10
    LIST_ENTRY InInitializationOrderLinks;     // +0x20
    PVOID DllBase;                             // +0x30
    PVOID EntryPoint;                          // +0x38
    ULONG SizeOfImage;                         // +0x40
    UNICODE_STRING FullDllName;                // +0x48
    UNICODE_STRING BaseDllName;                // +0x58
    ULONG Flags;                               // +0x68
    USHORT LoadCount;                          // +0x6C
    USHORT TlsIndex;                           // +0x6E
    LIST_ENTRY HashLinks;                      // +0x70
    ULONG TimeDateStamp;                       // +0x80
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;

Locating Modules Via the PEB_LDR_DATA Table

As mentioned previously, it’s possible to assume the order of the first 3 modules for a native application. Therefore, the simplest code to obtain the module entry for the current executable, ntdll, and kernel32 is as follows.

#include <Windows.h>
#include <Stdio.h>

/* Insert PEB, PEB_LDR_DATA, and LDR_DATA_TABLE_ENTRY definition here */

int main()
{
    PEB *peb = (PEB *)__readgsqword(0x60);
    PEB_LDR_DATA* ldr = (PEB_LDR_DATA*)peb->Ldr;
    
    LDR_DATA_TABLE_ENTRY *main_module = (LDR_DATA_TABLE_ENTRY * )ldr->InLoadOrderModuleList.Flink;
    LDR_DATA_TABLE_ENTRY *ntdll = (LDR_DATA_TABLE_ENTRY * )main_module->InLoadOrderLinks.Flink;
    LDR_DATA_TABLE_ENTRY *kernel32 = (LDR_DATA_TABLE_ENTRY * )ntdll->InLoadOrderLinks.Flink;
    
    printf("Module name: %S, Base address: 0x%p, Entrypoint: 0x%p\n", 
           main_module->BaseDllName.Buffer, main_module->DllBase, main_module->EntryPoint);

    printf("Module name: %S, Base address: 0x%p, Entrypoint: 0x%p\n",
           ntdll->BaseDllName.Buffer, ntdll->DllBase, ntdll->EntryPoint);

    printf("Module name: %S, Base address: 0x%p, Entrypoint: 0x%p\n",
           kernel32->BaseDllName.Buffer, kernel32->DllBase, kernel32->EntryPoint);
}

Example output from above code

Is assembly the following very simple code would be sufficed.

mov rax, gs:60h         ; get PEB
mov rax, [rax+0x18]     ; get PEB_LDR_DATA address from PEB
mov rax, [rax+0x10]     ; get InLoadOrderModuleList from PEB_LDR_DATA

mov rax, [rax]          ; rax = module entry for application.exe
mov rcx, [rax+0x30]     ; rcx = base address of application.exe

mov rax, [rax]          ; rax = module entry for ntdll.dll
mov rcx, [rax+0x30]     ; rcx = base address of ntdll.dll

mov rax, [rax]          ; rax = module entry for kernel32.dll
mov rcx, [rax+0x30]     ; rcx = base address of kernel32.dll

For more complicated situations, the module list can be iterated and a specific module sought out by name.

The following C code demonstrates looking up the module entry for kernelbase.dll:

#include <Windows.h>
#include <Stdio.h>

/* Insert PEB, PEB_LDR_DATA, and LDR_DATA_TABLE_ENTRY definition here */

int main()
{
    PEB* peb = (PEB*)__readgsqword(0x60);
    PEB_LDR_DATA* ldr = (PEB_LDR_DATA*)peb->Ldr;

    LIST_ENTRY* head = &ldr->InLoadOrderModuleList;
    LIST_ENTRY* curr = ldr->InLoadOrderModuleList.Flink;

    while (curr != head) {
        LDR_DATA_TABLE_ENTRY* ldr_data = (LDR_DATA_TABLE_ENTRY*)curr;

        wchar_t target_module[] = L"kernelbase.dll";

        if (_wcsicmp(ldr_data->BaseDllName.Buffer, target_module) == 0) {
            printf("Found module entry for %S!, Base address: 0x%p, Entrypoint: 0x%p\n", 
                   ldr_data->BaseDllName.Buffer, ldr_data->DllBase, ldr_data->EntryPoint);
        }

        curr = curr->Flink;
    }
}

Discussion

It’s extremely useful to have a solid understanding of Windows internals structures such as the Process Environment Block. Since the PEB is accessible via a fixed offset and contain abundant information about the current process and environment, it’s often leveraged by malicious code. A few examples of operations which can be performed without any API calls via only the PEB are:

  • Find module base addresses or load information
  • Get the Operating System version
  • Check if the process is being debugged
  • Read the command line arguments, process named, and PID
  • Detect the number of CPU cores present