18.10 动态链接库
18.10.1 DLL概述
动态链接库(DLL)是Windows NT的基本组成部分。DLL存在背后的主要动机是,它们可以在进程之间轻松共享,因此DLL的单个副本位于RAM中,所有需要它的进程都可以共享DLL的代码。在早期,RAM比现在小得多,使得内存节省非常重要。即使在今天,内存节省也非常重要,因为一个典型的进程使用了几十个DLL。
DLL是可移植可执行(PE)文件,可以包含以下一个或多个:代码、数据和资源。每个用户模式进程都使用子系统dll,如kernel32.dll、user32.dll、gdi32.dll和advapi32.dll,实现文档化的Windows API。当然,Ntdll.Dll在每个用户模式进程中都是必需的,包括原生应用程序。
DLL是可以包含函数、全局变量和资源(如菜单、位图和图标)的库。某些函数(和类型)可以通过DLL导出,以便加载DLL的其他DLL或可执行文件可以直接使用它们。DLL可以在进程启动时隐式加载到进程中,也可以在应用程序调用LoadLibrary或LoadLibraryEx函数时显式加载。
18.10.2 显式加载
显式链接到DLL可以更好地控制何时加载和卸载DLL。此外,如果DLL加载失败,进程不会崩溃,因此应用程序可以处理错误并继续。显式DLL链接的一个常见用途是加载语言相关资源。例如,应用程序可能尝试加载带有当前系统区域设置中资源的DLL,如果未找到,则可以加载默认资源DLL,该DLL始终作为应用程序安装的一部分提供。对于显式链接,不使用导入库,因此加载程序不会尝试加载DLL(可能不存在)。这也意味着不能使用#include来获取导出的符号声明,因为链接器将因“未解决的外部”错误而失败。我们如何使用这样的DLL?
第一步是在运行时加载它,通常在需要的地方加载。这是LoadLibrary的工作:
HMODULE LoadLibrary(LPCTSTR lpLibFileName);
LoadLibrary只接受文件名或完整路径。如果只指定了文件名,则搜索DLL的顺序与隐式加载DLL的顺序相同。如果指定了完整路径,则只尝试加载该文件。在实际搜索开始之前,加载器检查是否有一个具有相同名称的模块已加载到进程地址空间中。如果是,则不执行搜索,并返回现有DLL的句柄。例如,如果SimpleDell.Dll已加载(无论从哪个路径),并调用LoadLibrary加载名为SimpleDll.Dll的文件(在任何路径或不带路径的情况下),不会加载其他Dll。
成功加载DLL后,可以使用GetProcAddress从DLL访问导出的函数:
FARPROC GetProcAddress(HMODULE hModule, LPCSTR lpProcName);
函数返回DLL中导出符号的地址。第二个参数是符号的名称,注意,名称必须是ASCII。返回值是一个通用的FARPROC,其中“远”和“近”表示不同的东西。如果符号不存在(或未导出,这是相同的),则GetProcAddress返回NULL。举个例子:
// dll的其中一个函数声明
__declspec(dllexport) bool IsPrime(int n);
// 加载dll,导出上面的函数地址,并调用。
auto hPrimesLib = ::LoadLibrary(L"SimpleDll.dll");
if (hPrimesLib)
{
// DLL found
using PIsPrime = bool (*)(int);
auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "IsPrime");
if (IsPrime)
{
bool test = IsPrime(17);
printf("%d\n", (int)test);
}
}
这段代码看起来相对简单——DLL已加载。不幸的是,对GetProcAddress的调用失败,GetLastError返回127(找不到指定的进程)。显然,GetProcAddress无法定位导出的函数,即使它已导出。为什么?
原因与函数的名称有关,如果我们使用Dumpbin查探关于SimpleDll.Dll的信息:
0 000111F9 ?IsPrime@@YA_NH@Z = @ILT+500(?IsPrime@@YA_NH@Z)
原因找到了——链接器“弄乱”了要使用的函数的名称:IsPrime@@YA_NH@Z。原因与IsPrime
在C++中不够唯一有关。iPrime函数可以是A类、B类以及全局函数,也可能是某个命名空间C的一部分。此外,由于C++函数重载,同一作用域中可能有多个名为IsPrime的函数。因此,链接器为函数提供了一个奇怪的名称,其中包含这些独特的属性。我们可以尝试在前面的代码示例中替换这个损坏的名称,如下所示:
auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "?IsPrime@@YA_NH@Z");
你会发现它是有效的!然而,这非常无趣且不太实用,我们必须用一种方法来查找损坏的名称,以使其正确。通常的做法是将所有导出的函数转换为C风格的函数。因为C不支持函数重载或类,所以链接器不必进行复杂的修改。下面是一种将函数导出为C的方法:
extern "C" __declspec(dllexport) bool IsPrime(int n);
如果编译C文件,以上将是默认值。
通过此更改,可以简化获取指向IsPrime函数的指针:
auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "IsPrime");
但是,这种将函数转换为C风格的方案不能用于类中的成员函数——正是使用GetProcAddress访问C++函数不实际的原因,也是大多数用于LoadLibrary/GetProcAddress的DLL仅公开C风格函数的原因。
如果DLL不再需要,可以使用以下API释放之:
BOOL FreeLibrary(HMODULE hLibModule);
系统为每个加载的DLL维护每个进程计数器。如果对同一DLL多次调用LoadLibrary,则需要相同数量的FreeLibrary调用才能从进程地址空间真正卸载DLL。如果需要加载DLL的句柄,则可以使用GetModuleHandle检索它:
HMODULE GetModuleHandle(LPCTSTR lpModuleName);
18.10.3 调用约定
调用约定(Calling Convention)表明函数参数如何传递给函数,以及如果在堆栈上传递,谁负责清理参数。对于x64,只有一个调用约定。对于x86,有几个,最常见的是标准调用约定stdcall和C调用约定cdecl。stdcall和cdecl都使用堆栈传递参数,从右向左推送。它们之间的主要区别在于,对于stdcall,被调用方(函数体本身)负责清理堆栈,而对于cdecl,调用方负责清理堆栈。
stdcall的优点是更小,因为堆栈清理代码只显示一个(作为函数体的一部分)。使用cdecl,对函数的每次调用都必须跟随一条指令,以清除堆栈中的参数。cdecl函数的优点是它们可以接受可变数量的参数(在C/C++中由…指定),因为只有调用方知道传入了多少参数。
Visual C++中用户模式项目中使用的默认调用约定是cdecl,通过在返回类型和函数名之间放置适当的关键字来指定调用约定。为此,Microsoft编译器重新识别__cdecl
和__stdcall
关键字,使用的关键字也必须在实现中指定,以下是将IsPrime设置为使用stdcall的示例:
extern "C" __declspec(dllexport) bool __stdcall IsPrime(int n);
这也意味着,在定义函数指针以与GetProcAddress一起使用时,还必须指定正确的调用约定,否则我们将得到运行时错误或堆栈损坏:
using PIsPrime = bool (__stdcall *)(int);
// or
typedef bool(__stdcall* PIsPrime)(int);
__stdcall
是大多数Windows API使用的调用约定,通常是使用WINAPI、APIENTRY、PASCAL、CALLBACK之一的宏,它们的含义完全相同。
18.10.4 DllMain函数
DLL可以有一个入口点(但不是必须,也可以没有),传统上称为DllMain,必须具有以下原型:
BOOL WINAPI DllMain(HINSTANCE hInsdDll, DWROD reason, PVOID reserved);
hInstance参数是DLL加载到进程中的虚拟地址,如果显式加载DLL,则它与从LoadLibrary返回的值相同。reason参数指示调用DllMain的原因,其值如下表所述:
Reason值 | 描述 |
---|---|
DLL_PROCESS_ATTACH | 当DLL附加到进程时调用 |
DLL_PROCESS_DETACH | 在从进程卸载DLL之前调用 |
DLL_THREAD_ATTACH | 在进程中创建新线程时调用 |
DLL_THREAD_DETACH | 在线程退出进程之前调用 |
18.10.5 Dll注入
在某些情况下,需要将DLL注入另一个进程。注入DLL是指以某种方式强制另一个进程加载特定的DLL,允许DLL在目标进程的上下文中执行代码。这种能力有很多用途,但它们本质上都归结为某种形式的定制或目标进程内操作的拦截。以下是一些具体的例子:
- 反恶意软件解决方案和其他应用程序可能希望在目标进程中挂接API函数。
- 通过子类化窗口或控件自定义窗口的能力,允许对UI进行行为更改。
- 作为目标进程的一部分,可以无限访问该进程中的任何内容。有些是好的,比如监控应用程序行为以定位错误的DLL,但有些是坏的。
18.10.5.1 远程线程注入
通过在加载所需DLL的目标进程中创建线程来注入DLL可能是最广为人知和最直接的技术。其思想是在目标进程中创建一个线程,该线程使用要注入的DLL路径调用LoadLibrary函数。将代码执行到目标进程中的示例代码如下:
int main(int argc, const char* argv[])
{
// 检查命令行参数
if (argc < 3)
{
printf("Usage: injector <pid> <dllpath>\n");
return 0;
}
// 注入器需要目标进程ID和要注入的DLL, 故而打开目标进程的句柄
HANDLE hProcess = ::OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD, FALSE, atoi(argv[1]));
if (!hProcess)
return Error("Failed to open process");
// 准备要加载的DLL路径, 路径字符串本身必须放在目标进程中,因为是执行LoadLibrary的地方. 可以使用VirtualAllocEx函数.
void* buffer = ::VirtualAllocEx(hProcess, nullptr, 1 << 12, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!buffer)
return Error("Failed to allocate buffer in target process");
// 使用WriteProcessMemory将DLL路径复制到分配的缓冲区
if (!::WriteProcessMemory(hProcess, buffer, argv[2], ::strlen(argv[2]) + 1, nullptr))
return Error("Failed to write to target process");
// 创建远程线程
DWORD tid;
HANDLE hThread = ::CreateRemoteThread(hProcess, nullptr, 0,
(LPTHREAD_START_ROUTINE)::GetProcAddress(::GetModuleHandle(L"kernel32"), "LoadLibraryA"),
buffer, 0, &tid);
if (!hThread)
return Error("Failed to create remote thread");
// 等待远程线程退出
printf("Thread %u created successfully!\n", tid);
if (WAIT_OBJECT_0 == ::WaitForSingleObject(hThread, 5000))
printf("Thread exited.\n");
else
printf("Thread still hanging around...\n");
// 释放和清理
::VirtualFreeEx(hProcess, buffer, 0, MEM_RELEASE);
::CloseHandle(hThread);
::CloseHandle(hProcess);
}
以上代码中,必须指定DLL的完整路径,因为加载规则是从目标进程的角度,而不是调用方的角度。注入的DLL的DllMain显示了一个简单的消息框:
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, PVOID lpReserved)
{
switch (reason)
{
case DLL_PROCESS_ATTACH:
wchar_t text[128];
::StringCchPrintf(text, _countof(text), L"Injected into process %u", ::GetCurrentProcessId());
::MessageBox(nullptr, text, L"Injected.Dll", MB_OK);
break;
}
return TRUE;
}
18.10.5.2 Windows挂钩
Windows挂钩指的是一组与用户界面相关的挂钩,可通过SetWindowsHookEx等API使用:
HHOOK SetWindowsHookEx(int idHook, HOOKPROC lpfn, HINSTANCE hmod, DWORD dwThreadId);
lpfn提供的钩子函数具有以下原型:
typedef LRESULT (CALLBACK* HOOKPROC)(int code, WPARAM wParam, LPARAM lParam);
示例代码:
int main()
{
DWORD tid = FindMainNotepadThread();
if (tid == 0)
return Error("Failed to locate Notepad");
auto hDll = ::LoadLibrary(L"HookDll");
if (!hDll)
return Error("Failed to locate Dll\n");
using PSetNotify = void (WINAPI*)(DWORD, HHOOK);
auto setNotify = (PSetNotify)::GetProcAddress(hDll, "SetNotificationThread");
if (!setNotify)
return Error("Failed to locate SetNotificationThread function in DLL");
auto hookFunc = (HOOKPROC)::GetProcAddress(hDll, "HookFunction");
if (!hookFunc)
return Error("Failed to locate HookFunction function in DLL");
// 设置挂钩。
auto hHook = ::SetWindowsHookEx(WH_GETMESSAGE, hookFunc, hDll, tid);
if (!hHook)
return Error("Failed to install hook");
(...)
}
在DLL(或EXE)中共享变量的技术。注入应用程序在其自身进程的上下文中调用SetNotificationThread,但函数将信息写入共享变量,因此这些变量可用于使用相同DLL的任何进程:
18.10.5.3 API挂钩
API挂钩是指拦截Windows API(或更一般地说,任何外部函数)的行为,以便可以检查其参数并可能改变其行为,是一种非常强大的技术,首先是反恶意软件解决方案所采用的技术,通常将自己的DLL注入每个进程(或大多数进程),并挂接他们关心的某些函数,如VirtualAllocEx和CreateRemoteThread,将它们重定向到DLL提供的备用实现。在该实现中,他们可以检查参数并在向调用方返回错误代码或将调用转发到原始函数之前执行任何需要的操作。
- IAT挂钩
导入地址表(Import Address Table,IAT)挂钩可能是函数挂钩的最简单方法,设置相对简单,不需要任何特定于平台的代码。每个PE映像都有一个导入表,其中列出了它所依赖的DLL以及它从DLL中使用的函数。使用dumpbin或图形工具检查PE文件来查看这些导入,以下是notepad.exe的模块概览:
D:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.29.30133\bin\Hostx64\arm>dumpbin /imports c:\Windows\System32\notepad.exe
Microsoft (R) COFF/PE Dumper Version 14.29.30133.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file c:\Windows\System32\notepad.exe
File Type: EXECUTABLE IMAGE
Section contains the following imports:
KERNEL32.dll
1400268B8 Import Address Table
14002D3D8 Import Name Table
0 time date stamp
0 Index of first forwarder reference
2B8 GetProcAddress
DC CreateMutexExW
1 AcquireSRWLockShared
114 DeleteCriticalSection
221 GetCurrentProcessId
2BE GetProcessHeap
281 GetModuleHandleW
10A DebugBreak
387 IsDebuggerPresent
342 GlobalFree
(...)
GDI32.dll
140026800 Import Address Table
14002D320 Import Name Table
0 time date stamp
0 Index of first forwarder reference
34 CreateDCW
39F StartPage
39D StartDocW
366 SetAbortProc
180 DeleteDC
18E EndDoc
(...)
USER32.dll
140026B50 Import Address Table
14002D670 Import Name Table
0 time date stamp
0 Index of first forwarder reference
2AF PostMessageW
28C MessageBoxW
177 GetMenu
43 CheckMenuItem
1C2 GetSubMenu
E9 EnableMenuItem
38D ShowWindow
142 GetDC
2FC ReleaseDC
(...)
Summary
3000 .data
1000 .didat
2000 .pdata
A000 .rdata
1000 .reloc
1000 .rsrc
25000 .text
调用这些导入函数的方式是通过导入地址表,该表包含加载程序(NtDll.Dll)在运行时映射这些函数后这些函数的最终地址。这些地址事先不知道,因为DLL可能不会在其首选地址加载。
IAT挂钩利用了所有调用都是间接调用的事实,在运行时只替换表中的函数地址以指向替代函数,同时保存原始地址,以便在需要时调用实现。这种挂钩可以在当前进程上完成,也可以在另一个进程的上下文中与DLL注入相结合。
必须在所有进程模块中搜索要挂钩的函数,因为每个模块都有自己的IAT。例如,记事本可以调用函数CreateFileW.exe模块本身,但当调用“打开文件”对话框时,ComCtl32.dll也可以调用它。如果只对记事本的调用感兴趣,那么它的IAT是唯一需要连接的。否则,必须搜索所有加载的模块,并且必须替换CreateFileW的IAT条目。
下面的示例代码从User32.Dll中挂接GetSysColor API,并在应用程序中更改一些颜色,而无需接触应用程序的UI代码:
void HookFunctions()
{
auto hUser32 = ::GetModuleHandle(L"user32");
// save original functions
GetSysColorOrg = (decltype(GetSysColorOrg))::GetProcAddress(hUser32, "GetSysColor");
// IAT辅助函数使得挂钩使用变得很简单
auto count = IATHelper::HookAllModules("user32.dll", GetSysColorOrg, GetSysColorHooked);
ATLTRACE(L"Hooked %d calls to GetSysColor\n");
}
18.10.5.4 “迂回”式挂钩
挂接函数的另一种常见方法是执行以下步骤:
- 找到原始函数的地址并保存。
- 用JMP汇编指令替换代码的前几个字节,保存旧代码。
- JMP指令调用挂钩函数。
- 如果要调用原始代码,使用第一步中保存的地址。
- 取消挂钩时,恢复修改的字节。
该方案比IAT挂钩更强大,因为无论是否通过IAT调用,实际函数代码都会被修改。但这种方法有两个缺点:
- 替换的代码是特定于平台的。x86、x64、ARM和ARM64的代码不同,因此更难正确使用。
- 上述步骤必须自动完成。进程中可能有其他线程在程序集字节被替换时调用挂钩函数,可能会导致程序崩溃。
实现这种挂钩很困难,需要复杂的CPU指令和调用约定知识,更不用说上面的同步问题了。有几个开源和免费的库提供此功能,其中一个叫做“Detours”(迂回),来自微软,但也有其他如MinHook和EasyHook。如果需要这种挂钩,优先考虑使用现有的库。以下是Detours挂钩使用示例:
#include <detours.h>
bool HookFunctions()
{
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach((PVOID*)&GetWindowTextOrg, GetWindowTextHooked);
DetourAttach((PVOID*)&GetWindowTextLengthOrg, GetWindowTextLengthHooked);
auto error = DetourTransactionCommit();
return error == ERROR_SUCCESS;
}
Detours与事务的概念一起工作,事务是一组提交以原子方式执行的操作。我们需要保存原始函数,可以在挂接之前使用GetProcAddress完成,也可以使用指针定义完成:
decltype(::GetWindowTextW)* GetWindowTextOrg = ::GetWindowTextW;
decltype(::GetWindowTextLengthW)* GetWindowTextLengthOrg = ::GetWindowTextLengthW;
18.10.6 DLL基地址
每个DLL都有一个首选加载(基)地址,即PE头的一部分,甚至可以使用Visual Studio中的项目属性来指定它(下图)。
默认情况下没有任何内容,使得VisualStudio使用一些默认值。对于32位DLL,这些值为0x10000000;对于64位DLL,它们为0x180000000。可以通过dumping从PE标头信息来验证:
dumpbin /headers HookDll_x64.dll
...
OPTIONAL HEADER VALUES
20B magic # (PE32+)
...
112FD entry point (00000001800112FD) @ILT+760(_DllMainCRTStartup)
1000 base of code
180000000 image base (0000000180000000 to 0000000180025FFF)
...
dumpbin /headers HookDll_x86.dll
...
OPTIONAL HEADER VALUES
10B magic # (PE32)
...
111B8 entry point (100111B8) @ILT+435(__DllMainCRTStartup@12)
1000 base of code
1000 base of data
10000000 image base (10000000 to 1001FFFF)
...
18.10.7 延迟加载DLL
前面研究了链接到DLL的两种主要方式:使用LIB文件的隐式链接(最简单、最方便)和动态链接(显式加载DLL并查找要使用的函数)。事实证明,还有第三种方法,在静态链接和动态链接之间有一种“中间地带”——延迟加载DLL(Delay-Load DLL)。
通过延迟加载,有两个好处:静态链接的便利性和仅在需要时动态加载DLL。要使用延迟加载DLL,需要对使用这些DLL的模块进行一些更改,无论是可执行DLL还是其他DLL。应延迟加载的DLL将添加到输入选项卡中的链接器选项中(下图)。
如果要支持动态卸载延迟加载DLL,在“高级链接器”选项卡中添加该选项(“卸载延迟加载的DLL”)。剩下的就是链接DLL的导入库(LIB)文件,并使用导出的功能,就像使用隐式链接的DLL一样。以下是延迟加载DLL示例:
#include "..\SimpleDll\Simple.h"
#include <delayimp.h>
bool IsLoaded()
{
auto hModule = ::GetModuleHandle(L"simpledll");
printf("SimpleDll loaded: %s\n", hModule ? "Yes" : "No");
return hModule != nullptr;
}
int main()
{
IsLoaded();
bool prime = IsPrime(17);
IsLoaded();
printf("17 is prime? %s\n", prime ? "Yes" : "No");
// 卸载dll
__FUnloadDelayLoadedDLL2("SimpleDll.dll");
IsLoaded();
prime = IsPrime(1234567);
IsLoaded();
return 0;
}
输出结果:
SimpleDll loaded: No
SimpleDll loaded: Yes
17 is prime? Yes
SimpleDll loaded: No
SimpleDll loaded: Yes