API 监控无处不在






4.79/5 (21投票s)
2003年5月13日
20分钟阅读

215854

3395
显示了用于监视第三方应用程序的未公开内容。
概念
应用程序通常需要被监控。比如,你需要了解应用程序的性能如何,以及需要跟踪其资源使用情况。如果你是应用程序的作者,或者可以控制源代码,这是完全可以实现的。但如果应用程序是第三方应用程序呢?你如何计划监控一个完全的黑盒子?本文将聚焦于此领域,并向你展示实现这一目标的各种方法。尽管本文中使用的API在Microsoft开发者网络(MSDN)中有文档记录,但你找不到一个正确的代码片段来展示如何使用它们。当你阅读文档时,你会发现理解写的内容已经很复杂了,更不用说如何使用了!因此,我想说,从现在开始,所有以程序代码形式出现的都将是“未公开”的内容。与所有未公开的代码一样,此代码不受供应商支持,并且可能在不同版本之间发生更改。
需求
在我们开始理解如何实现上述目标之前,首先让我们尝试充分理解为什么需要这样做,或者“商业效益”是什么。即使在我学习这项技术时,我正在从事一个需要监控一组特定API的项目。虽然我的范围是整个系统和监控每个运行的应用程序,但本文的范围将仅限于监控一个正在运行的应用程序。以下是为什么需要这样做的几个原因:
- 记录应用程序如何使用特定API。现在,这个API也可能是你通过某个自定义COM组件或库dll暴露的函数。在正常情况下,你需要将整个日志代码写在你的dll中。但通过API监控系统,你可以实际保持日志代码的通用性,并将监控特定于正在运行的进程。
- 更改特定API的行为。假设你需要禁用对某个特定API的所有调用。例如,user32.dll有一个导出的API名为
OpenFile
,为了阻止某个应用程序使用此API,你可以覆盖OpenFile
API并编写自己的代码(在这种情况下,向调用者返回一个失败值)。通过这种方式,突出了两点:首先,你可以禁用任何dll中的任何此类API;其次,你可以覆盖某个API的默认行为并提供自己的功能。
代码注入方法
在我们开始理解如何将代码“注入”到正在运行的进程之前,让我们先尝试理解应用程序是如何工作的以及进程内存是如何组织的。如果这个解释变得太复杂,你可以跳过这部分,直接跳转到下一节,“编写代码”。
导入表 |
内部共享内存 |
进程堆栈和堆 |
导出表 |
上表简要地说明了Windows环境下任何正在运行的进程的内存分配方式。
每个进程在内存中都有区域,如图所示。当进程启动时,全局/共享数据被加载到“内部共享内存”区域。“内部”是因为数据在进程内部是全局的,但在进程边界之外不是。之后,进程的“导入表”和“导出表”会被填充。这些信息来自进程的二进制文件。基本上,“导入表”和“导出表”具有以下含义:
导入表
导入表列出了进程将要使用的所有dll。从现在开始,我将把dll称为“模块”。它不仅列出了进程将使用的所有模块,还列出了指向函数的指针。请记住这一点,因为我们将深入研究这个“导入表”并更改函数指针指向我们的函数。尽管实现这一点的机制听起来非常简单直接,但当你看到代码时,你会发现事实并非如此。
导出表
导出表列出了正在运行的进程将要导出的所有函数及其相应的函数指针。如果进程本身是一个动态链接库,则情况是这样的。
进程堆栈和堆
这些是进程动态使用的位置。堆栈用于存储进入函数所需的所有函数指针表和相应变量。
我们将用于注入特定代码的方法将按以下方式运行。稍后,我将为你展示这些步骤的相应代码:
- 开发一个dll,其中包含要监控/覆盖的API的导出函数。
- 获取一个正在运行的进程的进程ID,该进程需要被修改。
- 使用
OpenProcess
API打开与进程ID对应的进程。
- 在正在运行的进程内部创建一个远程线程,以控制该进程的
LoadLibrary
功能。
- 执行此线程,以确保正在运行的进程将你的dll加载到其内存中。
- 一旦你的dll被加载,并且当dll附加时,获取指向正在运行进程内部导入列表的指针。这一部分有点棘手,需要理解如何迭代导入表列表。为了简化生活,有一些辅助API可供使用。在解释代码时将对此进行详细说明。
- 现在,在获得指向导入函数和指向函数本身的指针后,将此指针替换为指向dll中你的函数的指针。
遵循以上步骤后,你会发现对特定API的任何调用都将导致你的函数被调用。通过这种方式,你可以监控或覆盖任何模块中的任何API,前提是你知道模块的名称。
一点澄清
现在我已经解释了涉及的步骤,让我们继续编写代码来实现这一点。在我们编写代码之前,我们需要明确以下几点:
- 此代码“注入”系统只能在当前登录用户的上下文中工作。这意味着无法监控在不同用户上下文下运行的应用程序。这也意味着你无法通过网络监控或向在不同用户上下文下运行的应用程序注入代码。
- 此系统使用
OpenProcess
API,并带有OPEN_ALL_ACCESS
位,这意味着当你打开进程时,你需要是机器的管理员或进程的创建者。此外,你不能打开具有完全不同安全描述符创建的进程。有一种方法可以处理这种情况,但这可能并不总是有效。有一种叫做“以调试特权打开进程”的东西,请在MSDN中阅读。
那么,我们需要编写哪些代码片段呢?对于API监控,请考虑以下代码片段:
- 共享模块 - 这是将导出对应API函数的dll。
- 注入器应用程序 - 这是我们将编写的代码注入实用程序。该应用程序基本上接受我们要监控的应用程序的进程ID,并将我们的共享模块注入到该应用程序的进程空间中。
- [可选] - 用于新进程创建/销毁通知的设备驱动程序。这是一个可选组件,我将解释它,但不会提供源代码。这是一个设备驱动程序,它使用Microsoft Visual C++ 6.0编译器与Microsoft Windows DDK一起编译。该驱动程序基本上在进程创建或销毁时触发一个事件。如果你编写系统级别的注入代码并且需要知道进程何时创建或销毁,那么这个驱动程序非常重要。我为此编写了一个设备驱动程序(内核模式),因为在用户模式下找不到替代方案。甚至无法使用挂钩!
现在我们将深入研究共享模块。这是一个Windows动态链接库,一个非常简单的DLL,其中包含被覆盖的代码以及实际的注入代码。如果我们已经有了DLL,为什么还需要注入器应用程序来完成注入部分呢?我们需要注入器应用程序的原因很简单:DLL不能独立运行。另一个原因是注入器应用程序将在远程进程空间内创建内存、加载DLL等。
现在,让我们看看如何在MS VC++ 6.0中编写这样的DLL:只需按照以下步骤操作,它们非常简单,即使你从未用过VC++ IDE,你也可以轻松创建DLL并编写相应的代码:
- 启动VC++ IDE。从“项目”选项卡中选择项目模板为“Win32动态链接库”。不要触碰任何其他设置或复选框。将项目名称输入为“SharedModule”。
- 现在,选择项目类型为“一个简单的DLL项目”。
- 现在,向导将为你创建文件并注入基本代码。从工作区视图打开文件视图。如果工作区视图不可见,请按ALT+0。
- 在文件视图中,双击SharedModule.cpp文件。该文件将在“源文件”树下可见。
- 在
#include “stdafx.h”
行下方添加SharedModule.cpp中的代码。在我们完成所有步骤后,将提供对代码的点对点解释。
现在编译应用程序,它应该会成功编译并生成DLL。如果失败,请随时将错误日志的转储发送给我。请发送至Parag_pp@yahoo.com。
我将解释上面的代码,然后我们可以继续编写注入器应用程序的代码。
现在让我们尝试理解代码。假设你是一名C/C++开发人员。如果不是,你可以跳过本节。除了标准的stdio和stdlib,你还会看到包含了imghelp — 你需要这个头文件来使用我们使用的“辅助API”。为了找到我们自己的DLL的模块句柄,我们需要存储传递给我们DllMain入口点的模块句柄。它存储在g_hModule
变量中。你现在应该已经猜到了,我们正在挂钩user32.dll库的CopyFileW
函数。为了在完成我们的工作后调用原始函数,我们需要存储原始函数的函数指针。它存储在g_OriginalCopyFileW
变量中。
接下来,由于我们使用的是函数指针,我们需要一个typedef,其中包含函数在dll中存在的确切原型声明。CopyFileW
的原型是MyCopyFileW_t
。就在原型下方,你会看到我们的函数,它将在挂钩完成后被调用。如果你看到函数体,你会注意到该函数目前什么也不做。它只是调用由g_OriginalCopyFileW
指针指向的原始函数。正是在这里,你可以编写你的实现代码,也可以选择不调用原始函数。通过这种方式,你可以覆盖CopyFileW
库函数的默认行为。
我敢肯定你一定在想,为什么CopyFile
API的末尾总是有一个“W”?这是因为CopyFileW
是CopyFile函数的Unicode实现。还有一个ANSI版本的CopyFile
API,称为CopyFileA
。如果你是或曾经是Visual Basic开发人员,你在使用“Declare…. Alias….”等声明API时,可能在API调用末尾使用了一个A。但现在我想你可能已经知道末尾的A究竟意味着什么了。
我们继续。现在到了实际上执行挂钩的最重要函数。这个函数叫做SetHook。SetHook接受四个参数。以下是这四个参数及其含义:
hModuleOfCaller
- 这是调用此API的调用者的模块句柄。现在,假设有一个名为“A”的进程正在调用CopyFile API。但API调用的实际实现可能在进程本身,也可能不在。它也可能存在于进程A引用的dll中。
- 当我们遍历一个特定进程的所有此类已加载模块(dll)时,我们需要确保所有这些模块/进程都调用我们的函数而不是原始库的函数。因此,此参数是其中一个已加载模块的模块句柄。
LibraryName
- 这是库的文本名称。如果原始函数属于user32.dll,则此参数将包含“user32.dll”。
OldFunctionPointer
- 这是来自基库的原始函数的指针。因此,如果我们正在挂钩user32.dll的CopyFile
函数,此参数将包含来自user32.dll的原始函数的指针。这可以通过使用GetProcAddress
API获得。
NewFunctionPointer
- 这是我们函数的指针。
深入SetHook
现在让我们尝试详细了解SetHook
函数。如前所述,我们需要遍历导入描述符列表并更改函数指针以指向我们的函数。这正是SetHook
函数所做的。让我们了解它是如何做到的。为了快速清晰地理解这一点,考虑将其任务划分为以下列表:
- 获取模块导入段的地址。这是使用
ImageDirectoryEntryToData
辅助API完成的。请在MSDN中查阅此API以获取更多信息。这是一个非常强大且有趣的函数。
- 循环遍历所有描述符,找到包含函数引用的导入描述符。
- 一旦我们得到模块导入段的地址,我们就遍历各种条目并比较传递给函数的库名称。
- 获取调用者导入地址表(IAT)的指针。一旦找到库,就获取实际包含各种函数指针的导入地址表(IAT)的指针。
- 迭代导入列表,找到我们要挂钩的函数,并将原始函数指针替换为我们的函数。
现在迭代IAT以找到我们的函数。这个函数指针将与调用GetProcAddress
获得的结果完全相同。找到后,用指向我们函数的指针替换旧函数指针。
SetHook
和多个模块
现在我们已经了解了如何在已加载的模块中使用SetHook
函数进行挂钩,让我们来了解一下在哪里应用这些知识。一个进程很有可能同时加载了多个模块。为了有效地应用进程级别的挂钩,我们需要在进程加载的每个模块中设置挂钩。这正是EnumAndSetHooks
函数所做的。现在让我们尝试理解这个函数是如何工作的。
以下简单步骤解释了EnumAndSetHook
的工作原理:
- 加载原始库并获取原始函数的地址。
- 根据传递的参数,它决定我们是需要
Hook
函数还是恢复(Unhook
)函数。这由传递给此函数的UnHook
标志决定。
- 然后调用PSAPI.DLL中的
EnumProcessModules
API。此API获取进程所有已加载模块的列表。由于dll在被挂钩的进程中加载和运行,GetCurrentProcess
返回当前正在运行进程的句柄。
- 在
hMods
中获取模块列表后,我们遍历列表并将每个模块句柄传递给后续的SetHook
调用。
DllMain
DllMain
是我们dll的入口点。它执行以下操作:
- 如果请求的操作类型为“
DLL_PROCESS_ATTACH
”,则存储原始模块句柄,并调用EnumAndSetHooks
,将UnHook
设置为FALSE
,表示我们正在挂钩此API。EnumAndSetHooks
返回指向原始函数的指针;我们在卸载API时需要它。
- 如果请求的操作类型为“
DLL_PROCESS_DETACH
”,则通过调用EnumAndSetHooks
并将UnHook
设置为TRUE
来恢复原始函数指针。
现在我们将继续理解注入器应用程序。
既然我们已经准备好了sharedmodule.dll并理解了代码,让我们继续进行“行动应用程序”:注入器应用程序。这里处理所有“远程”魔法。注入器应用程序是我们传递要监控的应用程序进程ID的应用程序。如前所述,此应用程序将打开进程并将我们的DLL加载到进程空间中。以下是执行此操作的代码。注入器应用程序是一个EXE文件,因此请按照以下步骤创建这样一个EXE文件。我们将在VC++中创建EXE,就像我们在VC++中创建DLL一样。步骤非常简单。请在遵循以下步骤时不要修改任何复选框或设置:
- 启动VC++ IDE。从“项目”选项卡中选择项目模板为“Win32控制台应用程序”。请记住不要更改任何其他设置或复选框。将项目名称输入为“Injector”。
- 现在,选择项目类型为“一个‘Hello, World!’应用程序”。
- 现在,向导将为你创建文件并注入基本代码。从工作区视图打开文件视图。如果工作区视图不可见,请按ALT+0。
- 在文件视图中,双击Injector.cpp文件。该文件将在“源文件”树下可见。
- 在
#include "stdafx.h"
行正下方添加Injector.cpp中的代码。
请编译sharedmodule.dll和Injector.exe。现在转到命令提示符并运行Injector.exe。将你希望监控的应用程序的进程ID传递给它。必须在命令提示符下将此进程ID传递给injector.exe。例如,如果Explorer.exe的进程ID是1676,那么你需要这样运行Injector.exe:
C:\work\vc6\injector\injector.exe 1676
运行后,你的应用程序将列出Explorer.exe加载的所有模块。然后它将挂钩Explorer.exe并等待你按键,之后它将从Explorer.exe中解除挂钩。
如果您在编译Injector.exe时遇到任何问题,请发送电子邮件至parag_pp@yahoo.com。
现在让我们来理解代码中发生的事情。
工作原理
注入器应用程序按以下步骤工作:
- 从命令行(argv[1])获取应用程序的进程ID。
- 调用
DoHook
函数,并将UnHook
参数设置为false。
- 枚举该进程的所有模块,并在过程中存储我们“sharedmodule.dll”模块的句柄。
- 调用
DoHook
,并将UnHook
设置为true
,并将模块句柄设置为存储的模块句柄。
因此,如你至今所理解的,应用程序的核心在于两个主要函数DoHook
和EnumModules
。
DoHook
- 进程挂钩器
此函数以三个参数调用:
pid
- 我们将要挂钩/监控的进程的进程ID。
UnHook
- 布尔标志,指示我们要挂钩还是解除挂钩进程。
hFreeModule
- 将托管我们远程线程的模块句柄。在挂钩进程时忽略此参数。
DoHook
本身按以下步骤工作:
- 使用
OpenProcess
API以所有可能的访问权限(PROCESS_ALL_ACCESS
)打开进程。
- 使用
VirtualAllocEx
API在远程进程中分配内存。我们需要这块内存来存储模块的路径。当我们的线程被调用时,远程进程将使用此路径来加载模块。
- 将模块路径复制到我们在远程进程中分配的内存中。
用于此的API是WriteProcessMemory
。
- 现在,如果操作是挂钩操作(意味着UnHook为false),则从Kernel32.dll获取
FreeLibrary
函数的地址;如果是反向操作,则从Kernel32.dll获取LoadLibraryA
函数的地址。
- 如果操作是挂钩操作,则调用
LoadLibraryA
并传入要加载的模块的路径。这是神奇的调用,它实际上将我们的DLL加载到远程进程的地址空间!一旦我们的DLL被加载,共享模块中相应的DllMain
代码就会被调用,事情就开始了!如果操作是卸载操作,则调用FreeLibrary
并传入我们模块的句柄。这将强制调用DLL_PROCESS_DETACH
。
- 在这两种情况下,无论是挂钩还是卸载,我们都必须等待
LoadLibraryA
或FreeLibrary
完成。这通过WaitForSingleObject
API调用来实现。
- 完成后,释放在远程进程中分配的内存。这是通过
VirtualFreeEx
API实现的。
- 现在,关闭相应的句柄,释放内存。因此,正如你必须理解的,正是这个函数打开了远程进程并将一个线程推入该进程。然后通过调用后续线程来加载我们的模块,行动就开始了。
为了释放或解除应用程序与我们模块的挂钩,在它被加载到远程进程中后,我们必须存储我们模块的句柄。为了实现这一点,我们有EnumModules
函数,下面将进行解释。
EnumModules
这是一个非常简单的函数,它按以下步骤工作:
- 打开进程,其进程ID(pid)传递给此函数。这是通过调用
OpenProcess
API并使用PROCESS_ALL_ACCESS
实现的。
- 由于我们将调用PSAPI.dll库中的API,有时会缺少相应的头文件。考虑到这一点,让我们加载PSAPI.dll以能够调用此模块中的函数。
- 我们将在PSAPI.dll中调用的两个函数是:
EnumProcessModules
- 此函数枚举进程的所有模块,并将它们的句柄存储在一维数组中。GetModuleFileNameEx
- 此函数获取已加载模块的路径。
- 现在,使用打开进程的句柄调用
EnumProcessModules
。
- 迭代所有已加载的模块,并进行后续的
GetModuleFileNameEx
调用。
- 在迭代过程中,将检索到的模块与我们的模块路径进行比较。
- 如果找到,则释放此模块并将模块句柄返回给调用者。
你必须理解,只有在我们卸载DLL并需要DLL的模块句柄时,才需要此函数。
就是这样!看,多简单啊?对于文章中提供的所有API,试着在MSDN中查找它们。找到它们后,甚至不要考虑实现它们,即使理解它们如何工作也会显得困难。我们希望这个练习能让你对这些API如何使用有一个相当好的理解。你可以修改被覆盖的MyCopyFileA
函数来做任何你想做的事情!这样,从远程应用程序内部对MyCopyFileA
的所有后续调用都将调用你的代码!!!
你可能还记得我们提到过设备驱动程序。好吧,这里是它的简要介绍:运行在Windows操作系统上的所有应用程序都在两种模式下工作——用户模式和内核模式。据我们所知,在用户模式下没有办法知道进程何时被创建或删除。虽然你可以通过设置系统范围的CBT(计算机辅助培训)挂钩来知道哪个窗口被创建或销毁,但你仍然无法知道在用户模式下哪个进程被创建或销毁。为了收到此通知,Windows OS提供了一个名为PsSetCreateProcessNotifyRoutine
的内核模式API。使用这个API,你可以收到进程创建/删除的通知。现在,你可能在想,内核模式应用程序如何与用户模式应用程序通信?很简单:通过设置一个内核模式事件并在用户模式下对其进行确认!如果你有兴趣获取演示此功能的源代码,请发送电子邮件至上述地址。