1. 引言
我们常常通过很多方法来获取进程的模块信息,例如 EnumProcessModules 函数、CreateToolhelp32Snapshot 函数、WTSEnumerateProcesses 函数、ZwQuerySystemInformation 函数等。但是调用这些接口进行模块枚举的原理是什么我们并不知道。通过学习 PEB 中 PEB_LDR_DATA 结构的知识,我们可以对进程模块信息的查询以及相关存储数据结构有进一步的了解。
2. 技术细节
2.1 基本原理
在开始使用 TEB/PEB 获取进程的模块信息之前,我想有必要解释一下这两个名词:PEB 指的是进程环境块(Process Environment Block),用于存储进程状态信息和进程所需的各种数据。每个进程都有一个对应的 PEB 结构体。TEB 指的是线程环境块(Thread Environment Block),用于存储线程状态信息和线程所需的各种数据。每个线程同样都有一个对应的 TEB 结构体。
PEB 中包含了进程的代码、数据段指针、进程的环境变量、进程启动参数信息以及加载的模块信息等。在 x86-32 体系下,FS 段寄存器偏移 0x30 处存放了索引,索引查找的指针指向当前进程的 PEB 结构体,在 x86-64 下该指针位于 FS 段寄存器偏移 0x60 处。其他进程可以通过访问自己的 PEB 结构体来获取自己的状态和信息。
TEB 中包含了线程的堆栈指针、TLS(线程本地存储)指针、异常处理链表指针、用户模式分页表指针等信息。在 x86-32 体系下,FS 段寄存器偏移 0x18 处通常为指向 TEB 结构体的指针,在 x86-64 下该指针位于 FS 段寄存器偏移 0x30 处。其他线程可以通过访问自己的 TEB 结构体来获取自己的状态和信息。
通常,我们可以通过下面的代码在 MSVC 编译器中通过寄存器获得 PEB 结构体指针:
#ifdef _WIN64
PPEB_LDR_DATA64 pPebLdrData = NULL;
ULONGLONG ModuleSum = NULL;
PPEB64 peb = (PPEB64)__readgsqword(0x60);
#else
PPEB_LDR_DATA32 pPebLdrData = NULL;
ULONG ModuleSum = NULL;
PPEB32 peb = (PPEB32)__readfsdword(0x30);
#endif
而对于 PEB 结构体,微软是没有公开文档的,需要自己进行重定义(原始结构体定义中缺少我们需要的部分),经查阅逆向文献,得到如下的结构体定义(部分不需要用到的成员已经被截断):
typedef struct _PEB_LDR_DATA32
{
ULONG Length; // +0x00
BOOLEAN Initialized; // +0x04
PVOID SsHandle; // +0x08
LIST_ENTRY InLoadOrderModuleList; // +0x0c
LIST_ENTRY InMemoryOrderModuleList; // +0x14
LIST_ENTRY InInitializationOrderModuleList;// +0x1c
} PEB_LDR_DATA32, * PPEB_LDR_DATA32; // +0x24
typedef struct _PEB32
{
UCHAR InheritedAddressSpace; //0x0
UCHAR ReadImageFileExecOptions; //0x1
UCHAR BeingDebugged; //0x2
union
{
UCHAR BitField; //0x3
struct
{
UCHAR ImageUsesLargePages : 1; //0x3
UCHAR IsProtectedProcess : 1; //0x3
UCHAR IsImageDynamicallyRelocated : 1; //0x3
UCHAR SkipPatchingUser32Forwarders : 1; //0x3
UCHAR IsPackagedProcess : 1; //0x3
UCHAR IsAppContainer : 1; //0x3
UCHAR IsProtectedProcessLight : 1; //0x3
UCHAR IsLongPathAwareProcess : 1; //0x3
};
};
PVOID Mutant; //0x4
PVOID ImageBaseAddress; //0x8
PEB_LDR_DATA32* Ldr; //0xc
RTL_USER_PROCESS_PARAMETERS* ProcessParameters; //0x10
PVOID SubSystemData; //0x14
PVOID ProcessHeap; //0x18
RTL_CRITICAL_SECTION* FastPebLock; //0x1c
SLIST_HEADER* volatile AtlThunkSListPtr; //0x20
PVOID IFEOKey; //0x24
} PEB32, * PPEB32;
typedef struct _STRING64
{
USHORT Length; //0x0
USHORT MaximumLength; //0x2
ULONGLONG Buffer; //0x8
}STRING64, * LPSTRING64;
typedef struct _PEB_LDR_DATA64
{
ULONG Length; //0x0
UCHAR Initialized; //0x4
PVOID SsHandle; //0x8
LIST_ENTRY InLoadOrderModuleList; //0x10
LIST_ENTRY InMemoryOrderModuleList; //0x20
LIST_ENTRY InInitializationOrderModuleList; //0x30
PVOID EntryInProgress; //0x40
UCHAR ShutdownInProgress; //0x48
PVOID ShutdownThreadId; //0x50
}PEB_LDR_DATA64, *PPEB_LDR_DATA64;
typedef struct _PEB64
{
UCHAR InheritedAddressSpace; //0x0
UCHAR ReadImageFileExecOptions; //0x1
UCHAR BeingDebugged; //0x2
union
{
UCHAR BitField; //0x3
struct
{
UCHAR ImageUsesLargePages : 1; //0x3
UCHAR IsProtectedProcess : 1; //0x3
UCHAR IsImageDynamicallyRelocated : 1; //0x3
UCHAR SkipPatchingUser32Forwarders : 1; //0x3
UCHAR IsPackagedProcess : 1; //0x3
UCHAR IsAppContainer : 1; //0x3
UCHAR IsProtectedProcessLight : 1; //0x3
UCHAR IsLongPathAwareProcess : 1; //0x3
};
};
UCHAR Padding0[4]; //0x4
ULONGLONG Mutant; //0x8
ULONGLONG ImageBaseAddress; //0x10
PEB_LDR_DATA64* Ldr; //0x18
ULONGLONG ProcessParameters; //0x20
ULONGLONG SubSystemData; //0x28
ULONGLONG ProcessHeap; //0x30
ULONGLONG FastPebLock; //0x38
ULONGLONG AtlThunkSListPtr; //0x40
ULONGLONG IFEOKey; //0x48
}PEB64, *PPEB64;
下面,我们分析一下为什么可以通过如此复杂的 PEB 结构获取模块信息。
2.1.1 PEB_LDR_DATA 结构体
以 x86-32 为例:PEB 结构体中偏移为 0xC 的成员变量是 Ldr 该变量是一个指向 PEB_LDR_DATA 结构体的指针。
下面我们来看一下该结构体的部分定义:
typedef struct _PEB_LDR_DATA32
{
ULONG Length; // +0x00
BOOLEAN Initialized; // +0x04
PVOID SsHandle; // +0x08
LIST_ENTRY InLoadOrderModuleList; // +0x0c
LIST_ENTRY InMemoryOrderModuleList; // +0x14
LIST_ENTRY InInitializationOrderModuleList; // +0x1c
} PEB_LDR_DATA32, * PPEB_LDR_DATA32; // +0x24
该结构体的第一个变量 Length 表示链表长度信息,大小是结点数乘以当前范围的大小(x32 是0x4,x64 是 0x8),最后再减去 1。
然后从偏移 0xC 开始,就是三个 LIST_ENTRY 链表的头结点,链表中结点数据类型都是 LIST_ENTRY,只是链表的排序模式不同。
2.2.2 LIST_ENTRY 结构体
LIST_ENTRY 结构是模块链表结构里的结点数据结构,它包含两个成员指针, Flink 指向下一个链表结点,Blink 指向前一个链表结点。模块链表属于一种双向链表的数据结构。
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink; // 后驱指针
struct _LIST_ENTRY *Blink; // 前驱指针
} LIST_ENTRY, *PLIST_ENTRY, *RESTRICTED_POINTER PRLIST_ENTRY;
2.2.3 LDR_DATA_TABLE_ENTRY 结构体
对于每一个指针,它实际指向的数据结构并不是 LIST_ENTRY 结构体,而是 LDR_DATA_TABLE_ENTRY 结构体,这是每一个结点指向的模块信息数据结构。
typedef struct _LDR_DATA_TABLE_ENTRY
{
LIST_ENTRY InLoadOrderLinks; // 0x0
LIST_ENTRY InMemoryOrderLinks; // 0x8
LIST_ENTRY InInitializationOrderLinks; // 0x10
PVOID DllBase; // 0x18
PVOID EntryPoint; // 0x1c
ULONG SizeOfImage; // 0x20
UNICODE_STRING FullDllName; // 0x24
UNICODE_STRING BaseDllName; // 0x2c
}LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY; // 0xa4
对于该结构中的 DllBase 是当前模块的基址,EntryPoint 是模块的入口地址,SizeOfImage 是映像大小,FullDllName 是模块完整路径字符串。
可以通过遍历链表结点的方式获取所有模块的信息: LDR_DATA_TABLE_ENTRY 结构中的 LIST_ENTRY 结构对应下一个 LDR_DATA_TABLE_ENTRY 结点中的 LIST_ENTRY 结构。
如:头结点中的 InInitializationOrderModuleList 是一个 LIST_ENTRY 结构,该结构中 Flink 指针指向的是第二个结点的首地址。而不是另外两个 LIST_ENTRY 结构。然后,第二个结点的 Flink 指向第三个结点,依此类推。
于是,头结点的 Blink 指向最后一个结点,而最后一个结点的 Blink 指向它前一个结点,依次链接到前一个结点,直到第二个结点的 Blink 指向头结点,可以看出这是一个循环链表。同理,Flink 后驱指针也是这样的,一直指向后一个结点,最后一个数据的后驱指针指向头结点。可以说,整个 LDR_DATA_TABLE_LIST 是一个闭环双向链表。
相信已经注意到了 LIST_ENTRY 结构和 LDR_DATA_TABLE_ENTRY 结构并不一样,这个链表到底是如何链接的呢?
实际上,仔细观察就会发现, LDR_DATA_TABLE_ENTRY 结构的成员变量就有不同排序模式下结点的 LIST_ENTRY ,这个和 PEB_LDR_DATA 中的 LIST_ENTRY* 是一致的,也就是说, PEB_LDR_DATA 中的 LIST_ENTRY 头结点里面的 Flink 指针是指向一个 LDR_DATA_TABLE_ENTRY 表中 LIST_ENTRY 成员的指针。也就是说,这里的数据结构有一个特点,就是他是利用表的数据结构将表中的指针成员映射到一个链表的数据结构中,就像手账本将纸张串联在一起一样。
LDR_DATA_TABLE_ENTRY 通过 LIST_ENTRY 映射到一个双向链表中,并且该 LIST_ENTRY 链表是闭环的,即末尾结点的后驱指针不是指向 NULL,而是指向头结点;头结点的前驱指针也不是指向 NULL,而是指向末尾结点。LIST_ENTRY 相当于链表中每个 LDR_DATA_TABLE_ENTRY 结构的入口媒介,因为我们可以通过同样的映射关系(偏移地址)逆映射出 LDR_DATA_TABLE_ENTRY 的地址。
2.2 通过成员变量的地址定位结构体
2.2.1 空指针的特殊作用
空指针往往是不能够进行访问的,但是对于指向结构体的指针变量来说,如果他是一个 nullptr,那么,结构体将向着 0 地址对齐。于是,我们可以通过指针引用获取结构体中成员变量的地址,该地址是相对于 0 地址而言的,所以,它实际上是结构体中成员相对于该结构首地址的偏移量。
例如下面的代码,就是利用了该性质准确获取成员变量的偏移(因为编译优化,数据结构内部变量的排序和对齐方式可能会被编译期调整,所以通过这种方式获取的偏移比直接硬编码的稳定安全):
struct Node
{
int Flink;
float Blink;
};
// typedef unsigned long long uint64_t; in x86-64 system
Node* pNode = nullptr;
uint64_t offset_F = (uint64_t)(&(pNode->Flink)); // offset_F==0
uint64_t offset_B = (uint64_t)(&(pNode->Blink)); // offset_B==4
我们可以正确得到成员的偏移。但是,试想一下,如果我们知道一个结构体的某个成员变量的地址,那么我们如何定位该结构体的首地址呢?
很容易想到,成员的地址 - 该成员的偏移量 = 结构体的首地址。
2.2.2 CONTAINING_RECORD 宏
CONTAINING_RECORD 宏的定义位于 winnt.h 中,如下所示:
//
// Calculate the address of the base of the structure given its type, and an
// address of a field within the structure.
//
#define CONTAINING_RECORD(address, type, field) ((type *)( \
(PCHAR)(address) - \
(ULONG_PTR)(&((type *)0)->field)))
CONTAINING_RECORD 宏的功能,是根据某个结构体中成员变量的地址,计算出该结构体的首地址。
参数解释:
- address,成员变量的地址
- type,结构体的数据类型
- field,成员变量名
该宏定义内部的运算原理,就是前面分析的使用 0 指针获取成员偏移,然后再使用成员变量地址 - 成员的偏移,就得到了结构体的首地址。
3. 原理验证
3.1 代码实现
在验证代码中,我们进行了以下操作:
- 加载模块:首先,我们利用 LoadLibrary 加载了 advapi32.dll 用于测试。
- 通过寄存器获取指向 PEB 的指针:通过 fs 或 gs 寄存器索引偏移获取 PPEB 的值。该指针指向 进程的 PEB 结构。
- 获取 PEB_LDR_DATA 结构: PEB 结构体的 Ldr 成员变量是指向 PEB_LDR_DATA 结构的指针。
- 获取头结点 LIST_ENTRY 结构: PEB_LDR_DATA 结构的 LIST_ENTRY 对应三个链表各自的头结点。
- 通过宏获取实际的 LDR_DATA_TABLE_ENTRY 结构:通过头结点的 Flink 指向的地址,获取第一个 LDR_DATA_TABLE_ENTRY 结构的地址,这个是链表存放数据的第一个实结点。
- 通过结构读取链接库信息: LDR_DATA_TABLE_ENTRY 结构的多个成员包含了 Dll 的加载信息,通过读取该信息,可以完成功能要求。
- 遍历该过程并打印所有结点:通过遍历每一个实结点的 LIST_ENTRY 映射结点,通过映射的 Flink 找到下一个结点,然后逐个打印结点,直到 Flink 指向的下一个结点回到头结点为止。至此,遍历结束。
- 卸载模块和进程退出:用 FreeLibrary 卸载用于测试的模块。
下面是以上功能的完整实现代码:
#include <iostream>
#include <windows.h>
#include <winternl.h>
#include <TlHelp32.h>
// 这部分的结构体需要自己重写一下
typedef struct _PEB_LDR_DATA32
{
ULONG Length; // +0x00
BOOLEAN Initialized; // +0x04
PVOID SsHandle; // +0x08
LIST_ENTRY InLoadOrderModuleList; // +0x0c
LIST_ENTRY InMemoryOrderModuleList; // +0x14
LIST_ENTRY InInitializationOrderModuleList;// +0x1c
} PEB_LDR_DATA32, * PPEB_LDR_DATA32; // +0x24
typedef struct _PEB32
{
UCHAR InheritedAddressSpace; //0x0
UCHAR ReadImageFileExecOptions; //0x1
UCHAR BeingDebugged; //0x2
union
{
UCHAR BitField; //0x3
struct
{
UCHAR ImageUsesLargePages : 1; //0x3
UCHAR IsProtectedProcess : 1; //0x3
UCHAR IsImageDynamicallyRelocated : 1; //0x3
UCHAR SkipPatchingUser32Forwarders : 1; //0x3
UCHAR IsPackagedProcess : 1; //0x3
UCHAR IsAppContainer : 1; //0x3
UCHAR IsProtectedProcessLight : 1; //0x3
UCHAR IsLongPathAwareProcess : 1; //0x3
};
};
PVOID Mutant; //0x4
PVOID ImageBaseAddress; //0x8
PEB_LDR_DATA32* Ldr; //0xc
RTL_USER_PROCESS_PARAMETERS* ProcessParameters; //0x10
PVOID SubSystemData; //0x14
PVOID ProcessHeap; //0x18
RTL_CRITICAL_SECTION* FastPebLock; //0x1c
SLIST_HEADER* volatile AtlThunkSListPtr; //0x20
PVOID IFEOKey; //0x24
} PEB32, * PPEB32;
typedef struct _STRING64
{
USHORT Length; //0x0
USHORT MaximumLength; //0x2
ULONGLONG Buffer; //0x8
}STRING64, * LPSTRING64;
typedef struct _PEB_LDR_DATA64
{
ULONG Length; //0x0
UCHAR Initialized; //0x4
PVOID SsHandle; //0x8
LIST_ENTRY InLoadOrderModuleList; //0x10
LIST_ENTRY InMemoryOrderModuleList; //0x20
LIST_ENTRY InInitializationOrderModuleList; //0x30
PVOID EntryInProgress; //0x40
UCHAR ShutdownInProgress; //0x48
PVOID ShutdownThreadId; //0x50
}PEB_LDR_DATA64, *PPEB_LDR_DATA64;
typedef struct _PEB64
{
UCHAR InheritedAddressSpace; //0x0
UCHAR ReadImageFileExecOptions; //0x1
UCHAR BeingDebugged; //0x2
union
{
UCHAR BitField; //0x3
struct
{
UCHAR ImageUsesLargePages : 1; //0x3
UCHAR IsProtectedProcess : 1; //0x3
UCHAR IsImageDynamicallyRelocated : 1; //0x3
UCHAR SkipPatchingUser32Forwarders : 1; //0x3
UCHAR IsPackagedProcess : 1; //0x3
UCHAR IsAppContainer : 1; //0x3
UCHAR IsProtectedProcessLight : 1; //0x3
UCHAR IsLongPathAwareProcess : 1; //0x3
};
};
UCHAR Padding0[4]; //0x4
ULONGLONG Mutant; //0x8
ULONGLONG ImageBaseAddress; //0x10
PEB_LDR_DATA64* Ldr; //0x18
ULONGLONG ProcessParameters; //0x20
ULONGLONG SubSystemData; //0x28
ULONGLONG ProcessHeap; //0x30
ULONGLONG FastPebLock; //0x38
ULONGLONG AtlThunkSListPtr; //0x40
ULONGLONG IFEOKey; //0x48
}PEB64, *PPEB64;
int main(void)
{
setlocale(NULL, "chs");
PLDR_DATA_TABLE_ENTRY pLdrDataEntry = NULL;
PLIST_ENTRY pListEntryStart = NULL, pListEntryEnd = NULL;
// 1、加载链接库用于测试
HMODULE hdll = LoadLibraryW(L"advapi32.dll");
// 2、通过寄存器偏移访问 PEB
#ifdef _WIN64
PPEB_LDR_DATA64 pPebLdrData = NULL;
ULONGLONG ModuleSum = NULL;
PPEB64 peb = (PPEB64)__readgsqword(0x60);
#else
PPEB_LDR_DATA32 pPebLdrData = NULL;
ULONG ModuleSum = NULL;
PPEB32 peb = (PPEB32)__readfsdword(0x30);
#endif
// 3、通过 PEB 的 Ldr 成员获取 PEB_LDR_DATA 结构
pPebLdrData = peb->Ldr;
// 4、通过 PEB_LDR_DATA 的 InMemoryOrderModuleList 成员获取 LIST_ENTRY 结构
pListEntryStart = pPebLdrData->InMemoryOrderModuleList.Flink;
pListEntryEnd = pPebLdrData->InMemoryOrderModuleList.Blink;
// 6、查找所有已载入到内存中的模块
for (u_int i = 0; pListEntryStart != pListEntryEnd; i++)
{
// 7、通过 LIST_ENTRY 的 Flink 成员获取 LDR_DATA_TABLE_ENTRY 结构
pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)CONTAINING_RECORD(pListEntryStart,
LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
// 8、输出 LDR_DATA_TABLE_ENTRY 的 BaseDllName 或 FullDllName 成员信息
#ifdef _WIN64
printf("[第 %d 模块] \n\t路径:%ws \n\t基址:0x%0I64X\n",
i + 1,
pLdrDataEntry->FullDllName.Buffer,
reinterpret_cast<uint64_t>(pLdrDataEntry->DllBase));
#else
printf("[第 %d 模块] \n\t路径:%ws \n\t基址:0x%0IX\n",
i + 1,
pLdrDataEntry->FullDllName.Buffer,
reinterpret_cast<uint32_t>(pLdrDataEntry->DllBase));
#endif // _WIN64
pListEntryStart = pListEntryStart->Flink;
}
// 卸载 DLL
FreeLibrary(hdll);
system("pause");
return 0;
}
3.2 执行效果截图
这是 wow64上运行 86 位模式编译的程序。
4. 小结
通过对 PEB 中 PEB_LDR_DATA 的理解,我们发现模块的遍历用到了非常巧妙的数据结构和组织逻辑,比如他给出三种不同方式排序的链表:按加载顺序、按进程初始化载入顺序、按内存中排列顺序,这有利于对不同类型模块优化链表的查找性能,并且采用了映射结构的闭环双向链表,数据之间的插入删除操作也能够提升效率,相当于是一个“组合怪”。
更新于:2023.12.29