Check out our official Discord Server: https://discord.gg/malwaretech
Locating Modules Via The PEB (Process Environment Block)
Index of content
- Introduction
- Process Environment Block (PEB) Basics
- Loader Data Table (PEB_LDR_DATA) Basics
- Loader Data Table Entry (LDR_DATA_TABLE_ENTRY) Basics
- Discussion
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:
- application.exe (application name)
- ntdll.dll
- kernel32.dll
- 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:
- application.exe (application name)
- ntdll.dll
- mscoree.dll
- kernel32.dll
- 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