PEB-less GetModuleHandle | Version | N/A | |
---|---|---|---|
Updated | |||
Author | Joshua Finley | License | DBE |
1. Introduction
Native Windows code is overwhelmingly position-dependant. This is the case for a multitude of reasons, including legacy support, performance, and simplicity. On the other hand, this simultaneously introduces challenges for both attack and defense. For what is now decades, Windows malware has often required some degree of position independence, especially in shellcode and first-stage payloads. Additionally, due to issues of stealth and evasion, malware must frequently work without the aid of conveniences offered by the operating system and external libraries. Examples include the typical utilities that allow dynamic dependency resolution.
This article reviews standard methods for retrieving module base addresses and introduces a new method that does not rely on API calls or the Process Environment Block (PEB) 1 . It is introduced as Module Discovery by Code Traversal. This methods relies only on information available to the running code is suitable for use under certain circumstances where access to API calls or the Process Environment Block is impossible or unwanted. The method does have its own limitations, but it is fairly simple and has unique advantages and disadvantages in certain situations.
2. Method Review
GetModuleHandle
In native Windows code, the most basic method of retrieving a module base is using the Windows API GetModuleHandle
. When called, this function does one of two things:
- If passed a
NULL
argument, get the current image base from a global variable referencing the Process Environment Block data structure (PEB) and accessing the offset0x10
, correlating toPPEB->ImageBase
(Windows 10 19045) and return the address. - Call a second function from
ntdll
to locate and return the image base of a separate module.
In the second case, GetModuleHandle
will call LdrGetDllHandle
.LdrGetDllHandle
lives in ntdll
and itself diverts to LdrGetDllHandleEx
. This will kick off a series of calls that will eventually access the PEB to get the module base by name or path.
The conventional wisdom in malware writing is to be wary of carelessly using such procedures, because at any point in the call chain, defense tools may have inserted some sort of monitoring hook. Instead, malware authors very often opt to implement the process manually.
Manual Module Resolution by PEB Traversal
On x64 Windows, the PEB is a data structure accessible using the gs
segment register. The structure contains a linked list of loaded modules and their associated information. It is therefore possible to manually traverse the data structure from user code to retrieve loaded module information.
- Retrieve the Process Environment Block (PEB) from
gs:0x60
. - Obtain the head of the
InMemoryOrderModuleList
from theLoaderData
of the PEB. - Initialize a loop starting from the first entry in the
InMemoryOrderModuleList
, iterating through the list until it loops back to the head. - In each iteration, compute the address of the current module’s
LDR_MODULE
structure based on the list entry address. - Check if the
BaseDllName.Buffer
of theLDR_MODULE
is notNULL
:
- If it is not
NULL
, convert the name to a character string. - Compare the names and return
BaseAddress
if they match
- Move to the next list entry.
3. Monitoring of Module Base Retrieval Methods
API Hooking and Behavior Monitoring
The most straightforward way of monitoring attempts to retrieve module base addresses would be with usermode hooks on the Windows and NT API procedures. One example of a behavioral check would involve checking if the GetModuleHandle
call is in proximity to a GetProcAddress
call, indicating dynamic API import.
PEB Access Monitoring
Through emulation or debugging, it is possible to monitor access to the PEB or TEB [1]. With these tools, access to PEB memory is straightforward to detect.
Static Signatures
Finally, a very simple detection for code accessing the PEB or TEB is to match suspicious code to a pattern matching a move operation with the x86_64 segment override prefix for the gs
segment register. An example pattern would be something like:
65 48 8b * 25 60 00 00 00 00 ; PEB access using 0x65 segment override prefix
65 48 8b * 25 30 00 00 00 00 ; TEB access using 0x65 segment override prefix
4. Module Discovery by Code Traversal
Given the assumption that the address is within a portable executable image in the current process memory, we can easily search backwards in memory for the base address of the module. If that module explicitly imports any functions from external dynamic libraries, the PE headers may be parsed to retrieve an address inside that module and then find its own module base. This can be repeated for every loaded module in the process.
INT main()
{
BOOL OK = FALSE;
INT Distance = NULL;
QWORD_PTR RIP = NULL;
QWORD_PTR MzLoc = NULL;
QWORD_PTR SearchAddr = NULL;
QWORD_PTR BaseAddr = NULL;
BYTE MzSig[5] = { 0x4D, 0x5A, 0x90, 0x00, 0x03 };
// 1. Get the instruction pointer (or in this case, an approximation it)
RIP = GetInstructionPointer();
// 2. Find the DOS signature of the current module
SearchAddr = (QWORD_PTR)FindByteSig(
RIP, MzSig, sizeof(MzSig), 0xFFFF0, TRUE, &Distance);
if (!SearchAddr) return ERROR_NOT_FOUND;
// 3. Find the first Kernel32 import from IAT
SearchAddr = FindFirstModuleImport((PBYTE)SearchAddr, HASH_K32);
if (!SearchAddr) return ERROR_NOT_FOUND;
// 4. Find kernel32 base from the export
SearchAddr = (QWORD_PTR)FindByteSig(
SearchAddr, MzSig, sizeof(MzSig), 0xFFFFF, TRUE, &Distance);
if (!SearchAddr) return ERROR_NOT_FOUND;
// 5. Get address inside ntdll
SearchAddr = FindFirstModuleImport((PBYTE)SearchAddr, HASH_NTDLL);
if (!SearchAddr) return ERROR_NOT_FOUND;
// 6. Get ntdll base
BaseAddr = (QWORD_PTR)FindByteSig(
SearchAddr, MzSig, sizeof(MzSig), 0xFFFFF, TRUE, &Distance);
return ERROR_SUCCESS;
}
Advantages
This method is almost as robust at enabling module discovery as standard methods such as GetModuleHandle or using the PEB. The entire list of modules that are specified in a given executable’s headers may be resolved without calling any APIs or accessing the PEB.
Disadvantages
The main downside of this method compared to traditional means is that modules loaded at runtime will not be discoverable merely by searching through the known import directories. Additionally, this method requires knowledge of an address backed by a PE image. This means that shellcode threads will need to be provided this information. Finally, there is room for error in the pattern matching and PE format parsing.
5. Proof-of-Concept: PEB-Less GetModuleHandle
The following code offers a complete demonstration of a GetModuleHandle
-alike function which uses Method 1 to locate module base addresses from a known code location (in this case RIP
). The code will recursively search accessible module import information for the given DLL name. At no point is the PEB accessed and no API calls are used.
#include <Windows.h>
// Convenience types
#define QWORD DWORD64
#define QWORD_PTR DWORD64 *
#define MAX_VISITED 124
extern "C" QWORD_PTR GetInstructionPointer();
// Find the first instance of a matching byte sequence with directionality
PVOID FindByteSig(PVOID SearchBase, PVOID Sig, INT EggSize, INT Bound, BOOL Rev)
{
if (!Rev)
{
for (INT i = 0; i < Bound; i++)
{
if (!memcmp(&((PBYTE)SearchBase)[i], Sig, EggSize))
{
return &((PBYTE)SearchBase)[i];
}
}
}
else {
for (INT i = 0; i < Bound; i++)
{
if (!memcmp(&((PBYTE)SearchBase)[-i], Sig, EggSize))
{
return &((PBYTE)SearchBase)[-i];
}
}
}
return NULL;
}
// Compare a module's reported name (Optional directory) to a string
BOOL CheckModNameByExportDir(PBYTE BaseAddr, PCHAR ModName)
{
DWORD ExportDirRVA = NULL;
DWORD NameRVA = NULL;
PCHAR Name = NULL;
SIZE_T NameLength = NULL;
PIMAGE_DOS_HEADER pDosHeader = NULL;
PIMAGE_NT_HEADERS pNtHeaders = NULL;
PIMAGE_EXPORT_DIRECTORY pExportDir = NULL;
pDosHeader = (PIMAGE_DOS_HEADER)BaseAddr;
if (pDosHeader->e_magic != IMAGE_DOS_SIGNATURE) return FALSE;
pNtHeaders = (PIMAGE_NT_HEADERS)(BaseAddr + pDosHeader->e_lfanew);
if (pNtHeaders->Signature != IMAGE_NT_SIGNATURE) return FALSE;
ExportDirRVA = pNtHeaders->OptionalHeader.
DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
if (ExportDirRVA == 0) return FALSE;
pExportDir = (PIMAGE_EXPORT_DIRECTORY)(BaseAddr + ExportDirRVA);
NameRVA = pExportDir->Name;
if (NameRVA == 0) {
return FALSE; // No name
}
Name = (PCHAR)(BaseAddr + NameRVA);
NameLength = strlen(Name);
if (strcmp(ModName, Name) == 0)
{
return TRUE;
}
return FALSE;
}
// Given a base address, find the first import from a given DLL
QWORD_PTR FindFirstModuleImport(PBYTE MzLoc, PCHAR ModName)
{
CHAR CurrentName[MAX_PATH];
PIMAGE_DOS_HEADER pDosHeader = NULL;
PIMAGE_NT_HEADERS pNtHeaders = NULL;
PIMAGE_OPTIONAL_HEADER pOptHeader = NULL;
PIMAGE_IMPORT_DESCRIPTOR pImportDesc = NULL;
PCHAR pImportName = NULL;
PIMAGE_THUNK_DATA pThunk = NULL;
PIMAGE_THUNK_DATA pIATThunk = NULL;
// Initialize locals
pDosHeader = (PIMAGE_DOS_HEADER)MzLoc;
// Validate DOS header
if (pDosHeader->e_magic != IMAGE_DOS_SIGNATURE)
return NULL;
// Initialize NT Headers
pNtHeaders = (PIMAGE_NT_HEADERS)(MzLoc + pDosHeader->e_lfanew);
// Validate PE header
if (pNtHeaders->Signature != IMAGE_NT_SIGNATURE)
return NULL;
// Initialize Optional Header
pOptHeader = &pNtHeaders->OptionalHeader;
// Initialize Import Descriptor
pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)(
MzLoc
+ pOptHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]
.VirtualAddress);
while (pImportDesc && pImportDesc->Name)
{
pImportName = (PCHAR)(MzLoc + pImportDesc->Name);
if (pImportName)
{
strcpy_s(CurrentName, sizeof(CurrentName), pImportName);
for (int i = 0; CurrentName[i]; i++) {
CurrentName[i] = (CHAR)tolower((unsigned char)CurrentName[i]);
}
if (strcmp(CurrentName, ModName) == 0)
{
// Get the OriginalFirstThunk
pThunk = (PIMAGE_THUNK_DATA)(
MzLoc + pImportDesc->OriginalFirstThunk);
// Get the corresponding entry in the IAT
pIATThunk = (PIMAGE_THUNK_DATA)(
MzLoc + pImportDesc->FirstThunk);
if (pThunk && pIATThunk) // Check if thunks are valid
{
// Return the full VA of the first function
return (QWORD_PTR)(pIATThunk->u1.Function);
}
}
}
pImportDesc++;
}
return NULL;
}
// Function to check if a module has already been visited
bool IsModuleVisited(PVOID* Visited, int nVisited, PVOID ModBase) {
for (int i = 0; i < nVisited; i++) {
if (Visited[i] == ModBase) {
return true;
}
}
return false;
}
PVOID PeblessFindModuleRecursively(
PBYTE StartAddr,
PCHAR ModName,
PVOID* Visited,
PINT nVisited)
{
DWORD ImportDirRVA = NULL;
PCHAR pModuleName = NULL;
PVOID FirstImport = NULL;
PVOID FoundBase = NULL;
PIMAGE_DOS_HEADER pDosHeader = NULL;
PIMAGE_NT_HEADERS pNtHeaders = NULL;
PIMAGE_OPTIONAL_HEADER pOptionalHeader = NULL;
PIMAGE_IMPORT_DESCRIPTOR pImportDesc = NULL;
CHAR CurrentName[MAX_PATH] = { NULL };
BYTE MzSig[5] = { 0x4D, 0x5A, 0x90, 0x00, 0x03 };
if (IsModuleVisited(Visited, *nVisited, StartAddr)) {
return NULL; // Avoid infinite recursion
}
Visited[*nVisited] = StartAddr;
(*nVisited)++;
if (CheckModNameByExportDir(StartAddr, ModName)) {
return StartAddr;
}
pDosHeader = (PIMAGE_DOS_HEADER)StartAddr;
pNtHeaders = (PIMAGE_NT_HEADERS)(StartAddr + pDosHeader->e_lfanew);
pOptionalHeader = &pNtHeaders->OptionalHeader;
ImportDirRVA = pOptionalHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)(StartAddr + ImportDirRVA);
while (pImportDesc->Name) {
pModuleName = (char*)(StartAddr + pImportDesc->Name);
strcpy_s(CurrentName, sizeof(CurrentName), pModuleName);
for (INT i = 0; CurrentName[i]; i++) {
CurrentName[i] = (CHAR)tolower((unsigned char)CurrentName[i]);
}
FirstImport = FindFirstModuleImport(StartAddr, CurrentName);
if (!FirstImport) {
pImportDesc++;
continue;
}
FoundBase = FindByteSig(
FirstImport, MzSig, sizeof(MzSig), 0xFFFFF, TRUE);
if (FoundBase) {
FoundBase = PeblessFindModuleRecursively(
(PBYTE)FoundBase, ModName, Visited, nVisited);
if (FoundBase) {
return FoundBase;
}
}
pImportDesc++;
}
return NULL;
}
// Get a module base address without using the PEB
// NOTE: Does not locate libraries loaded with LoadLibrary
PVOID PeblessGetModuleHandle(PCHAR szModuleName) {
PVOID Visited[MAX_VISITED] = { NULL };
PBYTE StartAddr = NULL;
INT nVisited = 0;
BYTE MzSig[5] = { 0x4D, 0x5A, 0x90, 0x00, 0x03 };
QWORD_PTR RIP = (QWORD_PTR)GetInstructionPointer();
StartAddr = (PBYTE)FindByteSig(
(PVOID)RIP, MzSig, sizeof(MzSig), 0xFFFFF, TRUE);
if (szModuleName == NULL) return StartAddr;
return PeblessFindModuleRecursively(
StartAddr, szModuleName, Visited, &nVisited);
}
// Program entry point
INT main()
{
PVOID BaseNtdll = PeblessGetModuleHandle((PCHAR)"ntdll.dll");
if (!BaseNtdll) return ERROR_NOT_FOUND;
PVOID BaseCurrent = PeblessGetModuleHandle(NULL);
if (!BaseCurrent) return ERROR_NOT_FOUND;
return 0;
}
6. Speculation: Stack-Analysis for Module Identification
In addition to the first method, weaknesses in Windows ASLR may be exploited to predict valid system module addresses from data on the stack. New threads begin with ntdll!RtlUserThreadStart
followed by KERNEL32!BaseThreadInitThunk
. Therefore it should be possible to obtain base addresses if an ASLR attack is used. Statistical modeling of ASLR could be done to evaluate:
- Mean distance from user code to system module code
- Spread of module load locations
- Weaknesses in ASLR address range constraints for system DLLs
Testing design should account for peculiarities in Windows ASLR, such as module base reuse across processes with same names.
I performed an initial evaluation of the predictibility of system module address, but the results are not conclusive and therefore not worth sharing.
7. Conclusion
This article presented an analysis of the standard methods used to retrieve module base addresses in native Windows code, including the widely-used GetModuleHandle
API and manual module resolution through the Process Environment Block (PEB). It also introduced a method—Module Discovery by Code Traversal—as an alternative that is robust yet does not rely on API calls or the PEB. This method offers unique advantages and disadvantages, depending on the constraints and requirements of the situation.
Bibliography
Footnotes
- Unless otherwise noted, all testing was performed on Windows 10 19045 with the bottom-up randomization and high-entropy ASLR enabled. ↩