Windows编程总结之 DLL

+-- 版本 --+-- 编辑日期 --+-- 作者 -------------+
|   V1.0   |   2014.9.16  | yin_caoyuan@126.com |
+----------+--------------+---------------------+


这篇文章是对 《Windows核心编程(第五版)》 19,20,22 这三章的总结。
这篇文章共有 12 小节:
    1. Dll 和 进程的地址空间;
    2. 隐式载入时链接 和 显式运行时链接;
    3. 构建 dll;
    4. 构建 可执行模块;
    5. 运行 可执行模块;
    6. 入口点函数;
    7. 函数转发器;
    8. DLL 重定向;
    9. 模块的基地址重定位;
    10. 模块的绑定;
    11. DLL 注入;
    12. API 拦截;
以下是这 12 小节的概要:
    1:     介绍 dll 与 进程地址空间 之间的关系;
    2~5:   介绍 dll 载入 进程地址空间 的方法,这些方法是如何起作用的;
    6~8:   介绍 dll 中涉及到的其它知识点;
    9~10:  介绍 dll 载入速度的优化方法,基地址重定位和绑定技术;
    11~12: 介绍 dll 注入 技术,和基于 DLL注入 的 API拦截 技术; 
其中,1~8 节是基础知识,9~12 节的知识依赖于 1~8 节;



1. DLL 和 进程的地址空间:
在 可执行模块 能够调用一个 dll 中的函数之前,必须将该 dll 的文件映像映射到进程的 地址空间中。
注意:
在 dll 中预定地址空间或者分配内存,这段内存是从进程地址空间中分配的,因此当 dll 被卸载时,之前由 dll 分配的内存并不会被清理掉。比如在 dll 中的一个函数 new 了一块内存,如果稍后将这个 dll 卸载,这块内存并不会被清理。
当 dll 被卸载时,dll 中的“全局变量”也将被卸载。
多个可执行文件共享一个 dll 时,dll 中的全局变量和静态变量并不会共享,当一个进程将一个 dll 映像文件映射到自己的地址空间时,系统会为全局变量和静态变量创建新的实例。
可执行文件和 dll 有可能使用不同的 C运行时库,比如在 dll 中使用 malloc 分配一块内存,在可执行文件中使用 free 去释放内存,可能因为两者使用了不同的 C运行时库,free 不能正确释放 malloc 分配的内存。针对这种问题,当一个模块提供一个内存分配函数时,必须同时提供另一个用来释放内存的函数。
void* DllAllocMem()
{
    return (malloc(100));
}
void DllFreeMem(void* pv)
{
    free(pv);
}


2. 隐式载入时链接 和 显式运行时链接
将 dll 文件载入到 进程地址空间中,有两种方法, 隐式载入时链接 和 显式运行时链接。
隐式载入时链接,是指在可执行模块载入的时候,把这个可执行模块需要用到的 dll 载入到进程地址空间中。
显式运行时链接,是指在可执行模块运行的时候,动态载入指定的 dll,然后设法获取导出内容的地址,进行调用。
使用 隐式载入时链接,需要在可执行模块编译的时候,传入一个 .lib 文件,这个 .lib 文件称为导入库文件。
使用 显式运行时链接,不需要 .lib 文件,仅从 .dll 文件中就可以解析出导出的内容。
隐式链接 dll,需要在工程的设置面板中设置 附加库文件,或者使用 #pragma comment(lib,"xxx.lib") 命令,告知链接器去链接指定的 .lib 文件。
显式链接 dll 需要用到的函数:
LoadLibrary(pszDllPathName);        // 载入 dll 到进程地址空间中 
LoadLibraryEx(pszDllPathName, hFile, dwFlags);      // 提供额外参数
FreeLibrary(hInstDll);              // 从进程地址空间中卸载 dll 
GetModuleHandle(pszModuleName);     // 可以用来检查一个 模块 是否被载入 
FARPROC GetProcAddress(hInstDll, pszSymbolName);    // 得到 dll 中的指定导出函数。
使用 隐式载入时链接 可以在代码中直接引用 dll 中的符号,非常方便, 显式运行时链接 不能直接引用,但是可以在程序运行时动态地去加载 dll。
隐式载入时链接 为何能直接在代码中引用 dll 的符号(编译时并不知道 dll 的符号存在于哪里),会在下面的构建过程中给出解释。


3. 构建 dll
dll 模块的 构建过程:
       testdll.h
a.cpp    b.cpp    c.cpp
  |        |        |
  V        V        V
编译器   编译器   编译器
  |        |        |
  V        V        V
a.obj    b.obj    c.obj
     \     |     /
      V    V    V
         链接器 <-- (.def)
           |
           V
       .dll, .lib
编译期:
在编译器编译各个 .cpp 文件的时候,如果发现 __declspec(dllexport) 修饰符修饰的 变量、函数、或C++类 的话,就会在生成的 .obj 文件里嵌入这些要导出的内容的信息
链接期:
链接器会检测 .obj 中嵌入的导出信息,并利用这些信息生成一个 导出段(export section),这个导出段中列出了导出的变量、函数和类的符号名,链接器还会保存相对虚拟地址RVA(relative virtual address),表示每个符号可以在 dll 中的何处找到。
此外,链接器还会用导出信息 生成一个 .lib 文件,这个 .lib 文件列出了这个 dll 导出的符号。
我们可以使用 dumpbin.exe (加上-exports 选项)来查看一个 .dll 文件的导出段:
...
ordinal hint  RVA      name
   1    0     00001010 ReadBinaryFileToBuffer = ReadBinaryFileToBuffer
   2    1     00001090 WriteBinaryFileWithBuffer = WriteBinaryFileWithBuffer
...
如上,hint表示序号,也可以使用这个序号来访问 dll 中导出的内容,name 表示导出符号的名字,而 RVA 表示一个偏移量,导出的符号位于 dll 映像文件的这个位置。
注意:
    1. 为什么要有 __declspec(dllexport):
        我们必须告诉编译器和链接器,哪些函数、变量、C++类是需要导出的。因此需要 __declspec(dllexport) 这个修饰符来修饰那些需要导出的内容。
    2. 生成的 .dll 里包含了哪些信息?
        一个导出段,标识了这个 dll 里有哪些导出符号,如何寻找这些符号。
        这个导出段记录了访问导出内容所需要的全部信息。借助于导出段,只需要一个 dll 文件就足以访问 dll 中导出的所有内容。
    3. 生成的 .lib 里包含了哪些信息?
        既然只要有 .dll 就可以访问到 dll 中导出的内容,为什么还需要一个 .lib 呢?
        .lib 文件是专门为了隐式链接 dll 而创建的,其中仅包含了 dll 导出的符号。使用 .def 文件也可以产生出 .lib 文件,可见 .lib 中的信息有多简单。
    4. C++代码的名称粉碎问题:
        C++ 编译器在编译 C++ 代码的时候,会对 C++ 代码进行名称粉碎,比如 ReadBinaryFileToBuffer 被重命名为 ?ReadBinaryFileToBuffer@@YGKPB_WPAEI@Z, 这是为了实现函数重载,不同的参数调用不同的函数。
        因为 C++ 会有名称粉碎,而 C 没有,所以使用 C++ 编写的 dll 被 C模块 调用的时候,C 模块无法使用 ReadBinaryFileToBuffer 找到正确的函数。
        C++ 为了解决这个问题,引入了一个修饰符: extern "C" ,这要求编译器不要对指定的符号进行 名称粉碎。注意: extern "C" 是 C++ 的特性,C 语言中是没有这个修饰符的。
        如果使用 C++ 编写 dll,那么要在声明导出函数的时候加上 extern "C"
    5. 导出 C++类 的名称粉碎问题:
        针对导出 C++类,对于类来说,名称粉碎是必须的,不能通过 extern "C" 来消除,这就要求只有当导出 C++ 类的模块使用的编译器与导入 C++ 类的模块使用的编译器由同一厂商提供时,我们才可以导出 C++ 类,这样才可以保证C++类名称粉碎之后的结果是一致的。因此,除非知道可执行模块的开发人员与 dll 模块的开发人员使用的是相同的工具包,否则我们应该避免从 dll 中导出类。
    6. C 代码的名称粉碎问题:
        之前说过 C 编译器不会进行名称粉碎,但是不知道为啥,即使根本没有用到 C++,Microsoft 的 C 编译器也会对 C 函数进行名称粉碎,和 C++编译器粉碎的结果不大一样, ReadBinaryFileToBuffer 被粉碎为 _ReadBinaryFileToBuffer@12
        因此如果我们在VC上使用 C 语言编写 dll 模块,然后这个 dll 模块要给别的厂商使用的话,名称粉碎问题仍然会可执行模块不能正确找到 ReadBinaryFileToBuffer
        解决的办法是使用 模块定义文件 .def 。
        当链接器链接各个 .obj 文件的时候,会从 .obj 里找到对应的导出信息,比如 _ReadBinaryFileToBuffer@12,如果有 .def 文件,又从 .def 里找到了 ReadBinaryFileToBuffer,这两个函数是匹配的,链接器就会使用 .def 里定义的名字来作为导出的函数名。
        注意:即使在 .cpp 文件里没有使用 __declspec(dllexport) 修饰导出函数,在 .def 里声明了导出函数的话,也一样是可以的。
        
        
4. 构建可执行模块
可执行文件的构建过程:
        testexe.h
a.cpp    b.cpp    c.cpp
  |        |        |
  V        V        V
编译器   编译器   编译器
  |        |        |
a.obj    b.obj    c.obj
     \     |     /
      V    V    V
         链接器  <-- (.lib)
           |
           V
       testexe.exe
编译期:
编译各个 .cpp 文件产生出 .obj文件,如果使用到了 dll 中的符号(隐式载入时链接 dll),把它当作外部符号暂不处理。
链接期:
将各个 .obj 文件合并,并使用 .lib 来解析对导入的函数、变量的引用,.lib 中只是包含了 dll 中导出的符号,链接器只是想知道被引用的符号确实存在,以及符号来自于哪个 dll。
如果链接器能够解决对所有外部符号的引用,就能链接成功生成可执行模块。如果没有包含 .lib 但是引用了 dll 中的符号的话,将会出现 error Link2091: 无法解析的外部符号 xxxFunc,因为链接器无法知道这个外部符号是否存在。
对于引用了外部符号的代码,链接器将这段代码编译为跳转到一个地址表中,在链接期不知道导入函数的地址所以这个地址表是空的,当可执行模块载入的时候,这个地址表将被导入函数的地址填充起来。
链接器解决导入符号的时候,会在生成的可执行模块中嵌入一个特殊的段,称为 导入段(import section)。导入段中列出了需要使用的 dll 模块,以及从每个 dll 模块中引用的符号。
我们可以使用 dumpbin.exe (加上 -imports 选项)来查看一个模块的导入段:
...
TestDll.dll
    4020B4 Import Address Table
    40242C Import Name Table
        0 time date stamp
        0 Index of first forwarder reference
        1 _WriteBinaryFileWithBuffer@12
        0 _ReadBinaryFileToBuffer@12
...
如上,TestDll.dll 是该可执行模块所依赖的 dll 的名称,Import Address Table 是 导入内容地址表,在 TestDll.dll 被加载之后,dll 中导出函数的地址将被填充到这个表里,此时为空。Import Name Table 是导入内容名称表,其中记录了从 TestDll.dll 导入的函数名称。
注意:
    1. 可执行文件构建完成后,只是知道依赖于哪些 dll,哪些dll中存在着外部符号。它的 Import Address Table 是空的,可执行模块和 dll 被加载的时候, Import Address Table 将被填充起来。
    2. .lib 文件并没有什么神奇的,它只是包含了 dll 导出函数的名称,链接器使用它只是为了确认被引用的外部符号存在与哪个dll中,根据这一条信息,链接器就可以产生针对某个 dll 的导入段。
    3. 我们可以使用 pexports.exe 工具由 .dll 产生出 .def, 然后使用 VC 的 lib.exe 工具由 .def 产生出 .lib 文件。
    
    
5. 运行可执行模块
运行过程:
    1. 为进程创建虚拟地址空间;
    2. 把可执行模块映射到进程地址空间中;
    3. 检查可执行模块的导入段,根据规则搜索程序路径和系统路径,找到所需的 dll 并加载;
    4. 检查 dll 的导入段,如果这个 dll 还依赖别的 dll,那么继续去定位所需的 dll 并加载;
    5. 开始修复所有对导入符号的引用,此时会再次查看所有模块的导入段。对导入段中列出的每个符号,加载程序会检查对应 dll 的导出段,看符号是否存在,如果符号存在,就从 dll 的导出段中取出 RVA 并加上模块的虚拟地址,这样就得到了这个符号在进程地址空间中的地址。
    6. 得到符号的地址后,加载程序会把这个虚拟地址保存到可执行模块的导入段中,此时 Import Address Table 将被填充起来。
    7. 当代码引用到一个导入符号的时候,会查看 Import Address Table 得到导入符号的地址,这样就能访问被导入的 变量、函数、C++类了。
注意:
    1. 在第三步定位 dll 的时候,如果没有找到所需要的 dll,则会弹出错误提示:“无法启动,因为计算机中缺失 xxx.dll”
    2. 在第五步修复导入符号引用的时候,如果在 dll 的导出段中没有找到对应的导出符号,则会弹出错误提示:“程序入口点 xxxFunc 无法定位到动态链接库 xxx.dll 上”
    
    
6. 入口点函数
BOOL APIENTRY DllMain( HMODULE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
                     )
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:    // dll 被映射到地址空间时,会调用 DllMain 并传入 DLL_PROCESS_ATTACH
        break;
    case DLL_THREAD_ATTACH:     // 进程中有线程创建时,新创建的线程会调用 DllMain 并传入 DLL_THREAD_ATTACH, DllMain 可以根据这个消息执行与线程相关的初始化
        break;
    case DLL_THREAD_DETACH:     // 当线程函数返回时,会调用 ExitThread 函数,即将终止的线程会在 ExitThread 中调用 DllMain 并传入 DLL_THREAD_DETACH
        break;
    case DLL_PROCESS_DETACH:    // 当 dll 从进程地址空间中卸载时,发出卸载 dll 指令的线程会调用 DllMain 并传入 DLL_PROCESS_DETACH
        break;
    }
    return TRUE;
}
参数 hModule 是这个 dll 实例的句柄;
参数 lpReserved 表示 dll 是如何载入的,如果是隐式载入 lpReserved 不为零,如果是显式载入 lpReserved 将为零;
参数 ul_reason_for_call 是 DllMain 被调用的原因,可能是下列 4 个值之一: DLL_PROCESS_ATTACH,DLL_THREAD_ATTACH,DLL_THREAD_DETACH,DLL_PROCESS_DETACH
注意:
    1. DLL 使用 DllMain 函数来对自己初始化,当 DllMain 执行的时候,其它 DLL 的 DllMain 可能还没有被执行,此时如果我们在 DllMain 中调用其它 DLL 中的导出函数,就可能出现问题。Platform SDK 文档中说 DllMain 函数只应该执行简单的初始化,比如设置线程局部存储区,创建内核对象,打开文件等等。我们必须避免调用 User,Shell,ODBC,COM,RPC以及套接字函数,这是因为包含这些函数的 DLL 可能尚未初始化完毕。
    2. 如果要在 Dll 中创建全局或者静态 C++ 对象,会存在同样的问题,因为在 DllMain 被调用的同时,这些对象的构造函数和析构函数也会被调用。
    3. 当 DllMain 处理 DLL_PROCESS_ATTACH 的时候, DllMain 的返回值用来表示该 DLL 是否初始化成功,如果在这个时候 DllMain 返回了 FALSE ,则会弹出窗口,程序无法启动。
    4. 如果在 DLL_PROCESS_DETACH 中存在无限循环,有可能会导致进程无法终止,只有所有 DLL 的 DLL_PROCESS_DETACH 消息都被处理完,进程才会终止。除非使用 TerminateProcess 强行中止进程,这种情况下 DllMain 不会收到 DLL_PROCESS_DETACH 消息。
    5. 如果在 DLL_THREAD_DETACH 中存在无限循环,有可能会导致线程无法终止,除非使用 TerminateThread 强行终止线程。


7. 函数转发器
函数转发器(function forwarder)是 DLL 输出段中的一个条目,用来将一个函数调用转发到另一个 DLL 中的另一个函数。例如,用 dumpbin.exe(-exports) 工具查看 kernel32.dll 我们会看到类似下面的输出:
1486  5CD  WaitForThreadpoolIoCallbacks (forwarded to NTDLL.TpWaitForIoCompletion)
1487  5CE  WaitForThreadpoolTimerCallbacks (forwarded to NTDLL.TpWaitForTimer)
1488  5CF  WaitForThreadpoolWaitCallbacks (forwarded to NTDLL.TpWaitForWait)
1489  5D0  WaitForThreadpoolWorkCallbacks (forwarded to NTDLL.TpWaitForWork)
这个输出显示了4个被转发的函数。
如果使用隐式载入时链接 kernel32.dll ,当可执行文件运行的时候,加载程序会载入 kernel32.dll 并发现被转发的函数实际上是在 NTDLL.dll 中,然后它会将 NTDLL.dll 模块也一并载入。当可执行文件调用 WaitForThreadpoolIoCallbacks 的时候,实际上调用的是 NTDLL 的 TpWaitForIoCompletion 函数。
如果使用显式运行时链接 kernel32.dll ,如果在可执行文件运行的时候调用 WaitForThreadpoolIoCallbacks ,那么 GetProcAddress 会先在 kernel32.dll 的导出段中查找,并发现 WaitForThreadpoolIoCallbacks 是一个转发器函数,于是它会递归调用 GetProcAddress ,在 NTDLL.dll 的导出段中查找 TpWaitForIoCompletion
使用 pragma 指示符,我们可以在自己的 dll 模块中使用函数转发器。如下所示:
#pragma comment(linker, "/export:SomeFunc=DllWork.SomeOtherFunc")
这个 pragma 告诉链接器,正在编译的 DLL 应该输出一个名为 SomeFunc 的函数,但实际实现 SomeFunc 的是另一个名为 SomeOtherFunc 的函数,该函数被包含在另一个名为 DllWork.dll 的模块中。我们必须为每一个想要转发的函数单独创建一行 pragma。
注意:
pragma 语句要放在 函数定义的后面:
DLLTEST_LIB int DllTestFunc()
{
    return 10;
}
#pragma comment(linker, "/export:DllTestFunc=FileSystem.fnFileSystem")
利用这种技术,可以通过伪造 dll 的方式对目标 dll 中的函数进行拦截。使用伪造 dll 转发目标 dll 中的大部分函数,而想要拦截的函数则不转发。


8. DLL 重定向
DLL 重定向指的是我们可以通过某种手段强制系统在加载 DLL 的时候首先从应用程序的目录中载入模块。
这需要介绍 DLL 加载时候的搜索顺序。
// TODO 介绍加载 DLL 的搜索顺序,貌似这个和系统有关系。
DLL 重定向是在 Windows2000 之后添加的一项特性,在这之前,出于节约内存和磁盘的原因,dll 尽量被放在 系统目录中,当多个程序使用同一个 dll 的时候,就可能出现 DLL Hell 的问题。
因此微软提供了 DLL 重定向技术,强制先从应用程序目录中载入 dll 模块,也就是不同程序使用各自的 dll 互不影响。
// TODO 介绍如何使用 DLL 重定向,并确定 DLL 重定向在不同的系统中是否默认打开。


9. 模块的基地址重定位:
每个可执行文件和 DLL 模块都有一个首选基地址(preferred base address),它表示在将模块映射到进程的地址空间中时的首选位置。当我们在构建一个可执行文件的时候,链接器会将模块的首选基地址设为 0x00400000。对 DLL 模块来说,链接器会将首选基地址设为 0x10000000 。
我们可以用 dumpbin.exe(/headers) 来查看模块的首选基地址:
OPTIONAL HEADER VALUES
         10B magic # (PE32)
        9.00 linker version
        1200 size of code
         C00 size of initialized data
           0 size of uninitialized data
        1630 entry point (00401630)
        1000 base of code
        3000 base of data
      400000 image base (00400000 to 00405FFF)    <-- 首选基地址是 0x00400000
编译器和链接器会依据首选基地址来产生机器码:
在可执行文件中的一段机器码:(首选基地址为 0x00400000)
MOV    [0x00414540],5
在 DLL 中的一段机器码:(首选基地址为 0x10000000)
MOV    [0X10014540],5
如果可执行文件只依赖于一个 dll,那么不会有啥问题,dll 被加载时会正确载入到它的首选基地址 0x10000000 上;
如果可执行文件依赖于多个 dll,问题来了,默认情况下每个 dll 的首选基地址都是 0x10000000,第一个 dll 被正确载入到了 0x10000000 上,第二个 dll 就不可能载入到 0x10000000 上了。这种情况下,加载程序会对第二个 dll 进行基地址重定位,把它放到别的地方。
在 dll 加载时对其进行基地址重定位是个非常痛苦的过程。假如 dll 被重定位到了 0x20000000 处,那么这个 dll 中所有的机器码都应该改成 0x2xxxxxxx,这样才能正确运行这些机器码。
基地址重定位会损害应用程序的初始化时间。因此如果要将多个模块载入到同一个进程地址空间中,我们必须给每个模块指定不同的首选基地址。
指定首选基地址的方法:
1. 可以在 VS 的配置项中修改首选基地址:配置属性 --> 链接器 --> 高级 --> 基址
2. 在所有的 dll 编译完成者后,使用 Rebase.exe 工具,可以对需要载入到进程地址空间的所有模块进行基地址重定位,使每个 dll 都使用不同的基地址,彼此互不干扰。
Rebase.exe 运行的时候会模拟所有模块被加载时进行的基地址重定位操作,重定位后各个模块之间彼此互不干扰,然后将模拟的结果写入到各个模块的磁盘文件中。(0x1xxxxxxx 被修改为 0x2xxxxxxx)


10. 模块的绑定
回想一下隐式载入时链接的原理:
可执行模块的导入段中有一个 Import Address Table , 载入之前这个表是空的,可执行模块载入的时候,载入程序会加载需要的 dll ,获取 dll 的基地址,获取导出符号的 RVA ,基地址加上 RVA 就是导出符号的真实地址,每个导出符号的真实地址都会被加载程序填充到 Import Address Table 表中;
可执行模块运行期间如果调用了某个dll的导出函数,那么会跳转到 Import Address Table 来得到这个导出函数的地址,然后进行调用。
Import Address Table 中填充的是 dll 载入到地址空间的基地址+RVA。RVA在 dll 的导出段中已经有了,而通过基地址重定位技术可以确定 dll 载入的基地址是多少。也就是说如果一个 dll 已经进行过重定位,就可以直接推算出它的 Import Address Table 应该填充哪些内容。如果一开始就把 Import Address Table 填充在 dll 文件里的话,载入程序就不需要进行填充工作了,这可以加快应用程序的初始化速度。
进行这种填充类似于将所需的 dll 与可执行文件绑定在一起,可执行文件的 Import Address Table 与 dll 的基地址和导出符号 RVA 一一对应,所以这种技术被称为模块绑定技术。
VS 提供的 Bind.exe 提供了绑定可执行文件与dll的功能。
Bind.exe 的工作原理正如上面所写的那样,读取所有 dll 的基地址和RVA,将其填充到可执行文件的 Import Address Table 中。
注意:
    1. 如果要使用 Bind.exe 必须保证 dll 已经进行过重定位,dll 会被加载到首选基地址上。
    2. 何时使用 Bind.exe 进行模块绑定呢?因为不同的 Windows 版本系统 dll 可能会不同,所以针对不同版本的 Windows需要分别进行绑定,我们可以在应用程序的安装过程中来进行绑定。
    

11. DLL 注入
指将一个 DLL 注入到另外一个进程的地址空间中,从而跨越进程地址边界来访问另外一个进程的地址空间。
    1. 使用注册表来注入 DLL:
    HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows\ 在这个注册表项中可以找到两个注册表值: AppInit_Dlls,LoadAppInit_Dlls, 将 AppInit_Dlls 的值设定为我们要注入的 dll 路径,将 LoadAppInit_Dlls 的值设定为 1。当 User32.dll 被映射到一个新的进程的时候,会收到 DLL_PROCESS_ATTACH 通知,当 User32.dll 对这个通知进行处理的时候,会取得 AppInit_Dlls 中的值,并调用 LoadLibrary 来载入指定的 dll。
    这种方法利用 User32.dll 被加载时检索注册表的特性来实现 DLL 注入,这种方法有局限性,所有基于 GUI 的程序都会被注入,无法注入到指定的程序中。不过可以借鉴这种思路,如果我们知道目标程序会在加载的时候从注册表中加载 dll,就可以把自己的 dll 也添加到注册表里面。
    
    2. 使用 Windows 挂钩来注入 DLL:
    我们可以为另外一个进程安装挂钩,监听另外一个进程的消息,当另外一个进程的窗口即将处理一条消息的时候,将会引起挂钩函数的调用。安装挂钩使用 SetWindowsHookEx 函数:
    HHOOK SetWindowsHookEx(
        int idHook,         // 要安装的挂钩类型,比如 WH_KEYBOARD 用于监听键盘事件,WH_GETMESSAGE 用于监听消息被 Post 进窗口消息队列事件
        HOOKPROC lpfn,      // 函数地址,监听的事件发生时,系统会调用这个函数,如果我们要把挂钩安装到另外一个进程中,这个函数必须被放到一个 dll 中;如果只是安装到本进程,则不需要放到 dll 中。
        HINSTANCE hMod,     // 标识一个 dll, 这个 dll 中包含了 lpfn 函数。
        DWORD dwThreadId    // 要给哪个线程安装挂钩,如果指定为 0 的话,系统会为系统中所有的线程安装挂钩。
        )
    进程 A 调用 SetWindowsHookEx(WH_GETMESSAGE, GetMsgProc, hInstDll, 0) 将会发生的事情:
        0. 进程 A 在调用 SetWindowsHookEx 之前,已经把 hInstDll 映射到了自己的进程地址空间中, GetMsgProc 是函数在进程 A 的地址空间中的地址;
        1. 某进程 B 中的一个线程准备向窗口 Post 一条消息;
        2. 系统检查该线程是否安装了 WH_GETMESSAGE 挂钩;
        3. 系统检查 GetMsgProc 所在的 DLL 是否被映射到进程 B 的地址空间中;
        4. 如果 DLL 没有被映射, 系统将会强制将该 DLL 映射到进程 B 的地址空间中;
        5. 系统必须确定 GetMsgProc 在进程 B 的地址空间中的地址,可以用以下公式计算得来: GetMsgProc B = hInstDll B + (GetMsgProc A - hInstDll A)
        7. 系统在进程 B 的地址空间中调用 GetMsgProc 函数;
    通过安装挂钩的方式, hInstDll 这个 DLL 被注入到了进程 B 中,由于整个 dll 都被加载到了进程 B 的地址空间中, dll 中的所有函数都可以被进程 B 中的任何线程调用。
    问题:
        1. hInstDll 被注入到了所有进程中吗?
        2. GetMsgProc 是在进程 B 中被调用的,进程 A 可以通过这种方法直接获取到进程 B 的信息吗?
        
    3. 使用 远程线程 来注入 DLL
    利用远程线程,我们可以在目标进程中创建一个自己的线程,这个线程是自己创建的,但在目标进程的地址空间中执行,我们可以设置自己的线程函数,只要在线程函数里调用 LoadLibrary ,就可以将 DLL 注入到目标进程中。
    Windows 提供了 CreateRemoteThread 函数:
    HANDLE CreateRemoteThread(
        HANDLE hProcess,                     // 目标进程句柄
        LPSECURITY_ATTRIBUTES psa,           // 安全属性
        SIZE_T dwStackSize,                  // 线程栈大小
        LPTHREAD_START_ROUTINE lpStartAddr,  // 线程函数地址,这个地址应该在目标进程的地址空间中,因为线程函数的代码不能在我们自己进程的地址空间中执行。
        LPVOID lpParameter,                  // 传递给线程函数的参数
        DWORD dwCreationFlags                // 控制创建出来的线程,比如: CREATE_SUSPENDED
        LPDWORD lpThreadId                   // 线程 Id
    )
    我们可以把 LoadLibrary 作为线程函数传给 CreateRemoteThread, 让 LoadLibrary 载入指定的 dll ,来实现注入 DLL 的目的:
    HANDLE hThread = CreateRemoteThread(hProcessRemote, NULL, 0, LoadLibraryW, L"C:\\MyLib.dll", 0, NULL)
    上面的代码有几个问题:
        1. LoadLibraryW 这个函数是 kernel32.dll 的导出函数,我们的进程载入 kernel32.dll 的时候,会把 LoadLibraryW 的实际地址放入到进程的导入段中,我们给 CreateRemoteThread 传入的 LoadLibraryW ,实际上是 LoadLibraryW 的导入段地址,并不是 LoadLibraryW 的实际地址。而 lpStartAddr 要求这个地址是在目标进程的地址空间中,如果把导入段地址传过去的话,远程线程并不能执行到 LoadLibraryW。所以我们应该设法获取到 LoadLibraryW 在目标进程中的地址,然后再传给 CreateRemoteThread 。
        解决的办法使用 GetProcAddress 获取 LoadLibraryW 在 kernel32.dll 中的地址,因为进程每次加载 dll 的时候系统都会把 kernel32.dll 映射到相同的地址上面,所以不同进程间, LoadLibraryW 的地址都等于 LoadLibraryW 在 kernel32.dll 中的地址。
        PTHREAD_START_ROUTINE pfnThreadRtn = (PTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle(TEXT("kernel32")), "LoadLibraryW")
        HANDLE hThread = CreateRemoteThread(hProcessRemote, NULL, 0, pfnThreadRtn, L"C:\\MyLib.dll", 0, NULL)
        2. CreateRemoteThread 的参数 "C:\\MyLib.dll" 这个字符串位于当前进程而不是目标进程的地址空间中。目标进程访问它的时候就会引起访问违规,程序崩溃。所以我们需要设法在目标进程中分配一块内存,存储这个字符串。
        解决的办法是使用 VirtualAllocEx 函数在目标进程中分配内存,使用 ReadProcessMemory 和 WriteProcessMemory 来读写目标进程的内存,把 "C:\\MyLib.dll" 这个字符串写入其中。
        LPVOID lpRemoteMemory = VirtualAllocEx(hProcessRemote, 0, sizeof(L"C:\\MyLib.dll"), MEM_COMMIT, PAGE_READWRITE)
        BOOL bRet = WriteProcessMemory(hProcessRemote, lpRemoteMemory, L"C:\\MyLib.dll", sizeof(L"C:\\MyLib.dll"), 0)
        HANDLE hThread = CreateRemoteThread(hProcessRemote, NULL, 0, pfnThreadRtn, lpRemoteMemory, 0, NULL)
        在远程线程执行结束后,我们需要调用 VirtualFreeEx 来释放之前在目标进城中分配的内存。
        3. 在远程线程执行结束后,注入的 dll 仍然存在与目标进程中,我们需要再次使用 CreateRemoteThread ,在远程函数中执行 FreeLibrary ,将之前注入的 dll 从目标进程中卸载。
    参考文章:http://blog.csdn.net/g710710/article/details/7303081
    
    4. 使用木马 DLL 来注入 DLL
    这种方法是说,把我们知道的进程必然会载入的一个 DLL 给替换掉。我们的 dll 需要导出原有 dll 中的所有导出符号,这可以使用之前讲过的函数转发器来实现。
    如果只想把这个方法应用在某个应用程序中,则可以给我们的 dll 起一个独一无二的名称,并修改应用程序 .exe 模块的导入段。这要求我们要非常熟悉 .exe 和 .dll 的文件格式。
    
    
12. API 拦截
DLL 注入可以让我们访问另外一个进程的地址空间,获取其它进程内部的各种信息。但是,我们无法知道其它进程中的线程具体是怎么调用各种函数的,API 拦截指的是拦截 Windows 系统函数,并修改这些函数的行为。
在对另一个进程进行 API 拦截前,我们必须先进行 DLL 注入,这样另外的进程才能够执行我们的拦截代码。
通过修改模块的导入段来拦截 API:
之前关于dll的内容中说过, 可执行模块的导入段中包含了一个符号表,其中列出了该模块从各个 dll 中导入的符号,当可执行模块调用一个导入函数的时候,会先从导入段的符号表中获取到导入函数的地址,然后再跳转到那个地址。
因此,为了拦截一个特定的函数,我们所需要做的就是修改这个函数在模块的导入段中的地址。要达到这个目的,需要如下步骤:
    1. 获取可执行模块的导入段;
    2. 遍历导入段中导入了哪些 dll, 通过比对找到 目标API 所属 dll 在导入段中的信息;
    3. 遍历从这个 dll 中导入的函数,通过比对找到 目标API 在导入段中的位置。在这之前我们需要先获取 目标API 的实际地址,然后跟导入段中记录的导入函数地址相比对,才能确认这个导入函数是不是我们要找的函数;
    4. 修改这个导入段,将其所指向的地址改成 拦截API 的地址;
要实现上述步骤,需要以下的函数和数据结构:
    1. PIMAGE_IMPORT_DESCRIPTOR pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR) ImageDirectoryEntryToData(hExeMod, TRUE, IMAGE_DIRECTORY_ENTRY_IMPORT, &ulSize);  // IMAGE_DIRECTORY_ENTRY_IMPORT 标记会让这个函数返回导入段的地址;返回的结果是若干个  PIMAGE_IMPORT_DESCRIPTOR 结构体构成的数组;
    2. PIMAGE_IMPORT_DESCRIPTOR: 这个结构体描述了某一个 dll 的导入段信息, Name 字段标识这个 dll 的名字, FirstThunk 是一个 PIMAGE_THUNK_DATA 结构体,代表了从这个 dll 中导入的第一个函数的信息;
    3. PIMAGE_THUNK_DATA: PIMAGE_THUNK_DATA.u1.Function 就是这个导入函数的实际地址了;将这个地址与 GetProcAddress 获取的地址相比对,就能确定这个地址是不是我们要找的那个函数的地址;
    4. WriteProcessMemory 可以将这个地址替换为我们的 拦截函数 的地址;
通过上述函数和数据结构,我们可以构造如下的代码:
    PROC pfnOri = GetProcAddress(GetModuleHandle("kernel32"), "ExitProcess");    // 获取 ExitProcess 函数在进程中的地址;
    HMODULE hExeMod = GetModuleHandle("Database.exe");                           // 获取可执行模块的句柄;
    ULONG ulSize;                                                                // 获取可执行模块的导入段
    PIMAGE_IMPORT_DESCRIPTOR pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)ImageDirectoryEntryToData(hExeMod, TRUE, IMAGE_DIRECTORY_ENTRY_IMPORT, &ulSize);
    for(;pImportDesc->Name;pImportDesc++){                                       // 遍历导入段,找 dll 模块
        PSTR pszModName = (PSTR)((PBYTE)hExeMod + pImportDesc->Name);            // 获取 dll 模块的模块名
        if(lstrcmpiA("kernel32.dll", pszModName) == 0 ){                         // 找到模块名为 kernel32.dll 的模块
            PIMAGE_THUNK_DATA pTrunk = (PIMAGE_THUNK_DATA)((PBYTE)hExeMod + pImportDesc->FirstThunk);
            for(;pTrunk->u1.Function;pTrunk++) {                                 // 遍历从 kernel32.dll 中导入的函数
                PROC* ppfn = (PROC*)&pTrunk->u1.Function;
                if(ppfn == pfnOri){                                              // 比对 pfnOri 和 ppfn,如果一样,说明 pTrunk 中记录了 ExitProcess 在导入段中的信息
                    WriteProcessMemory(GetCurrentProcess(), ppfn, &pfnNew, sizeof(pfnNew), NULL)    // 使用新的地址 pfnNew 替换 ppfn
                    return;
                }
            }
        }
    }
    上面的代码拦截了 Database.exe 模块对 ExitProcess 函数的调用,Database.exe 对 ExitProcess 的调用,将被我们自己的函数 pfnNew 所替代;
    注意:上面的代码运行在目标进程的地址空间中,在进行 API 注入前,必须把包含这段代码的 dll 注入到目标进程中;
    上面的代码没有考虑安全性,原始代码参考《Windows核心编程》的相关章节;

 

posted on 2014-09-16 16:33  zuibunan  阅读(4983)  评论(0编辑  收藏  举报

导航