- A+
一:背景
1.讲故事
有朋友咨询个问题,他每次在调试 WinDbg 的时候,进程初始化断点之前都会有一些 dll 加载到进程中,比如下面这样:
Microsoft (R) Windows Debugger Version 10.0.25200.1003 X86 Copyright (c) Microsoft Corporation. All rights reserved. CommandLine: D:net6ConsoleApp1DebugConsoleApplication3.exe ************* Path validation summary ************** Response Time (ms) Location Deferred srv*c:mysymbols*https://msdl.microsoft.com/download/symbols Symbol search path is: srv*c:mysymbols*https://msdl.microsoft.com/download/symbols Executable search path is: ModLoad: 00400000 0041f000 ConsoleApplication3.exe ModLoad: 774b0000 77653000 ntdll.dll ModLoad: 753a0000 75490000 C:WindowsSysWOW64KERNEL32.DLL ModLoad: 75900000 75b14000 C:WindowsSysWOW64KERNELBASE.dll ModLoad: 79bc0000 79d36000 C:WindowsSysWOW64ucrtbased.dll ModLoad: 79ba0000 79bbe000 C:WindowsSysWOW64VCRUNTIME140D.dll (44c.4b0c): Break instruction exception - code 80000003 (first chance) eax=00000000 ebx=00000000 ecx=afe00000 edx=00000000 esi=774c1ff4 edi=774c25bc eip=77561a42 esp=0019fa20 ebp=0019fa4c iopl=0 nv up ei pl zr na pe nc cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246 ntdll!LdrpDoDebuggerBreak+0x2b: 77561a42 cc int 3
问是否可以用 WinDbg 解读下内部运作原理,哈哈,其实要了解运作原理,一定要熟知 PE 头,那这篇就安排上。
二:理解 PE 头结构
1. 测试代码
为了方便讲述,先上一段测试代码,这里故意加载 combase.dll
是为了提取 PE 中的某些数据结构,代码如下:
#include <iostream> #include <Windows.h> int main(int argc, char* argv[]) { LoadLibrary(L"combase.dll"); getchar(); }
其实你仔细想一想也能知道,既然能做到初始化加载,必然在 PE 头上藏了什么东西,这些东西让 Windows 加载器可以顺利加载诸如 ntdll.dll
, KERNEL32.dll
等等,接下来一起观察下。
2. 可视化观察 PE 头
要想可视化观察 PE 头,工具有很多,这里使用 PPEE
工具,截图如下:
从图中可以看到,其实初始化加载什么,由可选头中的 DIRECTORY_ENTRY_IMPORT
数据目录项决定,哪这里包含了哪些初始化 dll 呢? 可以选中右边的 DIRECTORY_ENTRY_IMPORT
项即可,如下图所示:
肯定有朋友说,WinDbg 上显示的是 5 个,你这里才 3 个,还有 2 个为什么没有? 很简单,多余的 ntdll.dll
和 KERNELBASE.dll
必然是依赖项哈。
3. 用 WinDbg 深入探究
玩 WinDbg 都喜欢刨根问底,拿可视化 PPEE 肯定忽悠不过去,那好吧,我们用 C 中的结构体去解剖它。
- DOS Header 节
这一块信息在源码中是用 ntdll!_IMAGE_DOS_HEADER
结构来承载的,可以用 dt 输出,起始点就是我们的 ConsoleApplication3.dll
在进程的首位置,即: 0x400000
。
0:000> dt 0x400000 _IMAGE_DOS_HEADER ConsoleApplication3!_IMAGE_DOS_HEADER +0x000 e_magic : 0x5a4d +0x002 e_cblp : 0x90 +0x004 e_cp : 3 ... +0x024 e_oemid : 0 +0x026 e_oeminfo : 0 +0x028 e_res2 : [10] 0 +0x03c e_lfanew : 0n232
- NT Header 节
接下来就是 NT Header 节,它在源码中是由 _IMAGE_NT_HEADERS
结构来承载的,起始位置的偏移已经保存在上面的 e_lfanew
字段中,即 0n232
。
0:000> dt 0x400000+0n232 _IMAGE_NT_HEADERS ConsoleApplication3!_IMAGE_NT_HEADERS +0x000 Signature : 0x4550 +0x004 FileHeader : _IMAGE_FILE_HEADER +0x018 OptionalHeader : _IMAGE_OPTIONAL_HEADER
- _IMAGE_DATA_DIRECTORY
在 PPEE 的第一张截图中,我们查看的是 Data Directorys
数组中的第二项 DIRECTORY_ENTRY_IMPORT
内容,它里面定义了我们需要初始化导入的 dll,我们可以用 dt r3
展开一下,然后一直点点点就好了,简化后如下:
0:000> dt -r3 0x400000+0n232 _IMAGE_NT_HEADERS ConsoleApplication3!_IMAGE_NT_HEADERS +0x000 Signature : 0x4550 +0x004 FileHeader : _IMAGE_FILE_HEADER +0x000 Machine : 0x14c ... +0x012 Characteristics : 0x103 +0x018 OptionalHeader : _IMAGE_OPTIONAL_HEADER +0x000 Magic : 0x10b +0x002 MajorLinkerVersion : 0xe '' ... +0x05c NumberOfRvaAndSizes : 0x10 +0x060 DataDirectory : [16] _IMAGE_DATA_DIRECTORY +0x000 VirtualAddress : 0 +0x004 Size : 0 0:000> dx -r1 (*((ConsoleApplication3!_IMAGE_DATA_DIRECTORY (*)[16])0x400160)) (*((ConsoleApplication3!_IMAGE_DATA_DIRECTORY (*)[16])0x400160)) [Type: _IMAGE_DATA_DIRECTORY [16]] [0] [Type: _IMAGE_DATA_DIRECTORY] [1] [Type: _IMAGE_DATA_DIRECTORY] [2] [Type: _IMAGE_DATA_DIRECTORY] ... [15] [Type: _IMAGE_DATA_DIRECTORY] 0:000> dx -r1 (*((ConsoleApplication3!_IMAGE_DATA_DIRECTORY *)0x400168)) (*((ConsoleApplication3!_IMAGE_DATA_DIRECTORY *)0x400168)) [Type: _IMAGE_DATA_DIRECTORY] [+0x000] VirtualAddress : 0x1b1cc [Type: unsigned long] [+0x004] Size : 0x50 [Type: unsigned long]
从输出的 VirtualAddress=0x1b1cc
中可以看到,我们 PPEE 截图二中的 DIRECTORY_ENTRY_IMPORT
真实内容是在偏移 0x1b1cc
处,它是一个 combase!_IMAGE_IMPORT_DESCRIPTOR
结构体,输出如下:
0:005> dt 0x400000+0x1b1cc combase!_IMAGE_IMPORT_DESCRIPTOR +0x000 Characteristics : 0x1b21c +0x000 OriginalFirstThunk : 0x1b21c +0x004 TimeDateStamp : 0 +0x008 ForwarderChain : 0 +0x00c Name : 0x1b40c +0x010 FirstThunk : 0x1b000
到这里就很关键了,涉及到如下几点信息:
- 加载的 dll 名字是什么?
可以从 Name
字段提取,参考如下代码:
0:005> da 0x400000+0x1b40c 0041b40c "KERNEL32.dll"
- 加载的 方法名 是什么?
这需要提取 OriginalFirstThunk
字段,这里是一个 _IMAGE_IMPORT_BY_NAME
类型的指针数组,代码如下:
0:005> dp 0x400000+0x1b21c 0041b21c 0001b3e8 0001b3fc 0001b954 0001b942 0041b22c 0001b934 0001b924 0001b912 0001b906 0041b23c 0001b8fa 0001b8ea 0001b8d6 0001b8ba ... 0:005> dt 0x400000+0x0001b3e8 combase!_IMAGE_IMPORT_BY_NAME +0x000 Hint : 0x382 +0x002 Name : [1] "I" 0:005> da 0x400000+0x0001b3e8+0x2 0041b3ea "IsDebuggerPresent"
结合上面的输出,我们知道 IsDebuggerPresent()
是属于 KERNEL32.dll
下的,有了这两点信息,Windows 加载器就可以用 LoadLibrary
和 GetProcAddress
方法将其加载到进程中了,转化为 C 代码大概是这样的。
typedef BOOL(CALLBACK* DeubbgerFunc)(); int main(int argc, char* argv[]) { HMODULE hModule = LoadLibrary(L"KERNEL32.dll"); DeubbgerFunc func = (DeubbgerFunc)GetProcAddress(hModule, "IsDebuggerPresent"); BOOL b= func(); }
- func 函数地址会保存吗?
当然会保存了,会放在 _IMAGE_IMPORT_DESCRIPTOR
结构下的 FirstThunk
字段中,这是一个函数地址的指针数组,可以用 dds 观察。
0:005> dt 0x400000+0x1b1cc combase!_IMAGE_IMPORT_DESCRIPTOR +0x000 Characteristics : 0x1b21c +0x000 OriginalFirstThunk : 0x1b21c +0x004 TimeDateStamp : 0 +0x008 ForwarderChain : 0 +0x00c Name : 0x1b40c +0x010 FirstThunk : 0x1b000 0:005> dds 0x400000+0x1b000 0041b000 753c20d0 KERNEL32!IsDebuggerPresentStub 0041b004 753c16c0 KERNEL32!LoadLibraryWStub 0041b008 753c2e80 KERNEL32!GetCurrentProcess 0041b00c 753bf550 KERNEL32!GetProcAddressStub 0041b05c 753b9910 KERNEL32!TerminateProcessStub ...
还有一点要注意,如果你在代码中使用 IsDebuggerPresent()
方法的话,它会从 0041b000
位置上取函数地址,参考如下汇编代码:
三:总结
对初学者来说,搞懂这些还是有一定困难的,我在网上找了一份很好的参考图,大家可以对照着这张图理解,在此感谢作者。
- INT 是 Windows 需要加载的函数名列表。
- IAT 是存放 GetProcAddress 返回函数地址的列表。