第15章:动态链接库
# 第15章:动态链接库
动态链接库(Dynamic Link Libraries,DLLs)自Windows NT诞生以来就是其基础组成部分。DLL存在的主要动机在于,它们可以在进程之间轻松共享,这样一个DLL的单一副本就可以存在于随机存取存储器(RAM)中,所有需要它的进程都能够共享该DLL的代码。在早期,RAM的容量比现在小得多,这使得这种内存节省变得非常重要。即便在今天,这种内存节省仍然意义重大,因为一个典型的进程会使用数十个DLL。
如今,DLL有诸多用途,本章我们将探讨其中的许多用途。
- 简介
- 构建DLL
- 显式链接和隐式链接
- DllMain函数
- DLL注入
- API挂钩
- DLL基地址
- 延迟加载DLL
- LoadLibraryEx函数
- 其他函数
# 简介
DLL是可移植可执行(Portable Executable,PE)文件,它可以包含以下一种或多种内容:代码、数据和资源。每个用户模式进程都会使用子系统DLL,例如kernel32.dll、user32.dll、gdi32.dll、advapi32.dll,这些DLL实现了有文档记录的Windows应用程序编程接口(API)。当然,Ntdll.Dll在每个用户模式进程中都是必需的,包括原生应用程序。
DLL是一种库,它可以包含函数、全局变量以及诸如菜单、位图、图标等资源。一些函数(和类型)可以由DLL导出,这样加载该DLL的其他DLL或可执行文件就能够直接使用它们。DLL可以在进程启动时隐式加载到进程中,也可以在应用程序调用LoadLibrary或LoadLibraryEx函数时显式加载。
# 构建DLL
我们先来看看如何构建一个DLL并导出符号。使用Visual Studio时,可以通过选择合适的项目模板来创建一个新的DLL项目(图15-1)。
图15-1:Visual Studio中的新建DLL项目
其中一个模板表明该DLL会导出符号,但任何DLL模板都可以。与可执行文件(EXE)项目相比,DLL项目唯一的根本性变化在于项目属性中的“配置类型”(图15-2)。
图15-2:Visual Studio中DLL项目的属性
Visual Studio创建的一个典型项目通常会包含以下文件:
- pch.h和pch.cpp - 预编译头文件和实现文件。
- framework.h - 被pch.h包含,应该包含所有 “标准” 的Windows头文件,如Windows.h。我通常会删除这个文件,直接把所有Windows头文件放在pch.h中。
- dllmain.cpp - 包含DllMain函数(本章后面会讨论)。
此时我们可以成功构建项目。然而,这个DLL还没什么用处。大多数DLL会导出一些功能供其他模块(其他DLL或EXE)调用。我们给这个DLL添加一个名为IsPrime的函数。首先在一个头文件中声明,该头文件可供DLL的使用者包含:
// Simple.h
bool IsPrime(int n);
2
然后在另一个文件中实现,因为这个实现不应该被DLL的使用者看到:
// Simple.cpp
#include "pch.h"
#include "Simple.h"
#include <cmath>
bool IsPrime(int n) {
int limit = (int)::sqrt(n);
for (int i = 2; i <= limit; i++)
if (n % i == 0)
return false;
return true;
}
2
3
4
5
6
7
8
9
10
11
12
就本节的目的而言,这个实现并不重要。关键在于,我们的DLL中有了一些功能,并且希望能够使用它。我们在Visual Studio的同一个解决方案中添加一个名为SimplePrimes的控制台应用程序项目。
为了能够使用DLL的功能,我们在main函数之前添加对Simple.h的包含:
// SimplePrimes.cpp
#include "..\SimpleDll\Simple.h"
// 其他包含...
2
3
我们通过调用IsPrime函数来添加一个简单的测试:
int main() {
bool test = IsPrime(17);
printf("%d\n", (int)test);
return 0;
}
2
3
4
5
如果我们编译这段代码,编译过程不会出错,但链接时会出现可怕的 “无法解析的外部符号” 错误:
error: SimplePrimes.obj : error LNK2019: unresolved external symbol “bool __cdecl IsPrime(int)”
(?IsPrime@@YA_NH@Z) referenced in function _main
2
编译器在Simple.h中找到了函数的声明,所以编译阶段相对顺利。它也会寻找函数的实现,但没有找到。它没有直接报错,而是向链接器发出信号,表明IsPrime函数的实现缺失,希望链接器能够解决这个问题。
链接器要如何解决呢?链接器对项目有一个 “全局” 的视角,并且知道可能作为已编译代码二进制片段提供的库。然而,链接器在它已知的库列表中没有找到任何相关内容,最终会因 “无法解析的外部符号” 错误而放弃。
这里缺少两个部分:一是关于在哪里找到实现的引用。我们可以通过右键单击SimplePrimes项目中的 “引用” 节点,然后从菜单中选择 “添加引用...” 来添加这个引用。这时会打开 “添加引用” 对话框(图15-3)。
图15-3:“添加引用” 对话框
你需要勾选Simple.Dll的复选框,然后点击 “确定”。“引用” 节点下会出现一个名为Simple.Dll的节点。现在构建项目仍然会产生相同的 “无法解析的外部符号” 错误。这就引出了缺失的第二部分:IsPrime函数必须被导出。在Simple.h中扩展IsPrime函数的声明,这是一种导出函数的方法:
__declspec(dllexport) bool IsPrime(int n);
一般来说,微软的编译器支持几种__declspec
类型,因为从模块导出符号并没有标准的方法。
C++ 20标准中的 “模块”(Modules)这一C++ 特性试图解决这个问题。然而,它不一定是专门针对DLL的。在撰写本文时,Visual C++ 编译器还没有完全实现这一特性。
现在我们可以构建项目,并且应该能够成功构建。我们可以运行SimplePrimes并得到预期的结果。添加dllexport
说明符后,IsPrime函数被添加到了导出符号列表中。但这仍然没有完全解释为什么链接器会满意,以及在运行时是如何找到DLL的。我们将在下一节详细讨论这些内容。
现在你可以打开任何PE查看器工具,查看Simple.Dll和SimplePrimes.exe。对于Simple.Dll,IsPrime函数应该会被列为导出函数(图15-4使用的是我自己的PE Explorer V2)。你也可以使用Dumpbin.exe命令行工具获取这些信息,如下所示:
C:\>dumpbin /exports SimpleDll.dll
Microsoft (R) COFF/PE Dumper Version 14.26.28805.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file c:\dev\Win10SysProg\Chapter15\Debug\SimpleDll.dll
File Type: DLL
Section contains the following exports for SimpleDll.dll
00000000 characteristics
FFFFFFFF time date stamp
0.00 version
1 ordinal base
1 number of functions
1 number of names
ordinal hint RVA name
1 0 000111F9 ?IsPrime@@YA_NH@Z = @ILT+500(?IsPrime@@YA_NH@Z)
Summary
1000 .00cfg
1000 .data
1000 .idata
1000 .msvcjmc
2000 .rdata
1000 .reloc
1000 .rsrc
7000 .text
10000 .textbss
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
注意与IsPrime相关的符号。它有一些奇怪的修饰,下一节会进行解释。
图15-4:Simple.dll中的导出函数
图15-5展示了SimplePrimes.exe的导入函数。其中一个导入自Simple.Dll,即IsPrime函数。
图15-5:SimplePrimes.exe中的导入函数
# 隐式链接和显式链接
有两种基本方式可以链接到动态链接库(DLL,Dynamic-Link Library),以便使用其功能。第一种也是最简单的方式是隐式链接(有时也称为与DLL的静态链接),上一节中使用的就是这种方式。第二种是显式链接,它更为复杂,但能对DLL的加载和卸载时机提供更多控制。
# 隐式链接
生成DLL时,默认也会生成一个伴随文件,称为导入库。这个文件的扩展名为LIB,它包含两部分信息:
- DLL的文件名(不含路径)
- 导出符号(函数和变量)列表
在上一节中,在Visual Studio里为DLL项目添加引用时,DLL项目生成的导入库会作为依赖项添加到EXE项目(或另一个想要使用该DLL的DLL项目)中。除了使用Visual Studio添加引用(早期版本的Visual Studio没有这个选项),还可以通过项目属性将LIB文件作为依赖项添加(图15-6)。
图15-6:项目属性中的导入库
图15-6使用了Visual Studio的$(TargetDir)变量来正确定位LIB文件,但一般来说,LIB文件可以复制到任何位置并进行引用。可以看到应用程序通过Visual Studio安装提供的导入库链接到的所有 “标准” 子系统DLL。
另一种链接导入库(或者就此而言,链接静态库)的方法是在代码中添加依赖项:
#ifdef _WIN64
#pragma comment(lib, ". ./x64/Debug/SimpleDll.lib")
#else
#pragma comment(lib, ". ./Debug/SimpleDll.lib")
#endif
2
3
4
5
定位LIB文件的过程并不优雅,但可以通过更多配置选项将生成的LIB文件放在更方便的目录中,或者配置其他默认搜索目录(这些在项目属性中都可以实现)来改进。
使用
#pragma
选项更容易查看,因为减少了对vcxproj文件的依赖。
有了导入库后,构建依赖项目(使用DLL的项目)需要以下步骤:
- 编译器遇到对某个函数(如SimpleDll中的IsPrime函数)的调用,但在任何源文件中都找不到该函数的实现。
- 编译器向链接器发出指令,让其查找这样的实现。
- 链接器尝试在静态库文件(通常也具有LIB扩展名)中查找实现,但失败了。
- 链接器在导入库中看到IsPrime函数在SimpleDll.Dll中实现。链接器将适当的数据添加到最终的可移植可执行文件(PE,Portable Executable)中,指示加载程序在运行时查找该DLL。
在运行时,加载程序(位于NtDll.Dll中)读取PE中的信息,并意识到需要查找SimpleDll.Dll文件。加载程序使用的搜索路径与第3章中描述的新创建进程查找所需DLL的路径相同,这就是隐式链接的实际过程。为方便起见,这里再次列出搜索列表(按顺序):
- 如果DLL名称是已知DLL(在注册表中指定)之一,则使用现有的映射文件,无需搜索。
- 可执行文件所在的目录。
- 进程的当前目录(由父进程确定)。
- 通过GetSystemDirectory函数返回的系统目录(例如c:\windows\system32)。
- 通过GetWindowsDirectory函数返回的Windows目录(例如c:\Windows)。
- PATH环境变量中列出的目录。
如果在这些目录中都找不到DLL,进程会显示图15-7所示的错误消息框,然后终止。
图15-7:隐式链接时未能找到DLL
已知DLL的注册表项指定了在尝试其他位置之前应在系统目录中搜索的DLL。这用于防止这些DLL被劫持。例如,恶意应用程序可能会将其自己的(比如)kernel32.dll副本放在应用程序目录中,导致进程加载恶意版本。不过,这是不可能发生的,因为kernel32.dll在已知DLL列表中。图15-8显示了位于HKLM\System\CurrentControlSet\Control\Session Manager\KnownDLLs的已知DLL注册表项。
图15-8:注册表中的已知DLL
系统初始化时会映射已知DLL(使用内存映射文件对象),这样将这些DLL加载到进程中会更快,因为在进程需要这些DLL之前,内存映射文件就已经准备好了。这些文件映射(节)对象可以在Sysinternals的WinObj(图15-9)或我自己的对象资源管理器中看到。
图15-9:WinObj中的已知DLL节
如果一个DLL依赖于其他DLL(又是隐式链接),则会以完全相同的方式递归搜索这些DLL。所有这些DLL都必须成功找到,否则进程会显示图15-7中的错误消息框并终止。
一旦找到隐式加载的DLL,其DllMain函数(如果提供了的话)会被执行,reason参数为DLL_PROCESS_ATTACH,这向DLL表明它现在已被加载到一个进程中(有关DllMain的更多信息,请参阅 “DllMain函数” 部分)。如果DllMain返回FALSE,则向加载程序表明DLL未成功初始化,进程会显示与图15-7类似的消息框并终止。
所有隐式加载的DLL在进程启动时加载,在进程退出/终止时卸载。尝试使用FreeLibrary函数(稍后讨论)卸载这些DLL看似成功,但实际上不起作用。
从开发人员的角度来看,与DLL进行隐式链接很容易。以下是想要与DLL隐式链接的应用程序开发人员所需步骤的总结:
- 添加相关的
#include
,在其中声明导出的函数/变量。 - 以前面描述的某种方式将DLL提供的导入库添加到其导入集中。
- 调用导出的函数或访问导出的变量。
“导出的函数” 不一定是全局函数,也可以是类中的C++ 成员函数。__declspec(dllexport)
指令可以这样应用于一个类:
class declspec(dllexport) PrimeCalculator {
public:
bool IsPrime(int n) const ;
std::vector<int> CalcRange(int from, int to);
};
2
3
4
5
使用这个类的方式很常规,例如:
PrimeCalculator calc;
printf("123 prime? %s\n", calc.IsPrime(123) ? "Yes" : "No");
2
# 显式链接
与DLL的显式链接可以更好地控制DLL的加载和卸载时机。此外,如果DLL加载失败,进程不会崩溃,因此应用程序可以处理错误并继续运行。显式链接DLL的一个常见用途是加载与语言相关的资源。例如,应用程序可能会尝试加载包含当前系统区域设置资源的DLL,如果找不到,则可以加载作为应用程序安装一部分始终提供的默认资源DLL。
使用显式链接时,不使用导入库,这样加载程序就不会尝试加载DLL(因为它可能存在也可能不存在)。这也意味着不能使用#include
来获取导出符号的声明,因为链接器会因 “未解析的外部符号” 错误而失败。那么我们如何使用这样的DLL呢?
第一步是在运行时加载它,通常在需要它的地方附近加载。这是LoadLibrary函数的工作:
HMODULE LoadLibrary(_In_ LPCTSTR lpLibFileName);
LoadLibrary只接受文件名或完整路径。如果只指定文件名,则按照上一节中描述的隐式加载DLL的相同顺序搜索DLL。如果指定了完整路径,则只尝试加载该文件。
在实际搜索开始之前,加载程序会检查进程地址空间中是否已经加载了同名的模块。如果是,则不进行搜索,直接返回现有DLL的句柄。例如,如果SimpleDll.Dll已经被加载(无论从哪个路径),并且调用LoadLibrary加载名为SimpleDll.Dll的文件(无论路径如何或不带路径),都不会加载额外的DLL。
如果成功找到DLL,它会被映射到进程地址空间,LoadLibrary的返回值是它在进程中映射到的虚拟地址。其类型是HMODULE,有时也用HINSTANCE,这两个类型可以互换,它们在任何意义上都不是 “句柄”。这些类型名是16位Windows的遗留产物。无论如何,这个返回值在进程地址空间中唯一代表该DLL,并且是与其他访问DLL中信息的函数一起使用的参数,我们很快就会看到。
与所有DLL加载一样,加载的DLL会调用DllMain函数。如果返回TRUE,则认为DLL加载成功,控制权返回给调用者。否则,DLL卸载,函数调用失败。
如果函数调用失败(因为找不到DLL或其DllMain返回FALSE),则向调用者返回NULL。
现在DLL已经加载,我们如何访问DLL中导出的函数呢?这里用到的函数是GetProcAddress:
FARPROC GetProcAddress(
_In_ HMODULE hModule,
_In_ LPCSTR lpProcName);
2
3
该函数返回DLL中导出符号的地址。它的第一个参数是LoadLibrary返回的DLL句柄,第二个参数是符号的名称。注意,名称必须是ASCII格式,没有Unicode版本。返回值是一个通用的FARPROC,这又是一个来自16位时代的类型,在那时 “far” 和 “near” 有不同的含义。FARPROC的实际定义并不重要,调用者会根据一些预先的了解(比如一个不能包含的头文件或老式文档)将返回值转换为适当的类型。如果符号不存在(或者没有导出,这是一回事),GetProcAddress返回NULL。
让我们回到SimpleDll.Dll中导出的IsPrime函数,如下所示:
declspec(dllexport) bool IsPrime(int n);
它看起来没什么问题。下面是一个动态加载SimpleDll.Dll,然后查找IsPrime函数的初次尝试:
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);
}
}
2
3
4
5
6
7
8
9
10
前面代码中的
using
语句是C++ 11及更高版本中创建类型定义的首选方式,实际上它替代了typedef
。如果你使用的是C语言或旧版本的编译器,可以用typedef bool (*PIsPrime)(int);
替换这条using
语句。函数类型定义从来都不美观,但using
让它们更容易接受。
代码看起来相对简单,DLL被加载(在Visual Studio中构建DLL和EXE作为同一个解决方案的一部分时,它位于可执行文件的目录中),所以定位没有问题。不幸的是,对GetProcAddress的调用失败了,GetLastError返回127(“找不到指定的程序”)。显然,GetProcAddress找不到导出的函数,尽管它已经被导出了。为什么呢?
原因与函数的名称有关。如果我们查看Dumpbin转储的关于SimpleDll.Dll的信息,会发现以下内容(见本章前面部分):
1 0 000111F9 ?IsPrime@@YA_NH@Z = @ILT+500(?IsPrime@@YA_NH@Z)
链接器将函数名 “修饰” 成了?IsPrime@@YA_NH@Z
。这是因为在C++ 中,“IsPrime” 这个名称不够唯一。IsPrime函数可以在类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,可以调用FreeLibrary将其从进程中卸载:
BOOL FreeLibrary(_In_ HMODULE hLibModule);
系统为每个加载的DLL维护一个进程级的计数器。如果对同一个DLL多次调用LoadLibrary,则需要调用相同次数的FreeLibrary才能真正将DLL从进程地址空间中卸载。
如果需要获取已加载DLL的句柄,可以使用GetModuleHandle来检索:
HMODULE GetModuleHandle(_In_opt_ LPCTSTR lpModuleName);
模块名不需要完整路径,只需DLL名。如果未提供扩展名,默认会追加“.dll”。该函数不会增加DLL的加载计数。如果模块名为NULL,则返回可执行文件的句柄。可执行文件和DLL一样被映射到进程地址空间,返回的“句柄”实际上是可执行文件映像加载到的虚拟地址。
# 调用约定
调用约定(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);
// 或者
typedef bool( stdcall* PIsPrime)(int);
2
3
stdcall是大多数Windows API使用的调用约定。通常使用以下宏之一来表示,它们的含义完全相同(WINAPI、APIENTRY、PASCAL、CALLBACK)。这就是为什么在Windows头文件中会使用其中一个宏。下面是Sleep函数的精确声明:
VOID WINAPI Sleep(_In_ DWORD dwMilliseconds);
为了简化函数声明并突出重点内容,我在展示函数声明时省略了这些宏。
对于stdcall函数还有一个额外的问题。链接器对它们的修饰方式与cdecl不同,会在函数名前加上下划线,后面加上“@”符号和作为参数传递的字节数。所以对于IsPrime函数,实际导出的名称在x86架构下是_IsPrime@4
,但在x64架构下只是IsPrime(x64架构不修饰名称,因为它只有一种调用约定 )。
解决stdcall函数问题的方法是使用模块定义(DEF,Module Definition)文件。可以将这个文件添加到DLL项目中以指定各种选项。它的主要用途是列出导出符号,这意味着不再需要使用__declspec(dllexport)
。无论调用约定如何,都可以通过简单名称查找导出函数。
可以像添加其他文件一样,使用Visual Studio的“添加新项...”菜单来添加DEF文件。可以搜索“def”,或者直接显式指定文件扩展名。DEF文件的名称必须与项目名称相同,这样它无需额外配置就能被处理。对于SimpleDll项目,文件名是SimpleDll.def。下面是用于以一致方式导出IsPrime函数的DEF文件内容:
LIBRARY
EXPORTS
IsPrime
2
3
如果需要导出更多函数,在不同行分别添加每个函数。有了这个文件,就可以使用GetProcAddress通过简单名称查找IsPrime函数。
# DLL搜索与重定向
当仅使用文件名调用LoadLibrary
时,会使用特定的搜索路径。可以通过SetDllDirectory
添加自定义路径来搜索DLL:
BOOL SetDllDirectory(_In_opt_ LPCTSTR lpPathName);
指定的路径会在可执行文件所在目录之后进行查找。如果lpPathName
为NULL
,则之前通过SetDllDirectory
设置的任何目录都会被移除,恢复默认搜索顺序。如果lpPathName
为空字符串,那么进程的当前目录会从搜索列表中移除。每次调用SetDllDirectory
都会替换之前的调用。
如果需要多个搜索目录,可以调用AddDllDirectory
:
DLL_DIRECTORY_COOKIE AddDllDirectory(_In_ PCTSTR NewDirectory);
该函数会将指定目录添加到搜索路径中,并返回一个不透明指针,代表此次“注册”。不过,通过AddDllDirectory
添加的目录不会自动使用。必须额外调用SetDefaultDllDirectories
才能启用这些额外目录:
BOOL SetDefaultDllDirectories(_In_ DWORD DirectoryFlags);
标志(flags
)可以是表15-1中列出值的组合。
表15-1:SetDefaultDllDirectories的标志
值(LOAD_LIBRARY_SEARCH_ 前缀) | 描述 |
---|---|
APPLICATION_DIR | 可执行文件目录包含在搜索范围内 |
USER_DIRS | 将通过AddDllDirectory 添加的目录添加到搜索范围 |
SYSTEM32 | 将System32 目录添加到搜索范围 |
DEFAULT_DIRS | 组合之前所有的值 |
DLL_LOAD_DIR | 已加载DLL的目录会临时添加到依赖DLL的搜索范围中 |
为了让AddDllDirectory
添加的目录对未来的LoadLibrary
调用产生影响,可以使用以下调用:
::SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_USER_DIRS);
添加的目录在某些时候应该使用RemoveDllDirectory
移除:
BOOL RemoveDllDirectory(_In_ DLL_DIRECTORY_COOKIE Cookie);
没有明确的函数可以返回默认搜索路径。处理这个问题的最佳方法是,对每个AddDllDirectory
调用RemoveDllDirectory
,并使用NULL
调用SetDllDirectory
。或者,LoadLibraryEx
函数可用于“一次性”搜索路径更改(本章后面会介绍)。
# DllMain函数
DLL可以有一个入口点,传统上称为DllMain
,其必须具有以下原型:
BOOL WINAPI DllMain(HINSTANCE hInsdDll, DWORD reason, PVOID reserved);
hInstance
参数是DLL加载到进程中的虚拟地址。如果DLL是显式加载的,它与LoadLibrary
返回的值相同。reason
参数指示调用DllMain
的原因。它可以具有表15-2中列出的值。
表15-2:DllMain的原因值
原因值 | 描述 |
---|---|
DLL_PROCESS_ATTACH | 当DLL附加到进程时调用 |
DLL_PROCESS_DETACH | 在DLL从进程卸载之前调用 |
DLL_THREAD_ATTACH | 当进程中创建新线程时调用 |
DLL_THREAD_DETACH | 在进程中线程退出之前调用 |
当DLL加载到进程中时,DllMain
会以DLL_PROCESS_ATTACH
作为原因被调用。如果同一个DLL多次加载到同一个进程中(多次调用LoadLibrary
),DLL的内部引用计数器会增加,但DllMain
不会再次被调用。对于DLL_PROCESS_ATTACH
,DllMain
必须返回TRUE
以指示DLL正确初始化,否则返回FALSE
。如果返回FALSE
,DLL将被卸载。
与DLL_PROCESS_ATTACH
相反的是DLL_PROCESS_DETACH
,在DLL卸载之前调用。这可能是因为整个进程正在关闭,或者因为调用了FreeLibrary
来卸载这个DLL。请记住,使用TerminateProcess
强制终止进程不会调用DllMain
(更多详细信息请参见第3章)。
剩下的两个值会在创建新线程(DLL_THREAD_ATTACH
)和线程退出之前(DLL_THREAD_DETACH
)导致DllMain
被调用。许多DLL并不关心其宿主进程中创建或销毁的线程。在这种情况下,一个有用的优化方法是调用DisableThreadlibraryCalls
:
BOOL DisableThreadLibraryCalls(_In_ HMODULE hLibModule);
使用DLL的模块句柄调用这个函数,会告诉系统不要为与线程相关的事件调用DllMain
。这个调用通常在收到DLL_PROCESS_ATTACH
原因时进行。
进程中的第一个线程不会触发DLL_THREAD_ATTACH
原因——DLL应该使用DLL_PROCESS_ATTACH
来处理这种情况。
如果DLL使用线程本地存储(TLS,第10章讨论过),那么它可能需要为进程中的每个线程分配一些结构。DLL_THREAD_ATTACH
和DLL_THREAD_DETACH
原因对于这种分配和释放很有用。
reason
还支持第五个值,称为DLL_PROCESS_VERIFIER
(等于4),可用于编写应用程序验证DLL,尽管它没有正式文档说明。我将在第20章详细介绍应用程序验证器。
DllMain
的最后一个参数名为“reserved
”,但它指示DLL是隐式加载(lpReserved
不为NULL
)还是显式加载(lpReserved
为NULL
)。
使用Visual Studio创建DLL项目会提供一个基本的DllMain
,对于所有通知都只返回TRUE
。下面是一个简单的DllMain
,在DLL加载到进程中时调用DisableThreadlibraryCalls
:
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
switch (reason) {
case DLL_PROCESS_ATTACH:
::DisableThreadLibraryCalls(hModule);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
调用DllMain
时会持有加载器锁(Loader Lock)。可以将加载器锁视为临界区(critical section)。这意味着在DllMain
中调用某些函数是不允许的或危险的,因为它们可能导致死锁。例如,如果DllMain
代码(比如在DLL_THREAD_ATTACH
中)等待获取由应用程序管理的互斥锁(mutex),而另一个线程持有该互斥锁。在释放互斥锁之前,另一个线程调用了某个会尝试获取加载器锁的函数,比如调用LoadLibrary
或GetModuleHandle
,这就会导致死锁。
建议很简单:在DllMain
中尽量少做操作,将其他初始化操作推迟到DllMain
返回后立即调用的显式函数中。应该避免使用创建/销毁进程和DLL的函数(CreateProcess
、CreateThread
等)。使用堆(heap)或虚拟API、I/O函数、TLS以及kernel32.dll
中的大多数其他函数是安全的。
# DLL注入
在某些情况下,需要将DLL注入到另一个进程中。“注入DLL”是指以某种方式强制另一个进程加载特定的DLL。这使得该DLL能够在目标进程的上下文中执行代码。这种能力有很多用途,但本质上都归结为对目标进程内操作的某种形式的自定义或拦截。以下是一些具体示例:
- 反恶意软件解决方案和其他应用程序可能希望挂钩(hook)目标进程中的API函数。下一个主要部分将介绍挂钩。
- 能够通过子类化窗口或控件来自定义窗口,从而实现对用户界面(UI)行为的更改。
- 成为目标进程的一部分可以无限制地访问该进程中的任何内容。有些用途是积极的,比如用于监测应用程序行为以查找错误的DLL;也有些用途是恶意的。
在本节中,我们将介绍一些常见的DLL注入技术。这些技术绝不是详尽无遗的,因为网络安全社区总是能想出巧妙的方法将代码注入到目标进程中。本节重点介绍更“传统”或“标准”的技术,以便理解基本原理。
# 使用远程线程注入
通过在目标进程中创建一个线程来加载所需的DLL,这可能是最广为人知且相对直接的注入技术。其思路是在目标进程中创建一个线程,该线程使用要注入的DLL路径调用LoadLibrary
函数。问题是,如何让代码在目标进程中执行呢?
Injector
项目展示了这种技术。首先,我们需要检查命令行参数:
int main(int argc, const char* argv[]) {
if (argc < 3) {
printf("Usage: injector <pid> <dllpath>\n");
return 0;
}
2
3
4
5
注入器需要目标进程的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");
2
3
4
5
很快我们就会看到,为了使这种注入技术有足够的权限,我们需要不少访问掩码位。这意味着有些进程可能无法访问。
这种注入方法的技巧在于,从二进制角度来看,LoadLibrary
函数和线程函数本质上是相同的:
HMODULE WINAPI LoadLibrary(PCTSTR);
DWORD WINAPI ThreadFunction(PVOID);
2
这两个原型都接受一个指针,技巧就在这里:我们可以创建一个运行LoadLibrary
函数的线程!这很好,因为LoadLibrary
的代码已经在目标进程中(因为它是kernel32.dll
的一部分,而kernel32.dll
必须加载到每个属于Windows子系统的进程中)。
接下来的任务是准备要加载的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");
2
3
4
使用VirtualAllocEx
需要PROCESS_VM_OPERATION
访问掩码,我们在调用OpenProcess
时已经请求了该权限。我们分配了一个4KB的缓冲区,这有些过大,但即使指定小于这个大小的值,它也会被向上取整到4KB。注意,返回的指针在调用进程中没有意义——它是目标进程中分配的地址。
接下来,我们需要使用WriteProcessMemory
将DLL路径复制到分配的缓冲区中:
if (!::WriteProcessMemory(hProcess, buffer, argv[2], ::strlen(argv[2]) + 1, nullptr))
return Error("Failed to write to target process");
2
使用WriteProcessMemory
要求进程句柄具有PROCESS_VM_WRITE
访问掩码,我们的句柄具备该权限。代码使用ASCII将从命令行获取的DLL路径写入,这可能看起来不常见。我们稍后会明白原因。一切准备就绪——是时候创建远程线程了:
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");
2
3
4
5
6
7
CreateRemoteThread
接受目标进程句柄(必须具有PROCESS_CREATE_THREAD
访问掩码,我们的句柄具备该权限)、NULL
安全描述符、默认堆栈大小以及线程的启动例程。这就是我们利用LoadLibrary
和线程函数二进制等效性的地方。
首先,LoadLibrary
实际上不是一个函数——它是一个宏。我们必须选择LoadLibraryA
或LoadLibraryW
——我选择了LoadLibraryA
,只是因为使用起来更方便一些。这就是上面复制的字符串是ASCII的原因。我们也可以使用LoadLibraryW
并将Unicode字符串复制到目标进程中,本质上会得到相同的结果。
GetProcAddress
调用用于动态定位LoadLibraryA
的地址,利用了它在当前进程中地址相同这一事实。这是这种技术的关键——无需将代码复制到目标进程中。线程的参数是buffer
——我们在目标进程中复制DLL路径的地址。
就是这样。需要注意的一件重要事情是,注入的DLL必须与目标进程具有相同的“位数”,因为Windows不允许32位进程加载64位DLL,反之亦然。
剩下的就是进行一些清理工作:
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");
// be nice
::VirtualFreeEx(hProcess, buffer, 0, MEM_RELEASE);
::CloseHandle(hThread);
::CloseHandle(hProcess);
2
3
4
5
6
7
8
9
10
11
等待线程终止不是必需的,但在调用VirtualFreeEx
移除使用VirtualAllocEx
进行的分配之前,我们需要给它一些时间。这是礼貌的做法,但并非绝对必要。我们也可以让那4KB在目标进程中保持提交状态。
本章的解决方案中有一个名为Injected
的DLL项目,可用于测试这种技术。以下是一个测试的命令行示例:
C:\>Injector.exe 44532 C:\Dev\Win10SysProg\Chapter15\x64\Debug\Injected.dll
如果安装了杀毒软件,可能会收到通知,因为上述API的组合通常会被监测并被视为恶意行为。我机器上的Windows Defender将Injector.exe
标记为恶意软件,并威胁要删除它。
必须指定DLL的完整路径,因为加载规则是从目标进程的角度出发,而不是调用者的角度。Injected
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;
}
2
3
4
5
6
7
8
9
10
11
12
# Windows挂钩
本节中提到的“Windows挂钩(Windows Hooks)”是指一组可通过SetWindowsHookEx
API使用的与用户界面相关的挂钩:
HHOOK SetWindowsHookEx(
_In_ int idHook,
_In_ HOOKPROC lpfn,
_In_opt_ HINSTANCE hmod,
_In_ DWORD dwThreadId);
2
3
4
5
第一个参数是挂钩类型。有几种挂钩类型,每种都有其特定的语义。可以在官方文档中找到完整列表和详细信息。这些挂钩可以为特定线程(由dwThreadId
参数提供)安装,也可以全局安装,应用于调用者桌面中的所有进程(dwThreadId
设置为零)。有些挂钩类型只能全局安装(WH_JOURNALRECORD
、WH_JOURNALPLAYBACK
、WH_MOUSE_LL
、WH_KEYBOARD_LL
、WH_SYSMSGFILTER
),而其他挂钩类型既可以全局安装,也可以为特定线程安装。
由lpfn
提供的挂钩函数具有以下原型:
typedef LRESULT (CALLBACK* HOOKPROC)(int code, WPARAM wParam, LPARAM lParam);
每个挂钩类型都有对这些参数含义的具体描述。该函数必须在被挂钩进程的上下文中有效。如果挂钩是全局使用或用于不同进程的线程,回调函数必须是DLL的一部分,并被注入到目标进程中。在这种情况下,hmod
参数是调用者提供的DLL句柄。如果被挂钩的线程在调用进程内,模块句柄可以为NULL
,挂钩回调可以是调用者进程的一部分。
关于全局挂钩还有其他一些细节。由于32位DLL不能被64位进程加载,反之亦然,那么如何同时挂钩32位和64位进程呢?一种选择是有两个挂钩应用程序,32位和64位各一个,每个都提供具有正确位数的DLL。另一种选择是只使用一个安装应用程序,但这会导致另一种位数的进程远程回调到挂钩应用程序,挂钩应用程序必须泵送消息(pump messages)。这种方法可行,但速度较慢,并且回调不是在目标进程的上下文中运行。查看文档可获取更多详细信息。
SetWindowsHookEx
的返回值是挂钩的句柄,如果函数失败则返回NULL
。SetWindowsHookEx
的优点是,如果提供了DLL,Win32k.sys会在后台自动注入该DLL。这比使用诸如CreateRemoteThread
之类的方法隐蔽得多。
SetWindowsHookEx
并非完美无缺,它存在以下一些缺点:
- 它只能用于加载了
user32.dll
的进程。没有图形用户界面(GUI)的进程通常不会加载user32.dll
。 - 全局挂钩对使用调用者桌面的所有线程都是“全局”的。因此,它无法挂钩其他会话中的进程,即使这些进程有图形用户界面。
# 使用SetWindowsHookEx进行DLL注入和挂钩
下面的示例使用SetWindowsHookEx
函数和WH_GETMESSAGE
挂钩类型,将一个动态链接库(DLL,Dynamic Link Library )注入到找到的第一个记事本(Notepad)进程中,并监控输入的所有按键。这些按键会被发送到监控应用程序,这样监控应用程序就能有效地看到用户在记事本中输入的每一个按键。
这个系统涉及两个项目:用于注入的可执行文件(HookInject)和通过SetWindowsHookEx
间接注入的DLL(HookDll)。让我们从注入应用程序开始。
要测试这些内容,先运行记事本,然后运行HookInject。现在在记事本中开始输入内容。你会看到相同的文本在HookInject的控制台窗口中回显(图15-10)。
图15-10:挂钩的记事本
你可以通过在进程资源管理器(Process Explorer)中查找记事本,并检查其加载的模块,来确认DLL是否确实被注入(图15-11)。
图15-11:记事本进程中注入的DLL
首要任务是找到第一个记事本实例的第一个线程,因为我们需要获取由记事本用户界面(UI,User Interface )处理的消息信息,而这些消息是由记事本的第一个线程处理的。为此,我们可以使用工具帮助API(Toolhelp API)编写一个线程枚举函数来找到该线程:
DWORD FindMainNotepadThread() {
auto hSnapshot = ::CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnapshot == INVALID_HANDLE_VALUE)
return 0;
DWORD tid = 0;
THREADENTRY32 th32;
th32.dwSize = sizeof(th32);
::Thread32First(hSnapshot, &th32);
do {
auto hProcess = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION,
FALSE, th32.th32OwnerProcessID);
if (hProcess) {
WCHAR name[MAX_PATH];
if (::GetProcessImageFileName(hProcess, name, MAX_PATH) > 0) {
auto bs = ::wcsrchr(name, L'\\');
if (bs && ::_wcsicmp(bs, L"\\notepad.exe") == 0) {
tid = th32.th32ThreadID;
}
}
::CloseHandle(hProcess);
}
} while (tid == 0 && ::Thread32Next(hSnapshot, &th32));
::CloseHandle(hSnapshot);
return tid;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
CreateToolhelp32Snapshot
函数与TH32CS_SNAPTHREAD
结合使用,用于枚举系统中的所有线程(该API不支持枚举特定进程中的线程)。对于每个线程,会打开其父进程的句柄。如果成功,使用GetProcessImageFileName
查找可执行文件的映像路径。如果路径以\Notepad.exe
结尾,那么就返回该线程的ID,因为它就是我们要找的第一个线程。
现在主函数可以开始工作了。它首先调用FindMainNotepadThread
函数:
int main() {
DWORD tid = FindMainNotepadThread();
if (tid == 0)
return Error("Failed to locate Notepad");
2
3
4
接下来,加载要注入的DLL,并提取两个导出函数:
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");
2
3
4
5
6
7
8
9
10
11
SetNotificationThread
是DLL导出的一个函数,稍后将用于从记事本进程向注入器/监控进程传递信息。HookFunction
是挂钩函数本身,必须传递给SetWindowsHookEx
函数。
现在是时候安装挂钩了:
auto hHook = ::SetWindowsHookEx(WH_GETMESSAGE, hookFunc, hDll, tid);
if (!hHook)
return Error("Failed to install hook");
2
3
挂钩类型是WH_GETMESSAGE
,它对于拦截发送到由被挂钩线程创建的窗口的消息很有用。这个挂钩的一个额外好处是,如果需要,挂钩函数可以在消息到达目标之前更改消息。
此时,当下一条消息发送到记事本窗口时,挂钩DLL会自动注入到记事本进程中,并且每个消息都会调用挂钩函数。挂钩函数可以对消息信息做什么呢?最简单的做法是将其写入某个文件。不过,为了让它更有趣一些,挂钩函数通过使用线程消息将所有按键操作通知给注入进程。挂钩函数(在记事本进程内部)需要知道将消息发送到哪个线程。这就是SetNotificationThread
函数的作用,现在调用它:
setNotify(::GetCurrentThreadId(), hHook);
::PostThreadMessage(tid, WM_NULL, 0, 0);
2
SetNotificationThread
函数接收两个信息:接收消息的调用者线程ID,以及挂钩函数稍后会用到的挂钩句柄。如果你仔细观察,这个调用似乎不太合理,因为它在本地进程中调用SetNotificationThread
函数——DLL被加载到这个进程中,但这两个参数传达的信息应该在记事本进程的上下文中才有用。这是怎么回事呢?我们很快就会解决这个难题。
调用PostThreadMessage
函数是一个技巧,它用一个空消息(WM_NULL
)唤醒记事本,如果挂钩DLL尚未加载,这将强制记事本加载它。
PostThreadMessage
是一个允许向线程发送窗口消息的函数。普通消息是针对窗口的(使用窗口句柄)。而使用PostThreadMessage
时,窗口句柄实际上为NULL
。其余参数与其他窗口消息发送函数(如SendMessage
和PostMessage
)相同。对于线程消息,没有“SendThreadMessage
”函数,这意味着PostThreadMessage
本质上是异步的——它将消息放入目标线程的队列中并立即返回。而针对窗口句柄的消息则更灵活——SendMessage
是同步的,PostMessage
是异步的。
现在剩下要做的就是等待来自被挂钩记事本的传入消息,并以某种方式处理它们:
MSG msg;
while (::GetMessage(&msg, nullptr, 0, 0)) {
if (msg.message == WM_APP) {
printf("%c", (int)msg.wParam);
if (msg.wParam == 13)
printf("\n");
}
}
::UnhookWindowsHookEx(hHook);
::FreeLibrary(hDll);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
GetMessage
函数检查当前线程的消息队列,只有在有消息时才返回。由于当前线程没有窗口,任何消息都是来自记事本进程并发送给该线程的。如果有除WM_QUIT
之外的消息可用,GetMessage
函数就会返回。如果记事本进程终止,我们希望挂钩函数发送WM_QUIT
消息。
预期收到的消息是WM_APP
(0x8000),它保证不会被标准窗口消息常量使用,并且它是由挂钩函数发送的消息(我们很快就会看到)。MSG
结构体的wParam
成员保存着按键(同样,由挂钩函数提供),应用程序只是将其回显到控制台。
最后,当收到WM_QUIT
消息时,进程通过卸载挂钩和释放DLL来进行清理。
现在让我们把注意力转向挂钩DLL。前面提到的难题通过一些作为DLL一部分的全局共享变量来解决:
#pragma data_seg(".shared")
DWORD g_ThreadId = 0;
HHOOK g_hHook = nullptr;
#pragma data_seg()
#pragma comment(linker, "/section:.shared,RWS")
2
3
4
5
在DLL(或可执行文件)中共享变量的这种技术在第12章中有所描述。注入应用程序在其自身进程的上下文中调用SetNotificationThread
函数,但该函数将信息写入共享变量,因此使用同一DLL的任何进程都可以访问这些变量:
extern "C" void WINAPI SetNotificationThread(DWORD threadId, HHOOK hHook) {
g_ThreadId = threadId;
g_hHook = hHook;
}
2
3
4
这种安排如图15-12所示。
图15-12:注入和被注入的进程
DllMain
函数的实现如下:
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, PVOID pReserved) {
switch (reason) {
case DLL_PROCESS_ATTACH:
::DisableThreadLibraryCalls(hModule);
break;
case DLL_PROCESS_DETACH:
::PostThreadMessage(g_ThreadId, WM_QUIT, 0, 0);
break;
}
return TRUE;
}
2
3
4
5
6
7
8
9
10
11
12
13
首先,在DLL附加到进程时,它会调用DisableThreadLibraryCalls
函数,这表明该DLL不关心线程的创建和销毁。当DLL被卸载时(很可能是因为记事本正在退出),会向注入器的线程发送一条WM_QUIT
消息,这会导致注入器进程中GetMessage
函数的调用返回FALSE
。
有趣的工作由挂钩函数来完成:
extern "C" LRESULT CALLBACK HookFunction(int code, WPARAM wParam, LPARAM lParam) {
if (code == HC_ACTION) {
auto msg = (MSG*)lParam;
if (msg->message == WM_CHAR) {
::PostThreadMessage(g_ThreadId, WM_APP, msg->wParam, msg->lParam);
// prevent 'A' characters from getting to the app
//if (msg->wParam == 'A' || msg->wParam == 'a')
// msg->wParam = 0;
}
}
return ::CallNextHookEx(g_hHook, code, wParam, lParam);
}
2
3
4
5
6
7
8
9
10
11
12
13
每个与SetWindowsHookEx
一起使用的挂钩函数都有相同的原型,但规则不尽相同。你应该始终仔细阅读特定挂钩回调函数的文档。在我们的例子中(WH_GETMESSAGE
挂钩类型),如果code
为HC_ACTION
,挂钩函数就应该处理该通知。消息被打包在lParam
值中。如果消息是WM_CHAR
,表明是一个“可打印”字符,回调函数会将消息信息(wParam
保存着按键本身)发送给注入器的线程。
注释掉的代码展示了修改消息是多么简单,这样按键就永远不会到达记事本的线程。最后,建议调用CallNextHookEx
函数,以便让可能存在于挂钩链中的其他挂钩函数有机会执行它们的工作。不过,这不是强制要求的。
# API挂钩
术语“API挂钩(API Hooking)”指的是拦截Windows API(或者更普遍地说,任何外部函数)的行为,这样就可以检查其参数,并且可能改变其行为。这是一种极其强大的技术,首先被反恶意软件解决方案广泛采用。反恶意软件通常会将它们自己的DLL注入到每个进程(或者大多数进程)中,并挂钩它们关注的某些函数,比如VirtualAllocEx
和CreateRemoteThread
,将这些函数重定向到由它们的DLL提供的替代实现。在这个替代实现中,它们可以检查参数,并且在向调用者返回失败代码或者将调用转发给原始函数之前,执行任何它们需要的操作。
在本节中,我们将介绍两种常见的挂钩函数的技术。
# 导入地址表挂钩(IAT Hooking)
导入地址表(Import Address Table,IAT)挂钩可能是函数挂钩最简单的方法。它的设置相对简单,并且不需要任何特定于平台的代码。
每个PE(Portable Executable,可移植可执行文件)映像都有一个导入表,该表列出了它所依赖的动态链接库(DLL,Dynamic-Link Library)以及它从这些动态链接库中使用的函数。你可以使用Dumpbin工具或图形化工具检查PE文件来查看这些导入项。以下是从记事本(Notepad.exe)的模块中提取的部分内容:
dumpbin /imports c:\Windows\System32\notepad.exe
Microsoft (R) COFF/PE Dumper Version 14.26.28805.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
1400268B0 Import Address Table
14002D560 Import Name Table
0 time date stamp
0 Index of first forwarder reference
2B7 GetProcAddress
DB CreateMutexExW
1 AcquireSRWLockShared
113 DeleteCriticalSection
220 GetCurrentProcessId
2BD GetProcessHeap
...
GDI32.dll
1400267F8 Import Address Table
14002D4A8 Import Name Table
0 time date stamp
0 Index of first forwarder reference
34 CreateDCW
39F StartPage
39D StartDocW
366 SetAbortProc
...
USER32.dll
140026B50 Import Address Table
14002D800 Import Name Table
0 time date stamp
0 Index of first forwarder reference
157 GetFocus
2AF PostMessageW
177 GetMenu
43 CheckMenuItem
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
上述输出展示了记事本从其依赖的每个模块中使用的函数。反过来,每个模块都有自己的导入表。以下是User32.dll的示例:
dumpbin /imports c:\Windows\System32\User32.dll
Microsoft (R) COFF/PE Dumper Version 14.26.28805.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file c:\Windows\System32\user32.dll
File Type: DLL
Section contains the following imports:
win32u.dll
180092AD0 Import Address Table
1800AA0B0 Import Name Table
0 time date stamp
0 Index of first forwarder reference
297 NtMITSetInputDelegationMode
29B NtMITSetLastInputRecipient
363 NtUserEnableScrollBar
4FA NtUserTestForInteractiveUser
501 NtUserTransformRect
384 NtUserGetClassName
...
ntdll.dll
180092700 Import Address Table
1800A9CE0 Import Name Table
0 time date stamp
0 Index of first forwarder reference
8C5 chkstk
95F toupper
936 memcmp
96A wcscmp
937 memcpy
5A1 RtlSetLastWin32Error
BB NlsAnsiCodePage
...
GDI32.dll
180091C58 Import Address Table
1800A9238 Import Name Table
0 time date stamp
0 Index of first forwarder reference
309 PatBlt
36C SetBkMode
364 SelectObject
2E3 IntersectClipRect
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
这些导入函数的调用方式是通过导入地址表进行的,导入地址表包含了加载程序(NtDll.Dll)在运行时映射这些函数后的最终地址。由于动态链接库可能不会加载到其首选地址(请参阅本章后面的“动态链接库基地址”部分),因此这些地址在事先是未知的。
IAT挂钩利用了所有调用都是间接调用这一事实,在运行时只需替换表中的函数地址,使其指向另一个函数,同时保存原始地址,以便在需要时可以调用原始实现。这种挂钩可以在当前进程中进行,也可以与DLL注入相结合,在另一个进程的上下文中执行。
要挂钩的函数必须在所有进程模块中进行搜索,因为每个模块都有自己的IAT。例如,CreateFileW函数既可以由Notepad.exe模块本身调用,也可以在调用“打开文件”对话框时由ComCtl32.dll调用。如果只关注记事本的调用,那么只需要挂钩它的IAT。否则,必须搜索所有已加载的模块,并替换它们的CreateFileW函数的IAT条目。
为了演示这种技术,我将第13章的“工作集”应用程序复制到了本章的Visual Studio解决方案中。为了演示目的,我们将挂钩User32.Dll中的GetSysColor应用程序编程接口(API,Application Programming Interface),并在不触及应用程序用户界面(UI,User Interface)代码的情况下更改应用程序中的几种颜色。
在WinMain函数中,我们调用一个稍后介绍的辅助函数来进行挂钩。首先,我们需要一个变量来保存原始函数指针:
decltype(::GetSysColor)* GetSysColorOrg;
decltype关键字(C++ 11及更高版本)通过获取括号中表达式的正确类型来减少输入量并避免错误,在这种情况下,获取的是GetSysColor的类型。现在我们可以开始获取原始函数:
void HookFunctions() {
auto hUser32 = ::GetModuleHandle(L"user32");
// 保存原始函数
GetSysColorOrg = (decltype(GetSysColorOrg))::GetProcAddress(
hUser32, "GetSysColor");
2
3
4
5
由于一些辅助函数的存在,挂钩过程本身很简单,我们很快就会看到这些辅助函数:
auto count = IATHelper::HookAllModules("user32.dll",
GetSysColorOrg, GetSysColorHooked);
ATLTRACE(L"Hooked %d calls to GetSysColor\n");
2
3
GetSysColorHooked是我们用于替换GetSysColor的挂钩函数。它必须与原始函数具有相同的原型。以下是我们的自定义实现:
COLORREF WINAPI GetSysColorHooked(int index) {
switch (index) {
case COLOR_BTNTEXT:
return RGB(0, 128, 0);
case COLOR_WINDOWTEXT:
return RGB(0, 0, 255);
}
return GetSysColorOrg(index);
}
2
3
4
5
6
7
8
9
10
11
挂钩后的函数为几个索引返回不同的颜色,并对所有其他输入调用原始函数。
当然,关键在于IATHelper::HookAllModules函数。这个函数和另一个辅助函数是一个名为IATHelper的静态库的一部分,该静态库也包含在同一个解决方案中,并与“工作集”项目链接。以下是类声明:
// IATHelper.h
struct IATHelper final abstract {
static int HookFunction(PCWSTR callerModule, PCSTR moduleName,
PVOID originalProc, PVOID hookProc);
static int HookAllModules(PCSTR moduleName, PVOID originalProc, PVOID hookProc);
};
2
3
4
5
6
HookFunction的任务是挂钩单个模块调用的单个函数。HookAllModules遍历进程中当前加载的所有模块,并调用HookFunction。HookAllModules接受要挂钩的函数所导出的模块名称(在我们的示例中是user32.dll)。请注意,它是以ASCII字符串而不是Unicode字符串的形式传递的,因为模块名称在导入表中是以ASCII格式存储的。接下来的参数是原始函数(以便在导入表中定位它)和用于替换旧函数的新函数。
int IATHelper::HookAllModules(PCSTR moduleName, PVOID originalProc, PVOID hookProc) {
HMODULE hMod[1024]; // 应该足够了(这话说得可真经典)
DWORD needed;
if (!::EnumProcessModules(::GetCurrentProcess(), hMod, sizeof(hMod), &needed))
return 0;
assert(needed <= sizeof(hMod));
WCHAR name[256];
int count = 0;
for (DWORD i = 0; i < needed / sizeof(HMODULE); i++) {
if (::GetModuleBaseName(::GetCurrentProcess(), hMod[i], name, _countof(name))) {
count += HookFunction(name, moduleName, originalProc, hookProc);
}
}
return count;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这个函数相当简单。它使用PSAPI(Process Status Helper API,进程状态帮助应用程序编程接口)函数EnumProcessModules来枚举当前进程中的模块,该函数声明如下:
BOOL EnumProcessModules(
_In_ HANDLE hProcess,
_Out_ HMODULE* lphModule,
_In_ DWORD cb,
_Out_ LPDWORD lpcbNeeded);
2
3
4
5
EnumProcessModules将填充提供的lphModule数组,最多填充到由cb设置的大小,并在lpcbNeeded中返回所需的大小。如果返回的大小大于cb,那么有些模块没有被返回,调用者应该重新分配内存并再次枚举。在我的代码中,为了简单起见,我假设模块数量不超过1024个,但在生产级代码中,这可能会遗漏一些模块。进程句柄必须具有PROCESS_QUERY_INFORMATION访问掩码,这对于当前进程来说从来都不是问题。
GetModuleBaseName是PSAPI中的另一个函数,它返回模块的基名称(不包含任何路径):
DWORD GetModuleBaseName(
_In_ HANDLE hProcess,
_In_opt_ HMODULE hModule,
_Out_ LPTSTR lpBaseName,
_In_ DWORD nSize);
2
3
4
5
HookFunction完成了所有的核心工作。它首先获取调用者的模块句柄,作为访问其导入表的基础:
int IATHelper::HookFunction(PCWSTR callerModule, PCSTR moduleName,
PVOID originalProc, PVOID hookProc) {
HMODULE hMod = ::GetModuleHandle(callerModule);
if (!hMod)
return 0;
2
3
4
5
现在到了棘手的部分。必须通过解析PE文件来定位导入表。幸运的是,dbghelp和imagehlp中的一些应用程序编程接口提供了部分解析逻辑。在这种情况下,使用dbghelp函数可以快速定位到导入表:
ULONG size;
auto desc = (PIMAGE_IMPORT_DESCRIPTOR)::ImageDirectoryEntryToData(hMod, TRUE,
IMAGE_DIRECTORY_ENTRY_IMPORT, &size);
if (!desc) // 没有导入表
return 0;
2
3
4
5
关于PE文件格式的讨论超出了本章的范围。更多信息可以在附录A中找到。完整的规范文档可在https://docs.microsoft.com/en-us/windows/win32/debug/pe-format上查阅。有相当多的文章和博客对该格式进行了详细介绍。
ImageDirectoryEntryToData返回所谓的数据目录之一,这些数据目录是PE映像的一部分。其声明如下:
PVOID ImageDirectoryEntryToData (
_In_ PVOID Base,
_In_ BOOLEAN MappedAsImage,
_In_ USHORT DirectoryEntry,
_Out_ PULONG Size);
2
3
4
5
Base是模块的基地址,正如我们所知,这就是模块的“句柄”。MappedAsImage应设置为TRUE,因为映像是作为真实映像映射到地址空间的,而不是作为数据文件加载的。DirectoryEntry是要检索的数据目录的索引,Size返回数据目录的大小。该函数的返回值是数据目录所在的虚拟地址。
特定模块的导入表可能包含该模块依赖的许多导入库。我们只需要定位到包含我们要挂钩函数的那个库。在“工作集”示例中,这个库是user32.dll。IMAGE_IMPORT_DESCRIPTOR是导入库的头部结构,我们的搜索从这里开始:
int count = 0;
for (; desc->Name; desc++) {
auto modName = (PSTR)hMod + desc->Name;
if (::_stricmp(moduleName, modName) == 0) {
2
3
4
这段代码遍历所有模块,并将它们的名称与目标模块进行比较。模块名称是以ASCII格式存储的,这就是为什么我们以ASCII字符串的形式将其传递给HookFunction函数。IMAGE_IMPORT_DESCRIPTOR的Name成员是模块名称在模块起始位置的偏移量。
现在已经找到了模块,我们需要遍历所有导入函数,查找我们的原始函数指针:
auto thunk = (PIMAGE_THUNK_DATA)((PBYTE)hMod + desc->FirstThunk);
for (; thunk->u1.Function; thunk++) {
auto addr = &thunk->u1.Function;
if (*(PVOID*)addr == originalProc) {
// 找到了
2
3
4
5
这段代码不太美观,它是基于构成PE信息的数据结构编写的,在这种情况下是IMAGE_THUNK_DATA。每个这样的“thunk”都存储一个函数的地址,因此我们将其与接收到的原始函数进行比较。如果它们相等,我们就找到了匹配项:
DWORD old;
if (::VirtualProtect(addr, sizeof(void*), PAGE_WRITECOPY, &old)) {
*(void**)addr = (void*)hookProc;
count++;
}
2
3
4
5
我们需要用新值替换现有值。然而,导入表的页面是受PAGE_READONLY保护的,因此我们必须将其替换为PAGE_WRITECOPY,以便获取我们需要访问的页面的可写副本。
结构和成员的布局是基于PE文件格式文档的。
结合前面章节中的DLL注入和API挂钩技术,在记事本进程而不是当前进程中挂钩GetSysColor函数。尝试挂钩其他函数!
基于IAT的挂钩有什么缺点呢?首先,如果稍后加载了新模块,也必须对其进行挂钩。矛盾的是,这可以通过挂钩LoadLibraryW、LoadLibraryExW和LdrLoadDll(这是NtDll.dll中未公开的函数,但可能会被使用)来实现。
其次,通过避免使用IAT(即直接使用GetProcAddress返回的函数指针调用应用程序编程接口),很容易绕过IAT挂钩。这意味着可以使用以下代码直接调用原始的GetSysColor函数:
return ((decltype(::GetSysColor)*)::GetProcAddress(GetModuleHandle(L"user32"),
"GetSysColor"))(index);
2
如果挂钩是出于安全目的,那么IAT挂钩可能是不可接受的,因为它很容易被绕过。如果是出于其他需求,并且正常代码在使用函数时不调用GetProcAddress,那么IAT挂钩既方便又可靠。
# “Detours”风格的挂钩
另一种常见的函数挂钩方式需遵循以下步骤:
- 找到原始函数的地址并保存。
- 用一条跳转(JMP)汇编指令替换代码的前几个字节,并保存旧代码。
- 跳转指令会调用被挂钩的函数。
- 如果要调用原始代码,则使用第一步保存的地址进行调用。
- 取消挂钩时,恢复被修改的字节。
这种方案比导入地址表(IAT)挂钩更强大,因为无论函数是通过导入地址表调用还是以其他方式调用,实际的函数代码都会被修改。不过,这种方法有两个缺点:
- 被替换的代码是特定于平台的。x86、x64、ARM和ARM64的代码有所不同,这使得正确实现变得更加困难。
- 上述步骤必须以原子操作的方式完成。在替换汇编字节时,进程中可能有其他线程调用被挂钩的函数,这很可能会导致程序崩溃。
实现这种挂钩操作难度较大,需要深入了解CPU指令和调用约定,更不用说上述的同步问题了。有多个开源免费库提供这种功能。其中一个是微软的“Detours”(因此本节以此命名),不过还有其他库,比如MinHook和EasyHook,你可以在网上找到。如果你需要这种挂钩功能,考虑使用现有的库,而非自己编写代码。
为了演示如何使用Detours库进行挂钩,我们将使用第14章中的“基本共享”(Basic Sharing)应用程序。我们将挂钩两个函数:GetWindowTextLengthW
和GetWindowTextW
。通过被挂钩的函数,代码将仅为编辑控件返回一个自定义字符串。
第一步是添加对Detours的支持。幸运的是,通过Nuget添加很容易,只需搜索“detours”即可(图15-13)。
图15-13:Nuget包管理器中的Detours库
设置挂钩相当简单。下面是完成此操作的辅助函数:
#include <detours.h>
bool HookFunctions() {
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach((PVOID*)&GetWindowTextOrg, GetWindowTextHooked);
DetourAttach((PVOID*)&GetWindowTextLengthOrg, GetWindowTextLengthHooked);
auto error = DetourTransactionCommit();
return error == ERROR_SUCCESS;
}
2
3
4
5
6
7
8
9
10
Detours基于事务的概念工作,事务是一组作为原子操作提交执行的操作。我们需要保存原始函数。这可以在挂钩之前使用GetProcAddress
来完成,或者在定义指针时直接完成:
decltype(::GetWindowTextW)* GetWindowTextOrg = ::GetWindowTextW;
decltype(::GetWindowTextLengthW)* GetWindowTextLengthOrg = ::GetWindowTextLengthW;
2
被挂钩的函数需要提供一些实现。这个演示是这样做的:
static WCHAR extra[] = L" (Hooked!)";
bool IsEditControl(HWND hWnd) {
WCHAR name[32];
return ::GetClassName(hWnd, name, _countof(name)) &&
::_wcsicmp(name, L"EDIT") == 0;
}
int WINAPI GetWindowTextHooked(
_In_ HWND hWnd,
_Out_ LPWSTR lpString,
_In_ int nMaxCount) {
auto count = GetWindowTextOrg(hWnd, lpString, nMaxCount);
if (IsEditControl(hWnd)) {
if (count + _countof(extra) <= nMaxCount) {
::StringCchCatW(lpString, nMaxCount, extra);
count += _countof(extra);
}
}
return count;
}
int WINAPI GetWindowTextLengthHooked(HWND hWnd) {
auto len = GetWindowTextLengthOrg(hWnd);
if (IsEditControl(hWnd))
len += (int)wcslen(extra);
return len;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
被挂钩的GetWindowTextW
函数仅为编辑控件添加额外的字符串。如果你现在运行“基本共享”应用程序,在编辑框中输入“hello”,点击“写入”再点击“读取”,你会得到图15-14所示的结果。
图15-14:被挂钩后的“基本共享”应用程序
点击“写入”按钮的处理程序会调用GetDlgItemText
,而GetDlgItemText
会调用GetWindowText
,进而调用被挂钩的函数。
使用Detours以类似于“基本共享”应用程序的方式挂钩“记事本”应用程序。
# DLL基地址
每个动态链接库(DLL)都有一个首选加载(基)地址,它是可移植可执行文件(PE)头的一部分。甚至可以在Visual Studio中通过项目属性来指定这个地址(图15-15)。
图15-15:在Visual Studio中设置DLL的基地址
默认情况下这里没有设置任何值,这使得Visual Studio使用一些默认值。32位DLL的默认值是0x10000000,64位DLL的默认值是0x180000000。你可以通过转储PE的头信息来验证这些值:
dumpbin /headers c:\dev\Win10SysProg\Chapter15\x64\Debug\HookDll.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 c:\dev\Win10SysProg\Chapter15\Debug\HookDll.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)
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在过去(Windows Vista之前),当地址空间加载随机化(ASLR)还不存在时,DLL会坚持加载到其首选地址。如果该地址已被其他DLL或数据占用,DLL就需要进行重定位。加载程序必须在进程地址空间中为DLL找到一个新位置。此外,它还必须执行代码修正(由链接器存储在PE中),因为有些代码必须更改。例如,原本预期在地址x的字符串现在移动到了其他地址y,加载程序必须对此进行修正。这些修正需要时间,并且会占用额外内存,因为该页面中的代码不再能被共享。
在“进程资源管理器”(Process Explorer)中,重定位的DLL很容易识别。首先,可以启用一个淡黄色的标识,名为“重定位的DLL”,它会在“模块(DLL)”视图中显示每个重定位的DLL。其次,识别重定位DLL的可靠方法是查看“映像基址”列与“基址”列是否不同(图15-16)。
图15-16:“进程资源管理器” 中的重定位DLL
在图15-16中,我选择了Visual Studio的进程(devenv.exe),该进程显示了许多重定位的DLL。不过,这种情况如今不像过去那么常见了。大多数进程中重定位的DLL非常少。
解决重定位问题的方法是为不同的DLL选择不同的地址,以尽量减少冲突的可能性。有时会使用Windows SDK中的rebase.exe工具来完成这项工作,它可以同时对多个DLL执行该操作。由于该工具是对PE进行操作,所以不需要源代码。rebase.exe工具的功能可以通过调试帮助API中的ReBaseImage64函数以编程方式实现。
大多数DLL都有 “动态基址”(Dynamic Base)特征标志,表明该DLL可以接受重定位。有了地址空间加载随机化(ASLR),加载程序会选择一个与之前加载的DLL不冲突的地址,所以冲突的可能性非常低,因为使用的地址是从高位地址开始,并且随着每个加载的DLL逐渐降低。一个DLL可以指定它想要一个固定地址,从而禁止重定位,但如果其首选地址范围已被占用,这可能会导致DLL加载失败。DLL坚持使用固定基地址应该有非常充分的理由。
# 延迟加载DLL
我们已经探讨了链接到DLL的两种主要方式:一种是通过LIB文件进行隐式链接(最简单且最方便),另一种是动态链接(显式加载DLL并定位要使用的函数)。实际上,还有第三种方式,它有点类似于静态链接和动态链接之间的 “中间地带”——延迟加载DLL。
通过延迟加载,我们可以兼得两种方式的优点:既具有静态链接的便利性,又能在需要时才动态加载DLL。
要使用延迟加载DLL,需要对使用这些DLL的模块进行一些更改,无论该模块是可执行文件还是另一个DLL。应延迟加载的DLL会被添加到链接器选项的 “输入” 选项卡中(图15-17)。
图15-17:在项目属性中指定延迟加载的DLL
如果你希望支持延迟加载DLL的动态卸载,可以在链接器的 “高级” 选项卡中添加相应选项(“卸载延迟加载的DLL”)。
剩下要做的就是链接DLL的导入库(LIB)文件,并像使用隐式链接的DLL一样使用其导出的功能。
下面是来自SimplePrimes2项目的一个示例,该项目对SimpleDll.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");
__FUnloadDelayLoadedDLL2("SimpleDll.dll");
IsLoaded();
prime = IsPrime(1234567);
IsLoaded();
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
看起来有些奇怪的函数 __FUnloadDelayLoadedDLL2
(来自delayimp.h)用于卸载延迟加载的DLL。如果你调用 FreeLibrary
,DLL会被卸载;然而,如果之后需要再次使用该DLL,仅调用其导出函数是无法重新加载它的,反而会引发访问冲突异常。
运行上述程序会显示:
SimpleDll loaded: No
SimpleDll loaded: Yes
17 is prime? Yes
SimpleDll loaded: No
SimpleDll loaded: Yes
2
3
4
5
当你调用延迟加载DLL的导出函数时,实际上调用的是另一个函数(由延迟加载基础架构提供),这个函数会调用 LoadLibrary
和 GetProcAddress
,然后调用目标函数,并修正导入表,以便后续对同一函数的调用可以直接执行其实现。这也解释了为什么卸载延迟加载的DLL必须使用一个特殊函数,该函数能够恢复初始行为。
# LoadLibraryEx函数
LoadLibraryEx
是 LoadLibrary
的扩展函数,定义如下:
HMODULE LoadLibraryEx(
_In_ LPCTSTR lpLibFileName,
_Reserved_ HANDLE hFile, // 必须为NULL
_In_ DWORD dwFlags);
2
3
4
LoadLibraryEx
与 LoadLibrary
的目的相同:显式加载DLL。如你所见,LoadLibraryEx
支持一组标志,这些标志会影响所加载DLL的搜索和 / 或加载方式。一些可接受的标志如表15-1所示,通过这些标志可以在一定程度上修改搜索路径和顺序。以下是其他一些可能有用的标志(查看文档获取完整列表):
LOAD_LIBRARY_AS_DATAFILE
——此标志表示应仅将DLL映射到进程地址空间,但忽略其PE映像属性。不会调用DllMain
,并且像GetModuleHandle
或GetProcAddress
这样的函数对返回的句柄操作会失败。LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE
——与LOAD_LIBRARY_AS_DATAFILE
类似,但文件是以独占访问方式打开的,这样在加载期间其他进程无法修改它。LOAD_LIBRARY_AS_IMAGE_RESOURCE
——将DLL作为图像文件加载,但不执行任何初始化操作,如调用DllMain
。此标志通常与LOAD_LIBRARY_AS_DATAFILE
一起指定,用于提取资源。LOAD_WITH_ALTERED_SEARCH_PATH
——如果指定的路径是绝对路径,则该路径将用作搜索的基础。
当使用 LOAD_LIBRARY_AS_IMAGE_RESOURCE
时,返回的句柄可用于通过诸如 LoadString
、LoadBitmap
、LoadIcon
等API提取资源。它也支持自定义资源,在这种情况下,要使用的函数包括 FindResource(Ex)
、SizeOfResource
、LoadResource
和 LockResource
。
# 其他函数
在本节中,我们将简要介绍一些其他可能有用的与DLL相关的函数。我们从 GetModuleFileName
和 GetModuleFileNameEx
开始:
DWORD GetModuleFileName(
_In_opt_ HMODULE hModule,
_Out_ LPTSTR lpFilename,
_In_ DWORD nSize);
DWORD GetModuleFileNameEx(
_In_opt_ HANDLE hProcess,
_In_opt_ HMODULE hModule,
_Out_ LPWSTR lpFilename,
_In_ DWORD nSize);
2
3
4
5
6
7
8
9
10
这两个函数都返回已加载模块的完整路径。GetModuleFileNameEx
可以获取另一个进程中模块的此类信息(句柄必须具有 PROCESS_QUERY_INFORMATION
或 PROCESS_QUERY_LIMITED_INFORMATION
访问掩码)。如果 hModule
为NULL,则返回主模块路径(可执行文件路径)。如果需要获取模块列表,可以使用 EnumProcessModules
(本章前面已展示)。
LoadPackagedLibrary
(Windows 8及以上版本)是 LoadLibrary
的一个变体,UWP进程可使用它来加载属于其包的DLL:
HMODULE LoadPackagedLibrary (
_In_ LPCWSTR lpwLibFileName,
_Reserved_ DWORD Reserved); // 必须为零
2
3
如果一个线程需要卸载其代码正在其中运行的DLL,则不能使用 FreeLibrary
函数后再调用 ExitThread
,因为一旦 FreeLibrary
返回,该线程的代码将不再属于该进程,这会导致崩溃。为了解决这个问题,可以使用 FreeLibraryAndExitThread
:
VOID FreeLibraryAndExitThread(
_In_ HMODULE hLibModule,
_In_ DWORD dwExitCode);
2
3
该函数会释放指定的模块,然后调用 ExitThread
,这意味着此函数不会返回。
# 总结
在本章中,我们探讨了DLL,包括如何构建它们以及如何使用它们。我们还了解了将DLL注入另一个进程的能力,这使得DLL在目标进程中拥有很大的权限。在下一章中,我们将把注意力转向一个完全不同的主题:安全性。