65.9K
CodeProject 正在变化。 阅读更多。
Home

API Hooking揭秘

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (315投票s)

2002年4月6日

CPOL

38分钟阅读

viewsIcon

3003527

downloadIcon

64210

本文演示了如何构建一个用户模式Win32 API监视系统。

<!-- 下载链接 --><!-- 文章图片 -->

Sample Image - demo.jpg

引言

拦截 Win32 API 调用一直是大多数 Windows 开发者面临的挑战性课题,我必须承认,这也是我最喜欢的主题之一。“Hooking”(钩子)一词代表了一种获取对特定代码执行控制的基本技术。它提供了一种直接的机制,可以轻松地修改操作系统行为或第三方产品的行为,而无需拥有其源代码。

许多现代系统通过采用间谍技术来利用现有 Windows 应用程序的能力,从而吸引了人们的注意。钩子(Hooking)的关键动机不仅在于为高级功能做出贡献,还在于为调试目的注入用户提供的代码。

与 DOS 和 Windows 3.xx 等相对“旧”的操作系统不同,当前的 Windows 操作系统(如 NT/2K 和 9x)提供了复杂的机制来分隔每个进程的地址空间。这种架构提供了真正的内存保护,因此没有应用程序能够损坏另一个进程的地址空间,甚至在最坏的情况下也不会导致操作系统崩溃。这使得开发系统感知型钩子更加困难。

我写这篇文章的动机是需要一个真正简单的钩子框架,它将提供易于使用的界面和捕获不同 API 的能力。它旨在揭示一些可以帮助您编写自己的间谍系统的技巧。它提出了一个单一的解决方案,用于构建一套在 NT/2K 以及 98/Me(文章中简称为 9x)系列 Windows 上钩取 Win32 API 函数的方法。为了简单起见,我决定不添加对 UNICODE 的支持。但是,通过对代码进行一些小的修改,您可以轻松地完成这项任务。

监控应用程序提供了许多优势

  1. API 函数监控
    控制 API 函数调用的能力非常有帮助,使开发人员能够追踪 API 调用过程中发生的特定“看不见”的操作。它有助于对参数进行全面验证,并报告通常在后台被忽略的问题。例如,有时监控与内存相关的 API 函数来捕获资源泄漏可能会非常有帮助。
  2. 调试和逆向工程
    除了标准的调试方法外,API Hooking 作为一种流行的调试机制也享有盛誉。许多开发人员采用 API Hooking 技术来识别不同的组件实现及其关系。API 拦截是获取有关二进制可执行文件信息的一种非常强大的方式。
  3. 深入了解操作系统
    开发人员通常热衷于深入了解操作系统,并受到充当“调试器”角色的启发。Hooking 也是一种非常有用的技术,用于解码未记录或记录不充分的 API。
  4. 通过将自定义模块嵌入到外部 Windows 应用程序中来扩展原始提供的功能通过注入钩子来重新路由正常代码执行,可以轻松地更改和扩展现有模块的功能。例如,许多第三方产品有时无法满足特定的安全要求,需要根据您的具体需求进行调整。应用程序监控允许开发人员在原始 API 函数前后添加复杂的预处理和后处理。这种能力对于改变已编译代码的行为极其有用。

钩子系统的功能要求

在开始实现任何类型的 API Hooking 系统之前,需要做出几个重要决定。首先,您应该确定是挂钩单个应用程序还是安装一个系统感知型引擎。例如,如果您只想监控一个应用程序,则不需要安装系统范围的钩子;但如果您要追踪对TerminateProcess()WriteProcessMemory()的所有调用,唯一的方法是使用系统感知型钩子。您选择哪种方法取决于具体情况和要解决的具体问题。

API 间谍框架的通用设计

通常,Hook 系统至少由两部分组成——Hook Server 和 Driver。Hook Server 负责在适当的时候将 Driver 注入到目标进程中。它还管理驱动程序,并可以选择性地从驱动程序接收有关其活动的信息,而 Driver 模块则执行实际的拦截。
这种设计很粗糙,无疑不能涵盖所有可能的实现。然而,它勾勒了 Hook 框架的边界。

一旦您有了 Hook 框架的需求规范,就需要考虑一些设计要点

  • 您需要挂钩哪些应用程序
  • 如何将 DLL 注入到目标进程中,或者遵循哪种植入技术
  • 使用哪种拦截机制

我希望接下来的几节能解答这些问题。

注入技术

  1. 注册表
    为了将 DLL 注入到链接了 USER32.DLL 的进程中,您只需将 DLL 名称添加到以下注册表项的值中

    HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs

    其值包含一个 DLL 名称或一组 DLL,这些 DLL 用逗号或空格分隔。根据 MSDN 文档[7],由该键值指定的 DLL 将由当前登录会话中运行的每个 Windows 应用程序加载。有趣的是,这些 DLL 的实际加载发生在 USER32 初始化的一部分。USER32 读取上述注册表项的值,并在其DllMain代码中为这些 DLL 调用LoadLibrary()。然而,这种技巧仅适用于使用 USER32.DLL 的应用程序。另一个限制是,此内置机制仅由 NT 和 2K 操作系统支持。尽管这是一种向 Windows 进程注入 DLL 的无害方式,但存在一些缺点

    • 为了激活/停用注入过程,您必须重新启动 Windows。
    • 要注入的 DLL 将仅映射到使用 USER32.DLL 的进程中,因此您无法期望将您的钩子注入到控制台应用程序中,因为它们通常不导入 USER32.DLL 中的函数。
    • 另一方面,您对注入过程没有任何控制。这意味着它会被植入到每个 GUI 应用程序中,无论您是否需要。这是一种冗余开销,特别是当您只打算挂钩少数应用程序时。有关更多详细信息,请参阅[2]“使用注册表注入 DLL”。
  2. 系统范围的 Windows Hook
    注入 DLL 到目标进程中一个非常流行的技术无疑依赖于 Windows Hooks 提供的功能。正如 MSDN 指出的那样,Hook 是系统消息处理机制中的一个陷阱。应用程序可以安装自定义过滤器函数来监视系统中的消息流,并在消息到达目标窗口过程之前处理某些类型的消息。

    Hook 通常在 DLL 中实现,以满足系统范围 Hook 的基本要求。这类 Hook 的基本概念是,Hook 回调过程在系统中每个被 Hook 的进程的地址空间中执行。要安装 Hook,请使用适当的参数调用SetWindowsHookEx()。一旦应用程序安装了系统范围的 Hook,操作系统就会将 DLL 映射到其每个客户端进程的地址空间中。因此,DLL 中的全局变量将是“每个进程”的,无法在加载了 Hook DLL 的进程之间共享。所有包含共享数据的变量必须放在共享数据段中。下图显示了一个由 Hook Server 注册并注入到名为“Application one”和“Application two”的地址空间中的 Hook 示例。

    图 1

    系统范围的 Hook 仅在执行SetWindowsHookEx()时注册一次。如果没有发生错误,则返回 Hook 的句柄。该返回值在自定义 Hook 函数结束时需要,以便在调用CallNextHookEx()时使用。在成功调用SetWindowsHookEx()后,操作系统会自动(但不一定立即)将 DLL 注入到所有符合此特定 Hook 过滤器要求的进程中。让我们仔细看看下面的示例WH_GETMESSAGE过滤器函数

    //---------------------------------------------------------------------------
    // GetMsgProc
    //
    // Filter function for the WH_GETMESSAGE - it's just a dummy function
    //---------------------------------------------------------------------------
    LRESULT CALLBACK GetMsgProc(
    	int code,       // hook code
    	WPARAM wParam,  // removal option
    	LPARAM lParam   // message
    	)
    {
    	// We must pass the all messages on to CallNextHookEx.
    	return ::CallNextHookEx(sg_hGetMsgHook, code, wParam, lParam);
    }
    

    系统范围的 Hook 由多个不共享相同地址空间的进程加载。

    例如,由SetWindowsHookEx()获得的 Hook 句柄sg_hGetMsgHook,并且用作CallNextHookEx()的参数,必须在所有地址空间中实际使用。这意味着它的值必须在被 Hook 的进程以及 Hook Server 应用程序之间共享。为了使该变量对所有进程“可见”,我们应该将其存储在共享数据段中。

    以下是一个使用#pragma data_seg()的示例。我在这里想提一下,共享段中的数据必须已初始化,否则变量将被分配到默认数据段,并且#pragma data_seg()将无效。

    //---------------------------------------------------------------------------
    // Shared by all processes variables
    //---------------------------------------------------------------------------
    #pragma data_seg(".HKT")
    HHOOK sg_hGetMsgHook       = NULL;
    BOOL  sg_bHookInstalled    = FALSE;
    // We get this from the application who calls SetWindowsHookEx()'s wrapper
    HWND  sg_hwndServer        = NULL; 
    #pragma data_seg()
    您还应该向 DLL 的 DEF 文件添加 SECTIONS 语句
    SECTIONS
    	.HKT   Read Write Shared
    或使用
    #pragma comment(linker, "/section:.HKT, rws")

    一旦 Hook DLL 被加载到目标进程的地址空间中,就无法卸载它,除非 Hook Server 调用UnhookWindowsHookEx()或被 Hook 的应用程序关闭。当 Hook Server 调用UnhookWindowsHookEx()时,操作系统会遍历一个内部列表,其中包含所有被强制加载 Hook DLL 的进程。操作系统会减少 DLL 的锁定计数,当计数变为 0 时,DLL 会自动从进程的地址空间中取消映射。

    以下是这种方法的优点

    • 此机制得到 NT/2K 和 9x Windows 系列的支持,并有望在未来的 Windows 版本中得到维护。
    • 与注入 DLL 的注册表机制不同,此方法允许在 Hook Server 决定不再需要 DLL 并调用UnhookWindowsHookEx()时卸载 DLL。

    尽管我认为 Windows Hooks 是非常有用的注入技术,但它也有其缺点

    • Windows Hooks 会显著降低系统的整体性能,因为它们增加了系统必须为每个消息执行的处理量。
    • 调试系统范围的 Windows Hooks 需要大量的精力。但是,如果您同时运行多个 VC++ 实例,它将简化更复杂场景的调试过程。
    • 最后但并非最不重要的一点是,这种 Hook 会影响整个系统的处理,在某些情况下(例如 bug),您必须重新启动您的机器才能恢复它。
  3. 使用CreateRemoteThread() API 函数注入 DLL
    好吧,这是我最喜欢的一种。不幸的是,它仅受 NT 和 Windows 2K 操作系统的支持。奇怪的是,您也可以在 Win 9x 上调用(链接)此 API,但它只返回NULL而不做任何事情。

    通过远程线程注入 DLL 是 Jeffrey Ritcher 的想法,并且在他的文章[9]“使用 INJLIB 将 32 位 DLL 加载到另一个进程的地址空间”中有详细的文档记录。

    基本概念非常简单,但非常优雅。任何进程都可以使用LoadLibrary() API 动态加载 DLL。问题是我们如何强制外部进程代表我们调用LoadLibrary(),如果我们无法访问进程的线程呢?好吧,有一个名为CreateRemoteThread()的函数,它负责创建远程线程。这里是技巧——看看传递给CreateRemoteThread()的线程函数的签名(即LPTHREAD_START_ROUTINE

    DWORD WINAPI ThreadProc(LPVOID lpParameter);
    这是LoadLibrary API 的原型
    HMODULE WINAPI LoadLibrary(LPCTSTR lpFileName);
    是的,它们具有“相同”的模式。它们使用相同的调用约定WINAPI,都接受一个参数,并且返回值大小相同。这种匹配为我们提供了一个提示,我们可以使用LoadLibrary()作为线程函数,它将在远程线程创建后执行。让我们看看下面的示例代码
    hThread = ::CreateRemoteThread(
    	hProcessForHooking, 
    	NULL, 
    	0, 
    	pfnLoadLibrary, 
    	"C:\\HookTool.dll", 
    	0, 
    	NULL);
    	
    通过使用GetProcAddress() API,我们获得了LoadLibrary() API 的地址。棘手的地方是 Kernel32.DLL 始终映射到每个进程的相同地址空间,因此LoadLibrary()函数在任何正在运行的进程的地址空间中的地址都具有相同的值。这确保我们将一个有效的指针(即pfnLoadLibrary)作为CreateRemoteThread()的参数传递。

    作为线程函数的参数,我们使用 DLL 的完整路径名,将其转换为LPVOID。当远程线程恢复时,它将 DLL 的名称传递给 ThreadFunction(即LoadLibrary)。这就是使用远程线程进行注入的全部技巧。

    在通过CreateRemoteThread() API 进行植入时,有一个重要的事情需要考虑。每次在操作目标进程的虚拟内存并调用CreateRemoteThread()之前,注入器应用程序都会首先使用OpenProcess() API 打开进程,并将PROCESS_ALL_ACCESS标志作为参数传递。当我们需要对该进程获得最大访问权限时,将使用此标志。在这种情况下,OpenProcess()对于某些 ID 号较低的进程将返回NULL。这种错误(尽管我们使用有效的进程 ID)是由未在具有足够权限的安全上下文中运行引起的。如果您稍微考虑一下,就会意识到这是有道理的。所有这些受限制的进程都是操作系统的一部分,普通应用程序不应该被允许操作它们。如果某个应用程序出现错误并意外尝试终止操作系统的进程,会发生什么?为了防止操作系统出现此类崩溃,要求给定的应用程序必须具有足够的权限来执行可能影响操作系统行为的 API。要通过OpenProcess()调用访问系统资源(例如 smss.exe、winlogon.exe、services.exe 等),您必须获得调试特权。这种能力非常强大,并提供了一种访问通常受限制的系统资源的方式。调整进程特权是一项微不足道的任务,可以用以下逻辑操作来描述

    • 使用调整特权所需的权限打开进程令牌
    • 给定特权名称“SeDebugPrivilege”,我们应该找到其本地 LUID 映射。特权按名称指定,可以在 Platform SDK 文件 winnt.h 中找到。
    • 通过调用AdjustTokenPrivileges() API 来调整令牌,以启用“SeDebugPrivilege”特权。
    • 关闭由OpenProcessToken()获得的进程令牌句柄
    有关更改特权的更多详细信息,请参阅[10]“使用特权”。
  4. 通过 BHO 插件植入
    有时您只需要将自定义代码注入 Internet Explorer。幸运的是,Microsoft 为此目的提供了一种简单且文档齐全的方式——浏览器帮助对象 (Browser Helper Objects)。BHO 实现为一个 COM DLL,一旦注册正确,每次启动 IE 时都会加载所有实现了IObjectWithSite接口的 COM 组件。
  5. MS Office 插件
    与 BHO 类似,如果您需要将您自己的代码植入 MS Office 应用程序,您可以利用提供的标准机制,通过实现 MS Office 插件。有许多可用的示例展示了如何实现这类插件。

拦截机制

将 DLL 注入到外部进程的地址空间是间谍系统的关键要素。它提供了控制进程线程活动的绝佳机会。但是,如果想要拦截进程中的 API 函数调用,仅仅注入 DLL 是不够的。

本文档的这一部分旨在简要回顾几种可用的真实 Hook 方面。它侧重于它们的几个基本概述,揭示它们的优点和缺点。

就 Hook 应用的级别而言,API 间谍有两种机制——内核级别和用户级别。为了更好地理解这两个级别,您必须了解 Win32 子系统 API 和 Native API 之间的关系。下图显示了不同 Hook 的设置位置,并说明了 Windows 2K 上的模块关系及其依赖关系。

图 2

它们之间的主要实现差异在于,内核级别 Hook 的拦截引擎被封装为内核模式驱动程序,而用户级别 Hook 通常使用用户模式 DLL。

  1. NT 内核级别 Hook
    有几种方法可以在内核模式下实现 NT 系统服务的 Hook。最流行的拦截机制最初由 Mark Russinovich 和 Bryce Cogswell 在他们的文章[3]“Windows NT System-Call Hooking”中演示。他们的基本思想是在用户模式正下方注入一个拦截机制来监视 NT 系统调用。这种技术非常强大,并提供了一种极其灵活的 Hook 方法,用于所有用户模式线程在被 OS 内核服务之前经过的点。

    您也可以在“Undocumented Windows 2000 Secrets”中找到出色的设计和实现。在他的伟大著作中,Sven Schreiber 解释了如何从头开始构建一个内核级别的 Hook 框架[5]。

    Prasad Dabak 在他的著作“Undocumented Windows NT”[17] 中提供了另一个全面的分析和出色的实现。

    然而,所有这些 Hook 策略都超出了本文的范围。

  2. Win32 用户级别 Hook
    1. 窗口子类化。
      这种方法适用于可以通过新实现的窗口过程来改变应用程序行为的情况。要完成此任务,您只需使用GWLP_WNDPROC参数调用SetWindowLongPtr(),并传递指向您自己的窗口过程的指针。一旦设置了新的子类过程,每次 Windows 将消息分派到指定窗口时,它都会查找与该特定窗口关联的窗口过程的地址,并调用您的过程而不是原始过程。

      这种机制的缺点是子类化仅限于特定进程的边界内。换句话说,应用程序不应该子类化由另一个进程创建的窗口类。

      通常,这种方法适用于您通过插件(即 DLL / In-Proc COM 组件)挂钩应用程序,并且您可以获得您想要替换其过程的窗口的句柄。

      例如,不久前我为 IE 编写了一个简单的插件(浏览器帮助对象),它使用子类化替换了 IE 提供的原始弹出菜单。

    2. 代理 DLL(特洛伊 DLL)
      一种简单的 hacking API 方法就是用一个名称相同且导出原始 DLL 所有符号的 DLL 来替换它。这种技术可以使用函数转发器轻松实现。函数转发器基本上是 DLL 导出节中的一个条目,它将函数调用委托给另一个 DLL 的函数。

      您可以通过简单地使用#pragma comment来完成此任务

      #pragma comment(linker, "/export:DoSomething=DllImpl.ActuallyDoSomething")

      然而,如果您决定采用此方法,您应负责提供与原始库新版本兼容性。有关更多详细信息,请参阅[13a]“导出转发”部分和[2]“函数转发器”。

    3. 代码覆盖
      有几种方法基于代码覆盖。其中一种改变了 CALL 指令使用的函数地址。这种方法很困难且容易出错。基本思想是追踪内存中的所有 CALL 指令,并将原始函数的地址替换为用户提供的地址。

      另一种代码覆盖方法需要更复杂的实现。简而言之,这种方法的基本概念是找到原始 API 函数的地址,并将其前几个字节更改为 JMP 指令,该指令将调用重定向到自定义提供的 API 函数。这种方法极其棘手,并且涉及对每个单独调用进行一系列恢复和 Hook 操作。重要的是要指出,如果函数处于未 Hook 模式,并且在此阶段进行了另一个调用,则系统将无法捕获第二个调用。
      主要问题是它违反了多线程环境的规则。

      然而,有一个聪明的解决方案可以解决其中一些问题,并为实现 API 拦截器的绝大多数目标提供了一种复杂的途径。如果您有兴趣,可以查阅[12] Detours 实现。

    4. 通过调试器进行间谍
      Hook API 函数的替代方法是在目标函数中设置调试断点。然而,这种方法存在几个缺点。这种方法的主要问题是调试异常会暂停所有应用程序线程。它还需要一个调试器进程来处理此异常。另一个问题是由以下事实引起的:当调试器终止时,调试器会被 Windows 自动关闭。
    5. 通过修改导入地址表进行间谍
      这项技术最初由 Matt Pietrek 发布,然后由 Jeffrey Ritcher([2]“通过操纵模块的导入节进行 API Hooking”)和 John Robbins([4]“Hooking Imported Functions”)进行了阐述。它非常健壮、简单且易于实现。它还满足了针对 Windows NT/2K 和 9x 操作系统的 Hook 框架的大多数要求。这项技术的核心在于 Portable Executable (PE) Windows 文件格式的优雅结构。要理解这种方法的工作原理,您需要熟悉 PE 文件格式的一些基本知识,它是 Common Object File Format (COFF) 的扩展。Matt Pietrek 在他的精彩文章中详细介绍了 PE 格式——[6]“Peering Inside the PE.”,以及[13a/b]“An In-Depth Look into the Win32 PE file format”。我将为您提供 PE 规范的简要概述,仅足够让您了解通过操纵导入地址表进行 Hook 的想法。

      总的来说,PE 二进制文件被组织起来,使其所有代码和数据节的布局都符合可执行文件的虚拟内存表示。PE 文件格式由几个逻辑节组成。每个节维护特定类型的数据,并满足 OS 加载程序的特定需求。

      我希望将您的注意力集中在.idata节上,它包含有关导入地址表的信息。PE 结构的這個部分對於構建基於修改 IAT 的間諜程序來說非常關鍵。
      每个符合 PE 格式的可执行文件都有一个大致由下图描述的布局。

      图 3

      程序加载程序负责将应用程序及其所有链接的 DLL 加载到内存中。由于无法预知每个 DLL 加载到的地址,加载程序无法确定每个导入函数的实际地址。加载程序必须执行一些额外的工作,以确保程序能够成功调用每个导入函数。但是,逐个检查内存中的每个可执行映像并修复所有导入函数的地址将花费不合理的时间并导致巨大的性能下降。那么,加载程序如何解决这个挑战呢?关键在于,对每个导入函数的调用都必须分派到函数代码在内存中驻留的相同地址。对每个导入函数的调用实际上是一个间接调用,通过 IAT 由一个间接 JMP 指令进行路由。这种设计的优点是加载程序不必搜索文件数据的整个映像。解决方案似乎很简单——它只需修复 IAT 中所有导入项的地址。下面是一个使用[8] PEView 工具拍摄的简单 Win32 应用程序的快照 PE 文件结构的示例。如您所见,TestApp 的导入表包含 GDI32.DLL 导出的两个函数——TextOutA()GetStockObject()

      图 4

      实际上,导入函数的 Hook 过程并不像乍一看那么复杂。简而言之,使用 IAT 修补的拦截系统需要发现保存导入函数地址的位置,并通过覆盖它来将其替换为用户提供的函数的地址。一个重要的要求是,新提供的函数必须与原始函数具有完全相同的签名。以下是替换周期的逻辑步骤
      • 定位进程加载的每个 DLL 模块以及进程本身的 IAT 中的导入节
      • 查找导出该函数的 DLL 的IMAGE_IMPORT_DESCRIPTOR块。实际上,我们通常通过 DLL 名称搜索此条目
      • 定位保存导入函数原始地址的IMAGE_THUNK_DATA
      • 将函数地址替换为用户提供的地址

      通过更改 IAT 中导入函数的地址,我们可以确保所有对被 Hook 函数的调用都将被重定向到函数拦截器。

      在 IAT 中替换指针之所以可行,是因为.idata节不一定必须是可写节。这要求我们必须确保.idata节可以被修改。这个任务可以通过使用VirtualProtect() API 来完成。

      另一个值得注意的问题是 Windows 9x 系统上GetProcAddress() API 的行为。当应用程序在调试器外部调用此 API 时,它会返回一个指向函数的指针。然而,如果在调试器内部调用此函数,它实际上会返回一个与在调试器外部调用时不同的地址。这是因为在调试器内部,每次调用GetProcAddress()都会返回一个指向实际指针的包装器。GetProcAddress()返回的值指向PUSH指令,后面跟着实际地址。这意味着在 Windows 9x 上,当我们遍历 thunks 时,我们必须检查检查的函数的地址是否是PUSH指令(在 x86 平台上为 0x68),并相应地获取函数地址的正确值。

      Windows 9x 不实现写时复制,因此操作系统会尝试让调试器远离进入 2GB 边界以上的函数。这就是为什么GetProcAddress()返回调试 thunk 而不是实际地址的原因。John Robbins 在[4]“Hooking Imported Functions”中讨论了这个问题。

确定何时注入 Hook DLL

本节揭示了当所选的注入机制不是操作系统功能一部分时,开发人员面临的一些挑战。例如,当您使用内置的 Windows Hooks 来植入 DLL 时,注入的执行不是您关心的。操作系统负责强制所有符合此特定 Hook 要求的正在运行的进程加载 DLL[18]。事实上,Windows 会跟踪所有新启动的进程,并强制它们加载 Hook DLL。通过注册表管理注入与 Windows Hooks 非常相似。所有这些“内置”方法的最大优势在于它们是操作系统的一部分。

与上面讨论的植入技术不同,通过CreateRemoteThread()进行注入需要维护所有当前正在运行的进程。如果注入不及时,可能会导致 Hook 系统错过一些它声称已拦截的调用。Hook Server 应用程序实现一种智能机制来接收新进程启动或关闭的通知至关重要。在这种情况下,建议的方法之一是拦截CreateProcess() API 系列函数并监视所有调用。因此,当调用用户提供的函数时,它可以调用原始CreateProcess(),并将dwCreationFlagsCREATE_SUSPENDED标志进行 OR 运算。这意味着目标应用程序的主线程将处于挂起状态,Hook Server 将有机会手动注入 DLL 并使用 ResumeThread() API 恢复应用程序。有关更多详细信息,您可以参考[2]“使用CreateProcess()注入代码”。

检测进程执行的第二种方法是实现一个简单的设备驱动程序。它提供了最大的灵活性,并值得更多的关注。Windows NT/2K 提供了一个名为PsSetCreateProcessNotifyRoutine()的特殊函数,由 NTOSKRNL 导出。此函数允许添加一个回调函数,该函数在进程创建或删除时被调用。有关更多详细信息,请参阅参考部分的[11]和[15]。

枚举进程和模块

有时我们更愿意使用通过CreateRemoteThread() API 注入 DLL,尤其是在系统在 NT/2K 下运行时。在这种情况下,当 Hook Server 启动时,它必须枚举所有活动进程并将 DLL 注入到它们的地址空间中。Windows 9x 和 Windows 2K 提供了 Tool Help Library 的内置实现(即由 Kernel32.dll 实现)。另一方面,Windows NT 使用 PSAPI 库来实现相同目的。我们需要一种方法来允许 Hook Server 运行,然后动态检测哪个进程“助手”可用。因此,系统可以确定支持的库是什么,并相应地使用适当的 API。

我将介绍一种面向对象的架构,它实现了一个简单的框架,用于在 NT/2K 和 9x[16] 上检索进程和模块。我的类的设计允许根据您的具体需求扩展该框架。实现本身非常直接。

CTaskManager实现了系统的处理器。它负责创建一个特定库处理程序(即CPsapiHandlerCToolhelpHandler)的实例,该处理程序能够使用正确的进程信息提供程序库(即 PSAPI 或 ToolHelp32)。CTaskManager负责创建和维护一个容器对象,该对象保存当前所有活动进程的列表。实例化CTaskManager对象后,应用程序调用Populate()方法。它强制枚举所有进程和 DLL 库,并将它们存储在CTaskManager成员m_pProcesses维护的层次结构中。

以下 UML 类图显示了此子系统的类关系

图 5

需要强调的是,NT 的 Kernel32.dll 不实现任何 ToolHelp32 函数。因此,我们必须使用运行时动态链接显式链接它们。如果我们使用静态链接,代码将在 NT 上加载失败,无论应用程序是否尝试执行任何这些函数。有关更多详细信息,请参阅我的文章“在 NT 和 Win9x/2K 上枚举进程和模块的单一接口。”

Hook 工具系统的要求

现在我已经对各种 Hooking 过程的概念进行了简要介绍,是时候确定基本要求并探索特定 Hooking 系统的设计了。以下是一些 Hook 工具系统解决的问题

  • 提供用户级别的 Hooking 系统,用于监视按名称导入的任何 Win32 API 函数
  • 通过 Windows Hooks 和CreateRemoteThread() API 提供将 Hook Driver 注入所有正在运行进程的能力。框架应提供通过 INI 文件进行设置的能力
  • 采用基于修改导入地址表 (Import Address Table) 的拦截机制
  • 采用面向对象的、可重用和可扩展的分层架构
  • 提供一种高效且可扩展的 Hook API 函数的机制
  • 满足性能要求
  • 提供一种高效的通信机制,用于在驱动程序和服务器之间传输数据
  • 实现自定义提供的TextOutA/W()ExitProcess() API 函数版本
  • 将事件记录到文件
  • 该系统针对运行 Windows 9x、Me、NT 或 Windows 2K 操作系统的 x86 机器实现

设计和实现

本文档的这一部分讨论了框架的关键组件以及它们之间的交互方式。这种服装能够捕获按名称导入的任何类型的WINAPI函数。

在我概述系统的设计之前,我想让您关注几种注入和 Hook 的方法。

首先,选择一种满足将 DLL Driver 注入所有进程的要求的植入方法是至关重要的。因此,我设计了一种抽象方法,包含两种注入技术,每种技术根据 INI 文件中的设置和操作系统类型(即 NT/2K 或 9x)进行应用。它们是——系统范围的 Windows Hooks 和CreateRemoteThread()方法。示例框架提供了在 NT/2K 上通过 Windows Hooks 注入 DLL 的能力,以及通过CreateRemoteThread()手段进行植入的能力。这可以通过 INI 文件中的一个选项来确定,该文件保存了系统的所有设置。

另一个关键时刻是选择 Hook 机制。不出所料,我决定采用修改 IAT 作为一种极其健壮的 Win32 API 间谍方法。

为了实现所需的目标,我设计了一个简单的框架,由以下组件和文件组成

  • TestApp.exe - 一个简单的 Win32 测试应用程序,它只使用 TextOut() API 输出文本。此应用程序的目的是展示它是如何被 Hook 的。
  • HookSrv.exe - 控制程序
  • HookTool .DLL - 实现为 Win32 DLL 的间谍库
  • HookTool.ini - 配置文件
  • NTProcDrv.sys - 一个微小的 Windows NT/2K 内核模式驱动程序,用于监视进程的创建和终止。此组件是可选的,仅用于解决 NT 基于系统的进程执行检测问题。

HookSrv 是一个简单的控制程序。它的主要作用是加载 HookTool.DLL,然后激活间谍引擎。加载 DLL 后,Hook Server 调用InstallHook()函数,并将一个隐藏窗口的句柄传递给 DLL,DLL 应该将所有消息发布到该窗口。

HookTool.DLL 是 Hook Driver,也是所提供的间谍系统的核心。它实现了实际的拦截器,并提供了三个用户提供的函数:TextOutA/W()ExitProcess()函数。

尽管本文档侧重于 Windows 内部机制,并且没有必要使用面向对象的方法,但我决定将相关活动封装在可重用的 C++ 类中。这种方法提供了更大的灵活性,并使系统能够扩展。它还使开发人员能够将单个类用于此项目之外。

以下 UML 类图说明了 HookTool.DLL 实现中使用的类之间的关系。

图 6

在本节中,我想让您关注 HookTool.DLL 的类设计。为类分配职责是开发过程中的重要组成部分。每个呈现的类都封装了特定的功能,并代表了一个特定的逻辑实体。

CModuleScope是系统的主入口。它使用“Singleton”模式实现,并且是线程安全的。它的构造函数接受指向共享段中声明的数据的 3 个指针,这些数据将被所有进程使用。通过这种方式,这些系统范围变量的值可以在类内部非常容易地维护,从而遵守封装规则。

当应用程序加载 HookTool 库时,DLL 在收到DLL_PROCESS_ATTACH通知时会创建一个CModuleScope的实例。此步骤仅初始化CModuleScope的唯一实例。CModuleScope对象构造的一个重要部分是创建适当的注入器对象。选择使用哪个注入器将在解析 HookTool.ini 文件并确定[Scope]部分下UseWindowsHook参数的值后做出。如果系统在 Windows 9x 下运行,此参数的值将不会被系统检查,因为 Windows 9x 不支持通过远程线程注入。

实例化主处理器对象后,将调用ManageModuleEnlistment()方法。这是一个简化的实现版本

// Called on DLL_PROCESS_ATTACH DLL notification
BOOL CModuleScope::ManageModuleEnlistment()
{
	BOOL bResult = FALSE;
	// Check if it is the hook server 
	if (FALSE == *m_pbHookInstalled)
	{
		// Set the flag, thus we will know that the server has been installed
		*m_pbHookInstalled = TRUE;
		// and return success error code
		bResult = TRUE;
	}
	// and any other process should be examined whether it should be
	// hooked up by the DLL
	else
	{
		bResult = m_pInjector->IsProcessForHooking(m_szProcessName);
		if (bResult)
			InitializeHookManagement();
	}
	return bResult;
}

方法ManageModuleEnlistment()的实现很简单,它通过检查m_pbHookInstalled指向的值来检查调用是否由 Hook Server 做出。如果调用是由 Hook Server 发起的,它只是间接将标志sg_bHookInstalled设置为 TRUE。这表明 Hook Server 已启动。

Hook Server 采取的下一步行动是通过对InstallHook() DLL 导出函数的单个调用来激活引擎。实际上,它的调用被委托给CModuleScope的一个方法——InstallHookMethod()。此函数的主要目的是强制目标进程加载或卸载 HookTool.DLL。

 // Activate/Deactivate hooking
engine BOOL	CModuleScope::InstallHookMethod(BOOL bActivate, HWND hWndServer)
{
	BOOL bResult;
	if (bActivate)
	{
		*m_phwndServer = hWndServer;
		bResult = m_pInjector->InjectModuleIntoAllProcesses();
	}
	else
	{
		m_pInjector->EjectModuleFromAllProcesses();
		*m_phwndServer = NULL;
		bResult = TRUE;
	}
	return bResult;
}

HookTool.DLL 提供了两种将自身注入外部进程地址空间的机制——一种使用 Windows Hooks,另一种使用CreateRemoteThread() API 注入 DLL。系统架构定义了一个抽象类CInjector,它公开了用于注入和弹出 DLL 的纯虚函数。类CWinHookInjectorCRemThreadInjector继承自同一个基类——CInjector。然而,它们提供了对CInjector接口中定义的纯虚方法InjectModuleIntoAllProcesses()EjectModuleFromAllProcesses()的不同实现。

CWinHookInjector类实现了 Windows Hooks 注入机制。它通过以下调用安装一个过滤器函数

// Inject the DLL into all running processes
BOOL CWinHookInjector::InjectModuleIntoAllProcesses()
{
	*sm_pHook = ::SetWindowsHookEx(
		WH_GETMESSAGE,
		(HOOKPROC)(GetMsgProc),
		ModuleFromAddress(GetMsgProc), 
		0
		);
	return (NULL != *sm_pHook);
}

正如您所见,它向系统请求注册WH_GETMESSAGE Hook。服务器仅执行此方法一次。SetWindowsHookEx()的最后一个参数为 0,因为GetMsgProc()被设计为用作系统范围的 Hook。系统将在窗口处理特定消息之前每次调用回调函数。有趣的是,我们必须提供一个几乎是虚拟的GetMsgProc()回调实现,因为我们不打算监视消息处理。我们只提供这个实现,以便获得操作系统提供的免费注入机制。

在调用SetWindowsHookEx()之后,OS 会检查导出GetMsgProc()的 DLL(即 HookTool.DLL)是否已映射到所有 GUI 进程中。如果 DLL 尚未加载,Windows 会强制那些 GUI 进程映射它。一个有趣的事实是,系统范围的 Hook DLL 在其DllMain()中不应返回FALSE。这是因为操作系统会验证DllMain()的返回值,并会一直尝试加载该 DLL,直到其DllMain()最终返回TRUE

CRemThreadInjector类演示了一种截然不同的方法。这里的实现基于使用远程线程注入 DLL。CRemThreadInjector通过提供接收进程创建和终止通知的机制来扩展 Windows 进程的维护。它持有一个CNtInjectorThread类的实例,该实例监视进程执行。CNtInjectorThread对象负责从内核模式驱动程序获取通知。因此,每次创建进程时,都会调用CNtInjectorThread ::OnCreateProcess();当进程退出时,CNtInjectorThread ::OnTerminateProcess()会自动调用。与 Windows Hooks 不同,依赖于远程线程的方法要求每次创建新进程时都进行手动注入。监视进程活动将为我们提供一种简单的技术来在新进程启动时发出警报。

CNtDriverController类实现了围绕管理服务和驱动程序的 API 函数的包装器。它旨在处理内核模式驱动程序 NTProcDrv.sys 的加载和卸载。稍后将讨论其实现。

在将 HookTool.DLL 成功注入到特定进程后,将在DllMain()中调用ManageModuleEnlistment()方法。回想一下我之前描述的方法实现。它通过CModuleScope的成员m_pbHookInstalled检查共享变量sg_bHookInstalled。由于服务器的初始化已经将sg_bHookInstalled的值设置为 TRUE,系统会检查此应用程序是否需要被 Hook,如果是,它实际上会激活该特定进程的间谍引擎。

启用黑客引擎在CModuleScope::InitializeHookManagement()的实现中进行。此方法的思想是为一些重要的函数(如LoadLibrary() API 系列以及GetProcAddress())安装 Hook。通过这种方式,我们可以监视初始化过程之后的 DLL 加载。每次新 DLL 将被映射时,都有必要修复其导入表,从而确保系统不会错过对捕获函数的任何调用。

InitializeHookManagement()方法的末尾,我们为实际要间谍的函数提供了初始化。

由于示例代码演示了捕获多个用户提供的函数,因此我们必须为每个单独的 Hook 函数提供一个实现。这意味着使用这种方法,您不能仅仅将 IAT 中的地址更改为指向单个“通用”拦截函数。间谍函数需要知道这个调用来自哪个函数。同样重要的是,拦截例程的签名必须与原始WINAPI函数原型完全相同,否则堆栈将损坏。例如,CModuleScope实现了三个静态方法MyTextOutA()MyTextOutW()MyExitProcess()。一旦 HookTool.DLL 被加载到进程的地址空间中,并且间谍引擎被激活,每次调用原始TextOutA()时,都会调用CModuleScope:: MyTextOutA()来代替。

提出的间谍引擎本身的设计非常高效,并提供了极大的灵活性。然而,它主要适用于预先知道拦截函数集并且其数量有限的场景。

如果您想向系统添加新的 Hook,只需像我为MyTextOutA/W()MyExitProcess()所做的那样声明和实现拦截函数。然后,您必须按照 InitializeHookManagement() 实现所示的方式进行注册。

拦截和跟踪进程执行是实现需要操纵外部进程的系统的非常有用的机制。在进程启动时通知相关方是开发进程监视系统和系统范围 Hook 的经典问题。Win32 API 提供了大量出色的库(PSAPI 和 ToolHelp [16]),允许您枚举系统中当前运行的进程。尽管这些 API 非常强大,但它们不允许您在新的进程启动或结束时收到通知。幸运的是,NT/2K 提供了一套 API,在 Windows DDK 文档中记录为由 NTOSKRNL 导出的“进程结构例程”。其中一个 API PsSetCreateProcessNotifyRoutine()允许注册一个系统范围的回调函数,该函数在每次新进程启动、退出或被终止时由 OS 调用。所提到的 API 可以通过实现 NT 内核模式驱动程序和用户模式 Win32 控制应用程序作为跟踪进程的简单方法来使用。驱动程序的作用是检测进程执行并通知控制程序这些事件。Windows 进程观察器 NTProcDrv 的实现提供了在 NT 基于系统的进程监视所需的最小功能集。有关更多详细信息,请参阅文章[11]和[15]。驱动程序的代码可以在NTProcDrv.c文件中找到。由于用户模式实现动态安装和卸载驱动程序,因此当前登录的用户必须具有管理员权限。否则,您将无法安装驱动程序,并且它会干扰监视过程。解决方法是作为管理员手动安装驱动程序,或使用 Windows 2K 提供的“以其他用户身份运行”选项运行 HookSrv.exe。

最后但并非最不重要的,提供的工具可以通过简单地更改 INI 文件(即HookTool.ini)的设置来管理。此文件确定是使用 Windows Hooks(适用于 9x 和 NT/2K)还是CreateRemoteThread()(仅适用于 NT/2K)进行注入。它还提供了一种指定必须 Hook 哪些进程以及不应拦截哪些进程的方法。如果您想监视进程,在 [Trace] 部分有一个选项(Enabled),允许记录系统活动。此选项允许您使用 CLogFile 类公开的方法报告丰富的错误信息。事实上,ClogFile 提供了线程安全的实现,您不必担心访问共享系统资源(即日志文件)的同步问题。有关更多详细信息,请参阅 CLogFile 和 HookTool.ini 文件内容。

示例代码

该项目使用 VC6++ SP4 进行编译,需要 Platform SDK。在生产环境的 Windows NT 中,您需要提供 PSAPI.DLL 才能使用提供的CTaskManager实现。

在运行示例代码之前,请确保 HookTool.ini 文件中的所有设置都已根据您的具体需求进行设置。

对于那些喜欢底层的东西并对内核模式驱动程序 NTProcDrv 代码的进一步开发感兴趣的人,他们必须安装 Windows DDK。

超出范围

为了简化起见,以下是一些我故意未包含在本文档范围内的主题

  • 监视 Native API 调用
  • 用于监视 Windows 9x 系统上进程执行的驱动程序。
  • UNICODE 支持,尽管您仍然可以 Hook UNICODE 导入的 API

结论

本文档远未提供关于无限 API Hooking 主题的完整指南,并且毫无疑问会遗漏一些细节。然而,我试图在这几页中包含足够重要的信息,可能会帮助那些对用户模式 Win32 API 间谍感兴趣的人。

参考文献

[1] 《Windows 95 System Programming Secrets》, Matt Pietrek
[2] 《Programming Application for MS Windows》, Jeffrey Richter
[3] 《Windows NT System-Call Hooking》, Mark Russinovich and Bryce Cogswell, Dr.Dobb's Journal 1997年1月
[4] 《Debugging applications》, John Robbins
[5] 《Undocumented Windows 2000 Secrets》, Sven Schreiber
[6] “Peering Inside the PE: A Tour of the Win32 Portable Executable File Format” by Matt Pietrek, 1994年3月
[7] MSDN 知识库 Q197571
[8] PEview Version 0.67, Wayne J. Radburn
[9] “使用 INJLIB 将 32 位 DLL 加载到另一个进程的地址空间” MSJ 1994年5月
[10] 《Programming Windows Security》, Keith Brown
[11] “检测 Windows NT/2K 进程执行” Ivo Ivanov, 2002
[12] “Detours” Galen Hunt and Doug Brubacher
[13a] “An In-Depth Look into the Win32 PE file format”, part 1, Matt Pietrek, MSJ 2002年2月
[13b] “An In-Depth Look into the Win32 PE file format”, part 2, Matt Pietrek, MSJ 2002年3月
[14] 《Inside MS Windows 2000 Third Edition》, David Solomon and Mark Russinovich
[15] “Nerditorium”, James Finnegan, MSJ 1999年1月
[16] “在 NT 和 Win9x/2K 上枚举进程和模块的单一接口。”, Ivo Ivanov, 2001
[17] 《Undocumented Windows NT》, Prasad Dabak, Sandeep Phadke and Milind Borate
[18] Platform SDK: Windows User Interface, Hooks

历史

2002年4月21日 - 更新源代码

2002年5月12日 - 更新源代码

2002年9月4日 - 更新源代码

2002年12月3日 - 更新源代码和演示

© . All rights reserved.