第6章 动态链接库
程序中用到的Windows API函数都包含在动态链接库(Dynamic Link Library,DLL)中,其中3个最重要的动态链接库(DLL)分别是Kernel32.dll、User32.dll和Gdi32.dll。Kernel32.dll中包含的函数用来管理内存、进程以及线程等,User32.dll中包含的函数用来执行与用户界面相关的任务,例如创建窗口和发送消息等,Gdi32.dll中包含的函数用来绘制图像和显示文字等。此外,Windows还提供了其他一些动态链接库,例如,AdvAPI32.dll中包含的函数与对象的安全性、注册表的操控以及事件日志有关,ComCtl32.dll支持所有常用的窗口控件,Ws2_32.dll用于Windows套接字网络编程。
当运行一个程序时,PE加载器会为新的进程创建一个虚拟地址空间,并将可执行模块映射到新进程的地址空间中,PE加载器继续解析可执行模块的导入表,并把导入表中列出的每个DLL映射到进程的地址空间中,当可执行模块和所有DLL模块都映射到进程的地址空间中后,进程的主线程就开始执行。
动态链接库是Windows系统中实现共享函数库的一种方式,大部分动态链接库以扩展名为.dll的文件形式存在,但并不是只有扩展名为.dll的文件才是动态链接库,系统中的某些可执行文件(*.exe)、字体文件(*.fon)、一些驱动程序(*.drv和*.sys)、各种控件(*.ocx)和输入法模块(*.ime)等都是动态链接库。一个文件是不是动态链接库取决于它的文件结构,DLL文件和可执行文件都是标准的PE文件格式,只是文件头中的属性位不同。与可执行文件一样,在动态链接库中也可以定义并使用各种资源,导入并使用其他动态链接库中的函数。
6.1 静态链接库
我们先来了解一下静态链接库(Static Link Library),静态链接库是扩展名为.lib的对象库(Object Library)文件,在编译链接时,程序中用到的静态链接库中的函数会复制到生成的可执行文件中,因此在发布软件时,不需要一同发布静态链接库.lib文件。
创建静态链接库.lib项目的方法与创建其他Windows桌面程序相同,只是在最后需要选择应用程序类型为静态库(.lib),如原书的图6.1所示。
接下来我们编写一个简单的静态链接库文件,StaticLinkLibrary.h头文件中的内容为函数声明:
#pragma once
int funAdd(int a, int b);
int funMul(int a, int b);
StaticLinkLibrary.cpp源文件中的内容为函数实现:
#include "StaticLinkLibrary.h"
int funAdd(int a, int b)
{
return a + b;
}
int funMul(int a, int b)
{
return a * b;
}
单击VS的生成菜单项→生成StaticLinkLibrary(U)或者按快捷键Ctrl + B,即可生成Chapter6\ StaticLinkLibrary\Debug\StaticLinkLibrary.lib文件。
接下来创建一个Win32桌面应用程序StaticLinkLibraryTest,StaticLinkLibraryTest.cpp源文件的内容如下:
#include <Windows.h>
#include "StaticLinkLibrary.h" // StaticLinkLibrary.h头文件
#pragma comment(lib, "StaticLinkLibrary.lib") // StaticLinkLibrary.lib对象库
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
TCHAR szBuf[256] = { 0 };
wsprintf(szBuf, TEXT("funAdd(5, 6) = %d\nfunMul(5, 6) = %d"), funAdd(5, 6), funMul(5, 6));
MessageBox(NULL, szBuf, TEXT("提示"), MB_OK);
}
需要注意的是,StaticLinkLibrary项目中的StaticLinkLibrary.h头文件,以及生成的StaticLinkLibrary.lib对象库文件,都需要复制到StaticLinkLibraryTest项目目录Chapter6\StaticLinkLibraryTest\StaticLinkLibraryTest中。
编译运行程序,程序运行效果如原书的图6.2所示。
6.2 动态链接库
静态链接库是传统的共享函数库的一种方式,显而易见的一个缺点是,如果有多个程序用到静态链接库中的同一个函数,那么所有这些可执行文件中都会包含一份完全相同的代码,这是对磁盘空间的一种浪费。另一个缺点是,如果某个函数因为发现有错误或者需要更新算法而进行版本升级时,必须找到所有使用该函数的可执行文件来重新编译,否则程序中使用的还是旧版本的代码。另外,Windows是多任务操作系统,如果有多个程序用到静态链接库中的同一个函数,并且这些程序同时运行,就会有多份相同的代码被载入内存,这是对内存空间的一种浪费。
Windows的解决方法是使用动态链接库。动态链接库也是共享函数库的一种方式,其提供的函数也可以被多个程序使用,但是它和静态链接库在使用方法上有很多不同点。静态链接库仅在编译链接时使用,编译完成后,可执行文件即可脱离库文件单独使用,而动态链接库中的代码在程序编译时并不会被插入可执行文件中,在程序运行时才将动态链接库的代码载入内存,因此称为"动态链接"。如果有多个程序用到同一个动态链接库,Windows物理内存中只存在一份动态链接库的代码,然后通过分页机制将这份代码映射到不同进程的地址空间中,库代码实际占用的物理内存永远只有一份。当然,动态链接库使用的数据段会被映射到不同的物理内存中,有多少个程序在使用动态链接库就会有多少份数据段,每个使用DLL的进程都有自己的DLL数据副本。
动态链接库是被映射到其他进程的地址空间中执行的,它和应用程序可以看作是一体的,应用程序进程地址空间是动态链接库的宿主,动态链接库可以使用应用程序的资源,动态链接库中的资源也可以被应用程序使用。
6.2.1 创建DLL项目
一个DLL可以导出变量、C++类和函数供其他模块(可执行模块或其他DLL模块)使用,在实际开发中,应该避免从DLL中导出变量和C++类,因为从DLL中导出变量不利于代码维护,由不同C++编译器或同一个C++编译器但不同版本编译生成的DLL中的C++类互不兼容。在DLL中可以使用两种函数:内部函数和导出函数,内部函数只能在DLL内部使用,而导出函数可以被其他模块调用,DLL的主要功能是向外导出函数供其他模块调用。
在包含导出函数的DLL文件中,导出信息保存在DLL文件的导出表中,导出表包括导出函数的名称、序数和入口地址等信息,PE加载器通过这些信息来完成动态链接的过程。后面章节会详细介绍导出表。
创建动态链接库项目(以下简称DLL项目)与创建其他Windows桌面程序项目相同,只是在最后的步骤中需要选择应用程序类型为动态链接库(.dll)。接下来创建一个DLL项目 DllSample,头文件DllSample.h的内容如下:
#pragma once
// 声明导出的变量、类和函数
#ifdef DLL_EXPORT
#define DLL_VARABLE extern "C" __declspec(dllexport)
#define DLL_CLASS __declspec(dllexport)
#define DLL_API extern "C" __declspec(dllexport)
#else
#define DLL_VARABLE extern "C" __declspec(dllimport)
#define DLL_CLASS __declspec(dllimport)
#define DLL_API extern "C" __declspec(dllimport)
#endif
/****************************************************************/
typedef struct _POSITION
{
int x;
int y;
}POSITION, * PPOSITION;
// 导出变量
DLL_VARABLE int nValue; // 导出普通变量
DLL_VARABLE POSITION ps; // 导出结构体变量
// 导出类
class DLL_CLASS CStudent
{
public:
CStudent(LPTSTR lpName, int nAge);
~CStudent();
public:
LPTSTR GetName();
int GetAge();
private:
TCHAR m_szName[64];
int m_nAge;
};
// 导出函数
DLL_API int funAdd(int a, int b);
DLL_API int funMul(int a, int b);
DllSample.cpp源文件的内容如下:
// 定义DLL的导出变量、类和函数
#include <Windows.h>
#include <strsafe.h>
#include <tchar.h>
#define DLL_EXPORT
#include "DllSample.h"
// 变量
int nValue; // 普通变量
POSITION ps; // 结构体变量
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
nValue = 5;
ps.x = 6;
ps.y = 7;
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
// 类
CStudent::CStudent(LPTSTR lpName, int nAge)
{
if (m_szName)
StringCchCopy(m_szName, _countof(m_szName), lpName);
m_nAge = nAge;
}
CStudent::~CStudent(){}
LPTSTR CStudent::GetName()
{
return m_szName;
}
int CStudent::GetAge()
{
return m_nAge;
}
// 函数
int funAdd(int a, int b)
{
return a + b;
}
int funMul(int a, int b)
{
return a * b;
}
单击VS的生成菜单→生成DllSample(U)或者按快捷键Ctrl + B,即可生成对应的DLL文件,同时生成的还有一个.lib导入库文件,在其他源程序中调用生成的DLL中的函数时,需要用到DllSample.h、DllSample.lib和DllSample.dll这3个文件。
以.lib为后缀的库有两种,一种是静态链接库的对象库,另一种是动态链接库的导入库,对象库和导入库虽然使用相同的后缀名,但是实质上截然不同。对象库中包含函数名以及函数的实现代码,而导入库只包含对应函数的一些基本信息,函数的具体实现代码存在于DLL文件中。编译链接DLL时,链接程序查找关于导出变量、C++类和函数的信息,并自动生成一个.lib文件,该.lib文件包含一个DLL文件导出的符号列表,当调用一个DLL中的函数时,导入库是必不可少的。
如前所述,在实际开发过程中,应该避免从DLL中导出变量和C++类,因为从DLL中导出变量不利于代码维护,由不同C++编译器或同一C++编译器但不同版本编译生成的DLL中的C++类互不兼容。所以前面的头文件DllSample.h的内容可以简化为如下形式:
#pragma once
// 声明导出的函数
#ifdef DLL_EXPORT
#define DLL_API extern "C" __declspec(dllexport)
#else
#define DLL_API extern "C" __declspec(dllimport)
#endif
// 导出函数
DLL_API int funAdd(int a, int b);
DLL_API int funMul(int a, int b);
DllSample.cpp源文件的内容可以简化为如下形式:
// 定义DLL的导出函数
#include <Windows.h>
#include <tchar.h>
#define DLL_EXPORT
#include "DllSample.h"
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
// 导出函数
int funAdd(int a, int b)
{
return a + b;
}
int funMul(int a, int b)
{
return a * b;
}
从一个DLL中导入变量、类和函数的模块可以称为可执行模块,导出变量、类和函数以供可执行模块使用的模块称为DLL模块,DLL模块也可以导入一些包含在其他DLL模块中的变量、类和函数。在创建DLL时,应该首先创建一个头文件包含想要导出的变量、类和函数的声明,DLL的源文件中应该包含这个头文件,即DLL的源文件中含导出变量、类和函数的定义。调用DLL中的函数的可执行模块也需要用到该头文件。
__declspec(dllexport)表示从DLL模块中导出变量、类和函数,__declspec(dllimport)表示在可执行模块中导入DLL模块的变量、类和函数。
DLL_EXPORT宏(也可以是其他名称)应该在DLL的源代码中定义,然后再包含头文件,例如:
// 定义DLL的导出变量、类和函数
#include <Windows.h>
#include <tchar.h>
#define DLL_EXPORT
#include "DllSample.h"
如果DLL_EXPORT宏已经定义,则定义以下宏:
#ifdef DLL_EXPORT
#define DLL_VARABLE extern "C" __declspec(dllexport)
#define DLL_CLASS __declspec(dllexport)
#define DLL_API extern "C" __declspec(dllexport)
在要导出的变量、类和函数前面使用DLL_VARABLE、DLL_CLASS或DLL_API宏,表示要从DLL模块中导出变量、类和函数。
在可执行模块的源代码中,则不定义DLL_EXPORT宏,而是定义以下宏表示在可执行模块中导入DLL模块的变量、类和函数:
#else
#define DLL_VARABLE extern "C" __declspec(dllimport)
#define DLL_CLASS __declspec(dllimport)
#define DLL_API extern "C" __declspec(dllimport)
在编写C++代码时建议使用extern "C"声明,如果编写C代码则不应该使用extern "C"。
C++编译器会对C++类名、函数名和变量名进行改编得到一个修饰名。这里以C++对函数名的改编为例进行说明,修饰名由编译器在编译函数时生成,包括函数名称和调用约定、函数类型、函数参数和其他信息,通过修饰名可以帮助链接器在链接可执行文件时找到正确的函数。删除DllSample.h头文件中DLL_API宏的extern "C"声明,重新编译生成DLL,然后使用Depends.exe打开DllSample.dll(见原书的图6.3)。
可以发现,C++编译器使用的函数名称修饰方式比较复杂,函数名前面有一个"?",然后是函数名,"@@YA"后面的内容表示参数列表,参数列表的第1项表示函数返回值类型,其后依次为参数的数据类型,参数列表后面以"@Z"标识整个修饰名的结束。参数列表以如下代号表示:X表示void,D表示char,E表示unsigned char,F表示short,H表示int,I表示unsigned int,N表示double等。C++编译器采用修饰名的一个重要原因是函数重载,C++允许在同一范围中声明几个功能类似的同名函数,但是这些同名函数的形式参数(指参数的个数、类型或者顺序)必须不同。如果在DllSample.h和DllSample.cpp中再声明定义一个返回值和形式参数均为double的funMul函数:
DLL_API DOUBLE funMul(DOUBLE a, DOUBLE b);
DOUBLE funMul(DOUBLE a, DOUBLE b)
{
return a * b;
}
重新编译生成DLL,可以看到上述函数的修饰名为?funMul@@YANNN@Z。
C++编译器采用了修饰名,因此采用C++修饰方式命名的DLL函数无法被其他语言使用,在其他语言编写的源代码中调用funAdd、funMul会出现找不到?funAdd@@YAHHH@Z、?funMul@@YAHHH@Z等错误提示。在函数声明前面加上extern "C"关键字后,C++会对该函数强制使用标准C的函数名称修饰方式,不会对函数名、变量名进行改编,在DLL中用这种方式导出的函数可以被其他语言使用;反之,其他语言编写的DLL函数在C++的头文件中进行声明时,前面也必须添加extern "C"关键字,这样C++才会在lib文件中找到正确的函数修饰名。
添加extern "C"关键字后,生成的函数调用约定为__cdecl(C调用约定),函数参数按照从右到左的顺序压入栈,由函数调用方负责平衡栈。如果希望使用__stdcall(WINAPI、CALLBACK等)函数调用约定,例如:
// 导出函数
DLL_API int WINAPI funAdd(int a, int b);
DLL_API int WINAPI funMul(int a, int b);
当使用__stdcall来导出C函数时,编译器会对函数名进行改编,函数名前面有一个下划线"_",然后是函数名,函数名后面是一个@符号后跟作为参数传递给函数的字节数组成,如原书的图6.4所示。
这种情况下,我们可以添加一个.def文件指示编译器导出funAdd和funMul函数,而不是_funAdd@8和_funMul@8函数。用鼠标右键单击VS左侧解决方案资源管理器中的源文件,然后选择添加→新建项命令,打开添加新项对话框,选择Visual C++→代码→模块定义文件(.def),文件名称可以设为DllSample.def,单击"添加"按钮,输入以下内容:
EXPORTS
funAdd
funMul
DllSample.def文件中就是一个EXPORTS关键字,然后下面每一行指定一个要导出的函数名。然后生成DLL,这样即可导出未经改编的funAdd和funMul函数。
6.2.2 在可执行模块中使用DLL
我们编写一个Win32桌面应用程序项目Test,在源代码中使用DllSample.dll导出的变量、C++类和函数。Test.cpp源文件的内容如下:
#include <Windows.h>
#include <strsafe.h>
#include <tchar.h>
#include "DllSample.h" // DllSample.h头文件
#pragma comment(lib, "DllSample.lib") // DllSample.lib导入库文件
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
TCHAR szBuf[256] = { 0 };
TCHAR szBuf2[512] = { 0 };
// 测试导出变量
wsprintf(szBuf, TEXT("nValue = %d\nps.x = %d, ps.y = %d\n"), nValue, ps.x, ps.y);
StringCchCopy(szBuf2, _countof(szBuf2), szBuf);
// 测试导出类
CStudent student((LPTSTR)TEXT("老王"), 40);
wsprintf(szBuf, TEXT("姓名:%s, 年龄:%d\n"), student.GetName(), student.GetAge());
StringCchCat(szBuf2, _countof(szBuf2), szBuf);
// 测试导出函数
wsprintf(szBuf, TEXT("funAdd(5, 6) = %d\nfunMul(5, 6) = %d"), funAdd(5, 6), funMul(5, 6));
StringCchCat(szBuf2, _countof(szBuf2), szBuf);
MessageBox(NULL, szBuf2, TEXT("提示"), MB_OK);
}
在可执行模块源代码中不仅需要包含DllSample.h,还需要链接.lib导入库文件,有以下两种方法。
(1)使用VS配置链接信息。用鼠标右键单击项目名称打开属性页,然后选择配置属性→链接器→输入,在"附加依赖项"中输入所需要的.lib文件。如果.lib文件不在项目的当前目录,那么还需要在"配置属性→链接器→常规"的"附加库目录"中输入该.lib文件所在的路径,建议把.lib文件复制到项目的当前目录。可以看到在"附加依赖项"中已经存在Kernel32.lib、User32.lib、Gdi32.lib等导入库文件,在创建VS项目时,最基本的导入库文件VS已经自动帮我们添加好了,因此在调用这些DLL中的函数时,只需要包含相关头文件,Windows.h头文件中包含了最基本、最常用的一些头文件。
(2)使用#pragma链接命令,如下所示:
#pragma comment(lib, "DllSample.lib")
另外,DllSample.dll也需要复制到项目的当前目录。在发布软件时,Test.exe和DllSample.dll这两个文件需要同时发布,如果缺少DllSample.dll,运行Test.exe时会提示原书的图6.5所示的错误。
我们再看一下如果dll文件中不存在某个函数时的情况,删除DllSample.h头文件中最后一行funMul函数声明前面的DLL_API,这表示funMul函数是一个内部函数,不会导出。重新编译生成DLL文件,复制到Test项目中,运行Test.exe会有原书的图6.6所示的错误提示。
在可执行模块(或其他DLL)能够调用一个DLL中的函数前,必须将该DLL的文件映射到调用进程的地址空间中,可以通过两种方法来达到这一目的:隐式链接和显式链接。
隐式链接是最常见的链接类型,也是我们一直使用的包含头文件和导入库文件的方式,在编译链接生成可执行文件时,系统会把DLL文件名以及用到的函数写入可执行文件的导入表,这样一来当运行一个可执行文件时,PE加载器会解析可执行文件的导入表,把导入表中列出的每个DLL映射到进程的地址空间中,并根据函数名在每个DLL中查找导出函数,后面章节会详细介绍导入表。
显式链接是指在需要调用一个DLL文件中的导出函数时,通过调用LoadLibrary(Ex)函数自行加载动态链接库到进程地址空间中,然后通过调用GetProcAddress函数获取导出函数在动态链接库中的地址,最后通过GetProcAddress函数返回的函数指针进行函数调用。在调用一些系统DLL提供的未公开函数时,通常需要显式链接。另外显式链接的好处是无论DLL文件是否存在,在加载DLL并使用其中的导出函数以前可执行文件都可以正常运行。
运行一个可执行文件时,PE加载器会为进程创建虚拟地址空间,并把可执行文件映射到进程的地址空间中,然后PE加载器会解析可执行文件的导入表,对所需的DLL进行定位并将它们映射到进程的地址空间中。导入表中只包含DLL的名称,不包含DLL的路径,下面是定位DLL文件的顺序。
(1)可执行文件目录。
(2)Windows系统目录。
(3)Windows目录。
(4)进程的当前目录。
(5)PATH环境变量中列出的所有目录。
C标准定义了一系列常用的函数,称为C库函数,C标准仅仅定义了函数原型,并没有提供相关实现,这个任务留给了各个支持C语言标准的编译器,每个编译器通常会实现标准C的超集,称为C运行时库(C Run Time Libray,CRT库)。对VC++编译器来说,它提供的CRT库支持C标准定义的标准C函数,同时也有一些专门针对Windows系统特别设计的函数。与C语言类似,C++也定义了自己的标准,同时编译器提供相关支持库。
VC++完美支持C/C++标准,并按照C/C++标准定义的函数原型实现了C/C++运行时库。为方便有不同需求的客户使用,VC++分别实现了运行时库的静态库LIB版本和动态链接库DLL版本。在VS中,用鼠标右键单击项目名称,然后选择属性,打开属性页,配置属性→C/C++→代码生成→运行库,可以看到4个选项:多线程(/MT)、多线程调试(/MTd)、多线程DLL(/MD)和多线程调试DLL(/MDd)。含义如表6.1所示。
表6.1
| 选项 | 含义 |
|---|---|
| /MT | 多线程静态库发行版,编译器会静态链接相关C/C++链接库 |
| /MTd | 多线程静态库调试版,编译器会静态链接相关C/C++链接库 |
| /MD | 多线程动态库发行版,编译器会动态链接相关C/C++链接库 |
| /MDd | 多线程动态库调试版,编译器会动态链接相关C/C++链接库 |
对于MT / MTd,由于已经静态链接相关C/C++链接库,在编译链接时会将用到的C/C++运行库中的函数代码集成到程序中成为程序中的代码,程序体积增大,编译后的程序可以在其他机器上正常运行;但是对于MD / MDd,因为是动态链接相关C/C++链接库,在程序运行时会动态加载相关的DLL,程序体积会减小,但是在其他机器上运行时可能会提示缺少相关动态链接库的错误。
在平时做练习时,为了方便调试,通常应该编译为Debug调试版本,运行库选项可以选择多线程调试(/MTd)。在发布软件时,通常应该编译为Release发行版本,Release版本会对代码进行大量优化,运行库选项则可以选择多线程(/MT),如原书的图6.7所示。
6.2.3 入口点函数DllMain
一个DLL可以有一个入口点函数DllMain(区分大小写),系统会在不同的时刻调用这个入口点函数,当系统装载、卸载动态链接库,以及进程中有线程被创建、退出时,系统会调用入口点函数。系统调用入口点函数是通知性质,通知DLL执行一些与进程或线程有关的初始化和清理工作,如果DLL不需要这些通知,可以不必在源代码中实现该入口点函数,例如要创建一个只包含资源的DLL,则不需要实现该函数。入口点函数的格式如下:
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
// dll正被映射到进程的地址空间中
break;
case DLL_PROCESS_DETACH:
// dll正在从进程的地址空间中卸载
break;
case DLL_THREAD_ATTACH:
// 进程中创建了一个新的线程
break;
case DLL_THREAD_DETACH:
// 进程中有一个线程正在退出
break;
}
return TRUE;
}
hModule参数是DLL模块的句柄,也就是DLL加载到进程地址空间中的基地址,如果在动态链接库中定义了资源并且需要加载一个资源,那么在处理DLL_PROCESS_ATTACH时应该保存这个模块句柄到一个全局变量中,从DLL模块中加载资源时需要使用模块句柄。
ul_reason_for_call参数表示调用DLL入口点函数的原因码,可以是表6.2所示的值之一。
表6.2
| 原因码 | 值 | 含义 |
|---|---|---|
| DLL_PROCESS_ATTACH | 1 | 当系统第一次将一个DLL映射到进程的地址空间中时(隐式链接或显式链接),会调用DllMain函数,并在ul_reason_for_call参数中传入DLL_PROCESS_ATTACH,如果后续进程中有一个线程再调用LoadLibrary(Ex)函数来加载一个已经被映射到进程地址空间中的DLL,则系统只是递增该DLL的引用计数,而不会再次使用DLL_PROCESS_ATTACH来调用DllMain函数。在处理DLL_PROCESS_ATTACH时,可以根据需要保存DLL的模块句柄,然后做一些初始化工作,处理完DLL_PROCESS_ATTACH通知以后应返回TRUE表示初始化成功,如果返回FALSE,则DLL加载会失败。如果任何一个DLL的DllMain函数在处理DLL_PROCESS_ATTACH时返回FALSE,即初始化失败,则系统会向用户显示一个消息框来告诉用户进程无法正常启动,并把所有的文件映像从地址空间中清除,然后终止整个进程。如果ul_reason_for_call参数是其他原因码(DLL_PROCESS_DETACH、DLL_THREAD_ATTACH或DLL_THREAD_DETACH),系统会忽略DllMain函数的返回值 |
| DLL_PROCESS_DETACH | 0 | 当系统将一个DLL从进程的地址空间中撤销映射时,会调用DLL的DllMain函数,并在ul_reason_for_call参数中传入DLL_PROCESS_DETACH。DLL处理这个通知时,应该执行与之相关的清理工作。如果是系统中的某个线程调用了TerminateProcess函数终止进程,则系统不会使用DLL_PROCESS_DETACH来调用每个DLL的DllMain函数,这意味着在进程终止前,已映射到进程地址空间中的任何DLL将没有机会执行任何清理代码,这可能会导致数据丢失,因此除非万不得已,我们应该避免使用TerminateProcess函数。以DLL_PROCESS_ATTACH和DLL_PROCESS_DETACH值调用DllMain函数在动态链接库的生命周期中只可能出现一次 |
| DLL_THREAD_ATTACH | 2 | 当进程中创建一个新线程时,系统会使用DLL_THREAD_ATTACH来调用每个DLL的DllMain函数,这时DLL可以执行一些与线程相关的初始化工作,当所有DLL都完成了对该通知的处理后,系统才允许新线程开始执行它的线程函数。只有在创建新线程时DLL已经被映射到进程的地址空间中的情况下系统才会使用DLL_THREAD_ATTACH来调用DLL的DllMain函数。当系统将一个新的DLL映射到进程的地址空间中时,如果进程中已经有多个线程在运行,那么系统不会允许任何已有的线程使用DLL_THREAD_ATTACH来调用DLL的DllMain函数。另外,进程的主线程不会使用DLL_THREAD_ATTACH来调用 DllMain 函数,在创建进程时被映射到进程地址空间中的任何 DLL 会收到DLL_PROCESS_ATTACH通知,但不会收到DLL_THREAD_ATTACH通知 |
| DLL_THREAD_DETACH | 3 | 当进程中有一个线程正在退出时,系统会使用DLL_THREAD_DETACH调用每个DLL的DllMain函数,这时DLL可以执行一些相关的清理工作,只有当每个DLL都处理完DLL_THREAD_DETACH通知后,系统才会真正终止线程。如果系统中某个线程调用了TerminateThread函数终止线程,系统不会使用DLL_THREAD_DETACH来调用所有DLL的DllMain函数,这意味着在线程终止前,已映射到进程地址空间中的任何DLL都将没有机会执行任何清理代码,这可能会导致数据丢失,因此除非万不得已,我们应该避免使用TerminateThread函数。如果是因为DLL加载失败、进程终止或调用FreeLibrary函数卸载DLL,则系统不会使用DLL_THREAD_DETACH来调用每个DLL的DllMain函数,仅向DLL发送DLL_PROCESS_DETACH通知 |
另外,当ul_reason_for_call参数为DLL_PROCESS_ATTACH时,如果是隐式链接的DLL,则lpvReserved参数为非NULL;如果是显式链接的DLL,则lpvReserved参数为NULL。当ul_reason_for_call参数为DLL_PROCESS_DETACH时,如果是因为调用FreeLibrary函数或DLL加载失败而卸载DLL,则lpvReserved参数为NULL;如果是因为进程终止导致正在卸载DLL,则lpvReserved参数为非NULL。
6.2.4 延迟加载DLL
延迟加载指的是通过隐式链接的DLL,可执行模块开始运行时并不加载延迟加载的DLL(也不会检查该DLL是否存在),只有当代码中调用延迟加载DLL中的函数时,系统才会实际载入该DLL。延迟加载DLL有如下特性。
(1)提高应用程序的加载速度。当运行一个程序时,PE加载器会解析可执行模块的导入表,并把导入表中列出的每个DLL加载到进程的地址空间中,加载每个DLL时还会调用入口点函数DllMain并做一些初始化工作。如果一个应用程序使用了特别多的DLL,则上述操作会严重拖慢程序的加载速度,而延迟加载则可以很好地解决这个问题,把加载DLL的工作延迟到代码中调用DLL中的函数时完成。
(2)提高应用程序的兼容性。当操作系统升级时,一些系统DLL中的函数会有所优化,系统DLL中的导出函数会有所增加,新版本DLL可能提供了旧版本DLL中不包含的新函数,如果我们的程序用到了一个只有新版本DLL中才具有的函数,而程序却运行在旧版本操作系统中,则程序初始化时会提示无法定位函数并终止程序的运行。延迟加载可以很好地解决这个问题,当应用程序初始化时,可以调用GetVersionEx等函数来检查操作系统的版本,如果发现程序运行在旧版本操作系统中,则通过其他方法实现该函数的功能。
(3)提高应用程序的可整合性。一个软件除了可执行文件本身,通常还需要一些DLL、配置文件、数据库等文件,丢失任何一个文件都可能导致软件不能正常运行,我们可以通过添加资源的方式把软件所需的数据文件添加到可执行文件资源中,运行程序时释放这些文件到相关目录中,但是如果在程序初始化时发现相关目录中不存在程序运行所需的DLL,那么程序的初始化同样会失败。延迟加载可以很好地解决这个问题。采用延迟加载后,可执行模块在开始运行时并不加载延迟加载的DLL(也不会检查该DLL是否存在)。
需要注意的是,如果一个DLL中导出了变量,则该DLL不支持延迟加载。Kernel32.dll不能延迟加载,因为延迟加载需要用到该DLL中的LoadLibrary(Ex)和GetProcAddress等函数。不应该在入口点函数DllMain中调用延迟加载DLL中的函数,否则可能会导致程序崩溃。
加密解密是当今信息社会的永恒话题,以下为GetMd5.dll导出的一个函数GetMd5:
BOOL GetMd5(LPCTSTR lpFileName, LPTSTR lpMd5)
该函数获取指定文件lpFileName的MD5,并将计算结果返回到lpMd5参数指定的缓冲区中,MD5、SHA是常用的加密算法,具体代码参见Chapter6\GetMd5项目。实际上获取一个文件或一段数据的MD5、SHA等,不一定必须使用微软提供的API,完全可以根据规定算法编写一个自定义函数。
接下来我们实现一个延迟加载GetMd5.dll的例子,GetMd5Test.exe程序运行效果如原书的图6.8所示。
与前面介绍的使用DLL编写可执行程序(例如 Test 程序)的方法完全相同,GetMd5Test项目同样需要GetMd5.h、GetMd5.lib和GetMd5.dll,编译运行程序,一切工作正常,如果删除GetMd5.dll,GetMd5Test程序会初始化失败。用鼠标右键单击项目名称,然后选择属性→配置属性→链接器→输入,在延迟加载的DLL中输入GetMd5.dll,单击"确定"按钮,重新编译运行程序,这时即使删除了GetMd5.dll,GetMd5Test程序仍然可以正常运行。但是,因为不存在GetMd5.dll,如果按下"获取MD5"按钮,程序会调用GetMd5.dll中的导出函数GetMd5,导致触发一个异常并终止运行。
前面说过,延迟加载可以提高应用程序的可整合性,我们可以把GetMd5.dll这个文件添加到GetMd5Test项目资源中,例如资源类型为自定义类型"MyDll",资源ID为IDR_MYDLL,程序处理WM_INITDIALOG消息时可以释放GetMd5.dll到可执行程序的当前目录中:
case WM_INITDIALOG:
// 如果当前目录中不存在GetMd5.dll
if (!PathFileExists(TEXT("GetMd5.dll")))
{
hResBlock = FindResource(g_hInstance, MAKEINTRESOURCE(IDR_MYDLL), TEXT("MyDll"));
if (!hResBlock)
return FALSE;
hRes = LoadResource(g_hInstance, hResBlock);
if (!hRes)
return FALSE;
lpDll = LockResource(hRes);
dwDllSize = SizeofResource(g_hInstance, hResBlock);
hFile = CreateFile(TEXT("GetMd5.dll"), GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE)
return FALSE;
WriteFile(hFile, lpDll, dwDllSize, NULL, NULL);
CloseHandle(hFile);
}
return TRUE;
完整代码参见Chapter6\GetMd5Test项目。
用户可以在编辑框中输入一个文件名,或者拖动一个文件到对话框中,如果是拖动文件,则程序会响应WM_DROPFILES消息,把所拖动文件的完整路径显示到编辑框中。如果CreateWindowEx函数的dwExStyle参数指定为WS_EX_ACCEPTFILES,则表示所创建的窗口可以接受拖放文件,对对话框程序来说可以通过对话框的Accept Files属性来设置。
如果窗口具有WS_EX_ACCEPTFILES样式,当用户拖动一个或多个文件到窗口中时,窗口过程会收到WM_DROPFILES消息,该消息的wParam参数是一个描述所拖动文件信息的HDROP类型结构句柄(用户不需要关心结构句柄的具体含义,直接使用即可),该句柄可以用于DragQueryFile、DragQueryPoint和DragFinish函数调用;lParam参数没有用到。程序处理完WM_DROPFILES消息后应返回0。
DragQueryFile函数用于查询所拖放文件的名称:
UINT DragQueryFile(
_In_ HDROP hDrop, // HDROP句柄
_In_ UINT iFile, // 要查询的文件的索引,设置为0xFFFFFFFF则函数返回所拖放文件的总数
_Out_ LPTSTR lpszFile, // 返回指定索引的文件名的缓冲区
UINT cch); // lpszFile缓冲区的大小,以字符为单位
-
iFile参数指定要查询的文件的索引(从0开始),设置为0xFFFFFFFF,函数返回所拖放文件的总数。如果该参数的值介于0和所拖放文件的总数之间,函数会将指定索引的文件名复制到lpszFile参数指向的缓冲区。
-
lpszFile参数是返回指定索引的文件名的缓冲区,如果该参数设置为NULL,那么函数会返回指定索引的文件名所需缓冲区的大小,以字符为单位,不包括终止的空字符。
如果函数执行成功,则返回值是复制到lpszFile参数指向的缓冲区中的字符数,不包括终止的空字符。
如果只想获取所拖动文件中第一个文件的文件名,可以按如下方式使用:
HDROP hDrop;
TCHAR szFileName[MAX_PATH] = { 0 };
case WM_DROPFILES:
hDrop = (HDROP)wParam;
DragQueryFile(hDrop, 0, szFileName, _countof(szFileName));
return FALSE;
如果需要获取所有拖动文件的文件名,可以按如下方式使用:
HDROP hDrop;
UINT uDragCount;
TCHAR szFileName[MAX_PATH] = { 0 };
case WM_DROPFILES:
hDrop = (HDROP)wParam;
uDragCount = DragQueryFile(hDrop, 0xFFFFFFFF, NULL, 0);
for (UINT i = 0; i < uDragCount; i++)
{
DragQueryFile(hDrop, i, szFileName, _countof(szFileName));
// 处理本次查询到的文件名
}
DragQueryPoint函数用于查询拖动操作期间鼠标指针的位置,DragFinish函数用于释放系统为拖动操作所分配的内存:
BOOL DragQueryPoint(
_In_ HDROP hDrop, // HDROP句柄
_Out_ POINT* lppt); // POINT结构
VOID DragFinish(HDROP hDrop); // HDROP句柄
用鼠标右键单击项目名称,然后选择属性→配置属性→链接器→输入,在延迟加载的DLL中输入GetMd5.dll(可以指定多个DLL,以;分隔),该设置告知编译器不要在可执行模块的导入表中设置GetMd5.dll及相关函数的信息,这样当进程初始化时,PE加载器不会隐式链接该DLL;同时,该设置使编译器在可执行模块中创建一个延迟加载导入表,延迟加载导入表记录了可执行模块要导入的DLL及相关函数的信息,与导入表不同的是,在可执行模块一开始运行时这些DLL并不会被PE加载器加载,只有当代码中调用这些DLL中的函数时,系统才会实际载入DLL;编译器还会在可执行模块中嵌入一个__delayLoadHelper2函数,对延迟加载DLL中函数的调用会跳转到该函数,该函数会解析延迟加载导入表,并调用LoadLibrary(Ex)和GetProcAddress等函数完成对延迟加载DLL的加载以及函数地址的解析。
当代码中调用延迟加载DLL中的一个函数但是DLL不存在时,__delayLoadHelper2函数会抛出一个软件异常;如果DLL已经存在,但是试图调用该DLL中一个不存在的函数,__delayLoadHelper2函数也会抛出一个软件异常,对于这两种情况,可以使用结构化异常处理(Structured Exception Handling,SEH)来捕捉异常并使应用程序继续运行;如果不捕捉该异常,则进程将会终止。
如果想在不需要延迟加载DLL时可以卸载该DLL,可以用鼠标右键单击项目名称,然后选择属性→配置属性→链接器→高级,在卸载延迟加载的DLL中选择(/DELAY:UNLOAD),即可在不需要延迟加载DLL的地方调用__FUnloadDelayLoadedDLL2函数卸载DLL,该函数会自动调用FreeLibrary函数卸载DLL并重置DLL中的函数地址。__FUnloadDelayLoadedDLL2函数在delayimp.h头文件中的定义如下:
BOOL WINAPI __FUnloadDelayLoadedDLL2(LPCSTR szDll);
szDll参数是一个ANSI字符串指针,并且区分大小写。
6.3 线程局部存储
线程局部存储(Thread Local Storage,TLS)相当于一个程序中的DWORD数组形式的全局变量。数组元素最少为TLS_MINIMUM_AVAILABLE(64)个,在需要时系统会分配更多的数组元素,最多可达1088个,这对任何应用程序来说都是足够的,程序中的每个线程可以使用相同的数组元素索引操作属于每个线程的数据。具体来说,系统会为每个进程分配一个4字即一个具有64个索引(0~63)的位数组,每个位的值可以是0(表示未使用)或1(表示已使用),程序可以从进程的位数组中申请一个闲置的索引(值为0的索引),例如索引3,程序应该把这个申请到的索引保存为全局变量,例如g_dwTlsIndex,创建每个线程时,系统会为每个线程分配一个LPVOID类型的数组,例如LPVOID TlsSlots[64],每个数组元素都会初始化为 NULL,假设程序具有多个线程并且具有相同的线程函 数,我们可以把每个线程的创建时间存放到TlsSlots[g_dwTlsIndex]参数中,线程 1 的 TlsSlotsg_dwTlsIndex可以存放线程1的创建时间,线程2的TlsSlotsg_dwTlsIndex可以存放线程2的创建时间,每个线程虽然使用相同的索引,但是操作的数据却与具体线程相关联,线程1不能通过一个索引来访问线程2的数据,使用TLS可以将数据与正在运行的指定线程关联起来,通过使用一个全局索引来访问每个线程的唯一数据。
在创建线程时,系统会为每个线程分配一个LPVOID类型的数组,实际上这个LPVOID类型的数组正是线程环境块TEB的TlsSlots字段,创建线程时系统会创建一个线程环境块用于管理线程,TEB结构在winternl.h头文件中定义如下:
typedef struct _TEB {
PVOID Reserved1[12];
PPEB ProcessEnvironmentBlock; // 进程环境块PEB结构的指针
PVOID Reserved2[399];
BYTE Reserved3[1952];
PVOID TlsSlots[64]; // 线程存储槽
BYTE Reserved4[8];
PVOID Reserved5[26];
PVOID ReservedForOle;
PVOID Reserved6[4];
PVOID TlsExpansionSlots;
} TEB, * PTEB;
程序可以通过调用NtCurrentTeb函数获取当前线程的线程环境块(TEB)结构的指针。
线程局部存储原理如原书的图6.9所示。
6.3.1 动态TLS
要使用动态TLS,首先需要调用TlsAlloc函数从进程中分配一个TLS索引,函数会把进程位数组中该索引处的位设置为1以表示已使用,进程中的每个线程都可以使用该索引操作属于自己的数据,分配到的TLS索引通常需要保存到一个全局变量中,每个线程都可以访问。如果函数执行成功,则返回一个TLS索引。另外,函数还会把每个线程的存储槽中该索引位置的数据初始化为NULL,如果函数执行失败,则返回值为TLS_OUT_OF_INDEXES((DWORD)0xFFFFFFFF)。
然后,线程可以通过调用TlsSetValue函数在存储槽中的指定索引处写入数据,该函数的返回值为BOOL类型。线程可以通过调用TlsGetValue函数获取存储槽中指定索引处的数据,如果函数执行成功,则返回值是调用线程存储槽中指定索引处的值;如果函数执行失败,则返回值为0,但是返回值为0并不代表函数执行失败,例如存储槽中该索引处本来就是存储的0值,因此还应该调用GetLastError函数确定错误代码是否为ERROR_SUCCESS。
当不再需要索引时,程序应该调用TlsFree函数释放TLS索引,函数会把进程位数组中该索引处的位设置为0以表示未使用,以便程序调用TlsAlloc函数时分配到该索引。TLS索引个数是有限的,应该节约使用。另外,TlsFree函数还会把每个线程的存储槽中该索引位置的数据初始化为NULL。有一点需要注意,如果存储槽中该索引处存储的是一个内存块指针,该函数不会释放该内存块,释放内存块由程序员负责完成,该函数的返回值为BOOL类型。
相关函数声明如下:
DWORD WINAPI TlsAlloc(void);
BOOL WINAPI TlsSetValue(
_In_ DWORD dwTlsIndex, // 由TlsAlloc函数分配的TLS索引
_In_opt_ LPVOID lpTlsValue); // 要存储在调用线程的TLS插槽中指定索引处的值
LPVOID WINAPI TlsGetValue(_In_ DWORD dwTlsIndex); // 由TlsAlloc函数分配的TLS索引
BOOL WINAPI TlsFree(_In_ DWORD dwTlsIndex); // 由TlsAlloc函数分配的TLS索引
接下来实现一个使用动态TLS的示例程序TlsDemo,程序运行效果如原书的图6.10所示。
用户单击"确定"按钮,程序创建5个线程,这些线程使用相同的线程函数,在线程函数中分配一个内存块并把内存块地址保存到对应线程的索引为g_dwTlsIndex的存储槽中,然后获取索引为g_dwTlsIndex的存储槽中的数据并显示到编辑控件中。每个线程的存储槽中存储的只是一个内存块指针,程序可以分配任意大小的内存块并在内存块中存储任何数据。TlsDemo.cpp源文件的内容如下:
#include <windows.h>
#include "resource.h"
// 宏定义
#define THREADCOUNT 5
// 全局变量
DWORD g_dwTlsIndex;
HWND g_hwndDlg;
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
// 线程函数
DWORD WINAPI ThreadProc(LPVOID lpParameter);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
HANDLE hThread[THREADCOUNT];
switch (uMsg)
{
case WM_INITDIALOG:
g_hwndDlg = hwndDlg;
return TRUE;
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_OK:
// 从进程中分配一个TLS索引
g_dwTlsIndex = TlsAlloc();
if (g_dwTlsIndex == TLS_OUT_OF_INDEXES)
{
MessageBox(hwndDlg, TEXT("TlsAlloc函数调用失败!"), TEXT("错误提示"), MB_OK);
return FALSE;
}
// 创建THREADCOUNT个线程
SetDlgItemText(g_hwndDlg, IDC_EDIT_TLSSLOTS, TEXT(""));
for (int i = 0; i < THREADCOUNT; i++)
{
if ((hThread[i] = CreateThread(NULL, 0, ThreadProc, (LPVOID)i, 0, NULL)) != NULL)
CloseHandle(hThread[i]);
}
// 等待所有线程结束,释放TLS索引
WaitForMultipleObjects(THREADCOUNT, hThread, TRUE, INFINITE);
TlsFree(g_dwTlsIndex);
break;
case IDCANCEL:
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
}
return FALSE;
}
DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
LPVOID lpData = NULL;
TCHAR szBuf[64] = { 0 };
lpData = new BYTE[256];
ZeroMemory(lpData, 256);
// 在存储槽中指定索引处写入的数据
if (!TlsSetValue(g_dwTlsIndex, lpData))
{
wsprintf(szBuf, TEXT("线程%d调用TlsSetValue失败"), (INT)lpParameter);
MessageBox(g_hwndDlg, szBuf, TEXT("错误提示"), MB_OK);
delete[]lpData;
return 0;
}
// 获取存储槽中指定索引处的数据
lpData = TlsGetValue(g_dwTlsIndex);
if (!lpData && GetLastError() != ERROR_SUCCESS)
{
wsprintf(szBuf, TEXT("线程%d调用TlsGetValue失败"), (INT)lpParameter);
MessageBox(g_hwndDlg, szBuf, TEXT("错误提示"), MB_OK);
}
// 每个线程存储槽中指定索引处的数据显示到编辑控件中
wsprintf(szBuf, TEXT("线程%d的索引%d处的值:0x%p\r\n"), (INT)lpParameter, g_dwTlsIndex, lpData);
SendMessage(GetDlgItem(g_hwndDlg, IDC_EDIT_TLSSLOTS), EM_SETSEL, -1, -1);
SendMessage(GetDlgItem(g_hwndDlg, IDC_EDIT_TLSSLOTS), EM_REPLACESEL, TRUE, (LPARAM)szBuf);
delete[]lpData;
return 0;
}
如果要使用多线程下载一个文件,使用相同的线程函数,我们需要在每个线程中维护一个文件偏移变量,每个线程写入文件中不同的文件偏移处,使用TLS技术可以很好地解决这个问题。
另外,TLS技术同样适用于DLL,因为DLL并不了解宿主程序的程序结构,而在编写可执行程序时,如果清楚自己的程序会创建多少个线程,我们可以使用一些替代方案为线程关联数据,例如对于全局变量,可以采取线程间同步机制。通常,如果DLL要使用TLS,可以在处理DLL_PROCESS_ATTACH时调用TlsAlloc函数从进程中分配一个TLS索引,在处理DLL_PROCESS_DETACH时调用TlsFree函数释放TLS索引,在DLL的内部函数和导出函数中可以调用TlsSetValue、TlsGetValue函数设置、获取线程局部存储数据。因为进程是DLL的宿主,一个DLL在进程中申请到的TLS索引不可以用于引用该dll的其他进程,仅可以用于当前DLL所属进程。
6.3.2 静态TLS
与动态TLS类似,静态TLS也可以将数据与线程关联起来,在使用时并不需要调用任何函数,因此静态TLS更易于使用。使用静态TLS时,可以按如下方式声明变量:
__declspec(thread) LPVOID gt_lpData;
__declspec(thread)前缀告诉编译器在编译链接时把变量放到可执行文件的一个区段中,变量可以声明为全局变量或静态变量(静态全局变量或静态局部变量),但是不可以为局部变量。局部变量存储在栈中,与特定的线程相关联。在命名变量时我们可以使用gt_前缀来表示全局TLS变量,用st_前缀来表示静态TLS变量,在编译链接生成可执行文件时,系统会把所有TLS变量放到一个名为.tls的区段中(如果编译为Release版本,该区段可能会被优化到其他区段中)。后面会介绍关于PE文件区段的概念。
在运行可执行文件时,系统会解析可执行文件中的.tls段,并分配一块足够大的内存来保存所有的TLS变量,当代码中引用其中一个TLS变量时,会定位到内存块中的一个地址处。与动态TLS相同,每个线程只能访问自己的TLS变量,而无法访问属于其他线程的TLS变量。
动态链接库DLL中也可以使用TLS变量。在运行可执行文件时,系统会确定应用程序和所有DLL中.tls段的大小,系统会分配一块足够大的内存来保存应用程序和所有隐式链接DLL需要的TLS变量。在Vista与Server 2008系统更新后,如果是显式链接一个DLL,系统会自动扩大TLS内存块;当显式卸载一个DLL时,系统会自动缩小TLS内存块。
我们使用静态TLS改写前面的TlsDemo程序,代码如下:
#include <windows.h>
#include "resource.h"
// 宏定义
#define THREADCOUNT 5
// 全局变量
__declspec(thread) LPVOID gt_lpData;
HWND g_hwndDlg;
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
// 线程函数
DWORD WINAPI ThreadProc(LPVOID lpParameter);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
HANDLE hThread[THREADCOUNT];
switch (uMsg)
{
case WM_INITDIALOG:
g_hwndDlg = hwndDlg;
return TRUE;
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_OK:
// 创建THREADCOUNT个线程
SetDlgItemText(g_hwndDlg, IDC_EDIT_TLSSLOTS, TEXT(""));
for (int i = 0; i < THREADCOUNT; i++)
{
if ((hThread[i] = CreateThread(NULL, 0, ThreadProc, (LPVOID)i, 0, NULL)) != NULL)
CloseHandle(hThread[i]);
}
break;
case IDCANCEL:
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
}
return FALSE;
}
DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
TCHAR szBuf[64] = { 0 };
gt_lpData = new BYTE[256];
ZeroMemory(gt_lpData, 256);
// 每个线程的静态TLS数据显示到编辑控件中
wsprintf(szBuf, TEXT("线程%d的gt_lpData值:0x%p\r\n"), (INT)lpParameter, gt_lpData);
SendMessage(GetDlgItem(g_hwndDlg, IDC_EDIT_TLSSLOTS), EM_SETSEL, -1, -1);
SendMessage(GetDlgItem(g_hwndDlg, IDC_EDIT_TLSSLOTS), EM_REPLACESEL, TRUE, (LPARAM)szBuf);
delete[]gt_lpData;
return 0;
}
6.4 Windows钩子
钩子(Hook)是Windows消息处理机制中的一个监视点,应用程序可以在这里安装一个监视子程序(钩子函数,是一个回调函数),以便监视系统中的消息,并在消息到达目标窗口过程前处理这些消息,即在目标窗口过程处理发生的消息前,先由钩子函数处理。钩子是应用程序拦截事件(如消息、鼠标和击键操作)的一种机制,钩子函数可以对它接收到的每个事件执行操作,例如修改或丢弃该事件。
Windows安装的钩子有两种类型:局部钩子和远程钩子。它们处理的消息范围不同,局部钩子称为局部线程钩子,仅挂钩属于自身进程的事件。远程钩子则可以挂钩其他进程中发生的事件。远程钩子又分为两种:基于其他进程中某一线程的线程钩子和系统范围的系统钩子,前者可以用来捕获其他进程中某一特定线程的事件,称为远程线程钩子;而系统范围的系统钩子可以捕捉系统中所有进程的线程中发生的事件,称为远程系统钩子或全局钩子。
SetWindowsHookEx函数用于将应用程序定义的钩子函数安装到钩子链中,钩子函数用于监视系统中某些类型的事件:
HHOOK WINAPI SetWindowsHookEx(
_In_ int idHook, // 要安装的钩子函数的类型
_In_ HOOKPROC lpfn, // 指向钩子函数的指针
_In_ HINSTANCE hMod, // 包含钩子函数的动态链接库DLL的模块句柄
_In_ DWORD dwThreadId); // 与钩子函数关联的线程ID,也就是要监视的线程
- idHook参数指定要安装的钩子函数的类型,可以是表6.3所示的值之一。
表6.3
| 常量 | 值 | 含义 |
|---|---|---|
| WH_GETMESSAGE | 3 | 每当GetMessage或PeekMessage函数从应用程序消息队列中获取到消息时系统会调用钩子函数 |
| WH_KEYBOARD | 2 | 每当GetMessage或PeekMessage函数从应用程序消息队列中获取到键盘消息(WM_KEYUP或WM_KEYDOWN)时系统会调用钩子函数 |
| WH_MOUSE | 7 | 每当GetMessage或PeekMessage函数从应用程序消息队列中获取到鼠标消息时系统会调用钩子函数 |
| WH_CALLWNDPROC | 4 | 在系统将消息发送到目标窗口过程前系统会调用钩子函数 |
| WH_CALLWNDPROCRET | 12 | 在目标窗口过程处理完消息以后系统会调用钩子函数 |
| WH_DEBUG | 9 | 在调用与任何类型的钩子关联的钩子函数前先调用本钩子函数,本钩子函数可以允许或禁止系统调用其他钩子函数,也就是说如果系统中安装了其他钩子,在调用相关钩子函数前会先调用本钩子函数 |
| WH_CBT | 5 | 系统在激活、创建、销毁、最小化、最大化、移动或调整窗口前、在完成系统命令前、在从系统消息队列中删除鼠标或键盘事件前、在设置键盘焦点前、在与系统消息队列同步前调用钩子函数,计算机辅助训练CBT应用程序也会使用钩子函数从系统接收有用的通知 |
| WH_FOREGROUNDIDLE | 11 | 每当前台线程即将变为空闲时系统会调用钩子函数 |
| WH_JOURNALRECORD | 0 | 日志记录钩子,用来记录发送给系统消息队列的所有消息,钩子函数不需要位于动态链接库中 |
| WH_JOURNALPLAYBACK | 1 | 日志回放钩子,用来回放日志记录钩子记录的系统事件,钩子函数不需要位于动态链接库中 |
| WH_MSGFILTER | 1 | 当用户对对话框、消息框、菜单和滚动条有所操作时,系统在发送对应的消息前会调用钩子函数,这种钩子通常是局部的 |
| WH_SYSMSGFILTER | 6 | 同WH_MSGFILTER,但是是系统范围的 |
| WH_SHELL | 10 | 当Windows Shell程序接收一些通知事件前调用钩子函数,如Shell被激活和重绘等 |
-
lpfn参数是指向钩子函数的指针,如果dwThreadId参数指定为0(系统范围的全局钩子)或由其他进程创建的线程ID(远程线程钩子),则钩子函数必须位于动态链接库DLL中;否则钩子函数位于当前进程的程序代码中。
-
hMod参数指定包含钩子函数的动态链接库DLL的模块句柄,如果dwThreadId参数指定为由当前进程创建的线程ID,则hMod参数应该设置为NULL。
-
dwThreadId参数指定与钩子函数关联的线程ID(要监视的线程),如果该参数设置为0,则钩子函数与系统中所有线程相关联(监视所有进程中的线程);如果该参数设置为其他进程创建的线程ID,则表示监视其他进程中的线程;如果该参数设置为当前进程的线程ID,则表示监视当前进程中的线程。
如果函数执行成功,则返回值是钩子函数的句柄;如果函数执行失败,则返回值为NULL。
当在自身进程中安装了一个局部钩子时,每当指定的事件发生,Windows就会调用该进程中的钩子函数;但是如果安装的是远程钩子(远程线程钩子或系统范围的全局钩子),则系统不能从其他进程的地址空间中调用钩子函数,因为不同进程的地址空间是隔离的。由于动态链接库DLL可以加载到其他进程的地址空间中,因此远程钩子的钩子函数必须位于一个动态链接库DLL中。但是有两个例外,日志记录钩子和日志回放钩子虽然属于远程钩子,但是其钩子函数可以放在安装钩子的程序中,并不需要单独放在一个动态链接库DLL中。
(1)钩子函数。钩子函数(钩子回调函数)的定义如下:
LRESULT CALLBACK HookProc(int nCode, WPARAM wParam, LPARAM lParam);
各种不同类型钩子的钩子函数的定义是相同的,但是对于不同类型的钩子,其nCode、wParam和lParam参数的含义却各不相同,具体含义在使用时请参考MSDN中对SetWindowsHookEx函数的解释。
(2)钩子链。系统支持多种不同类型的钩子,系统中可以安装多个不同类型或相同类型的钩子,并为每种类型的钩子维护一个钩子链。钩子链是同种类型钩子的钩子函数的指针列表,最近加入的钩子放在链表的头部。当一个事件发生时,Windows调用最后安装的钩子函数,因为系统中同一类型的钩子可能安装了多个,因此一个钩子函数应该把消息事件传递下去以便其他的钩子都有获得处理这一消息的机会。
CallNextHookEx函数用于把消息事件传递给钩子链中的下一个钩子函数:
LRESULT WINAPI CallNextHookEx(
_In_opt_ HHOOK hhk, // 忽略该参数
_In_ int nCode, // 钩子代码,使用钩子函数的同名参数即可
_In_ WPARAM wParam, // wParam参数,使用钩子函数的同名参数即可
_In_ LPARAM lParam); // lParam参数,使用钩子函数的同名参数即可
该函数的返回值是钩子链中下一个钩子函数的返回值。
安装钩子会影响系统的性能,因为系统在处理所有的相关事件时都会调用钩子函数,特别是监视范围是整个系统范围的全局钩子。另外,全局钩子通常用于调试目的,全局钩子可能与其他应用程序中同一类型的全局钩子发生冲突。当不再需要钩子时,应该调用UnhookWindowsHookEx函数卸载安装在钩子链中的钩子函数:
BOOL WINAPI UnhookWindowsHookEx(_In_ HHOOK hhk); // 调用SetWindowsHookEx函数返回的钩子句柄
对于不同类型的钩子,其钩子函数的nCode、wParam和lParam参数的含义各不相同,具体含义请参考MSDN中对SetWindowsHookEx函数的解释,这里以一个WH_KEYBOARD类型的全局钩子为例演示钩子的用法。WH_KEYBOARD键盘钩子的钩子函数定义如下:
LRESULT CALLBACK KeyboardProc(
_In_ int nCode, // 用来确定如何处理消息的钩子代码
_In_ WPARAM wParam, // 击键消息的键的虚拟键码,用于确定哪个键被按下或释放
_In_ LPARAM lParam); // 击键消息的一些附加信息,包含消息的重复计数、扫描码等
-
nCode参数用来确定如何处理消息的钩子代码,如果nCode参数小于0,则钩子函数必须将消息传递给CallNextHookEx函数并返回CallNextHookEx函数的返回值,这种情况下不需要做其他处理;如果nCode参数为HC_ACTION(0),则说明wParam和lParam参数包含有关击键消息的信息,这时候我们应该对击键消息进行处理,处理完后,应该调用CallNextHookEx函数将击键消息传递给钩子链中的下一个钩子函数,当然,钩子函数也可以通过返回TRUE来丢弃消息并阻止该消息的继续传递。
-
wParam和lParam参数的含义与系统击键消息、非系统击键消息的含义相同,wParam参数包含虚拟键码,用于确定哪个键被按下或释放;lParam参数是击键消息的一些附加信息,包含消息的重复计数、扫描码、扩展键标志、状态描述码、先前键状态标志和转换状态标志等。
远程钩子的钩子函数必须位于一个动态链接库DLL中,每当系统中的一个进程发生指定的事件时,系统会把包含钩子函数的DLL加载到自己的进程地址空间中以执行钩子函数,因此我们需要为键盘钩子编写一个DLL,钩子安装函数SetWindowsHookEx需要一个动态链接库DLL的模块句柄,这个DLL模块句柄可以从DllMain入口点函数中获取,因此钩子的安装和卸载工作也在DLL中完成(导出钩子的安装和卸载两个函数),如果是在可执行程序中安装钩子还需要额外获取DLL的模块句柄。
HookDll.h头文件的内容如下:
#pragma once
// 声明导出的函数
#ifdef DLL_EXPORT
#define DLL_API extern "C" __declspec(dllexport)
#else
#define DLL_API extern "C" __declspec(dllimport)
#endif
// 导出函数
DLL_API BOOL InstallHook(int idHook, DWORD dwThreadId, HWND hwnd);
DLL_API BOOL UninstallHook();
// 内部函数
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam);
HookDll.cpp源文件的内容如下:
// 定义DLL的导出函数
#include <Windows.h>
#include <tchar.h>
#define DLL_EXPORT
#include "HookDll.h"
// 全局变量
HINSTANCE g_hMod;
HHOOK g_hHookKeyboard;
TCHAR g_szBuf[256] = { 0 };
#pragma data_seg("Shared")
HWND g_hwnd = NULL;
#pragma data_seg()
#pragma comment(linker, "/SECTION:Shared,RWS")
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
g_hMod = hModule;
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
// 导出函数
BOOL InstallHook(int idHook, DWORD dwThreadId, HWND hwnd)
{
if (!g_hHookKeyboard)
{
g_hwnd = hwnd;
g_hHookKeyboard = SetWindowsHookEx(idHook, KeyboardProc, g_hMod, dwThreadId);
if (!g_hHookKeyboard)
return FALSE;
}
return TRUE;
}
BOOL UninstallHook()
{
if (g_hHookKeyboard)
{
if (!UnhookWindowsHookEx(g_hHookKeyboard))
return FALSE;
}
g_hHookKeyboard = NULL;
return TRUE;
}
// 内部函数
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
{
BYTE bKeyState[256];
COPYDATASTRUCT copyDataStruct = { 0 };
if (nCode < 0)
return CallNextHookEx(NULL, nCode, wParam, lParam);
if (nCode == HC_ACTION)
{
GetKeyboardState(bKeyState);
bKeyState[VK_SHIFT] = HIBYTE(GetKeyState(VK_SHIFT));
ZeroMemory(g_szBuf, sizeof(g_szBuf));
ToUnicode(wParam, lParam >> 16, bKeyState, g_szBuf, _countof(g_szBuf), 0);
copyDataStruct.cbData = sizeof(g_szBuf);
copyDataStruct.lpData = g_szBuf;
SendMessage(g_hwnd, WM_COPYDATA, (WPARAM)g_hwnd, (LPARAM)©DataStruct);
}
return CallNextHookEx(NULL, nCode, wParam, lParam);
}
WH_KEYBOARD键盘钩子的钩子函数的wParam参数包含虚拟键码,可以通过调用ToAscii函数把虚拟键码转换为ANSI字符,或者通过调用ToUnicode函数把虚拟键码转换为Unicode字符,这两个函数的用法相同。ToUnicode函数的用法如下:
int WINAPI ToUnicode(
_In_ UINT wVirtKey, // 要转换的虚拟键码
_In_ UINT wScanCode, // 按键的扫描码
_In_opt_ const BYTE* lpKeyState, // 指向包含当前键盘状态的256字节数组的指针
_Out_ LPWSTR pwszBuff, // 接收转换以后的一个或多个Unicode字符的缓冲区
_In_ int cchBuff, // pwszBuff参数指向的缓冲区的大小,以字符为单位
_In_ UINT wFlags); // 如果位0为1,则菜单处于活动状态
-
wVirtKey参数指定要转换的虚拟键码,使用WH_KEYBOARD键盘钩子的钩子函数的wParam参数即可。
-
wScanCode参数指定按键的扫描码,WH_KEYBOARD键盘钩子的钩子函数的lParam参数是击键消息的一些附加信息,包含消息的重复计数、扫描码、扩展键标志、状态描述码、先前键状态标志、转换状态标志等,因此wScanCode参数使用lParam参数的高16位即可(16~31位,lParam >> 16)。
-
lpKeyState参数指定为指向包含当前键盘状态的256字节数组的指针,每个数组元素都包含一个按键的状态,数组元素索引就是虚拟键码。如果数组元素的字节值的高位为1,则按键按下;如果为0,则按键抬起。这个包含当前键盘状态的256字节数组可以通过GetKeyboardState函数来获取,对于Shift、Ctrl等按键,GetKeyboardState函数获取到的键盘状态数组填充的是以VK_LSHIFT、VK_RSHIFT、VK_LCONTROL、VK_RCONTROL为索引的数组元素,而ToUnicode函数检测的是以VK_SHIFT、VK_CONTROL为索引的数组元素,这些按键是否按下会影响转换结果,比如同样是按键"1",Shift键没有按下对应的就是"1",按下的话就是"!"。在本例中我们通过GetKeyState函数获取Shift按键的状态然后赋值给bKeyStateVK_SHIFT。
得到按键对应的字符后,可以通过发送WM_COPYDATA消息把字符发送到监视程序(调用InstallHook函数的可执行程序)。另外需要注意,如果钩取的不是键盘消息而是其他窗口消息,应该使用PostMessage而不是SendMessage函数,否则可能会导致处理时间过长。
有了包含钩子函数、安装、卸载钩子的DLL后,我们还需要编写一个调用安装、卸载钩子的监视程序,HookApp程序的界面如原书的图6.11所示。
单击"安装键盘钩子"按钮后,每当在记事本、Word、QQ中有键盘输入时都会显示到编辑控件中。HookApp.cpp源文件的内容如下:
#include <windows.h>
#include "HookDll.h"
#include "resource.h"
#pragma comment(lib, "HookDll.lib")
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_INSTALLHOOK:
InstallHook(WH_KEYBOARD, 0, hwndDlg);
break;
case IDC_BTN_UNINSTALLHOOK:
UninstallHook();
break;
case IDCANCEL:
UninstallHook();
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
case WM_COPYDATA:
SendMessage(GetDlgItem(hwndDlg, IDC_EDIT_KEYBOARD), EM_SETSEL, -1, -1);
SendMessage(GetDlgItem(hwndDlg, IDC_EDIT_KEYBOARD), EM_REPLACESEL,
TRUE, (LPARAM)(LPTSTR)(((PCOPYDATASTRUCT)lParam)->lpData));
return TRUE;
}
return FALSE;
}
6.5 在同一个可执行文件的多个实例间共享变量
如前所述,运行一个可执行文件(.exe或.dll)的多个实例时,系统不会真正加载多份程序实例到内存中,每个程序实例只是可执行文件的一个内存映射视图,系统会共享一份程序的只读页面(程序可执行代码、只读数据),以及可写页面(例如全局变量、静态变量),但是采用了写时复制技术,即如果一个可执行文件(.exe或.dll)的多个实例中的一个修改了共享的可写页面,系统会为该程序实例分配一块内存存放刚刚修改的共享的可写页面,一个程序实例对可写页面进行修改不会影响其他程序实例,例如程序中可能用到了全局变量,该全局变量在每个程序实例中的值可能不同。
但是有时候我们可能需要在一个可执行文件(.exe或.dll)的多个实例中共享一些变量,即如果一个程序实例修改了共享变量,则其他所有程序实例都会受到影响,所有程序实例使用相同的共享变量值。
对远程系统钩子(全局钩子)来说,每当系统中的一个进程发生指定的事件时,系统会把包含钩子函数的DLL加载到自己的进程地址空间中以执行钩子函数,在钩子函数中我们发送WM_COPYDATA消息到监视程序(窗口句柄g_hwnd),全局变量g_hwnd需要在DLL的多个实例中共享。
本节我们介绍如何在同一个可执行文件(.exe或.dll)的多个实例间共享变量。每个.exe或.dll文件映像由许多Section(称为节区、区段或段)组成,每个标准的段名都以点号开始,例如在编译程序时,编译器会将可执行代码放在一个名为.text的段中,将已初始化的数据放在.data段中,将未经初始化的数据放在.bss段中,将只读数据放在.rdada段中,将程序资源放在.rsrc段中等。当然,不同编译器对区段的命名可能是不同的,例如有的编译器可能会把可执行代码放在名为.code的段中。
打开PEID,把Chapter6\HookDll\x64\Debug\HookDll.dll文件拖入PEID,单击EP段右侧的">"按钮,可以看到该DLL文件的节区(段)。每个段都有一些与之相关联的属性,如表6.4所示。
表6.4
| 属性 | 含义 |
|---|---|
| READ | 可以从该段读取数据 |
| WRITE | 可以向该段写入数据 |
| EXECUTE | 可以执行该段的内容 |
| SHARED | 该段的内容为多个实例所共享(关闭了写时复制机制) |
除编译器所创建的标准区段外,我们还可以使用下面的语法来创建自己的区段:
#pragma data_seg("段名")
变量类型 变量名 = 值
#pragma data_seg()
例如,在前面的HookDll.cpp源文件中使用下面的代码创建了一个名为"Shared"的区段,它只包含一个HWND类型的变量:
#pragma data_seg("Shared")
HWND g_hwnd = NULL;
#pragma data_seg()
当编译器编译这段代码时,会创建一个名为Shared的区段,并将pragma指示符之间所有的已初始化变量放到这个新的区段中。在上述示例中,g_hwnd变量被放到了Shared区段中,变量后面的#pragma data_seg()这一行告诉编译器停止把已初始化的变量放到Shared区段中,重新开始把它们放回到默认的数据段中。
需要注意的是,编译器只会将已初始化的变量保存到自定义段中,如果变量没有初始化那么编译器会将该变量放到Shared段以外的其他段中,例如:
#pragma data_seg("Shared")
HWND g_hwnd;
#pragma data_seg()
单纯创建一个自定义段没有意义,每个段都有一些与之相关联的属性,例如READ可读、WRITE可写、EXECUTE可执行以及SHARED可共享,如果需要在同一个可执行文件(.exe或.dll)的多个实例间共享变量,应该为自定义段指定READ、WRITE和SHARED属性。可以通过下面的语法为指定的段设置相关属性:
#pragma comment(linker, "/SECTION:Shared,RWS")
上面的代码表示为Shared区段设置R、W、E和S属性,R表示READ,W表示WRITE,E表示EXECUTE,S表示SHARED。
虽然我们可以创建共享段,但是微软公司并不鼓励使用共享段,因为一个程序实例对于共享变量的错误操作可能会影响其他程序实例。
6.6 注入DLL
在保护模式下,每个进程使用的内存地址称为虚拟地址,每个进程都有自己的虚拟地址空间,对32位进程来说,可以使用的虚拟地址空间范围为0x00000000~0xFFFFFFFF,即4GB大小,虚拟地址空间使应用程序认为它拥有"连续可用的内存",而实际上这些"连续可用的内存"通常由多个物理内存碎片组成,还有部分暂时存储在磁盘上,在需要的时候进行数据交换。例如,进程A在0x12345678地址处存储了一个数据结构,而进程B也可以在0x12345678地址处存储一个完全不同的数据结构,0x12345678是一个虚拟地址,程序在执行时还要通过MMU(内存管理单元)把虚拟地址转换为物理内存地址,进程A和B虽然都有虚拟地址0x12345678,但是它们被映射到了不同的物理内存地址处。当进程A中的线程访问位于地址0x12345678处的内存时,它们访问的是进程A的数据结构;当进程B中的线程访问位于地址0x12345678处的内存时,它们访问的是进程B的数据结构。进程A中的线程无法访问位于进程B的地址空间内的数据结构,反之亦然,进程之间的内存空间相互独立、隔离的特性提高了安全性。但是也使进程之间的相互通信,或者一个进程试图控制另一个进程有一些困难。
本节我们学习如何将一个DLL注入另一个进程的地址空间中,所谓的DLL注入就是使程序A强行加载程序B指定的Inject.dll,并执行程序B指定的Inject.dll中的代码。一开始程序B指定的Inject.dll并没有被程序A主动加载,但是当程序B通过某种手段使程序A加载Inject.dll后,Inject.dll就进入了程序A的地址空间中,程序A将会执行Inject.dll中的代码,而Inject.dll模块的程序逻辑由程序B的开发者设计,因此程序B的开发者可以对程序A进行控制。
6.6.1 通过Windows钩子注入DLL
前面对Windows钩子的学习使我们了解到,通过安装远程线程钩子和远程全局钩子都可以将包含钩子函数的DLL加载到其他进程的地址空间中。这里以一个示例来讲解这种技术,用鼠标右键单击桌面,然后选择显示设置,可以设置桌面的分辨率,例如一台计算机笔记本分辨率为1366×768,假设更改为800×600,那么桌面上的图标就会重新排列,如果再把分辨率改回1366×768,桌面图标的排列并不会恢复为原来的样子,我们必须手动重新排列这些桌面图标。为此,在更改分辨率前,我们可以把桌面上所有图标的位置保存到注册表中,当恢复分辨率设置时,从注册表中读取每个图标的位置并重新排列这些图标。不熟悉注册表函数的读者可以先学习第7章再来学习这些内容。
在保存桌面上所有列表项位置时,我们可以枚举桌面上的所有列表项,通过发送LVM_GETITEMTEXT消息来获取列表项的文本(wParam参数指定为列表项的索引,lParam参数是一个指向LVITEM结构的指针),通过发送LVM_GETITEMPOSITION消息来获取列表项的位置(wParam参数指定为列表项的索引,lParam参数是一个指向POINT结构的指针。在该结构中返回列表项左上角的坐标),我们可以创建一个子键HKEY_CURRENT_USER\Software\Desktop Item Position Saver,以列表项的文本为键名,以列表项的位置为键值,在上面的子键中为每个列表项创建一个键值项。
在恢复桌面上所有列表项位置时,我们可以通过调用注册表函数RegEnumValue枚举HKEY_CURRENT_USER\Software\Desktop Item Position Saver键中的所有键值项,通过发送LVM_FINDITEM消息来查找桌面上具有指定列表项文本的列表项(wParam参数指定开始搜索的列表项索引,不包括指定项),指定为−1表示从头开始搜索,lParam参数是一个指向LVFINDINFO结构的指针,该结构包含有关要搜索的内容的信息),在桌面上找到符合条件的列表项后可以通过发送LVM_SETITEMPOSITION消息设置该列表项的位置,其中wParam参数指定为列表项的索引,lParam参数指定为一个DWORD值,LOWORD(lParam)表示列表项左上角的X坐标,HIWORD(lParam)表示列表项左上角的Y坐标。
对于早期的Win16系统中存在的子窗口控件(通常是通过WM_COMMAND消息发送通知码的控件,例如按钮、编辑控件、列表框、组合框等),我们可以在一个进程中向另一个进程中的子窗口控件发送消息。但是新的子窗口控件(通常是通过WM_NOTIFY消息发送通知码的控件,例如列表视图控件、树视图控件等)无法跨越进程边界发送消息,例如LVM_GETITEMTEXT消息的lParam参数是一个指向LVITEM结构的指针,LVITEM结构属于发送消息的进程中的一个内存地址,无法在其他进程中引用该内存地址。
例如,我们可以在一个进程中向另一个进程创建的列表框控件发送一条LB_GETTEXT消息获取指定列表项的字符串文本,wParam参数指定为列表项的索引,lParam参数指定为字符串缓冲区。列表项的字符串文本可以返回到发送消息进程的缓冲区中,这是因为操作系统在内部创建了一个内存映射文件并在进程间复制字符串数据。为什么微软公司对早期的Win16系统中存在的子窗口控件进行这样的处理,而对新的子窗口控件却不这样处理呢?答案是由于兼容性,在Win16中,所有应用程序都在同一个地址空间中,一个应用程序可以向另一个应用程序创建的窗口发送LB_GETTEXT消息,为了便于将这些16位应用程序移植到Win32系统,微软公司采用内存映射文件的方式在进程间传递数据,但是对于那些在16位Windows中尚未出现的新子窗口控件,并不存在移植性的问题,因此微软公司没有为这些控件提供上述机制。
打开VS的工具菜单→Spy++,选择监视菜单项→查找窗口,打开查找窗口对话框,拖动靶子图标到桌面上,可以获取原书的图6.12所示的信息。
当然也可以使用前面我们自己编写的WindowSearch程序获取桌面的相关信息。可以发现桌面实际上是一个SysListView32列表视图控件(图标视图),属于Explorer.exe资源管理器进程。对于桌面列表视图控件,可以通过将代码注入Explorer.exe资源管理器进程来对其进行各种操作,本节我们通过安装WH_GETMESSAGE消息钩子的方式把对桌面列表项进行操作的DLL注入Explorer.exe资源管理器进程。
但是如果通过调用以下语句来获取桌面列表视图控件的窗口句柄通常不会成功:
FindWindow(TEXT("SysListView32"), TEXT("FolderView")); // 通过指定的窗口类名和窗口标题查找窗口
选择Spy++的监视菜单项→窗口,可以打开窗口列表,把窗口列表拖动到最后,可以看到原书的图6.13所示的信息。
窗口句柄为0x00010160的桌面列表视图控件的父窗口是窗口类名为SHELLDLL_DefView的窗口,类名为SHELLDLL_DefView的窗口的父窗口是窗口类名为ProgMan的窗口。窗口类名为ProgMan的窗口是程序管理器(Program Manager,属于Explorer.exe进程),系统中一定会存在程序管理器Program Manager,这是为了向后兼容那些为老版本Windows设计的应用程序,程序管理器Program Manager有且只有一个窗口类名为SHELLDLL_DefView的子窗口,窗口类名为SHELLDLL_DefView的子窗口有且只有一个窗口类名为SysListView32的子窗口(也就是桌面列表视图控件)。因此我们可以通过调用如下语句获取桌面列表视图控件的窗口句柄:
GetTopWindow(GetTopWindow(FindWindow(TEXT("ProgMan"), NULL))); // 后面会介绍这些函数
有了桌面列表视图控件的窗口句柄就可以通过调用GetWindowThreadProcessId函数来获取创建该窗口的线程ID(属于Explorer资源管理器进程),并且可以为该线程安装一个WH_GETMESSAGE消息钩子。
在做DLL注入时需要注意,32位DLL只能注入32位进程,64位DLL只能注入64位进程。调用DLL导出的安装、卸载消息钩子的程序称为控制程序,同样32位进程只能使用32位DLL,64位进程只能使用64位DLL,控制程序也必须编译为64位,这样一来64位的控制程序可以调用64位DLL中的安装钩子函数,并把该DLL注入64位的Explorer资源管理器进程。
DIPSHookDll.h头文件的内容如下:
#pragma once
// 声明导出的函数
#ifdef DLL_EXPORT
#define DLL_API extern "C" __declspec(dllexport)
#else
#define DLL_API extern "C" __declspec(dllimport)
#endif
// 导出函数
DLL_API BOOL InstallHook(int idHook, DWORD dwThreadId);// 两参数分别是钩子类型和资源管理器线程ID
DLL_API BOOL UninstallHook();
DIPSHookDll.cpp源文件的代码中用到了许多操作注册表的函数,而介绍注册表则是第7章的内容,读者可以先大致了解本程序,学习完注册表后再来重新理解本例的代码,源文件内容如下:
// 定义DLL的导出函数
#include <Windows.h>
#include <Commctrl.h>
#include "resource.h"
#define DLL_EXPORT
#include "DIPSHookDll.h"
// 全局变量
HINSTANCE g_hMod;
HHOOK g_hHook;
TCHAR g_szRegSubKey[] = TEXT("Software\\Desktop Item Position Saver");
// 内部函数
LRESULT CALLBACK GetMsgProc(int nCode, WPARAM wParam, LPARAM lParam);
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
VOID SaveListViewItemPositions(HWND hwndLV);
VOID RestoreListViewItemPositions(HWND hwndLV);
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
g_hMod = hModule;
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
// 导出函数
BOOL InstallHook(int idHook, DWORD dwThreadId)
{
if (!g_hHook)
{
g_hHook = SetWindowsHookEx(idHook, GetMsgProc, g_hMod, dwThreadId);
if (!g_hHook)
return FALSE;
}
// 消息钩子已经安装,通知资源管理器线程调用GetMsgProc钩子函数(为了及时响应所以主动通知)
PostThreadMessage(dwThreadId, WM_NULL, 0, 0);
return TRUE;
}
BOOL UninstallHook()
{
if (g_hHook)
{
if (!UnhookWindowsHookEx(g_hHook))
return FALSE;
}
g_hHook = NULL;
return TRUE;
}
// 内部函数
LRESULT CALLBACK GetMsgProc(int nCode, WPARAM wParam, LPARAM lParam)
{
// DLL是否刚被注入
static BOOL bFirst = TRUE;
if (nCode < 0)
return CallNextHookEx(NULL, nCode, wParam, lParam);
if (nCode == HC_ACTION)
{
if (bFirst)
{
bFirst = FALSE;
// 在资源管理器进程中创建一个服务器窗口来处理控制程序的请求(保存、恢复桌面图标等)
CreateDialogParam(g_hMod, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
}
}
return CallNextHookEx(NULL, nCode, wParam, lParam);
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_APP:
if (lParam)
SaveListViewItemPositions((HWND)wParam);
else
RestoreListViewItemPositions((HWND)wParam);
return TRUE;
case WM_CLOSE:
DestroyWindow(hwndDlg);
return TRUE;
}
return FALSE;
}
VOID SaveListViewItemPositions(HWND hwndLV)
{
int nCount;
HKEY hKey;
LVITEM lvi = { 0 };
TCHAR szName[MAX_PATH] = { 0 };
POINT pt;
// 先删除旧注册表
RegDeleteKey(HKEY_CURRENT_USER, g_szRegSubKey);
// 获取桌面列表项总数
nCount = SendMessage(hwndLV, LVM_GETITEMCOUNT, 0, 0);
// 创建子键HKEY_CURRENT_USER\Software\Desktop Item Position Saver
RegCreateKeyEx(HKEY_CURRENT_USER, g_szRegSubKey, 0, NULL, REG_OPTION_NON_ VOLATILE,
KEY_SET_VALUE, NULL, &hKey, NULL);
lvi.mask = LVIF_TEXT;
lvi.pszText = szName;
lvi.cchTextMax = _countof(szName);
// 为每个列表项创建一个键值项,以列表项的文本为键名,以列表项的位置为键值
for (int i = 0; i < nCount; i++)
{
ZeroMemory(szName, _countof(szName) * sizeof(TCHAR));
SendMessage(hwndLV, LVM_GETITEMTEXT, i, (LPARAM)&lvi);
SendMessage(hwndLV, LVM_GETITEMPOSITION, i, (LPARAM)&pt);
RegSetValueEx(hKey, szName, 0, REG_BINARY, (LPBYTE)&pt, sizeof(pt));
}
RegCloseKey(hKey);
}
VOID RestoreListViewItemPositions(HWND hwndLV)
{
HKEY hKey;
TCHAR szName[MAX_PATH] = { 0 };
POINT pt;
DWORD dwType;
LONG_PTR lStyle;
LONG lResult;
LVFINDINFO lvfi = { 0 };
int nItem;
// 打开子键HKEY_CURRENT_USER\Software\Desktop Item Position Saver
RegOpenKeyEx(HKEY_CURRENT_USER, g_szRegSubKey, 0, KEY_QUERY_VALUE, &hKey);
// 关闭桌面图标自动排列
lStyle = GetWindowLongPtr(hwndLV, GWL_STYLE);
if (lStyle & LVS_AUTOARRANGE)
SetWindowLongPtr(hwndLV, GWL_STYLE, lStyle & ~LVS_AUTOARRANGE);
// 枚举子键HKEY_CURRENT_USER\Software\Desktop Item Position Saver下的所有键值项
lResult = ERROR_SUCCESS;
for (int i = 0; lResult != ERROR_NO_MORE_ITEMS; i++)
{
DWORD dwchName = _countof(szName);
DWORD dwcbDaata = sizeof(pt);
lResult = RegEnumValue(hKey, i, szName, &dwchName, NULL, &dwType, (LPBYTE) &pt, &dwcbDaata);
if (lResult == ERROR_NO_MORE_ITEMS)
continue;
// 查找桌面上具有指定列表项文本的列表项,重新设置该列表项的位置
lvfi.flags = LVFI_STRING;
lvfi.psz = szName;
if ((dwType == REG_BINARY) && (dwcbDaata == sizeof(pt)))
{
nItem = SendMessage(hwndLV, LVM_FINDITEM, -1, (LPARAM)&lvfi);
if (nItem != -1)
SendMessage(hwndLV, LVM_SETITEMPOSITION, nItem, MAKELPARAM (pt.x,pt.y));
}
}
SetWindowLongPtr(hwndLV, GWL_STYLE, lStyle);
RegCloseKey(hKey);
}
控制程序(DIPSHookApp)中有安装消息钩子、保存桌面图标、恢复桌面图标和卸载消息钩子4个按钮。在控制程序中单击安装消息钩子时会调用InstallHook函数,我们调用SetWindowsHookEx函数为资源管理器线程安装WH_GETMESSAGE消息钩子,然后通过调用PostThreadMessage函数发送一个空消息 WM_NULL 通知资源管理器线程调用钩子函数 GetMsgProc。钩子函数 GetMsgProc 判断DLL是否是刚被注入,如果是,则调用CreateDialogParam函数在资源管理器进程中创建一个服务器窗口(非模态对话框)来处理控制程序的请求。DialogProc窗口过程用于处理控制程序的请求,当在控制程序中单击"保存桌面图标"按钮和"恢复桌面图标"按钮时,会向服务器窗口发送WM_APP消息,wParam参数指定为桌面列表视图控件的窗口句柄,lParam参数指定为TRUE表示保存桌面图标,指定为FALSE表示恢复桌面图标;当在控制程序中单击卸载消息钩子时,会向服务器窗口发送WM_CLOSE消息关闭服务器窗口,然后调用UninstallHook函数卸载消息钩子。
如果想隐藏服务器窗口,可以在DLL项目的资源管理器中把对话框的Visible属性设置为False,这样一来在任务栏、任务管理器中根本看不到关于服务器窗口的任何蛛丝马迹。DLL注入是实现窗口或进程隐藏的一种方法,一旦一个DLL注入其他进程,我们几乎就可以为所欲为。
服务器窗口使用的是非模态对话框,如前面所述,CreateDialogParam函数在创建对话框后,会根据对话框模板是否指定了WS_VISIBLE样式来决定是否显示对话框窗口。如果指定,则显示;如果没有指定,则程序需要自行调用ShowWindow函数来显示非模态对话框。而DialogBoxParam函数不管是否指定了WS_VISIBLE样式都会显示模态对话框。
控制程序DIPSHookApp的运行效果如原书的图6.14所示。
DIPSHookApp.cpp源文件的内容如下:
#include <windows.h>
#include "resource.h"
#include "DIPSHookDll.h"
#pragma comment(lib, "DIPSHookDll.lib")
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
static HWND hwndLV;
HWND hwndDIPSServer;
switch (uMsg)
{
case WM_INITDIALOG:
hwndLV = GetTopWindow(GetTopWindow(FindWindow(TEXT("ProgMan"), NULL)));
// 禁用保存桌面图标、恢复桌面图标和卸载消息钩子按钮
EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_SAVE), FALSE);
EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_RESTORE), FALSE);
EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_UNINSTALLHOOK), FALSE);
return TRUE;
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_INSTALLHOOK:
InstallHook(WH_GETMESSAGE, GetWindowThreadProcessId(hwndLV, NULL));
// 启用保存桌面图标、恢复桌面图标和卸载消息钩子按钮
EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_SAVE), TRUE);
EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_RESTORE), TRUE);
EnableWindow(GetDlgItem(hwndDlg, IDC_BTN_UNINSTALLHOOK), TRUE);
break;
case IDC_BTN_UNINSTALLHOOK:
// 获取服务器窗口句柄
hwndDIPSServer = FindWindow(NULL, TEXT("DIPSServer"));
// 使用SendMessage而不是PostMessage,确保卸载钩子以前,服务器对话框已经销毁
SendMessage(hwndDIPSServer, WM_CLOSE, 0, 0);
UninstallHook();
break;
case IDC_BTN_SAVE:
// 获取服务器窗口句柄
hwndDIPSServer = FindWindow(NULL, TEXT("DIPSServer"));
SendMessage(hwndDIPSServer, WM_APP, (WPARAM)hwndLV, TRUE);
break;
case IDC_BTN_RESTORE:
// 获取服务器窗口句柄
hwndDIPSServer = FindWindow(NULL, TEXT("DIPSServer"));
SendMessage(hwndDIPSServer, WM_APP, (WPARAM)hwndLV, FALSE);
break;
case IDCANCEL:
if (FindWindow(NULL, TEXT("DIPSServer")))
SendMessage(hwndDlg, WM_COMMAND, IDC_BTN_UNINSTALLHOOK, 0);
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
}
return FALSE;
}
GetTopWindow函数用于查找与指定父窗口关联的子窗口中Z顺序位于顶部的子窗口的句柄,即查找第一个子窗口的句柄:
HWND WINAPI GetTopWindow(
_In_opt_ HWND hWnd); // 父窗口的句柄,设置为NULL则该函数返回桌面窗口中Z顺序顶部的窗口句柄
如果函数执行成功,则返回值是Z顺序位于顶部的子窗口的句柄;如果指定的父窗口没有子窗口,则返回值为NULL。
有时候可能需要根据操作系统的不同(是32位还是64位)来决定执行不同的代码,这时候就需要判断操作系统的类型,前面学过GetSystemInfo函数,程序可以根据该函数SYSTEM_INFO结构的wProcessorArchitecture字段返回的值来确定操作系统是32 位还是 64 位。如果 SYSTEM_INFO.wProcessorArchitecture 等于 PROCESSOR_ARCHITECTURE_INTEL(0),则表明是32位操作系统;如果 SYSTEM_INFO.wProcessor Architecture 等于 PROCESSOR_ARCHITECTURE_AMD64(9)或PROCESSOR_ARCHITECTURE_IA64(6),则表明是64位操作系统。
但是,在Windows 10 64位系统中,把程序编译为32位,调用GetSystemInfo函数,SYSTEM_INFO.wProcessorArchitecture 字段返回的值始终等于 PROCESSOR_ARCHITECTURE_INTEL(0),这显然是错误的。我们可以调用GetNativeSystemInfo函数,该函数可以将系统信息返回到在WOW64下运行的应用程序,如果是在一个64位的应用程序中调用该函数,那么它等效于GetSystemInfo函数,即不管编译为32位还是64位程序,调用GetNativeSystemInfo函数总会得到正确的结果。稍后将解释WOW64。读者可以使用以下自定义函数来判断操作系统是32位还是64位:
BOOL Is64bitSystem()
{
SYSTEM_INFO si = { 0 };
GetNativeSystemInfo(&si);
if (si.wProcessorArchitecture == PROCESSOR_ARCHITECTURE_AMD64 ||
si.wProcessorArchitecture == PROCESSOR_ARCHITECTURE_IA64)
return TRUE;
else
return FALSE;
}
为了让32位应用程序能够正常运行在64位版本的Windows上,微软公司提供了一个Windows 32-bit On Windows 64-bit模拟层,称为WOW64。在64位Windows操作系统中,\Windows\System32目录下存放的是64位的系统文件,而\Windows\SysWOW64目录下存放的是32位的系统文件。WOW64通常会将32位应用程序对\Windows\System32目录的访问重定向到\Windows\SysWOW64目录,因此64位应用程序会加载System32目录下的相关动态链接库,而32位应用程序则会加载SysWOW64目录下的相关动态链接库。32位应用程序以32位CPU模式运行,当32位应用程序中发生API函数调用的时候,WOW64会将CPU模式切换为64位,将API函数调用中的32位参数扩展到64位,然后发出64位的相关API函数调用,返回的时候会将64位的返回值截断为32位,并切换回32位CPU模式。另外,注册表同样存在重定向的情况。
如果需要判断一个进程是否正运行在WOW64环境中,则可以调用IsWow64Process函数:
BOOL WINAPI IsWow64Process(
_In_ HANDLE hProcess, // 进程句柄
_Out_ PBOOL Wow64Process); // 返回TRUE或FASE
hProcess参数指定进程句柄,必须具有PROCESS_QUERY_INFORMATION或PROCESS_QUERY_LIMITED_INFORMATION访问权限;Wow64Process参数指向的BOOL类型变量会返回TRUE或FASE。如果32位进程运行在WOW64(即64位系统)下,则*Wow64Process的值为TRUE。如果32位进程运行在32位Windows下或者64位进程运行在64位Windows下,则*Wow64Process的值为FALSE。如果32位进程运行在WOW64下,则要获取系统信息需要调用GetNativeSystemInfo函数,而不是GetSystemInfo函数。
还可以使用更新版本的IsWow64Process2函数,该函数的使用方法比较简单,感兴趣的读者可以自行参阅MSDN。
6.6.2 通过创建远程线程注入DLL
我们无法操作其他进程中的线程,但是可以通过调用CreateRemoteThread函数在其他进程中创建一个远程线程以执行代码,CreateRemoteThread的函数声明如下:
HANDLE WINAPI CreateRemoteThread(
_In_ HANDLE hProcess, // 在哪个进程中创建远程线程
_In_ LPSECURITY_ATTRIBUTES lpThreadAttributes, // 指向线程安全属性结构的指针
_In_ SIZE_T dwStackSize, // 线程的栈空间大小,以字节为单位
_In_ LPTHREAD_START_ROUTINE lpStartAddress, // 线程函数指针
_In_ LPVOID lpParameter, // 传递给线程函数的参数
_In_ DWORD dwCreationFlags, // 线程创建标志
_Out_ LPDWORD lpThreadId); // 返回线程ID
与CreateThread函数相比,该函数只是多了一个在哪个进程中创建远程线程的hProcess参数。有一点需要注意,我们可以在一个进程中调用CreateRemoteThread函数在目标进程中创建一个远程线程以执行代码,但是线程函数lpStartAddress必须位于目标进程的地址空间中。
虽然可以在一个进程中通过调用CreateRemoteThread函数在其他进程中创建远程线程,但是线程函数是一个很难解决的问题,我们无法把本进程中的一段可执行代码直接写入其他进程的地址空间中执行。一方面,Windows Vista以上版本的系统开始可执行文件(PE文件)支持动态基地址,每次运行一个可执行文件,其加载到的基地址可能是不同的,不同可执行文件所加载到的基地址也可能是不同的。一个进程中用到的全局变量是一个绝对地址(相对于本进程),不可以在其他进程中直接引用,因为这些内存地址在其他进程中可能是非法的,很容易引发访问违规。另一方面,我们调用的API函数所在的DLL加载到不同的进程中时其基地址也可能是不同的,一个进程中用到的API函数地址也是一个绝对地址(相对于本进程),同一个API函数的地址在不同的进程中会随着DLL载入位置的不同而不同。如果在代码中直接调用API函数,那么系统会按照当前进程的DLL载入位置填入函数地址,这显然是错误的。
全局变量和API函数地址的定位问题解决起来有一定难度,我们可以采取一种变通的方法。Kernel32.dll、User32.dll和Gdi32.dll都是最常用的动态链接库,在不同的进程中,系统会将它们载入相同的内存地址处,对于这些动态链接库来说,在本进程中获取到的地址可以用在远程线程中,我们可以把CreateRemoteThread函数的线程函数lpStartAddress参数设置为LoadLibraryA / LoadLibraryW函数的地址(属于Kernel32.dll),通过执行LoadLibraryA / LoadLibraryW函数以加载指定的dll执行所需的代码,但是LoadLibraryA / LoadLibraryW函数所用的DLL地址参数是一个字符串,同样我们不可以把本进程中的一个字符串地址传递到另一个进程中使用,这时可以通过调用VirtualAllocEx函数在目标进程中分配一块内存地址,然后调用WriteProcessMemory函数在分配的内存地址处写入DLL的文件名称。在本进程中获取到LoadLibraryA / LoadLibraryW函数的地址,用于CreateRemoteThread函数的线程函数lpStartAddress参数,LoadLibraryA / LoadLibraryW函数所用的DLL地址参数则是远程进程中的一个字符串地址。
线程函数和LoadLibraryA / LoadLibraryW函数的定义几乎相同,所以可以把线程函数设置为LoadLibraryA / LoadLibraryW函数的地址:
DWORD WINAPI ThreadProc(LPVOID lpParameter);
HMODULE WINAPI LoadLibraryA(_In_ LPCSTR lpLibFileName);
HMODULE WINAPI LoadLibraryW(_In_ LPCWSTR lpLibFileName);
把一段可执行代码从一个进程中直接复制到其他进程中,代码保持不变,这样做可能会导致在其他进程中引用一个不合法的全局变量、API函数地址(使用的是绝对地址),但是当把一个DLL加载到进程地址空间时,DLL内部引用的绝对地址会通过DLL的重定位表进行修正,所有DLL中使用的绝对地址总会根据DLL加载到的基地址进行重新计算、修正,后面会介绍重定位表。
通过使用远程线程注入DLL的步骤总结如下。
(1)调用VirtualAllocEx函数在远程进程的地址空间中分配一块内存。
(2)调用WriteProcessMemory函数把要注入的DLL的路径复制到第1步分配的内存中。
(3)调用GetProcAddress函数得到LoadLibraryA/LoadLibraryW函数(Kernel32.dll中)的实际地址。
(4)调用CreateRemoteThread函数在远程进程中创建一个线程,新创建的远程线程会立即调用LoadLibraryA/LoadLibraryW函数,DLL会被注入远程进程的地址空间中,DLL的DllMain函数会收到DLL_PROCESS_ATTACH通知并且可以执行我们想要执行的代码。当DllMain函数返回时,远程线程会从LoadLibraryA / LoadLibraryW调用返回,远程线程终止,但DLL仍然存在于被注入进程中。
相关释放工作如下所示。
(1)调用VirtualFreeEx函数释放第1步分配的内存。
(2)调用GetProcAddress得到FreeLibrary函数(Kernel32.dll中)的实际地址。
(3)调用CreateRemoteThread函数在远程进程中创建一个新线程,使该线程调用FreeLibrary函数并在参数中传入已注入DLL的模块地址以卸载该DLL。
接下来实现一个示例程序RemoteApp,程序的运行效果如原书的图6.15所示。
单击"注入dll"按钮,程序会将F:\Source\Windows\Chapter6\RemoteDll\Debug\RemoteDll.dll注入指定的进程中(此处为ProcessList进程224288),DLL的DllMain函数会收到DLL_PROCESS_ATTACH通知,可以执行想要执行的代码。在该通知中,我们遍历被注入进程的地址空间,列出该进程使用的所有DLL模块,当然也可以在这里调用CreateThread函数创建一个线程以执行需要的代码。当DllMain函数返回时,远程线程会从LoadLibraryA / LoadLibraryW调用返回,远程线程终止。
RemoteDll项目不需要头文件,因为没有导出函数,RemoteDll.cpp源文件的内容如下:
#include <Windows.h>
#include <tchar.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
TCHAR szBuf[MAX_PATH] = { 0 }; // 模块名称
LPBYTE lpAddress = NULL; // 页面区域的起始地址
MEMORY_BASIC_INFORMATION mbi = { 0 }; // 返回页面信息
int nLen;
TCHAR szModName[MAX_PATH] = { 0 };
HWND hwndRemoteApp;
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
// 进程RemoteApp的窗口句柄
hwndRemoteApp = FindWindow(TEXT("#32770"), TEXT("RemoteApp"));
while (VirtualQuery(lpAddress, &mbi, sizeof(mbi)) == sizeof(mbi))
{
// 页面区域中页面的状态为MEM_FREE空闲
if (mbi.State == MEM_FREE)
mbi.AllocationBase = mbi.BaseAddress;
if ((mbi.AllocationBase == NULL) || (mbi.AllocationBase == hModule) ||
(mbi.BaseAddress != mbi.AllocationBase))
{
// 如果空间区域的基地址为NULL,或者空间区域的基地址是本模块基地址,
// 或者页面区域的基地址并不是空间区域的基地址(每一个模块就是一块空间区域)
nLen = 0;
}
else
{
// 获取加载到空间区域基地址处的模块文件名
nLen = GetModuleFileName(HMODULE(mbi.AllocationBase), szModName, _countof(szModName));
}
if (nLen > 0)
{
wsprintf(szBuf, TEXT("%p\t%s\r\n"), mbi.AllocationBase, szModName);
// 模块名称显示到进程RemoteApp的编辑控件中
SendDlgItemMessage(hwndRemoteApp, 1005, EM_SETSEL, -1, -1);
SendDlgItemMessage(hwndRemoteApp, 1005, EM_REPLACESEL, TRUE, (LPARAM)szBuf);
}
lpAddress += mbi.RegionSize;
}
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
RemoteApp.cpp源文件的内容如下:
#include <windows.h>
#include <tchar.h>
#include <TlHelp32.h>
#include "resource.h"
// 全局变量
HWND g_hwndDlg;
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
DWORD WINAPI ThreadProc(LPVOID lpParameter);
BOOL InjectDll(DWORD dwProcessId, LPTSTR lpDllPath);
BOOL EjectDll(DWORD dwProcessId, LPTSTR lpDllPath);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
HANDLE hThread;
DWORD dwProcessId;
TCHAR szDllPath[MAX_PATH] = { 0 };
switch (uMsg)
{
case WM_INITDIALOG:
g_hwndDlg = hwndDlg;
SetDlgItemText(hwndDlg, IDC_EDIT_PROCESSID, TEXT("请输入进程ID"));
SetDlgItemText(hwndDlg, IDC_EDIT_DLLPATH,
TEXT("F:\\Source\\Windows\\Chapter6\\RemoteDll\\Debug\\RemoteDll.dll"));
return TRUE;
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_INJECT:
// 创建新线程完成对目标进程中DLL的注入
hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
if (hThread)
CloseHandle(hThread);
break;
case IDC_BTN_EJECT:
dwProcessId = GetDlgItemInt(hwndDlg, IDC_EDIT_PROCESSID, NULL, FALSE);
GetDlgItemText(hwndDlg, IDC_EDIT_DLLPATH, szDllPath, _countof(szDllPath));
EjectDll(dwProcessId, szDllPath);
break;
case IDCANCEL:
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
}
return FALSE;
}
DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
DWORD dwProcessId;
TCHAR szDllPath[MAX_PATH] = { 0 };
dwProcessId = GetDlgItemInt(g_hwndDlg, IDC_EDIT_PROCESSID, NULL, FALSE);
GetDlgItemText(g_hwndDlg, IDC_EDIT_DLLPATH, szDllPath, _countof(szDllPath));
return InjectDll(dwProcessId, szDllPath);
}
BOOL InjectDll(DWORD dwProcessId, LPTSTR lpDllPath)
{
HANDLE hProcess = NULL;
LPTSTR lpDllPathRemote = NULL;
HANDLE hThread = NULL;
hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_CREATE_THREAD |
PROCESS_VM_OPERATION | PROCESS_VM_WRITE, FALSE, dwProcessId);
if (!hProcess)
return FALSE;
// 1. 调用VirtualAllocEx函数在远程进程的地址空间中分配一块内存
int cbDllPath = (_tcslen(lpDllPath) + 1) * sizeof(TCHAR);
lpDllPathRemote = (LPTSTR)VirtualAllocEx(hProcess, NULL, cbDllPath,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!lpDllPathRemote)
return FALSE;
// 2. 调用WriteProcessMemory函数把要注入的DLL的路径复制到第1步分配的内存中
if (!WriteProcessMemory(hProcess, lpDllPathRemote, lpDllPath, cbDllPath, NULL))
return FALSE;
// 3. 调用GetProcAddress函数得到LoadLibraryA / LoadLibraryW函数
// (Kernel32.dll)的实际地址
PTHREAD_START_ROUTINE pfnThreadRtn = (PTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle(TEXT("Kernel32")), "LoadLibraryW");
if (!pfnThreadRtn)
return FALSE;
// 4. 调用CreateRemoteThread函数在远程进程中创建一个线程
hThread = CreateRemoteThread(hProcess, NULL, 0, pfnThreadRtn, lpDllPathRemote, 0, NULL);
if (!hThread)
return FALSE;
WaitForSingleObject(hThread, INFINITE);
// 5. 调用VirtualFreeEx函数释放第1步分配的内存
if (!lpDllPathRemote)
VirtualFreeEx(hProcess, lpDllPathRemote, 0, MEM_RELEASE);
if (hThread)
CloseHandle(hThread);
if (hProcess)
CloseHandle(hProcess);
return TRUE;
}
BOOL EjectDll(DWORD dwProcessId, LPTSTR lpDllPath)
{
HANDLE hSnapshot;
MODULEENTRY32 me = { sizeof(MODULEENTRY32) };
BOOL bRet;
BOOL bFound = FALSE;
HANDLE hProcess = NULL;
HANDLE hThread = NULL;
hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwProcessId);
if (hSnapshot == INVALID_HANDLE_VALUE)
return FALSE;
bRet = Module32First(hSnapshot, &me);
while (bRet)
{
if (_tcsicmp(TEXT("RemoteDll.dll"), me.szModule) == 0 ||
_tcsicmp(lpDllPath, me.szExePath) == 0)
{
bFound = TRUE;
break;
}
bRet = Module32Next(hSnapshot, &me);
}
if (!bFound)
return FALSE;
hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_CREATE_THREAD |
PROCESS_VM_OPERATION, FALSE, dwProcessId);
if (!hProcess)
return FALSE;
// 6. 调用GetProcAddress得到FreeLibrary函数(Kernel32.dll)的实际地址
PTHREAD_START_ROUTINE pfnThreadRtn = (PTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle(TEXT("Kernel32")), "FreeLibrary");
if (!pfnThreadRtn)
return FALSE;
// 7. 调用CreateRemoteThread函数在远程进程中创建一个新线程,
// 让该线程调用FreeLibrary函数并在参数中传入已注入DLL的模块地址以卸载该DLL
hThread = CreateRemoteThread(hProcess, NULL, 0, pfnThreadRtn, me.modBaseAddr, 0, NULL);
if (!hThread)
return FALSE;
WaitForSingleObject(hThread, INFINITE);
if (hSnapshot != INVALID_HANDLE_VALUE)
CloseHandle(hSnapshot);
if (hThread)
CloseHandle(hThread);
if (hProcess)
CloseHandle(hProcess);
return TRUE;
}
6.6.3 通过函数转发器机制注入DLL
这里首先介绍一下函数转发器(Function Forwarder),函数转发器是DLL的一项特性------将对一个函数的调用转发到另一个DLL中某个函数的调用。使用VS的Developer Command Prompt工具,输入命令DumpBin -Exports C:\Windows\System32\kernel32.dll,可以看到原书的图6.16所示的界面。
C:\Windows\System32\kernel32.dll中存在许多被转发的函数,如果在程序中调用kernel32. AcquireSRWLockExclusive、kernel32.AcquireSRWLockShared函数,可执行程序会加载kernel32. dll。当执行这两个函数时,可执行程序发现这两个函数已被转发,于是加载NtDll.dll,并调用NtDll.RtlAcquireSRWLockExclusive、NtDll.RtlAcquireSRWLockShared函数,kernel32.dll中的AcquireSRWLockExclusive、AcquireSRWLockShared函数并没有具体的函数实现。
实现具有函数转发器功能的DLL可以使用pragma指示符,如下所示:
#pragma comment(linker, "/export:SomeFunc=DllWork.SomeOtherFunc")
上述pragma告知链接器,正在编译的DLL应该导出一个名为SomeFunc的函数,但是实际实现SomeFunc函数的是另一个名为SomeOtherFunc的函数,该函数包含在一个名为DllWork.dll的模块中,我们必须为每个想要转发的函数单独创建一行pragma。
为了方便生成函数转发代码,可以使用AheadLib软件,读者可以参考Chapter6\AheadLib\ AheadLib.exe。
这里以一个简单的DLL讲解实现函数转发器的方法,如果是单纯为了实现函数转发器,则根本不需要包括导出函数声明的头文件,FunctionForwarderDll.cpp源文件的内容如下:
#include <Windows.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
#pragma comment(linker, "/export:MyMessageBox=User32.MessageBoxW")
使用Depends.exe查看FunctionForwarderDll.dll,可以看到导出了一个名为MyMessageBox的函数(见原书的图6.17)。
接下来我们编写一个调用FunctionForwarderDll.MyMessageBox函数的可执行程序FunctionForwarderApp,FunctionForwarderApp.cpp源文件的内容如下所示:
#include <windows.h>
#include "resource.h"
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
HMODULE hFunctionForwarder = NULL;
typedef BOOL(WINAPI* pfnMyMessageBox)(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType);
pfnMyMessageBox pMyMessageBox = NULL;
switch (uMsg)
{
case WM_INITDIALOG:
return TRUE;
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_MYMESSAGEBOX:
hFunctionForwarder = LoadLibrary(TEXT("FunctionForwarderDll.dll"));
if (hFunctionForwarder)
{
pMyMessageBox = (pfnMyMessageBox)GetProcAddress(hFunctionForwarder,
"MyMessageBox");
if (pMyMessageBox)
pMyMessageBox(hwndDlg, TEXT("MyMessageBox"), TEXT("提示"), MB_OK);
FreeLibrary(hFunctionForwarder);
}
break;
case IDCANCEL:
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
}
return FALSE;
}
程序运行效果如原书的图6.18所示。
假设我们知道一个可执行程序会加载一个名为SomeDll.dll的动态链接库,则可以利用函数转发器创建一个同名的SomeDll.dll,包含函数转发器功能的SomeDll.dll导出了原SomeDll.dll的所有导出函数,包含函数转发器功能的SomeDll.dll导出的函数由SomeDllReplace.dll实现。包含函数转发器功能的SomeDll.dll和SomeDllReplace.dll创建好后,即可将这两个.dll文件复制到可执行文件目录中(包含函数转发器功能的SomeDll.dll替换原SomeDll.dll)。可执行程序调用原SomeDll.dll中的导出函数,实际调用的是SomeDllReplace.dll中的相关函数,包含函数转发器功能的SomeDll.dll和SomeDllReplace.dll相当于木马DLL,实现了DLL劫持。
接下来实现一个简单的示例,但是涉及几个项目,包括原SomeDll.dll项目(DrawDll),包含函数转发器功能的SomeDll.dll项目(DrawDll2),函数的真正实现SomeDllReplace.dll项目(DrawDllReplace),可执行程序项目(DrawApp)。DrawDll.dll中导出了一个绘制矩形的函数DrawRectangle和绘制椭圆的函数DrawEllipse,通过DrawDll2.dll(后期需要更名为DrawDll.dll,替换掉可执行程序目录中的原DrawDll.dll)和SomeDllReplace.dll。可执行程序DrawApp调用绘制矩形函数DrawRectangle时实际上绘制的是圆形,调用绘制椭圆函数DrawEllipse时实际上绘制的是矩形。可执行程序DrawApp的运行效果如原书的图6.19所示。
具体代码参见Chapter6\ReplaceDll项目。
DrawDll项目是可执行程序DrawApp使用的原DLL,DrawDll.h头文件的内容如下:
#pragma once
// 声明导出的函数
#ifdef DLL_EXPORT
#define DLL_API extern "C" __declspec(dllexport)
#else
#define DLL_API extern "C" __declspec(dllimport)
#endif
// 导出函数
DLL_API VOID DrawRectangle(HWND hwnd);
DLL_API VOID DrawEllipse(HWND hwnd);
DrawDll.cpp源文件的内容如下:
// 定义DLL的导出函数
#include <Windows.h>
#include <tchar.h>
#define DLL_EXPORT
#include "DrawDll.h"
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
// 导出函数
VOID DrawRectangle(HWND hwnd)
{
HDC hdc;
hdc = GetDC(hwnd);
Rectangle(hdc, 10, 10, 110, 110);
ReleaseDC(hwnd, hdc);
}
VOID DrawEllipse(HWND hwnd)
{
HDC hdc;
hdc = GetDC(hwnd);
Ellipse(hdc, 10, 10, 110, 110);
ReleaseDC(hwnd, hdc);
}
DrawDll2项目是函数转发器,不需要头文件,DrawDll2.cpp源文件的内容如下:
#include <Windows.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
#pragma comment(linker, "/export:DrawRectangle=DrawDllReplace.DrawRectangle")
#pragma comment(linker, "/export:DrawEllipse=DrawDllReplace.DrawEllipse")
DrawDllReplace项目是被转发函数的具体实现,DrawDllReplace.h头文件的内容如下:
#pragma once
// 声明导出的函数
#ifdef DLL_EXPORT
#define DLL_API extern "C" __declspec(dllexport)
#else
#define DLL_API extern "C" __declspec(dllimport)
#endif
// 导出函数
DLL_API VOID DrawRectangle(HWND hwnd);
DLL_API VOID DrawEllipse(HWND hwnd);
DrawDllReplace.cpp源文件的内容如下:
// 定义DLL的导出函数
#include <Windows.h>
#include <tchar.h>
#define DLL_EXPORT
#include "DrawDllReplace.h"
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
// 导出函数
VOID DrawRectangle(HWND hwnd)
{
HDC hdc;
hdc = GetDC(hwnd);
Ellipse(hdc, 10, 10, 110, 110);
ReleaseDC(hwnd, hdc);
}
VOID DrawEllipse(HWND hwnd)
{
HDC hdc;
hdc = GetDC(hwnd);
Rectangle(hdc, 10, 10, 110, 110);
ReleaseDC(hwnd, hdc);
}
可执行程序DrawApp.cpp的内容如下:
#include <windows.h>
#include "resource.h"
#include "DrawDll.h"
#pragma comment(lib, "DrawDll.lib")
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
static BOOL bFirst = TRUE;
static BOOL bRect;
HDC hdc;
PAINTSTRUCT ps;
switch (uMsg)
{
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_DRAWRECT:
bFirst = FALSE;
bRect = TRUE;
InvalidateRect(hwndDlg, NULL, TRUE);
break;
case IDC_BTN_DRAWELLIPSE:
bFirst = FALSE;
bRect = FALSE;
InvalidateRect(hwndDlg, NULL, TRUE);
break;
case IDCANCEL:
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
case WM_PAINT:
hdc = BeginPaint(hwndDlg, &ps);
if (!bFirst)
{
if (bRect)
DrawRectangle(hwndDlg);
else
DrawEllipse(hwndDlg);
}
EndPaint(hwndDlg, &ps);
return TRUE;
}
return FALSE;
}
6.6.4 通过CreateProcess函数写入ShellCode注入DLL
调用CreateProcess函数以挂起模式创建一个子进程,在子进程的主线程运行前,我们可以向子进程的地址空间中注入一些代码并率先得以执行,注入的代码执行完成后再转去执行主线程原来的代码,这种方法因为需要注入可执行代码,需要读者了解汇编,所以具有一定难度,但是该方法功能强大。
CreateProcessInjectDll程序运行效果如原书的图6.20所示。
单击"创建目标进程并注入dll"按钮,程序调用CreateProcess函数后调用GetThreadContext函数,目的是获取主线程EIP指令指针寄存器的值并保存,然后调用VirtualAllocEx函数在目标进程中分配一块可读可写可执行的内存空间(首地址lpMemoryRemote)用于存放我们设计的可执行代码ShellCode,接下来可以调用SetThreadContext函数把EIP指向lpMemoryRemote,最后调用ResumeThread函数恢复目标进程主线程的执行。
CreateProcessInjectDll.cpp源文件的内容如下:
#include <windows.h>
#include "resource.h"
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
BOOL CreateProcessAndInjectDll();
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_CREATE:
CreateProcessAndInjectDll();
break;
case IDCANCEL:
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
}
return FALSE;
}
BOOL CreateProcessAndInjectDll()
{
STARTUPINFO si = { sizeof(STARTUPINFO) };
PROCESS_INFORMATION pi = { 0 };
TCHAR szExePath[MAX_PATH] = TEXT("ThreeThousandYears.exe");
TCHAR szDllPath[MAX_PATH] = TEXT("MessageBoxDll.dll");
BOOL bRet;
// 29字节的机器指令和MAX_PATH * sizeof(TCHAR)字节要注入的DLL的名称
BYTE ShellCode[29 + MAX_PATH * sizeof(TCHAR)] =
{
0x60, // pushad
0x9C, // pushfd
0x68,0xAA,0xBB,0xCC,0xDD, // push [0xDDCCBBAA](0xDDCCBBAA是目标进程中要注入的DLL的名称)
0xFF,0x15,0xDD,0xCC,0xBB,0xAA, // call [0xDDCCBBAA](0xDDCCBBAA是LoadLibraryW函数的地址)
0x9D, // popfd
0x61, // popad
0xFF,0x25,0xAA,0xBB,0xCC,0xDD, // jmp [0xDDCCBBAA](0xDDCCBBAA为目标进程原入口点)
0xAA,0xAA,0xAA,0xAA, // 保存loadlibraryW函数地址的4字节数据区域
0xAA,0xAA,0xAA,0xAA, // 保存目标进程原入口点地址的4字节数据区域
0, // 后面是存放要注入的动态链接库名称的数据区域
};
// 以挂起模式创建一个进程
bRet = CreateProcess(szExePath, NULL, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);
if (!bRet)
return FALSE;
// 获取目标进程主线程环境(EIP)
CONTEXT context;
context.ContextFlags = CONTEXT_FULL;
if (!GetThreadContext(pi.hThread, &context))
return FALSE;
// 获得LoadLibraryW函数的地址
DWORD dwLoadLibraryWAddr = (DWORD)GetProcAddress(GetModuleHandle(TEXT("kernel32.dll")),
"LoadLibraryW");
if (!dwLoadLibraryWAddr)
return FALSE;
// 在目标进程中分配内存,存放ShellCode
LPVOID lpMemoryRemote = VirtualAllocEx(pi.hProcess, NULL, 29 + MAX_PATH * sizeof(TCHAR),
MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (!lpMemoryRemote)
return FALSE;
// push [0xDDCCBBAA](0xDDCCBBAA是目标进程中要注入的DLL的名称) 偏移ShellCode + 3
*(DWORD*)(ShellCode + 3) = (DWORD)lpMemoryRemote + 29;
// call [0xDDCCBBAA](0xDDCCBBAA是LoadLibraryW函数的地址)偏移ShellCode + 9
*(DWORD*)(ShellCode + 9) = (DWORD)lpMemoryRemote + 21;
// jmp [0xDDCCBBAA](0xDDCCBBAA为目标进程原入口点) 偏移ShellCode + 17
*(DWORD*)(ShellCode + 17) = (DWORD)lpMemoryRemote + 25;
// 保存loadlibraryW函数地址的4字节数据区域 偏移ShellCode + 21
*(DWORD*)(ShellCode + 21) = dwLoadLibraryWAddr;
// 保存目标进程原入口点地址的4字节数据区域 偏移ShellCode + 25
*(DWORD*)(ShellCode + 25) = context.Eip;
// 后面是存放要注入的动态链接库名称的数据区域 偏移ShellCode + 29
memcpy_s(ShellCode + 29, MAX_PATH * sizeof(TCHAR), szDllPath, sizeof(szDllPath));
// 把shellcode写入目标进程
if (!WriteProcessMemory(pi.hProcess, lpMemoryRemote, ShellCode,
29 + MAX_PATH * sizeof(TCHAR), NULL))
return FALSE;
// 修改目标进程的EIP,执行被注入的代码
context.Eip = (DWORD)lpMemoryRemote;
if (!SetThreadContext(pi.hThread, &context))
return FALSE;
// 恢复目标进程的执行
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return TRUE;
}
ShellCode字节数组中的粗体部分数据是暂时的占位数据,后期需要更改为具体的可用数据。
6.6.5 通过调试器写入ShellCode注入DLL
要想调试一个程序,在调用CreateProcess函数创建子进程时只需将dwCreationFlags参数指定为DEBUG_PROCESS或DEBUG_ONLY_THIS_PROCESS标志即可。在载入一个被调试进程时,会在被调试进程的主线程尚未执行任何代码前自动通知调试器(CREATE_PROCESS_DEBUG_EVENT),这时调试器可以将一些可执行代码注入被调试进程的地址空间中,保存被调试进程的CONTEXT线程环境,修改EIP指向我们注入的代码以执行,最后恢复被调试进程原来的CONTEXT继续执行,整个过程对于被调试的进程而言好像没有发生任何事情。
6.6.6 通过APC机制注入DLL
Windows为每个线程维护一个APC队列,可以通过调用QueueUserAPC函数将一个用户模式APC对象添加到指定线程的APC队列。参考CreateRemoteThread远程线程注入DLL的原理,可以把QueueUserAPC函数的APC回调函数指针设置为LoadLibraryA / LoadLibraryW函数的地址,把传递给APC回调函数的参数设置为欲加载到目标线程中的DLL的路径,这样一来目标线程在执行APC的时候就会调用LoadLibraryA / LoadLibraryW函数加载指定的DLL。
无法确定目标进程中哪个线程处于可通知的等待状态,为了确保成功执行插入APC,可以向目标进程的每个线程都注入该APC。本节的示例程序与通过CreateRemoteThread远程线程注入DLL的例子类似,参见Chapter6\APCInjectApp项目。
6.6.7 通过输入法机制注入DLL
用户切换输入法时,输入法管理器会加载所选择输入法对应的.ime文件到当前活动进程中以使用新选择的输入法,.ime文件实际上就是一个DLL文件。可以自己创建一个输入法.ime文件,通过输入法管理器提供的API函数加载自己的.ime文件(相当于用户选择了输入法),在该.ime文件的DllMain函数的DLL_PROCESS_ATTACH中调用LoadLibrary函数加载需要注入的DLL。
输入法编辑器(Input Method Editors,IME)是Microsoft提供的一套输入法编程规范。依照这套规范,开发人员不需要处理太多与输入法特性相关的操作,例如光标跟随、输入捕获以及字码转换后输出到活动窗口等,程序员只需要使用输入法管理器IMM32.dll提供的API函数实现IME规范规定必须导出的相关API即可。
笔者使用的是搜狗拼音输入法,通过PEInfo程序(PE文件格式深入剖析一章提供的示例程序)打开C:\Windows\system32\SogouPy.ime文件,可以看到导出了下列15个API函数,如原书的图6.21所示。
这里不是完整地介绍输入法编程,目的仅仅是通过输入法机制来注入DLL,因此我们自己的输入法.ime文件不需要实现上述API,只需要在DllMain函数的DLL_PROCESS_ATTACH中调用LoadLibrary函数加载需要注入的DLL即可。
通过输入法机制注入DLL需要3个项目,自己的输入法MyIME.ime、需要注入的MyIMETestDll.dll和可执行程序MyIMEInstaller.exe(负责安装、卸载和清理自己的输入法)。为了易于管理,编译后这3个文件应该放在同一个目录中。
MyIMEInstaller程序运行的效果如原书的图6.22所示。
在MyIME.ime的DllMain函数的DLL_PROCESS_ATTACH中需要调用LoadLibrary函数加载需要注入的MyIMETestDll.dll,笔者把注入DLL的路径写入内存映射文件中,以下是MyIMEInstaller程序在初始化时需要做的工作:
case WM_INITDIALOG:
g_hwndDlg = hwndDlg;
// 同目录下注入dll的完整路径
GetModuleFileName(NULL, szInjectDllName, _countof(szInjectDllName));
if (lpStr = _tcsrchr(szInjectDllName, TEXT('\\')))
StringCchCopy(lpStr + 1, _tcslen(TEXT("MyIMETestDll.dll")) + 1, TEXT("MyIMETestDll.dll"));
// 创建一个命名文件映射内核对象,4096字节,用于存放注入DLL的完整路径
hFileMap = CreateFileMapping(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE,
0, 4096, TEXT("DEAE59A6-F81B-4DC4-B375-68437206A1A4"));
if (!hFileMap)
{
MessageBox(hwndDlg, TEXT("CreateFileMapping调用失败"), TEXT("提示"), MB_OK);
return TRUE;
}
// 把文件映射对象hFileMap的全部内容映射到进程的虚拟地址空间中
lpMemory = MapViewOfFile(hFileMap, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0);
if (!lpMemory)
{
MessageBox(hwndDlg, TEXT("MapViewOfFile调用失败"), TEXT("提示"), MB_OK);
return TRUE;
}
// 复制注入DLL的完整路径到内存映射文件
StringCchCopy((LPTSTR)lpMemory, MAX_PATH, szInjectDllName);
return TRUE;
用于安装输入法的自定义函数InstallMyIME的代码如下所示:
VOID InstallMyIME()
{
// 复制.ime文件到系统目录中
CopyFile(TEXT("MyIME.ime"), TEXT("C:\\WINDOWS\\system32\\MyIME.ime"), FALSE);
// 获取当前默认输入法的键盘布局句柄(句柄包括语言ID和物理布局ID),通过全局变量g_hklDefault返回
SystemParametersInfo(SPI_GETDEFAULTINPUTLANG, 0, &g_hklDefault, FALSE);
// 安装自己的输入法
g_hklMy = ImmInstallIME(TEXT("MyIME.ime"), TEXT("我的输入法"));
StringCchPrintf(g_szKLID, _countof(g_szKLID), TEXT("%08X"), (DWORD)g_hklMy);
// 如果自己的输入法安装成功
if (ImmIsIME(g_hklMy))
{
// 加载自己的输入法到系统中
LoadKeyboardLayout(g_szKLID, KLF_ACTIVATE);
// 投递一条WM_INPUTLANGCHANGEREQUEST消息到前台窗口(模拟用户选择新的输入法)
PostMessage(GetForegroundWindow(), WM_INPUTLANGCHANGEREQUEST,
INPUTLANGCHANGE_SYSCHARSET, (LPARAM)g_hklMy);
// 设置为默认输入法
SystemParametersInfo(SPI_SETDEFAULTINPUTLANG, 0, &g_hklMy, SPIF_SENDCHANGE);
MessageBox(g_hwndDlg, TEXT("我的输入法已经设置为默认输入法"), TEXT("提示"), MB_OK);
}
}
SystemParametersInfo函数用于获取或设置系统参数,可以选择是否同时更新用户配置文件并广播WM_SETTINGCHANGE消息到所有顶级窗口:
BOOL WINAPI SystemParametersInfo(
_In_ UINT uiAction, // 要获取或设置的系统参数
_In_ UINT uiParam, // 辅助参数,其用法和格式取决于要获取或设置的系统参数
_Inout_ PVOID pvParam, // 辅助参数,其用法和格式取决于要获取或设置的系统参数
_In_ UINT fWinIni); // 是否同时更新用户配置文件并广播WM_SETTINGCHANGE消息到所有顶级窗口
-
uiAction参数指定为要获取或设置的系统参数,包括桌面、图标、输入(键盘、鼠标、输入语言等)、菜单、电源、屏保、超时、UI效果、窗口和辅助功能等类别,具体的系统参数很多,请读者自行参考MSDN。
-
uiParam和pvParam均是辅助参数,其用法和格式取决于要获取或设置的系统参数。
-
fWinIni参数表示是否同时更新用户配置文件并广播WM_SETTINGCHANGE消息到所有顶级窗口。如果设置为SPIF_UPDATEINIFILE表示将新的系统参数写入用户配置文件;如果设置为SPIF_SENDCHANGE表示将新的系统参数写入用户配置文件然后广播WM_SETTINGCHANGE消息到所有顶级窗口;如果不需要可以设置为0。
ImmInstallIME函数用于安装输入法,在安装前必须将输入法文件MyIME.ime复制到系统目录中,安装自己的输入法后会返回一个键盘布局句柄(也称为输入区域设置ID)到全局变量g_hklMy中。这里顺便保存了其字符串形式到全局变量g_szKLID中,后面会用到。
LoadKeyboardLayout函数用于加载自己的输入法到系统中,但是如果调用进程没有具有键盘焦点的窗口,那么函数调用会失败。为了保险起见,可以投递一条WM_INPUTLANGCHANGEREQUEST消息到前台窗口(模拟用户选择新的输入法)。
用于卸载输入法的自定义函数UninstallMyIME的代码如下:
VOID UninstallMyIME()
{
// 先设置回原来的默认输入法
SystemParametersInfo(SPI_SETDEFAULTINPUTLANG, 0, &g_hklDefault, SPIF_SENDCHANGE);
// 卸载自己的输入法
if (UnloadKeyboardLayout(g_hklMy))
MessageBox(g_hwndDlg, TEXT("我的输入法已经卸载成功"), TEXT("提示"), MB_OK);
}
当不再需要所安装的输入法的时候,应该进行清理。
安装输入法后,会创建一个"HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\Keyboard Layouts\键盘布局句柄(我这里为E0200804)"子键,有原书的图6.23所示的键值项。
同时会在HKEY_CURRENT_USER\Keyboard Layout\Preload子键下面创建原书的图6.24所示的键值项。
另外,还要删除系统目录中的MyIME.ime文件。用于清理输入法的自定义函数ClearMyIME的代码如下:
VOID ClearMyIME()
{
// 删除HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Keyboard Layouts\E0200804子键
TCHAR szSubKey[MAX_PATH] = TEXT("SYSTEM\\CurrentControlSet\\Control\\Keyboard Layouts\\");
StringCchCat(szSubKey, _countof(szSubKey), g_szKLID);
RegDeleteKey(HKEY_LOCAL_MACHINE, szSubKey);
// 删除HKEY_CURRENT_USER\Keyboard Layout\Preload下面键值为"E0200804"的键值项
HKEY hKey;
LPCTSTR lpSubKey = TEXT("Keyboard Layout\\Preload");
DWORD dwIndex = 0;
TCHAR szValueName[16] = { 0 };
DWORD dwchValueName;
TCHAR szValueData[MAX_PATH] = { 0 };
DWORD dwcbValueData;
LONG lRet;
RegOpenKeyEx(HKEY_CURRENT_USER, lpSubKey, 0, KEY_READ | KEY_WRITE, &hKey);
while (TRUE)
{
dwchValueName = _countof(szValueName);
dwcbValueData = sizeof(szValueData);
lRet = RegEnumValue(hKey, dwIndex, szValueName, &dwchValueName, NULL, NULL,
(LPBYTE)szValueData, &dwcbValueData);
if (lRet == ERROR_NO_MORE_ITEMS)
break;
if (_tcsicmp(g_szKLID, szValueData) == 0)
RegDeleteValue(hKey, szValueName);
dwIndex++;
}
// 删除输入法文件
if (!DeleteFile(TEXT("C:\\WINDOWS\\system32\\MyIME.ime")))
{
// 下次重新启动系统后删除
MoveFileEx(TEXT("C:\\WINDOWS\\system32\\MyIME.ime"), NULL, MOVEFILE_DELAY_UNTIL_REBOOT);
MessageBox(g_hwndDlg, TEXT("我的输入法已清理完毕,重启后删除.ime文件"), TEXT("提示"), MB_OK);
}
else
{
MessageBox(g_hwndDlg, TEXT("我的输入法已清理完毕"), TEXT("提示"), MB_OK);
}
}
前面说过,我们的目的仅仅是通过输入法机制来注入DLL,自己的输入法.ime文件不需要实现任何API,因此不需要头文件。MyIME.cpp源文件的内容如下所示:
#include <Windows.h>
#include <tchar.h>
#include <strsafe.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
static HANDLE hFileMap;
static LPVOID lpMemory;
static TCHAR szInjectDllName[MAX_PATH] = { 0 }; // 注入DLL完整路径
static HMODULE hModuleInject; // 注入DLL模块句柄
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
// 打开命名文件映射内核对象,从内存映射文件中获取注入DLL的完整路径
hFileMap = CreateFileMapping(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE,
0, 4096, TEXT("DEAE59A6-F81B-4DC4-B375-68437206A1A4"));
if (!hFileMap)
return FALSE;
// 把文件映射对象hFileMap的全部内容映射到进程的虚拟地址空间中
lpMemory = MapViewOfFile(hFileMap, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0);
if (!lpMemory)
return FALSE;
// 获取注入DLL的完整路径
StringCchCopy(szInjectDllName, _countof(szInjectDllName), (LPTSTR)lpMemory);
// 加载注入DLL
hModuleInject = LoadLibrary(szInjectDllName);
break;
case DLL_THREAD_ATTACH:
break;
case DLL_THREAD_DETACH:
break;
case DLL_PROCESS_DETACH:
UnmapViewOfFile(lpMemory);
CloseHandle(hFileMap);
if (hModuleInject)
FreeLibrary(hModuleInject);
break;
}
return TRUE;
}
.ime文件必须添加一个版本信息资源,其中FILETYPE设置为VFT_DRV,FILESUBTYPE设置为VFT2_DRV_INPUTMETHOD,其他信息则无关紧要,否则调用ImmInstallIME函数安装输入法时会失败。
可以修改编译得到的DLL后缀名为.ime,或者通过项目属性→配置属性→高级→目标文件扩展名,设置为.ime。
如果MyIME.ime、MyIMETestDll.dll和MyIMEInstaller.exe均编译为32位,并且操作系统为64位,调用CopyFile函数复制输入法文件到C:\Windows\System32,会被文件系统重定向,实际写入C:\Windows\SysWOW64目录中,因此自己的输入法不能用于64位程序。要想将自己的输入法应用于64位程序,还必须实现64位版本的MyIME.ime和MyIMETestDll.dll,将64位版本的MyIME.ime复制到C:\Windows\System32目录中。
在WOW64下运行的应用程序(32位程序运行在64位Windows上)默认情况下会启用文件系统重定向,要想将文件写入C:\Windows\System32目录中,需要关闭文件系统的重定向。
Wow64DisableWow64FsRedirection函数用于禁用调用线程的文件系统重定向:
BOOL WINAPI Wow64DisableWow64FsRedirection(
_Out_ PVOID* ppOldValue); // 一个指向PVOID类型变量的指针,返回一些WOW64文件系统重定向信息
ppOldValue参数是一个指向PVOID类型变量的指针,函数会通过*ppOldValue返回一些WOW64文件系统重定向信息,重新启用文件系统重定向的时候需要使用这些信息,程序不需要关心更不能修改这些信息。
禁用文件系统重定向会影响调用线程的所有文件操作,因此在执行完所需的操作后应该立即重新启用文件系统重定向。Wow64RevertWow64FsRedirection函数用于恢复调用线程的文件系统重定向:
BOOL WINAPI Wow64RevertWow64FsRedirection(
_In_ PVOID pOlValue); // Wow64DisableWow64FsRedirection函数返回的WOW64文件系统重定向信息
函数会同时释放Wow64DisableWow64FsRedirection函数返回的文件系统重定向信息。
每次对Wow64DisableWow64FsRedirection函数的成功调用都必须具有对Wow64RevertWow64FsRedirection函数的匹配调用,这可以确保重新启用重定向并释放相关的系统资源。例如下面的代码:
LPVOID pOldValue = NULL;
Wow64DisableWow64FsRedirection(&pOldValue);
// 文件操作
// ...
Wow64RevertWow64FsRedirection(pOldValue);
6.7 Shadow API技术
在使用OD调试程序时,通常为API函数设置断点,比如用户输入的注册码无效,那么程序可以调用MessageBox函数弹出一个注册失败的消息框。我们可以为MessageBoxA / MessageBoxW函数设置断点,这样一来程序会在系统弹出注册失败消息框时中断,然后我们可以按F8键单步执行,在程序执行完MessageBox函数的内部实现代码后,会返回到代码中call MessageBox指令的下一行,这时候我们可以往上查找关键代码。
加密解密的过程中会涉及Shadow API技术,该技术会使API函数断点失效。代码如下:
#include <windows.h>
#include "resource.h"
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_OK:
MessageBox(hwndDlg, TEXT("内容"), TEXT("标题"), MB_OK); // 实为MessageBoxW
break;
case IDCANCEL:
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
}
return FALSE;
}
程序运行效果如原书的图6.25所示。
当用户单击"打开一个消息框"按钮时,程序会弹出一个消息框(见原书的图6.26)。
把ShadowAPI程序编译为Release版本,OD载入ShadowAPI.exe。
用鼠标右键单击反汇编窗口,然后选择查找→当前模块中的名称(标签)或按快捷键Ctrl + N,可以打开一个函数名称窗口。该窗口中列出了程序中用到的API函数名称,输入法切换到英文状态,输入MessageBoxW,如原书的图6.27所示,可以发现程序中用到了User32.dll中的MessageBoxW函数。
查看菜单→CPU,回到CPU窗口,用鼠标右键单击反汇编窗口,然后选择转到→表达式快捷键Ctrl + G,打开输入表达式对话框,输入MessageBoxW,单击"OK"按钮,导航至MessageBoxW函数的代码实现处(见原书的图6.28)。
这就是 MessageBoxW 函数的完整实现代码(30 字节),其中调用了User32.MessageBoxTimeoutW函数。注意这是在Windows 10系统中MessageBoxW函数的实现代码,在其他操作系统中该函数的具体实现有所不同。例如在Windows 7系统中MessageBoxW函数的实现代码如图6.29所示。
这里以Windows 10为例介绍Shadow API,我们可以通过调用VirtualAlloc函数申请一块内存空间pMessageBoxWNew,把MessageBoxW函数的实现代码复制到以pMessageBoxWNew为基地址的内存空间中,在程序中需要调用MessageBox的地方调用pMessageBoxWNew(hwndDlg, TEXT("内容"), TEXT("标题"), MB_OK);。
如前所述,一个进程中用到的全局变量和API函数地址是一个绝对地址(相对于本进程)。如原书的图6.30所示,在反汇编窗口中双击7720DB85一行,可以发现call MessageBoxTimeoutW就是call 7720D9E0,这一句反汇编代码的Hex数据为E8 56FEFFFF,E8 表示 call 指令,0xFFFFFE56 实际上是一个相对地址,MessageBoxTimeoutW函数的地址是0x7720D9E0,call MessageBoxTimeoutW下一行的地址是0x7720DB8A,0x7720D9E0−0x7720DB8A等于0xFFFFFE56。如果把MessageBoxW函数的实现代码复制到其他地址,需要更改call指令的相对地址0xFFFFFE56。相对地址0xFFFFFE56的地址很容易确定,就是MessageBoxW函数起始地址偏移22的DWORD数据。接下来实现Shadow MessageBoxW,ShadowAPI.cpp源文件的内容如下:
#include <windows.h>
#include "resource.h"
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
typedef int (WINAPI* pfnMessageBoxW)(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType);
pfnMessageBoxW pMessageBoxW = NULL;
static pfnMessageBoxW pMessageBoxWNew = NULL; // 分配内存空间存放MessageBoxW函数机器码
BYTE bArr[30] = { 0 }; // 存放MessageBoxW函数实现代码的缓冲区
LPBYTE pMessageBoxTimeoutW = NULL; // MessageBoxTimeoutW函数的地址
DWORD dwReplace; // MessageBoxTimeoutW函数的相对地址
switch (uMsg)
{
case WM_INITDIALOG:
// 获取MessageBoxW函数的地址,并读取函数数据
pMessageBoxW = (pfnMessageBoxW)GetProcAddress(GetModuleHandle(TEXT ("User32.dll")),
"MessageBoxW");
ReadProcessMemory(GetCurrentProcess(), pMessageBoxW, bArr, sizeof(bArr), NULL);
// 分配内存空间存放MessageBoxW函数,可读可写可执行
pMessageBoxWNew = (pfnMessageBoxW)VirtualAlloc(NULL, sizeof(bArr), MEM_ RESERVE |
MEM_COMMIT, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(GetCurrentProcess(), pMessageBoxWNew, bArr, sizeof(bArr), NULL);
// 获取MessageBoxTimeoutW函数的地址
pMessageBoxTimeoutW = (LPBYTE)GetProcAddress(GetModuleHandle(TEXT ("User32.dll")),
"MessageBoxTimeoutW");
// 计算并修改相对地址,是pMessageBoxWNew函数偏移22的DWORD数据
dwReplace = pMessageBoxTimeoutW - (LPBYTE)pMessageBoxWNew - 26;
WriteProcessMemory(GetCurrentProcess(), (LPBYTE)pMessageBoxWNew + 22, &dwReplace,
4, NULL);
return TRUE;
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_OK:
// 如果在调试器中对MessageBoxW函数设置了int3断点,有可能第1字节被修改为0xCC
if (*(LPBYTE)pMessageBoxWNew == 0xCC)
*(LPBYTE)pMessageBoxWNew = 0x8B;
pMessageBoxWNew(hwndDlg, TEXT("内容"), TEXT("标题"), MB_OK);
break;
case IDCANCEL:
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
}
return FALSE;
}
当用户单击"打开一个消息框"按钮时,程序依然会弹出一个消息框。OD载入ShadowAPI.exe,函数名称窗口中已经没有了MessageBoxW函数,在OD底部的命令窗口中输入bp MessageBoxW然后按Enter键,表示在MessageBoxW函数起始地址处设置int3普通断点,然而我们按F9键运行程序,单击"打开一个消息框"按钮,程序并不会中断下来,消息框正常弹出(见原书的图6.31)。Shadow API技术可以很好地实现程序反调试。
关于Shadow API技术,应该针对不同的操作系统实现不同的编码,微软已经不建议使用GetVersionEx函数判断操作系统版本,如果需要判断操作系统版本,则可以使用Version Helper functions。
如前所述,E8是call指令,后面加上一个相对地址;实际上call指令还可以是FF15,这时后面加上一个绝对地址。还有其他可能的情况,如原书的图6.32所示。
关于call和jmp机器码的几种情况如表6.5所示(32位)。
表6.5
| 机器码 | 指令 | 含义 |
|---|---|---|
| 0xE8 | call | 后面的4字节是相对地址 |
| 0xFF | call | 后面加上一个寄存器 |
| 0xFF15 | call | 后面的4字节是存放地址的地址 |
| 0xE9 | jmp | 后面的4字节是相对地址,远跳 |
| 0xEB | jmp | 后面的2字节是相对地址,近跳 |
| 0xFF25 | jmp | 后面的4字节是存放地址的地址,远跳 |
另外需要注意的是,x86、x64、IA-64和其他CPU的call、jmp等指令的机器码有所不同。
6.8 Hook API技术
6.8.1 随机数
随机数是专门的随机试验的结果,在统计学的不同技术中需要使用随机数,比如在从统计总体中抽取具有代表性的样本时,或者在将实验动物分配到不同的试验组中时,或者在进行蒙特卡罗模拟法计算时等。产生随机数有多种不同的方法,这些方法称为随机数发生器。随机数最重要的特性是,它产生的下一个数与前面的数毫无关系。
根据密码学原理,随机数的随机性检验可以分为3个标准。
(1)统计学伪随机性:指在给定的随机比特流样本中,1的数量大致等于0的数量,例如"10""01""00""11"四者数量大致相等,满足这类要求的数字对人类来说"一眼看上去"是随机的。
(2)密码学安全伪随机性:定义为根据随机样本的一部分和随机算法,不能有效地演算出随机样本的剩余部分。
(3)真随机性:定义为随机样本不可重现。实际上只要给定边界条件,真随机数并不存在,但是如果一个真随机数样本的边界条件十分复杂且难以捕捉,那么使用这个方法就可以演算出真随机数。
随机数也分为3类。
(1)伪随机数:满足第1个条件的随机数。
(2)密码学安全伪随机数:同时满足前2个条件的随机数,通过密码学安全伪随机数生成器计算得出。
(3)真随机数:同时满足3个条件的随机数。
在实际应用中通常使用伪随机数就足够了。这些数列是"似乎"随机的数,实际上它们是通过一个固定的、可以重复的计算方法产生的。计算机或计算器产生的随机数有很长的周期性,伪随机数并不是真正的随机,但是它们具有类似于随机数的统计特征,这样的发生器叫作伪随机数发生器。使用计算机产生真随机数的方法是获取CPU频率与温度的不确定性,以及统计一段时间的每次运算都会产生不同的值、系统时间的误差以及声卡的底噪等。
通过C/C++运行库函数time、srand和rand可以生成一个伪随机数。rand函数生成的随机数的范围为0~RAND_MAX(32767)。在调用rand函数前应该先调用srand函数设置伪随机数生成器的起始种子值,即初始化伪随机数生成器。srand函数需要一个unsigned int类型的参数。如果在调用rand函数前没有调用srand函数,相当于调用了srand(1)。srand函数的unsigned int类型的参数通常可以通过调用time函数来生成。time函数用于获取系统时间,该函数返回自1970年1月1日午夜以来经过的秒数(协调世界时)。time函数的返回值可以直接用作srand函数的参数。这几个函数的原型如下:
time_t time(time_t* destTime);
void srand(unsigned int seed);
int rand(void);
例如,rand() % 100得到的是0~99之间的随机数,如果需要获取10~100之间的随机数可以这样使用:rand() % 91 + 10。
为了进一步提高rand函数生成的伪随机数的随机性,随时可以调用srand函数向伪随机数生成器提供一个新的种子。但是下面的代码将会生成10个完全相同的随机数:
for (int i = 0; i < 10; i++)
{
srand(time(NULL));
_tprintf (TEXT("%d\n"), rand());
}
因为计算机执行速度太快,而time函数获取到的系统时间以秒为单位,这相当于使用相同的种子值来生成伪随机数,所以生成了10个完全相同的数值,这种情况下可以把srand(time(NULL));放到循环体外来避免这个问题。上面的代码每次循环都会使用相同的参数调用srand函数,所以将会生成10个完全相同的随机数。如前所述,srand函数用于设置伪随机数生成器的起始种子值,即初始化伪随机数生成器,如果把srand(time(NULL));放到循环体外,相当于只初始化伪随机数生成器一次,这样一来就会根据上次生成的随机数来生成下一个随机数(下一次生成随机数时自动更新种子值)。上面的代码如果不调用srand函数,也不会生成10个完全相同的数值,但是每次运行程序所产生的10个数是相同的序列。
使用相同的种子值会生成相同的随机数,有时候可以利用这一特性,有时候应该避免。例如:
setlocale(LC_ALL, "chs");
srand(12345678);
_tprintf(TEXT("%d\n"), rand()); // 每次运行始终是11188
srand(1);
_tprintf(TEXT("%d\n"), rand()); // 每次运行始终是41
GetTickCount函数用于获取自系统启动以来经过的毫秒数,精度更高一些,返回值是一个DWORD类型,可以用于srand函数的参数,因此示例程序中在绘制红色水印时用到了srand(GetTickCount())。
Windows也提供了生成随机数的函数,相关函数包括CryptAcquireContext、CryptGenRandom、CryptReleaseContext,感兴趣的读者请自行参考MSDN。
6.8.2 通过远程线程注入DLL实现API Hook
很多加密视频在播放过程中会显示几个颜色不同且位置不断变化的浮动水印,因为知识产权问题,所以这里不能以具体的加密视频为例讲解绘制文本的相关函数Hook。我编写了一个类似程序FloatingWaterMark,程序每隔2 秒会把蓝色水印(调用的ExtTextOut)位置变化一下,每隔 5 秒会把红色水印(调用的DrawText)位置变化一下,之所以使用不同的函数绘制水印,是为了防止黑客Hook了一个API函数后,其他水印可以正常浮动显示。FloatingWaterMark程序运行效果如原书的图6.33所示。
在对话框中,用到了一个图像静态控件(IDC_STATIC_BMP),添加了一个BMP图像资源(IDB_EAGLE)。添加图像静态控件只需要通过工具箱中的Picture Control,添加后需要更改ID例如IDC_STATIC_BMP,然后设置图像静态控件的Type为Bitmap类型,设置Image为IDB_EAGLE即可。具体代码参见Chapter6\FloatingWaterMark项目。
现在以去掉蓝色水印(调用的ExtTextOut)为例讲解相关知识点。首先设置OD的选项菜单→调试设置,打开调试选项对话框,打开事件选项卡,设置第一次暂停于WinMain(若位置已知)。默认情况下暂停于系统断点,程序在编译时会添加进一些额外的东西,而直接暂停于WinMain比较直观,有利于我们进行分析。另外如果没有特别说明,本书中使用的OD是看雪网站提供的OllyDBG(或OllyIce中文版)。OD载入FloatingWaterMark程序的Release版本,如原书的图6.34所示。
程序在WinMain函数的起始地址处001E1000中断,这时OD右下角的栈窗口会出现WinMain函数的4个函数参数和返回地址,在栈窗口第一行的返回地址处可以看到:00B5F8B0 001E15D6/CALL到WinMain来自Floating.001E15D1,这说明WinMain函数在001E15D1地址处被调用,WinMain函数执行完后的返回地址是001E15D6。OD中使用的都是十六进制数值,书写时为了简单通常省略0x前缀。
这个反汇编窗口中的第1列是指令的地址,这个地址会随着可执行程序主模块加载到的基地址的不同而变化,但是一条指令与另一条指令的相对位置不会变化;第2列的Hex数据是该指令对应的十六进制机器码;第3列是程序的反汇编代码;第4列是注释,上图中的注释是OD软件自动添加的,我们也可以用鼠标右键单击反汇编窗口,然后选择注释(快捷键是;)为某一行添加注释,在调试程序过程中,如果遇到了关键代码,可以添加一个注释,以免下次OD重新载入时,忘记了该行指令的作用。
OD是一个动态调试工具,将IDA与SoftICE结合起来,Ring 3级调试器己代替SoftICE成为当今最为流行的调试解密工具,同时还支持插件扩展功能,是目前最强大的调试工具。OD的其他窗口如原书的图6.35所示。
(1)反汇编窗口用来显示被调试程序的反汇编代码,用鼠标右键单击反汇编窗口,然后选择界面选项→隐藏标题或显示标题可以切换是否显示标题栏(地址、HEX数据、反汇编、注释),用鼠标左键单击注释标签可以切换注释显示的方式。
(2)寄存器窗口用来显示当前线程的CPU寄存器内容,单击标签寄存器(FPU)可以切换显示寄存器的方式(寄存器FPU / 寄存器MMX / 寄存器3DNow)。
(3)信息窗口用来显示反汇编窗口中选中的或者当前执行的指令的参数及跳转目标地址、字符串等。例如上图中当程序F8单步到001E100E一行时,数据窗口中显示:栈ss:0133FAA8=001E0000 (Floating.001E0000),表示ebp + 0x8的栈地址是0133FAA8,该地址处的值是001E0000,可以通过栈窗口验证这一点。
(4)数据窗口通常用来显示.data数据段的数据,也可以显示.text代码段、.rdata只读数据段、.rsrc资源段、.reloc重定位段的数据,打开内存窗口→定位到相关区段→在CPU数据窗口中查看即可,用鼠标右键单击数据窗口可用于切换显示方式。
(5)栈窗口用来显示当前线程的栈(通常用于存放函数的参数、返回地址和局部变量)。
以上各个窗口的大小可以随意拖拉调整。
OD有一个插件菜单,菜单中的插件存放在OD同目录的Plugin目录中,在该目录中添加相关插件dll就可以增加插件,有时候我们确实需要插件的帮助,但是插件太多可能会发生冲突导致OD不稳定。OD同目录中还有一个UDD目录,这个UDD目录的作用是保存调试的工作状态,比如我们调试一个软件,设置了断点,添加了注释,但是这次没有做完(分析、破解),这时OD就会把我们所做的工作保存到这个UDD目录中,以便在下次调试时可以继续以前的工作。OD同目录中还有一个ollydbg.ini文件,这个是OD的配置文件,很多软件可能都需要INI配置文件,后面会有相关章节详细介绍INI配置文件的使用。
除可以直接启动OD来选择程序进行调试外,例如通过文件菜单→打开,或附加一个正在运行中的进程,或者把程序拖入OD,我们还可以把OD添加到资源管理器右键菜单,这样我们就可以直接在.exe及.dll文件上用鼠标右键单击,然后选择"用OD打开"来进行调试。要把OD添加到资源管理器右键菜单,可以单击OD的选项菜单→添加到资源管理器右键菜单,打开"添加到系统资源管理器"对话框,先单击"添加OD到系统资源管理器菜单",再单击"完成"按钮即可。要从右键菜单中将其删除也很简单,还是打开"添加到系统资源管理器"对话框,单击"从系统资源管理器菜单删除OD",再单击"完成"按钮即可。
选项菜单下还有界面选项和调试设置菜单,读者可以自行查看。对于调试设置,目前还没有学习到里面选项的含义,因此暂时不能随意修改。
对于工具栏,每一个按钮都对应着相关菜单,这些工具栏按钮只不过是菜单项的一种快捷方式,方便使用。鼠标悬停在相关工具栏按钮上时,下方状态栏会显示其功能,读者可以逐个测试。
要详细讲解OD的各个功能,需要占用大量篇幅,因此其他功能在我们用到的时候再讲解,现在读者已经对OD有了大致的了解。下面介绍程序调试过程中常用的几个快捷键。
-
F9:运行程序,如果没有设置相应断点,则被调试的程序将直接开始运行。
-
F8:单步步过,每按一次这个快捷键仅执行一条指令,遇到CALL指令不会进入函数内部。
-
F7:单步步入,功能与单步步过(F8)类似,区别是遇到CALL指令时会进入函数内部,进入后会停留在函数内部的第一条指令上。
-
F2:设置普通断点(int3,0xCC),定位到相关指令行按F2键即可,设置int3断点后指令的地址会变为红色,再按一次F2键则会删除断点。注意,设置int3断点后,OD中的反汇编代码表面上并没有变化,但是实际上在内存中已经修改为0xCC。
-
F4:运行到选定位置,就是直接运行到选中的指令行所在位置处然后中断。
-
Ctrl + F9:执行到返回,该命令在执行到一个ret(返回指令)指令时中断,常用于从系统领空(系统dll模块的代码中)返回到调试的程序领空(被调试程序的代码中)。
-
Alt + F9:执行到用户代码,可用于从系统领空快速返回到调试程序的领空。
上述几个快捷键基本上能够满足一般的调试要求,设置好断点,找到感兴趣的代码,然后按F8键或F7键来逐条分析指令即可,以上快捷键都在OD的调试菜单项中。
要删除蓝色水印,最简单直接的做法是修改call ExtTextOutW指令行前面几个push指令,也就是修改ExtTextOutW的函数参数,例如修改字符串开始位置的x、y坐标为负数,或者修改字符串长度参数为0等。OD载入FloatingWaterMark程序的Release版本,在命令行窗口中(OD左下角的Command编辑框)输入bpx ExtTextOutW,按Enter键,导航至模块间调用窗口,可以看到自动为001E13CF call dword ptr \<\&GDI32. ExtTextOutW\>一行设置了普通断点,用鼠标右键单击该行,然后选择反汇编窗口中跟随,如原书的图 6.36所示。
"bpx 函数名"是在当前模块中"call 函数名"的地址处设置普通断点,还有一个"bp 函数名",这个命令是在函数内部的第一条指令上设置普通断点,这两者没有优劣之分,需要根据实际情况选择使用哪一个。在OD中关闭程序,就是工具栏中的×按钮,单击OD的插件菜单→CleanupEx→All(*.udd *.bak)→确定,可以清除UDD目录中的所有.udd和.bak文件。OD重新载入FloatingWaterMark程序的Release版本,即工具栏中的<<按钮,在命令行窗口中(OD左下角的Command编辑框)输入bp ExtTextOutW,按Enter键,然后单击工具栏中的B按钮打开断点窗口,如原书的图6.37所示。
用鼠标右键单击该行,然后选择反汇编窗口中跟随,如原书的图6.38所示。
可以看到在Gdi32.ExtTextOutW函数的起始地址处设置了普通断点,这是Gdi32.dll系统领空,多数情况下我们并不想在系统领空徘徊(单步执行或其他工作),既然这里设置了断点,可以按F9键运行,程序在ExtTextOutW函数的起始地址处中断,栈窗口如见原书的图6.39所示。
查看第7行的String字符串,这并不是程序中定义的字符串,继续按F9键运行几次,结果如原书的图6.40所示。
查看栈窗口中第1行的函数返回地址和调用地址,可以发现这里的ExtTextOutW函数调用来自我们的程序。选中并用鼠标右键单击第7行,然后选择数据窗口中跟随,用鼠标右键单击数据窗口,然后选择Hex→Hex/Unicode(16位),如原书的图6.41所示。
这是程序中定义的字符串。栈窗口中的第1行是ExtTextOutW函数的返回地址,选中并用鼠标右键单击第1行,然后选择反汇编窗口中跟随,如原书的图6.42所示。
这是程序领空,001E13D5是call ExtTextOutW后的返回地址,通过这种方法很容易确定程序中ExtTextOutW函数调用的位置,用户可以打开断点窗口,用鼠标右键单击禁止或删除无关断点,然后在001E13BB 6A 00 push 0x0;/pSpacing = NULL一行设置断点,然后按F9键运行程序以便调试。
OD载入程序,可以把二进制的可执行文件反汇编为汇编代码,这是反汇编引擎的功能,有时反汇编引擎可能分析得不是很好,例如汇编代码看上去很杂乱、没有注释等,一种应对上述情况的办法是用鼠标右键单击反汇编窗口,然后选择分析→分析代码,另一种办法是,用鼠标右键单击反汇编窗口,然后选择分析→从模块中删除分析,如果以上两种方法均不奏效,可以重新打开OD载入程序。
利用插件删除所有.udd文件,重新载入程序,bpx ExtTextOutW,按Enter键,自动打开模块间调用窗口,可以看到001E13CF call dword ptr \<\&GDI32.ExtTextOutW\>一行的地址一列变成了红色,这说明对程序中ExtTextOutW函数的call调用已经设置了普通断点,用鼠标右键单击该行,然后选择反汇编窗口中跟随,导航至CPU窗口(即包含反汇编窗口、寄存器窗口、信息窗口、数据窗口和栈窗口的窗口),双击001E13CF这一指令行的机器码一列处可以设置或删除普通断点(相当于用鼠标右键单击反汇编窗口,然后选择断点→切换,快捷键F2),删除001E13CF一行的断点,然后在001E13BB一行设置断点,从该行开始,ExtTextOutW函数调用的参数开始入栈。如果没有特别说明,设置断点均是指int3普通断点(CC断点)。单击工具栏中的<<按钮重新载入程序,按F9键运行程序,中断在原书的图6.43所示的界面。
可以看到第2行、第4行和第5行是为了计算字符串长度并入栈(ExtTextOutW函数的倒数第2个字符串长度参数),选中001E13C2一行,双击该行的反汇编一列(快捷键Space),弹出汇编于此处对话框,可以在这里修改反汇编代码,输入push 0,按Enter键,接着输入nop,按Enter键,然后单击汇编于此处对话框的"取消"按钮。此时的反汇编代码如原书的图6.44所示。
单击工具栏中的B按钮打开断点窗口,选中001E13BB这一行并用鼠标右键单击,然后选择禁止,也可以选中这一行并用鼠标右键单击,然后选择删除(Delete键),但是有可能汇编代码修改错误,这时需要重新载入程序,然后打开断点窗口选中001E13BB这一行并用鼠标右键单击,然后选择激活,使该断点生效。禁止001E13BB指令行的断点后,按F9键运行程序,程序运行正常,蓝色水印消失,程序破解成功。
选中修改过的两行反汇编代码,如原书的图6.45所示。
用鼠标右键单击选中的两行反汇编代码,然后选择复制到可执行文件→所有修改→全部复制,弹出一个窗口,在该窗口中用鼠标右键单击,然后选择保存文件,保存文件为FloatingWaterMark_去蓝色水印版.exe,关闭OD,双击运行破解后的文件进行测试。有兴趣的读者可以自行尝试删除红色水印。
本节的主题是Hook API(API拦截)。拦截API的方法有很多,本节主要介绍通过覆盖被拦截函数一部分代码的方式来拦截API,其他方法在后面章节中进行介绍。我们可以修改被拦截函数起始地址处的一些字节码为call或jmp指令来跳转到自己设计的自定义函数(可以称之为代理函数),在自定义函数中进行一些处理,例如修改被拦截函数在栈中的参数,这相当于栈劫持,进行相关处理后可以继续执行被拦截函数。假设拦截Gdi32.ExtTextOutW函数,这是系统DLL提供的API函数,对该函数的修改不会影响其他进程对该函数的调用。
如前所述,每个进程的地址空间互相隔离,程序不可能从A进程的某函数跳转到B进程的某函数继续执行。对于自定义函数,我们可以书写汇编代码,然后写入目标进程,但是读者可能并不熟悉汇编语言;另外,当自定义函数执行完后,应该恢复执行被拦截函数起始地址处未修改的一些指令,然后继续执行被拦截函数的剩余部分。
这里采取原始的手动方式达到拦截FloatingWaterMark程序中ExtTextOutW函数的目的,即通过创建远程线程来注入DLL到FloatingWaterMark进程中,DLL中存放有自定义函数,在同一个进程的地址空间中从ExtTextOutW函数跳转到自定义函数是合情合理的。有一个问题需要注意,执行完自定义函数,在跳转回ExtTextOutW函数时,必须保证各寄存器的值和栈空间布局与调用ExtTextOutW函数前完全一致,否则会导致程序崩溃。
用于注入DLL的FWMApp程序的代码与RemoteApp基本相同,FWMApp程序运行效果如原书的图6.46所示。
FWMApp.cpp源文件的内容如下:
#include <windows.h>
#include <tchar.h>
#include "resource.h"
// 函数声明
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
BOOL InjectDll();
BOOL EjectDll();
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL, DialogProc, NULL);
return 0;
}
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_BTN_INJECT:
InjectDll();
break;
case IDC_BTN_EJECT:
break;
case IDCANCEL:
EndDialog(hwndDlg, 0);
break;
}
return TRUE;
}
return FALSE;
}
BOOL InjectDll()
{
TCHAR szCommandLine[MAX_PATH] = TEXT("FloatingWaterMark.exe");
STARTUPINFO si = { sizeof(STARTUPINFO) };
PROCESS_INFORMATION pi = { 0 };
LPCTSTR lpDllPath = TEXT("F:\\Source\\Windows\\Chapter6\\FWMDll\\Release\\ FWMDll.dll");
LPTSTR lpDllPathRemote = NULL;
HANDLE hThreadRemote = NULL;
GetStartupInfo(&si);
CreateProcess(NULL, szCommandLine, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);
// 1. 调用VirtualAllocEx函数在远程进程的地址空间中分配一块内存
int cbDllPath = (_tcslen(lpDllPath) + 1) * sizeof(TCHAR);
lpDllPathRemote = (LPTSTR)VirtualAllocEx(pi.hProcess, NULL, cbDllPath, MEM_COMMIT, PAGE_ READWRITE);
if (!lpDllPathRemote)
return FALSE;
// 2. 调用WriteProcessMemory函数把要注入的DLL的路径复制到第1步分配的内存中
if (!WriteProcessMemory(pi.hProcess, lpDllPathRemote, lpDllPath, cbDllPath, NULL))
return FALSE;
// 3. 调用GetProcAddress函数得到LoadLibraryA / LoadLibraryW函数(Kernel32.dll)的实际地址
PTHREAD_START_ROUTINE pfnThreadRtn = (PTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle(TEXT("Kernel32")), "LoadLibraryW");
if (!pfnThreadRtn)
return FALSE;
// 4. 调用CreateRemoteThread函数在远程进程中创建一个线程
hThreadRemote = CreateRemoteThread(pi.hProcess, NULL, 0, pfnThreadRtn, lpDllPathRemote, 0, NULL);
if (!hThreadRemote)
return FALSE;
WaitForSingleObject(hThreadRemote, INFINITE);
ResumeThread(pi.hThread);
// 5. 调用VirtualFreeEx函数释放第1步分配的内存
if (!lpDllPathRemote)
VirtualFreeEx(pi.hProcess, lpDllPathRemote, 0, MEM_RELEASE);
if (!pi.hThread)
CloseHandle(pi.hThread);
if (!pi.hProcess)
CloseHandle(pi.hProcess);
return TRUE;
}
BOOL EjectDll()
{
return TRUE;
}
这里没有实现用于卸载FWMDll.dll的自定义函数EjectDll,有兴趣的读者可以自行实现。
FWMDll.dll不需要导出函数,因此不需要定义头文件,FWMDll.cpp源文件的内容如下:
#include <Windows.h>
#include <tchar.h>
// 全局变量
LPBYTE pExtTextOutW;
TCHAR szText1[] = TEXT("屏幕");
TCHAR szText2[] = TEXT("用户名");
TCHAR szText3[] = TEXT("购买者");
TCHAR szTextReplace[] = TEXT(" ");
LPTSTR lpStr;
VOID InterceptExtTextOutW(LPTSTR lpText);
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
BYTE bExtTextOutWCall[] = { 0xFF, 0x74, 0x24, 0x18, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x90, 0x90, 0x90, 0x90 };
DWORD dwOldProtect;
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
// 获取ExtTextOutW函数的地址
pExtTextOutW = (LPBYTE)GetProcAddress(GetModuleHandle(TEXT("Gdi32.dll")), "ExtTextOutW");
*(LPINT)(bExtTextOutWCall + 5) = (INT)InterceptExtTextOutW - (INT) pExtTextOutW - 0x9;
// 把ExtTextOutW函数起始处改为Call
VirtualProtect(pExtTextOutW, 512, PAGE_EXECUTE_READWRITE, &dwOldProtect);
WriteProcessMemory(GetCurrentProcess(), pExtTextOutW, bExtTextOutWCall, sizeof
(bExtTextOutWCall), NULL);
VirtualProtect(pExtTextOutW, 512, dwOldProtect, &dwOldProtect);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
VOID InterceptExtTextOutW(LPTSTR lpText)
{
_asm
{
pushad
}
if ((lpStr = _tcsstr(lpText, szText1)) || (lpStr = _tcsstr(lpText, szText2)) || (lpStr =
_tcsstr(lpText, szText3)))
{
memcpy(lpStr, szTextReplace, _tcslen(lpStr) * sizeof(TCHAR));
}
_asm
{
popad
// 修改ExtTextOutW函数时,有一个push和call,导致esp减少了8字节
add esp, 8
// 已经恢复各通用寄存器和栈空间布局,开始执行原ExtTextOutW函数开头的一些指令行
mov edi, edi
push ebp
mov ebp, esp
push ecx
mov dword ptr[ebp - 0x4], 0x49414E
// 跳转到修改过的指令的下一条指令行继续执行,即ExtTextOutW + 0xD地址处
mov eax, pExtTextOutW
add eax, 0xD
jmp eax
}
}
为了理解上述DLL代码和方便后面的OD调试,在FloatingWaterMark.cpp源文件的WM_INITDIALOG消息处理中,在调用SetTimer创建两个计时器前添加Sleep(60000);命令使FloatingWaterMark程序暂停60秒后再执行SetTimer绘制水印,重新编译FloatingWaterMark程序为Release版本,复制到Chapter6\ FWMApp\Release目录中。
OD载入Chapter6\FWMApp\Release\FloatingWaterMark.exe,用鼠标右键单击反汇编窗口,然后选择转到→表达式或按快捷键Ctrl + G,打开输入要跟随的表达式对话框,输入ExtTextOutW,按Enter键,如原书的图6.47所示。
ExtTextOutW函数内部又调用了一个绘制文本的关键函数Gdi32Ful.ExtTextOutWImpl,修改74F23404(现在是76F13404)一行的指令为jmp即可使绘制文本功能失效(见原书的图6.48)。
把该行的第1字节由74修改为EB。无论Gdi32.dll每次加载到的基地址是多少,ExtTextOutW函数中被修改行与函数起始地址的偏移不会改变,双击第1行即ExtTextOutW函数起始地址处的地址一列,然后下面的每一条指令行的地址都会变为相对于函数起始地址处的偏移,被修改行的第一字节码的偏移为0x14。
jmp到76F1342A后,执行xor eax, eax指令,该指令的作用是将eax的值清零,执行jmp short 76F13424跳转到76F13424这一行,执行栈清理,然后函数返回。ExtTextOut函数的返回值为BOOL类型,返回值通过eax寄存器返回,eax清零表示ExtTextOut函数执行失败,因此我们应该修改76F1342A一行的xor eax, eax指令为两个nop,用鼠标右键单击76F1342A这一行,然后选择二进制→用NOP填充即可。更简单的方式是直接修改ExtTextOut函数第一行的指令为ret 0x20,使函数直接返回。为了方便找到ExtTextOutW,可以在函数首部设置一个断点,然后打开断点窗口禁用断点,按F9键运行程序,歌声已经响起,我们单击工具栏中的C按钮回到CPU窗口,60秒后出现FloatingWaterMark程序界面。另外就本例而言,选中ExtTextOutW函数中所有修改过的汇编代码→复制到可执行文件→所有修改→全部复制,在弹出的窗口中用鼠标右键单击,然后选择保存文件,这种修改方法是不合理的。因为Gdi32.dll是系统DLL文件,所以绝不可以修改。
另外,有时试图简单地去除水印可能会影响程序的其他功能,例如一些加密视频每隔一段时间就会弹出一个答题对话框,只有回答正确,视频才可以继续播放(见原书的图6.49)。
图6.49中的题目是通过文本绘制函数绘制出来的,如果修改文本绘制函数去除水印,则上面的题目内容就会消失(无法绘制出来),只有知道题目内容而且回答正确才可以继续播放。当然,我们可以使这个答题对话框永远不会弹出来,这将在后面进行介绍。
考虑到多方面的原因,我们删除水印字符串时应该谨慎操作。OD载入Chapter6\FWMApp\Release\ FloatingWaterMark.exe,在反汇编窗口中按Ctrl + G组合键,输入ExtTextOutW,按Enter键,导航至ExtTextOutW函数起始地址处,前提是需要确定输入焦点在反汇编窗口中,如果输入焦点正在数据窗口中时按下Ctrl + G组合键,则是在数据窗口中查找。在ExtTextOutW函数首部设置断点,按F9键运行程序,要等待60秒程序界面才会出现,程序在ExtTextOutW函数首部中断,此时栈窗口如原书的图6.50所示。
通过栈窗口的第1行可以确定本次调用并不是源于FloatingWaterMark程序,通过ExtTextOutW函数的第6个字符串参数也就是第7行可以确定这不是我们需要拦截的字符串,我们要拦截的字符串是"用户名:老王",继续F9运行几次,直到出现原书的图6.51所示的界面。
选中并用鼠标右键单击第7行,然后选择数据窗口中跟随,右击数据窗口→Hex→Hex/Unicode(16位),可以看到"用户名:老王"。
把这个字符串参数传递到要注入的DLL的自定义函数中,栈窗口中第1行就是当前栈顶指针ESP,如果不是,则可以在寄存器窗口中选中ESP→栈窗口中跟随,在栈窗口中选中第1行,双击第1列,第1列的数值变为相对于当前ESP寄存器值的偏移,可以看到esp + 0x18正是字符串的地址,把ExtTextOutW函数的第一行改为push dword ptr esp + 0x18,然后call到自定义函数,现在不知道自定义函数的地址,可以假设为0x0FA910F0,继续修改,输入call 0x0FA910F0(见原书的图6.52)。
上面的call 0FA910F0指令行的call字节码是0xE8,表示后面的4字节是相对地址,现在需要结合FWMDll.cpp源代码理解我们现在的操作。我们修改了13字节的机器码,call字节码0xE8后面的4字节的相对地址 = 自定义函数的地址−call指令下一行的地址,现在读者应该能理解FWMDll.cpp中DLL_PROCESS_ATTACH通知的代码含义。ExtTextOutW函数属于Gdi32.dll中代码段.text中的数据,代码段通常是只读的,所以必须在修改前调用VirtualProtect函数修改内存页属性为可读可写可执行,修改完以后再恢复原保护属性。
再来看自定义函数InterceptExtTextOutW,_asm{}表示嵌入一段汇编代码,pushad指令用于把8个通用寄存器的值压入栈,popad指令用于从栈中恢复8个通用寄存器的值。如前所述,执行完自定义函数,在跳转回ExtTextOutW函数时,必须保证各寄存器的值和栈空间布局与调用ExtTextOutW函数以前完全一致,否则程序会崩溃。
中间的if判断用于确定传递过来的字符串中是否以子字符串"屏幕"或"用户名"或"购买者"开头,如果是,则将其填充为空格,_tcslen函数获取到的字符个数不包括字符串结尾标志0,因此memcpy函数调用不会破坏原字符串的字符串结尾标志0,空格字符串szTextReplace应该定义的长一些,以免不够覆盖。要判断"屏幕"或"购买者"的原因是部分加密视频中包含以这些字符串开头的水印,本程序仅是示例,读者可以根据需要自行设置。
双击运行Chapter6\FWMApp\Release\FWMApp.exe,单击"创建进程并注入dll"按钮,等待60秒 FloatingWaterMark程序界面出现后,程序崩溃。
再次单击"创建进程并注入dll"按钮,打开OD,单击文件菜单→附加,找到FloatingWaterMark.exe进程。因为该进程刚刚启动,所以通常位于进程列表最下方,找到后选中该进程,单击"附加"按钮,FloatingWaterMark进程中断在原书的图6.53所示的界面。
按Ctrl + G组合键,输入ExtTextOutW,在ExtTextOutW函数首部设置断点,按F9键运行程序,如原书的图6.54所示。
可以发现注入的DLL对ExtTextOutW函数的修改很成功。用鼠标右键单击信息窗口中的"数据窗口中跟随数值",在数据窗口中可以看到"用户名:老王"。另外,此时的栈窗口如原书的图6.55所示。
由此推断问题出在自定义函数InterceptExtText OutW上,接下来按F7键单步,进入该函数内部(见原书的图6.56)。
先看前面被选中的7行,一个函数在执行前基本上都会先push ebp;mov ebp,esp;,而sub esp, 0x10通常是为函数内部的局部变量开辟空间,之后是push ebx;push esi;push edi;这3行保存这3个寄存器的值。如前所述,esp寄存器的值会由于不断压栈出栈经常发生变化,因此函数内部使用ebp寄存器作为指针来引用函数参数和局部变量,首先把ebp的值压入栈,然后将其赋给ebp,即可使用ebp作为指针来引用函数参数和局部变量,栈空间是按从大到小方向生长的,因此ebp + 一个数值表示函数参数,"sub esp,一个数值"通常用来在栈中为函数内部的局部变量开辟空间,因此ebp − 一个数值表示函数的局部变量。编译器为了提高程序的执行速度,编译时会有所优化,有时候会使用寄存器存放函数参数或局部变量。
我们的初衷是先执行pushad指令,所以需要修改上图中的选中部分,把pushad指令放在第一行,用鼠标右键单击选中部分,然后选择二进制→二进制复制
55 8B EC 83 EC 10 53 56 57 60
修改为
60 55 8B EC 83 EC 10 53 56 57
然后用鼠标右键单击选中部分,再选择二进制→二进制粘贴,即可完成修改。
F8单步到51B810FF一行,mov ecx,dword ptr ebp + 0x8中的ebp + 0x8表示从ExtTextOutW函数传递过来的参数,但是现在ebp + 0x8表示的地址并不是从ExtTextOutW函数传递过来的字符串参数,在寄存器窗口中选中ebp并用鼠标右键单击,然后选择栈窗口中跟随,这样一来栈窗口中ebp的值就到了第1行。双击第1行的第1列,我们发现ebp + 0x28才是从ExtTextOutW函数传递过来的字符串参数,因此修改图6.52中框中部分的3个mov ecx,dword ptr ebp + 0x8为mov ecx,dword ptr ebp + 0x28。字符串参数的地址变为ebp + 0x28,是因为执行pushad指令导致压入栈的8个通用寄存器的值需要32(0x20)字节的空间。选中51B8118D E8 EF0C0000 call memcpy这一行,按F4键(相当于用鼠标右键单击,然后选择断点→运行到选定位置),按F8键单步执行call memcpy指令后,发现数据窗口中"用户名:老王"变为了空格字符串。
反汇编窗口中执行到原书的图6.57所示的界面。
图中框选出来的部分是用于InterceptExtTextOutW函数内部栈平衡,选中部分是FWMDll.cpp源文件中书写的汇编代码。我们的初衷是执行完51B81192一行的add esp, 0xC指令后继续执行下面框中部分,然后再执行选中部分,即把选中部分移动至51B811B5 pop ebp这一行的后面,才可以保证栈平衡。现在选中原书的图6.58所示的部分。
用鼠标右键单击选中部分,然后选择二进制→二进制复制
83 C4 0C 61 83 C4 08 8B FF 55 8B EC 51 C7 45 FC 4E 41 49 00 A1 24 34 B8 51 83 C0 0D FF E0 5F 5E 5B 8B E5 5D
修改为
83 C4 0C 5F 5E 5B 8B E5 5D 61 83 C4 08 8B FF 55 8B EC 51 C7 45 FC 4E 41 49 00 A1 24 34 B8 51 83 C0 0D FF E0
然后用鼠标右键单击选中部分,然后选择二进制→二进制粘贴,即可完成修改。打开断点窗口,禁用所有断点,按F9键运行,音乐响起,蓝色水印消失,至此,FloatingWaterMark程序破解成功。
虽然FWMDll.dll的InterceptExtTextOutW函数还需要二次修改,但是这是为了使编译器生成汇编代码,而且FWMDll.cpp中用到了几个全局变量,开辟空间存放这些变量也是一个复杂问题。当选中InterceptExtTextOutW函数中所有修改过的汇编代码→复制到可执行文件→所有修改→全部复制时,弹出一个请确定更新重定位对话框:"选择部分包含修改过的重定位. 当加载DLL时,系统将调整重定位, 并修改您的代码. 若您不够仔细,这可能严重影响被调试的程序. 您真的要更新可执行文件吗?",先单击"否"按钮,回到CPU窗口的InterceptExtTextOutW函数中。InterceptExtTextOutW函数中使用了szText1、szText2、szText3、szTextReplace、lpStr和pExtTextOutW几个全局变量,因为ASLR(Address Space Layout Randomization,地址空间布局随机化),程序中用到的动态链接库DLL文件每次加载到的内存基地址可能会随机变化,函数中用到的全局变量和函数地址会根据DLL实际加载到的基地址,结合.reloc重定位表中的信息进行重新计算。后面章节还会详细介绍重定位表。现在的.exe程序也使用了ASLR技术,也有.reloc重定位表。回忆对InterceptExtTextOutW函数的修改,函数首部一部分代码的修改不影响需要重定位的内容,而且机器码还是原来的大小,因此不会影响szText1、szText2、szText3、szTextReplace、lpStr这些变量的重定位,受影响的只有全局变量pExtTextOutW的重定位,因为后面的汇编代码位置发生了变化(见原书的图6.59)。
关于如何修改和保存FWMDll.dll,参见视频教程Chapter6\FWMDll.dll修改与保存方法.exe。
自定义函数InterceptExtTextOutW使用_declspec(naked)修饰符,该修饰符告诉编译器不要在函数中做栈处理,例如函数开头的初始化部分(ebp作为指针使用、开辟局部变量空间、保存一些寄存器的值等),以及在函数尾部也不会平衡栈、恢复保存的寄存器的值,甚至没有ret返回指令。下面是使用_declspec(naked)修饰符的InterceptExtTextOutW函数,编译DLL后,不需要对其做任何修改,直接运行FWMApp程序注入即可:
_declspec(naked)VOID InterceptExtTextOutW(LPTSTR lpText)
{
_asm
{
// 大多数函数开头是这个样子
push ebp
mov ebp, esp
sub esp, 0x10
push ebx
push esi
push edi
// 额外保存ecx和edx,eax和esp不需要关心
push ecx
push edx
}
// 下面的C++代码中可能会有一些push指令,但是编译器会自动恢复esp的值,我们无须关心
if ((lpStr = _tcsstr(lpText, szText1)) || (lpStr = _tcsstr(lpText, szText2)) ||
(lpStr = _tcsstr(lpText, szText3)))
{
memcpy(lpStr, szTextReplace, _tcslen(lpStr) * sizeof(TCHAR));
}
_asm
{
// 恢复edx和ecx
pop edx
pop ecx
// 大多数函数结尾是这样
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
// 修改ExtTextOutW函数时,有一个push和call,导致esp减了8字节
add esp, 8
// 已经恢复各通用寄存器和栈空间布局,开始执行原ExtTextOutW函数开头的一些指令行
mov edi, edi
push ebp
mov ebp, esp
push ecx
mov dword ptr[ebp - 0x4], 0x49414E
// 跳转到我们修改过的指令的下一条指令行继续执行,即ExtTextOutW + 0xD地址处
mov eax, pExtTextOutW
add eax, 0xD
jmp eax
}
}
eax寄存器通常作为函数的返回值来使用,因此上述代码没有保存和恢复eax寄存器的操作。具体代码参见Chapter6\FWMDll2项目。
需要注意的是,64位程序不支持_declspec(naked)修饰符,也不支持内联汇编,但是依然有许多方法可以实现在64位程序中编写汇编代码(后面会讲)。
微软研究院提供了一个Detours-master开源库,可以实现对API的拦截,但是相关文档说明很少。
6.8.3 通过全局消息钩子注入DLL实现进程隐藏
实际上用于枚举进程的CreateToolhelp32Snapshot、EnumProcesses和WTSEnumerate Processes等函数都通过调用Ntdll.dll中的未公开内核函数ZwQuerySystemInformation来实现,所以只要Hook掉该函数即可实现进程隐藏。
ZwQuerySystemInformation函数用于获取指定的系统信息:
__kernel_entry NTSTATUS NTAPI ZwQuerySystemInformation(
IN SYSTEM_INFORMATION_CLASS SystemInformationClass, // 要获取的系统信息的类型
OUT PVOID SystemInformation, // 返回所请求信息的缓冲区
IN ULONG SystemInformationLength, // 缓冲区的大小,以字节为单位
OUT PULONG ReturnLength OPTIONAL); // 返回所请求信息的大小,可以设置为NULL
SystemInformationClass参数指定要获取的系统信息的类型,该参数是一个SYSTEM_INFORMATION_CLASS枚举类型,可用的值有很多,这里只列举两个,如表6.6所示。
表6.6
| 枚举值 | 含义 |
|---|---|
| SystemBasicInformation | 在这种情况下,SystemInformation参数需要指定为一个指向SYSTEM_BASIC_INFORMATION结构的指针,该结构包含系统中的逻辑CPU个数字段。建议使用GetSystemInfo函数获取该类信息 |
| SystemProcessInformation | 在这种情况下,ZwQuerySystemInformation函数返回一个SYSTEM_PROCESS_INFORMATION结构链表,每一个结构表示一个进程的信息 |
SYSTEM_PROCESS_INFORMATION结构的定义如下:
typedef struct _SYSTEM_PROCESS_INFORMATION {
ULONG NextEntryOffset; // 下一个SYSTEM_PROCESS_INFORMATION结构的偏移地址
ULONG NumberOfThreads; // 线程数目
BYTE Reserved1[48];
UNICODE_STRING ImageName; // 进程名称
KPRIORITY BasePriority; // 进程优先级
HANDLE UniqueProcessId; // 进程ID
PVOID Reserved2;
ULONG HandleCount; // 句柄数目
ULONG SessionId; // 会话ID
PVOID Reserved3;
SIZE_T PeakVirtualSize; // 峰值虚拟内存大小
SIZE_T VirtualSize; // 当前虚拟内存大小
ULONG Reserved4;
SIZE_T PeakWorkingSetSize; // 峰值工作集大小
SIZE_T WorkingSetSize; // 当前工作集大小
PVOID Reserved5;
SIZE_T QuotaPagedPoolUsage; // 分页池使用配额
PVOID Reserved6;
SIZE_T QuotaNonPagedPoolUsage; // 非分页池使用配额
SIZE_T PagefileUsage; // 进程提交的内存总量
SIZE_T PeakPagefileUsage; // 进程提交的内存总量峰值
SIZE_T PrivatePageCount;
LARGE_INTEGER Reserved7[6];
} SYSTEM_PROCESS_INFORMATION, * PSYSTEM_PROCESS_INFORMATION;
用户程序如果需要使用ZwQuerySystemInformation函数提供的功能,可以调用NtQuerySystem Information函数,NtQuerySystemInformation函数在winternl.h头文件中已经声明,可以直接使用,而使用ZwQuerySystemInformation函数的话则需要通过调用GetProcAddress函数手动获取。使用NtQuerySystemInformation函数获取进程列表的示例请参考Chapter6\ProcessListNtQuerySystemInformation。
要通过Hook掉ZwQuerySystemInformation函数实现进程隐藏,必须Hook掉所有进程中对该函数的调用。消息在每个进程中无时无刻不在发生,因此我们可以通过安装全局消息钩子WH_GETMESSAGE的方式把实现Hook功能的DLL注入每一个进程中。
要实现全局消息钩子,必须创建一个DLL,在DLL中导出安装全局消息钩子的函数InstallHook和卸载全局消息钩子的函数UninstallHook:
// 导出函数
DLL_API BOOL InstallHook(int idHook, DWORD dwThreadId, DWORD dwProcessId);
DLL_API BOOL UninstallHook();
调用dll中的导出函数的可执行模块的程序界面,如原书的图6.60所示。
可执行模块把用户输入的要隐藏的进程ID传递给InstallHook函数的dwProcessId参数。
在DllMain函数的DLL_PROCESS_ATTACH通知中(系统中的每个进程加载该DLL时),保存DLL模块句柄,然后调用自定义内部函数SetJmp,SetJmp函数首先通过调用GetProcAddress获取内核函数ZwQuerySystemInformation的函数地址,然后把ZwQuerySystemInformation函数首部的代码更改为Jmp指令,当系统中每个进程调用ZwQuerySystemInformation函数时跳转到我们自定义的内部函数HookZwQuerySystemInformation。SetJmp函数的代码如下:
BOOL SetJmp()
{
pfnZwQuerySystemInformation ZwQuerySystemInformation = NULL;
DWORD dwOldProtect;
ZwQuerySystemInformation = (pfnZwQuerySystemInformation)
GetProcAddress(GetModuleHandle(TEXT("ntdll.dll")), "ZwQuerySystemInformation");
#ifndef _WIN64
BYTE bDataJmp[5] = { 0xE9, 0x00, 0x00, 0x00, 0x00 };
*(PINT_PTR)(bDataJmp + 1) = (INT_PTR)HookZwQuerySystemInformation -
(INT_PTR)ZwQuerySystemInformation - 5;
// 保存ZwQuerySystemInformation函数的前5字节
memcpy_s(g_bDataJmp32, sizeof(g_bDataJmp32), ZwQuerySystemInformation, sizeof(bDataJmp));
#else
BYTE bDataJmp[12] = { 0x48, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xE0 };
*(PINT_PTR)(bDataJmp + 2) = (INT_PTR)HookZwQuerySystemInformation;
// 保存ZwQuerySystemInformation函数的前12字节
memcpy_s(g_bDataJmp64, sizeof(g_bDataJmp64), ZwQuerySystemInformation, sizeof(bDataJmp));
#endif
// 修改页面保护属性,写入jmp数据
VirtualProtect(ZwQuerySystemInformation, sizeof(bDataJmp), PAGE_EXECUTE_ READWRITE, &dwOldProtect);
memcpy_s(ZwQuerySystemInformation, sizeof(bDataJmp), bDataJmp, sizeof(bDataJmp));
VirtualProtect(ZwQuerySystemInformation, sizeof(bDataJmp), dwOldProtect, &dwOldProtect);
return TRUE;
}
Hook API使用的是call指令,本节我们来练习jmp指令的用法。在Windows 10中任务管理器Taskmgr.exe是64位程序,只能注入64位DLL;而我们编写的ProcessList.exe是32位程序,只能注入32位DLL。本节的HookZwQuerySystemInformation.dll我们希望既可以编译为32位又可以编译为64位,以针对不同的进程进行注入。
对于32位程序,jmp跳转指令可以写为"0xE9 + 4字节相对地址"的形式,如原书的图6.61所示。
对于64位程序,jmp跳转指令可以写为如下形式:
mov rax, 0x1122334455667788 // 0x1122334455667788是自定义内部函数HookZwQuery SystemInformation的地址
jmp rax
上述汇编指令的机器码,可以通过在64位调试器x64dbg.exe中输入以获取,如原书的图6.62所示。
自定义内部函数SetJmp用于Hook ZwQuerySystemInformation。用于UnHook ZwQuerySystem Information函数的自定义内部函数ResetJmp的编写方式很简单,因为ZwQuerySystemInformation函数首部的指令已经保存到全局变量字节数组g_bDataJmp325或g_bDataJmp6412中:
BOOL ResetJmp()
{
pfnZwQuerySystemInformation ZwQuerySystemInformation = NULL;
DWORD dwOldProtect;
ZwQuerySystemInformation = (pfnZwQuerySystemInformation)
GetProcAddress(GetModuleHandle(TEXT("ntdll.dll")), "ZwQuerySystemInformation");
#ifndef _WIN64
VirtualProtect(ZwQuerySystemInformation, sizeof(g_bDataJmp32), PAGE_EXECUTE_ READWRITE, &dwOldProtect);
memcpy_s(ZwQuerySystemInformation, sizeof(g_bDataJmp32), g_bDataJmp32, sizeof(g_bDataJmp32));
VirtualProtect(ZwQuerySystemInformation, sizeof(g_bDataJmp32), dwOldProtect, &dwOldProtect);
#else
VirtualProtect(ZwQuerySystemInformation, sizeof(g_bDataJmp64), PAGE_EXECUTE_ READWRITE, &dwOldProtect);
memcpy_s(ZwQuerySystemInformation, sizeof(g_bDataJmp64), g_bDataJmp64, sizeof(g_bDataJmp64));
VirtualProtect(ZwQuerySystemInformation, sizeof(g_bDataJmp64), dwOldProtect, &dwOldProtect);
#endif
return TRUE;
}
自定义内部函数HookZwQuerySystemInformation用于对原ZwQuerySystem Information函数获取到的进程信息列表进行处理。在自定义内部函数HookZwQuery SystemInformation中,首先需要调用自定义内部函数ResetJmp恢复ZwQuerySystem Information函数首部的指令,然后执行原ZwQuerySystemInformation函数。系统中的其他进程调用ZwQuerySystemInformation函数不一定是为了获取进程信息列表,因为SystemInformationClass参数可以指定为许多不同的枚举值以获取不同的系统信息,因此执行原ZwQuerySystemInformation函数后,我们需要判断本次调用是不是为了获取进程信息列表,如果是,则遍历进程信息列表找到要隐藏的进程,将要隐藏的进程信息从进程信息列表中删除。HookZwQuerySystemInformation函数代码如下:
NTSTATUS NTAPI HookZwQuerySystemInformation(SYSTEM_INFORMATION_CLASS System InformationClass,
PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength)
{
pfnZwQuerySystemInformation ZwQuerySystemInformation = NULL;
NTSTATUS status = -1;
PSYSTEM_PROCESS_INFORMATION pCur = NULL, pPrev = NULL;
ZwQuerySystemInformation = (pfnZwQuerySystemInformation)
GetProcAddress(GetModuleHandle(TEXT("ntdll.dll")), "ZwQuerySystemInformation");
// 因为首先需要执行原ZwQuerySystemInformation函数,所以先恢复函数首部数据
ResetJmp();
status = ZwQuerySystemInformation(SystemInformationClass, SystemInformation,
SystemInformationLength, ReturnLength);
if (NT_SUCCESS(status) && SystemInformationClass == SystemProcessInformation)
{
pCur = pPrev = (PSYSTEM_PROCESS_INFORMATION)SystemInformation;
while (TRUE)
{
// 如果是要隐藏的进程
if ((DWORD)pCur->UniqueProcessId == g_dwProcessIdHide)
{
if (pCur->NextEntryOffset == 0)
pPrev->NextEntryOffset = 0;
else
pPrev->NextEntryOffset += pCur->NextEntryOffset;
}
else
{
pPrev = pCur;
}
if (pCur->NextEntryOffset == 0)
break;
pCur = (PSYSTEM_PROCESS_INFORMATION)((LPBYTE)pCur + pCur->NextEntryOffset);
}
}
// Hook ZwQuerySystemInformation
SetJmp();
return status;
}
需要注意的是,一般操作系统是抢占式、多线程工作机制,一个线程覆盖被拦截函数起始地址处的代码是需要时间的。在这个过程中,另一个线程可能试图调用该被拦截函数,因此可能会导致程序崩溃。完整代码参见Chapter6\HookZwQuerySystemInformation项目,读者可以把DLL和可执行模块编译为32位通过ProcessList.exe进行测试,或者编译为64位通过任务管理器的进程列表进行测试。