zoukankan      html  css  js  c++  java
  • 回炉重造之重读Windows核心编程-022-第22章 插入DLL和挂接API

    第22章 插入DLL和挂接API

    22.0 简介

    由于Windows的进程地址空间是相对独立的,你对一个进程的内存地址(比如说是0x0012ABDC)的修改不会影响到另一个进程的地址空间的地址0x0012ABDC。而Win98是全部进程共享2GB的地址空间,情况就又不一样了。

    独立的机制也不是没有缺点的,毕竟进程之间有互相通信的需要,这种情况还不少:

    1. 当想要为另一个进程创建的窗口建立子类的时候。
    2. 当你需要调试的时候。
    3. 当你需要挂接其他线程的时候。

    下面提到的手段,可以把DLL插入到另一个进程的地址空间。由于在这个时候已经暂时地破坏了两个进程之间的一部分独立性,对于另一个进程来说这是非常严肃的情形,需要严阵以待。

    22.1 插入DLL的第一个例子:SetWindowLong

    如果要为另一个进程创建的窗口建立一个子类,那么就能改变窗口的行为特性。而这样的操作使用函数SetWindowLongPtr就可以完成,它可以替换内存块中的窗口过程地址,让它指向一个新的WinProc。当你使用下面的代码的时候,送往窗口hwnd的消息就改送去MySubclassProc了。

    SetWindowsLongPtr(hwnd, GWLP_WNDPROC, MySubclassProc);
    

    然而这会遇到一个问题,建立子类的过程地址不在目标的进程的地址空间中。为了避免这个问题的产生,应该让系统知道MySubclassProc函数的地址在目标进程的地址空间中。然后在调用旧的Windproc之前,让系统执行一次上下文转换。不过Microsoft并没有提供这个功能,原因有:

    1. 应用程序很少为其他的进程的线程创建的窗口建立子类,而只是为了自己的线程这样做,Windows的内存结构并不阻止这种情形。
    2. 切换活动进程需要占用许多CPU时间。
    3. 新的进程需要执行MySubclassProc中的代码。系统究竟应该使用新线程还是旧线程呢?

    显然这个问题并没有万全之策,所以Microsoft决定不让SetWindowLongPtr改变另一个进程创建的窗口过程。

    对于这种涉及到进程的地址空间边界的问题,新的窗口过程的代码必须被放入目标进程的地址空间,这样就可以调用SetWindowLongPtr函数改变目标进程的窗口地址了。

    22.2 使用注册表来插入DLL

    Windows系统使用注册表作为自己的配置文件,该表注册表就可以改变Windows的行为特性。对于下面的注册表的关键字中,包含了一个DLL文件名或者一组DLL文件名(用逗号或者空格隔开,所以DLL的名字也不应该是带有空格的)。这些DLL中的第一个DLL是可以带路径的,但是其他带路径的DLL就被忽略。因此最好DLL的名字不要带有空格。由于这个原因最好将DLL放入Windows系统的目录中,这样就不必设定了路径,例如(C:MyLib.dll)。

    HKEY_LOCAL_MACHINESoftWareMicrosoftWindows NTCurrentVersionWindowsAppInst_Dlls
    

    在重新启动Windows并初始化之后,系统就保存这个关键字的值。在User32.dll被映射到内存中后,它将收到一个DLL_PROCESS_ATTACH的通知。一旦这个通知被处理,User32.dll就会检测上面的注册表关键字的值,对字符串中指定的每个DLL调用LoadLibrary函数,每个DLL的DllMain就被触发,其中参数fdwReason的值是DLL_PROCESS_ATTACH。这样所有库就都能被初始化了。

    由于这些DLL的加载时期非常的早,所以调用其它DLL的函数时就应该格外的小心:调用Kernel32的库的内容估计被问题,但是其他的DLL的函数就不一定了。上面的LoadLibrary的操作不会对每个DLL是否成功做检查。

    这种方式比较方便,不过也有相对的不足:

    1. 你需要重启系统。
    2. 你的DLL只会映射到使用User32.dll的进程中。所有基于GUI的应用程序都使用User32.dll,不过系统中也存在很多CUI程序。这种方式对CUI程序是不起作用的。
    3. 你的DLL会映射到所有GUI线程中。一旦你的DLL中出了什么差错(比如进入死循环),影响的范围可就大了。DLL应该插入尽量少的进程中。
    4. 还有DLL中导出的东西(比如函数)的作用是临时性的,用完就必须卸载掉。但是这样用这种方式插入DLL的话,即使引用DLL的函数的线程结束了,DLL也不会被卸载。最好让DLL在使用的时候才保持插入的状态。

    22.3 使用Windows挂钩来插入DLL

    挂钩也是将DLL插入进程的地址空间的一个办法,只要使用SetWindowHookEx。例如下面的代码:

    HHOOK hHook = SetWindowHookEx(WH_GETMESSAGE, GetMsgProc, hInstDll, 0);
    

    参数WH_GETMESSAGE指明的挂钩的类型,参数GetMsgProc指向一个函数地址,窗口用它处理一个消息时系统应该调用的地址(在目标地址空间中),参数hInstDll指明包含GetMsgProc的DLL,也是DLL被映射到的进程的地址空间中的虚拟内存的地址,最后一个参数0指明要挂接的线程。如果最后一个参数是0,那么系统会挂接所有的GUI线程。其中的过程是:

    1. 一个线程准备将一条消息发送到一个窗口。
    2. 系统查看现场上是否已经安装了WH_GETMESSAGE挂钩。
    3. 系统查看包含GetMsgProc函数的DLL是否被映射到目标进程的地址空间中。
    4. 如果DLL未被映射,系统将强制DLL映射到进程B的地址空间,并将进程B中的DLL的映像的引用计数递增1。
    5. 当DLL的hinstDll用于进程B时,系统查看该函数,并检查改DLL的hinstDll是否与进程A所处的位置相同。
      1. 如果相同,那么系统DLL的地址相同,那么GetMsgProc函数的位置也是相同的,系统只需要调用它即可。
      2. 如果不同系统必须确定原进程的地址空间中GetMsgProc来确定。这个地址可以用一个公式:GetMsgProcB = instDllB + GetMsgProcA - hinstA来求得。
    6. 系统将目标进程中的DLL的映像的引用计数递增1。
    7. 系统调用目标进程的地址空间中的GetMsgProc函数。
    8. 在GetMsgProc函数返回的时候,系统将进程B的DLL映像的引用计数递减1。

    当DLL被挂接的时候,是整个DLL都被挂接进去的,不是只有GetMsgProc函数这部分。就相当于在目标进程中对DLL调用了LoadLibrary,效果基本相同。

    若要为另一个进程的线程创建的窗口创建子类,首先可以在创建该窗口的挂钩上设置一个WH_GETMESSAGE挂钩,然后当GetMsgProc函数被调用的时候,就可以使用SetWindowLongPtr函数来创建新的窗口子类。当然,子类的过程必须与GetMsgProc函数位于同一个DLL中。

    当不再需要DLL的时候,下面的函数可以将DLL删除,方法是调用下面的函数:

    BOOL UnHookWindowsHookEx(HHOOK hhook);
    

    当一个线程调用这个函数的时候,系统会遍历它插入过这个DLL的所有进程的列表,并对DLL的引用计数递减1。根据引用计数的性质,这个引用计数为0时,DLL就会从这个进程的地址空间中删掉。这意味着挂钩不会立即取消,一定的寿命期内都会保持有效。

    22.4 使用远程线程来插入DLL

    插入DLL的第三种方法是使用远程线程,可以灵活。不过这个方法也用上了好些Windows特性,如进程、线程、线程同步、虚拟内存管理、DLL和Unicode等。由于Windows的进程间是内存隔离的,不能随便对其他线程做任何事,不过这也是有例外的,为了调试程序等等。不过任何函数都可以调用这些函数。

    22.4.1 Inject Library 示例应用程序

    22.4.2 Image Walk DLL

    22.5 使用特洛伊木马来插入DLL

    假如有程序需要加载一个DLL,那么就可以用一个同样名字的DLL去替代,就可以进行hook了。只是在新的DLL中就需要有和旧的DLL一模一样 的导出函数,这个用函数转发器很容易就能做到。

    虽然这是种比较容易的方式,但是却不是很推荐使用。因为不具备版本升级的能力。一旦应用程序更换了DLL中的项目,在DLL中增加了新的函数,那么插入的DLL就会遇到问题而无法加载。

    22.6 将DLL作为调试程序来插入

    如果进程被调试了,那么调试程序可以有一些特殊的操作,比如强制修改被调试的程序的源代码,操作这个程序的线程CONTEXT(这意味着为了兼容性要对平台有要求),改变程序的运行流程。不过相应的是一旦调试程序停止运行,被调试的进程也会停止,无法控制。

    22.7 用Windows 98上的内存映射文件插入代码

    略。

    22.8 用CreateProcess插入代码

    每个进程都有一个运行的起点,这在可执行文件中有相对固定的位置。这就是说一个进程A可以启动另一个进程B,通过改写进程A的出发点就,就可以启动进程B。而且这个操作还可以嵌套执行。具体的操作过程是:

    1. 让进程A生成暂停运行的进程B.
    2. 在进程B的EXE模块的头文件中检索出主线程的起始内存地址。
    3. 将机器指令保存在搞内存地址中。
    4. 将某些硬编码的机器指令强制放入改地址中。这些指令应该调用LoadLibrary函数来加载DLL。
    5. 继续运行子进程的主线程,使得该代码得以执行。
    6. 将原始指令重新放入起始地址。
    7. 让进程继续从起始地址开始执行,就像没有发生任何事情一样。

    这样的方式有几个优点。首先它在应用程序执行之前就能得到地址空间。第二就是由于你不是调试者,因此能够轻易地就将DLL插入来调试应用程序。最后是这种方式可以同时应用于GUI程序和CUI程序。

    当然也有不足的地方,就是你的进程必须是目标进程的父进程,才能实现对DLL的插入。另外就是这种方式需要硬编码,这就是说对与平台是有限制的。

    22.9 挂接API的一个实例

    22.9.1 通过改写代码来挂接API

    API挂接并不是一个很新鲜的技术,有的时候就是可以通过挂接某些API来解决相应的问题。例如挂接ExitProcess:

    1.	找到ExitProcess在内存中的地址。
    2.	将该函数的头几个字节保存在你自己的内存中。
    3.	使用一个JUMP CPU指令改写该函数的头几个字节。这个指令会转移到你提供的替换函数的地址执行,当然你替换的函数必须和函数的参数、返回值和调用规则完全相同。
    4.	现在当一个线程调用已经被挂接的函数的时候,JUMP指令会带领CPU到替换函数的地址处执行。此时已经可以通过这个替换函数来做任何事。
    5.	如果需要取消挂接的状态,则是还原第2步中保存的函数头几个字节。
    6.	取消状态以后再调用这个函数就仍然还是原来的功能,并无异样。
    

    这是个非常具有操作性的手段。只是对CPU的依赖性很大,还是尽量避免使用。另外这个方式在抢占式的多线程环境下根本不起作用,在一个线程执行第二步的时候有可能已经被切换走了。所以使用这两种方式的时候必须考虑到存在的影响。

    22.9.2 通过操作模块的输入节来挂接API

    原理如果了解PE文件结构就不难理解。对于ExitProcess这样的函数来说,自然是从Kernel32.dll中导出的,在PE文件的一个节中必然有ExitProcess在进程的地址空间中的映射地址。因此只要找到这个地址,就能替换它了,于是API的挂接也就实现了。

    这个方法的好处是挂接的操作在加载应用程序前就完成了,没有同步的问题,也无需考虑平台的变化。 下面是一个实例的代码:

    void ReplaceIATEntryInOneMod(PCSTR pszCalleeModName, 
                                 PROC pfnCurrent, PROC pfnNew, HMODULE hmodCaller) {
      ULONG ulSize;
      PIMAGE_IMPORT_DESCRIPTOR pImportDesc =
        (PIMAGE_IMPORT_DESCRIPTOR)ImageDirectoryEntryToData(hmodCaller, TRUE,
    		IMAGE_DIRECTORY_ENTRY_IMPORT, &ulSize);
      if(pImportDesc == NULL) 
        return;  // This module has no import section
      // Find the import descriptor containing references
      // to callee's functions.
      for (; pImportDesc->Name;pImportDesc++) {
        PSTR pszModName = (PSTR) 
          ((PBYTE) hmodCaller + pImportDesc->Name);
        if (lstrcmpiA(pszModName, pszCalleeModName) == 0)
          break;
      }
      if(pImportDesc == 0) 
        return;  // This module doessnt import any functions from this callee.
      // Get caller's import address table (IAT)
      // for the callee's functions
      PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)
        ((PBYTE) hmodCaller + pImortDesc->FirstThunk);
      // Replace the current function address with new function address.
      for (; pThunk->ul.Function;pThunk++) {
        // Get the address of the function address.
        PROC* ppfun = (PROC*) &pThunk->ull.Function;
        // Is this is the function we're looking for?
        BOOL fFound = (*ppfn == pfnCurrent);
        if (fFound) {
          // The address match; change the import section address.
          WriteProcessMemory(GetCurrentProcess(), ppfn, &pfnNew, sizeof(pfnNew), NULL);
          return;  // it works; get out.
        }
      }
      // If we get to here, the function 
      // is not in the caller's import section.
    }
    

    对于如何使用这个函数,可以看一个例子。例如有一个模块叫DataBase.exe,其中的代码调用了Kernel32.dll中的ExitProcess函数,但是我们想要调用自己的DBExtend.dll模块中的MyExitProcess函数。于是就可以调用这个函数ReplaceIATEntryInOneMod:

    PROC pfnOrig = GetProcAddress(GetModuleHandle("Kernel32"), "ExitProcess");
    HHMODULE hmodCaller = GetModuleHandle("DataBase.exe");
    ReplaceIATEntryInOneMod(
    	"Kernel32.dll", // Module containing the function (ANSI)
      pfnOrig, 			  // Address of function in callee 
      MyExitProcess,  // Address of new function to be called
      hmodCaller		  // Handle of module that should call new function
    );
    

    ReplaceIATEntryInOneMod 函数有几个比较特别的地方:

    1. 给ImageDirectoryEntryToData这个函数传递参数 IMAGE_DIRECTORY_ENTRY_IMPORT就能获得输入节,当然这个函数的用法不仅仅可以用来获取输入节。
    2. 当扫描DLL中的模块中输入节的名字的时候一定要注意这里用的是ANSI字符串,这也是使用lstrcmpiA函数比较字符串的原因。
      1. 如果没有找到对“Kernel32.dll”中任何符号的引用,那么函数就不用做任何事。
      2. 如果模块的输入节确实引用了“Kernel32.dll”的符号,那么将得到一个IMAGE_THUNK_DATA结构的数组的地址,然后就可以在里面搜索与目标地址匹配的地址。
        1. 如果没有找到,表示这个DLL没有匹配的函数地址,直接返回。
        2. 如果找到了,那么就调用WriteProcessMemory函数,替换函数的地址。
          1. 注意必须只用WriteProcessMemory而不是InterlockedExchangePointer。因为WriteProcessMemory可以在修改内存时忽略内存的保护属性,而对于InterlockedExchangePointer函数如果内存的属性是PAGE_READONLY的话,就会发生访问违规。

    在上述的操作都顺利完成之后,在模块DataBase.exe中对于ExitProcess的调用已经转到另一个函数中。但是也仅止于这个模块而已,如果其他模块对于ExitProcess的调用也需要转到另一个函数,那么就要对每一个函数执行一次ReplaceIATEntryInOneMod函数了,可以编写另一个叫做ReplaceIATEntryInAllMods的函数实现这个功能。

    即使所有现有的进程对于ExitProcess的调用都转移到了新的调用,还是有问题。如果这个时候又有新的进程启动了,也使用了ExitProcess函数,那么新的进程对ExitProcess的调用可就不会转到新的调用了,而是用旧的。为了解决这个问题,还需要对LoadLibrary(Ex)(A/W)这几个函数调用ReplaceIATEntryInOneMod。

    最后一个问题,就是用户有可能让系统去获取Kernel32.dll中的ExitProcess函数的实际地址,然后调用。要是这样的话你的替代函数将不会被调用。为了解决这个问题,你也必须挂接GetProcAddress函数,而阻止这个操作。下一节会演示如何进行API的挂接。

    22.9.3 LastMsgBoxInfo 示例应用程序

    代码清单中22-4给出的LastMsgBoxInfo应用程序(“22 LastMsgBoxInfo”)展示了API挂接的方法。它挂接了对User32.dll包含的所有MessageBox函数的调用。

    运行程序LastMsgBoxInfo之后就会弹出一个对话框,里面是一个只读的编辑控件。程序一开始就进入等待状态,等待其他的进程调用MessageBox函数。比如打开一个Notepad程序,然后在上面输入一些东西,但是不保存。这样就可以测试,当点右上角的X关闭程序的时候,就会出现一个弹框,这个弹框就是MessageBox函数被调用的结果,应用程序LastMsgBoxInfo就会增加一个记录。

    这个程序最核心的地方是一个工具类CAPIHook,定义在APIHook.h文件,类的实现在APIHook.cpp文件中。这个类只有三个共有的成员函数:构造函数和析构函数、返回原始函数地址的函数。若要挂接一个函数,只需要像下面这样创建这个类的一个实例:

    CAPIHook g_MessageBoxA("User32.dll", "MessageBoxA", 
                           (PROC) Hook_MessageBoxA, TRUE);
    CAPIHook g_MessageBoxW("User32.dll", "MessageBoxW", 
                           (PROC) Hook_MessageBoxW, TRUE);
    

    构造函数只需知道你要挂接的API,并调用ReplaceIATEntryInAllMods函数进行实际的挂接操作。

    析构函数也是公有函数,里面用来调用ReplaceIATEntryInAllMods,将符号的地址还原成原来的地址。

    第三个公有函数返回原始函数的地址。这个成员函数通常从替换函数内部进行调用,以便调用原始函数。

    参看下面的代码:

    int WINAPI Hook_MessageBox(HWND hWnd, PCSTR pszText, 
        PCSTR pszCaption, UINT uType) {
      int Result = ((PFNMESSAGEBOXA)(PROC) g_MessageBoxA) (HWND hWnd, PCSTR pszText, 
        PCSTR pszCaption, UINT uType);
      SendLastMsgBoxInfo(FALSE, (PVOID) pszCaption, (PVOID) pszText, nResult);
      return(nResult);
    }
    

    上面的这个代码引用全局的g_MessageBoxA和CAPIHook对象。将这个对象转换成一个PROC数据类型将会导致成员返回User32.dll上的MessageBoxA的原始地址。

    CAPIHook这个类会自动建立一些实例,用来捕获LoadLibrary(Ex)(A/W)。这样CAPIHook这个类就能解决它自身遇到的一些问题。

  • 相关阅读:
    IDEA中快速排除maven依赖
    Maven构建war项目添加版本号
    运行shell脚本报/bin/bash^M: bad interpreter错误排查方法
    Shell杀tomcat进程
    根据URL下载文件
    关闭Centos的自动更新
    CentOS下建立本地YUM源并自动更新
    为Linux服务器伪装上Windows系统假象
    ServerInfo.INI解密
    请教给终端推销域名的邮件该怎么写?
  • 原文地址:https://www.cnblogs.com/leoTsou/p/13737496.html
Copyright © 2011-2022 走看看