EasyHook - Windows API Hooking 的革新






4.94/5 (73投票s)
现在支持非托管API、内核模式hooking,以及自Windows 2000 SP4以来使用纯托管处理程序扩展非托管API。
- 下载 EasyHook 2.5 测试版二进制文件 - 203 KB
- 下载 EasyHook 2.5 测试版源代码 - 336 KB
- 下载托管 API 参考指南 - 152 KB
- 下载非托管 API 参考指南 - 355 KB
- 下载本文 PDF 版 - 395 KB
项目位于此处。您可以在那里找到最新更新、绕过 PatchGuard 的驱动程序、讨论、问题跟踪等。
1 延续 Detours:Windows API Hooking 的革新
Microsoft® Detours 的最新版本发布于 2006 年 12 月。现在时代已经改变,.NET Framework 变得越来越受欢迎。除了众所周知的非托管代码 Hooking 之外,EasyHook 提供了一种从托管环境 Hook 非托管代码的方法。这带来了**几个优点**:
- 目标中不留下任何资源或内存泄漏。
- 您可以为非托管 API 编写纯托管 Hook 处理程序。
- 所有 Hook 都以稳定的方式安装并自动移除。
- 您可以使用托管代码提供的所有便利,例如 .NET Remoting、WCF 和 WPF。
- 您将能够编写编译为 AnyCPU 的注入库和宿主进程,这将允许您在所有情况下使用相同的程序集,将代码注入 32 位和 64 位进程(无论是从 64 位还是 32 位进程注入)。
通过这种方式,Hooking 成了一项简单的任务。您现在只需几行代码即可编写像 FileMon 或 RegMon 这样的 Hooking 应用程序。
此外,EasyHook 2.5 提供了**附加功能**,例如:
- 非托管代码的实验性隐形注入,不会引起任何现有杀毒软件的注意。
- 自 Windows XP 起,支持 32 位和 64 位内核模式 Hooking。
- 一个纯非托管 Hooking 核心,将提高性能、稳定性和兼容性。
- 一个可靠的非托管 API,用于在没有 .NET Framework 的情况下编写 Hooking 应用程序和库。
- 非托管核心不需要 CRT 绑定,因此部署大小将减少几兆字节。此外,Windows 2000 SP4 和 Windows Server 2008 SP1 现在可以使用相同的 EasyHook 二进制文件作为目标。
最终用户运行使用 EasyHook 的应用程序所需的**最低软件要求**
- Windows 2000 SP4 或更高版本
- Microsoft .NET Framework 2.0 可再发行组件
目录
注意
本指南仅涵盖 EasyHook 的托管部分。大部分内容也适用于非托管 API。有关更多信息,请参阅“非托管 API 参考”。“托管 API 参考”还包含此处涵盖内容的附加信息。
许可变更
EasyHook 现在根据 Lesser GPL 发布,而不是 MIT 许可证。
ProcessMonitor 截图
以下是我的 ProcessMonitor 演示的截图,它随源代码和二进制包一起提供。
它允许您拦截当前系统中任何进程的 `CreateFile` 调用。
1.1 安全顾问
与一些(商业)Hooking 库为了提高销量而宣传的不同,用户模式 Hooking **绝不能**作为安全地应用附加安全检查的选项。如果您只是想“沙盒”一个您非常了解的专用进程,并且该进程实际上不知道 EasyHook,这可能会成功!但是,切勿尝试基于用户模式 Hooking 编写任何安全软件。它不会奏效,我向您保证……这也是 EasyHook 不支持所谓“系统范围”注入的原因,这实际上只是一种幻想,因为正如我所说,使用用户模式 Hook,这始终是不可能的。但是,如果您想保持这种幻想,您可以坚持使用其他(商业)库来尝试这样做……自 EasyHook 2.5 起,您可以轻松地 Hook 32 位内核。即使 EasyHook 允许 Hook 64 位内核,我也不建议这样做,因为那样您将遇到 PatchGuard 的麻烦。绕过 PatchGuard 是可能的,至少现在是这样,但导致客户电脑蓝屏的几率太大了。您应该考虑购买 PatchGuard API,它将允许您基于内核模式拦截编写安全应用程序。内核模式 Hooking(或 PatchGuard API)是应用附加安全检查的唯一选择。自 Windows Vista 以来,Windows 过滤平台和其他 Vista 特定 API 将有助于编写安全软件!
那么,用户模式 Hooking 是为了什么呢?一般来说,用户模式 Hooking 旨在用于 API 监控(例如 Mark Russinovich 的 ProcessMonitor(又名 FileMon/RegMon))、资源泄漏检测、不需要关心安全问题的各种恶意软件、扩展您没有源代码的应用程序和库(破解可能属于这一类)、为现有应用程序在较新操作系统上运行添加兼容层等等。
如果有人在用户模式 Hook 的上下文中谈论安全性,您的警报器应该响起!
1.2 一个简单的 FileMon 派生
为了证明 EasyHook 确实使 Hooking 变得简单,请看以下演示应用程序,它将记录给定进程的所有文件访问。我们需要一个宿主进程来注入库并显示文件访问。可以将注入库和宿主进程组合在一个文件中,因为它们都被视为有效的 .NET 程序集,但我认为将它们分开是一种更一致的方法。本指南将全程使用此演示。
using System;
using System.Collections.Generic;
using System.Runtime.Remoting;
using System.Text;
using EasyHook;
namespace FileMon
{
public class FileMonInterface : MarshalByRefObject
{
public void IsInstalled(Int32 InClientPID)
{
Console.WriteLine("FileMon has been installed in target {0}.\r\n", InClientPID);
}
public void OnCreateFile(Int32 InClientPID, String[] InFileNames)
{
for (int i = 0; i < InFileNames.Length; i++)
{
Console.WriteLine(InFileNames[i]);
}
}
public void ReportException(Exception InInfo)
{
Console.WriteLine("The target process has reported" +
" an error:\r\n" + InInfo.ToString());
}
public void Ping()
{
}
}
class Program
{
static String ChannelName = null;
static void Main(string[] args)
{
try
{
Config.Register(
"A FileMon like demo application.",
"FileMon.exe",
"FileMonInject.dll");
RemoteHooking.IpcCreateServer<FileMonInterface>(
ref ChannelName, WellKnownObjectMode.SingleCall);
RemoteHooking.Inject(
Int32.Parse(args[0]),
"FileMonInject.dll",
"FileMonInject.dll",
ChannelName);
Console.ReadLine();
}
catch (Exception ExtInfo)
{
Console.WriteLine("There was an error while connecting " +
"to target:\r\n{0}", ExtInfo.ToString());
}
}
}
}
最复杂的部分是注入库,它必须满足各种要求。我们正在 Hook `CreateFile` API 并将所有请求重定向到我们的宿主进程。如果宿主进程终止,该库将被卸载。
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Runtime.InteropServices;
using EasyHook;
namespace FileMonInject
{
public class Main : EasyHook.IEntryPoint
{
FileMon.FileMonInterface Interface;
LocalHook CreateFileHook;
Stack<String> Queue = new Stack<String>();
public Main(
RemoteHooking.IContext InContext,
String InChannelName)
{
// connect to host...
Interface =
RemoteHooking.IpcConnectClient<FileMon.FileMonInterface>(InChannelName);
}
public void Run(
RemoteHooking.IContext InContext,
String InChannelName)
{
// install hook...
try
{
CreateFileHook = LocalHook.Create(
LocalHook.GetProcAddress("kernel32.dll", "CreateFileW"),
new DCreateFile(CreateFile_Hooked),
this);
CreateFileHook.ThreadACL.SetExclusiveACL(new Int32[] { 0 });
}
catch (Exception ExtInfo)
{
Interface.ReportException(ExtInfo);
return;
}
Interface.IsInstalled(RemoteHooking.GetCurrentProcessId());
// wait for host process termination...
try
{
while (true)
{
Thread.Sleep(500);
// transmit newly monitored file accesses...
if (Queue.Count > 0)
{
String[] Package = null;
lock (Queue)
{
Package = Queue.ToArray();
Queue.Clear();
}
Interface.OnCreateFile(RemoteHooking.GetCurrentProcessId(), Package);
}
else
Interface.Ping();
}
}
catch
{
// NET Remoting will raise an exception if host is unreachable
}
}
[UnmanagedFunctionPointer(CallingConvention.StdCall,
CharSet = CharSet.Unicode,
SetLastError = true)]
delegate IntPtr DCreateFile(
String InFileName,
UInt32 InDesiredAccess,
UInt32 InShareMode,
IntPtr InSecurityAttributes,
UInt32 InCreationDisposition,
UInt32 InFlagsAndAttributes,
IntPtr InTemplateFile);
// just use a P-Invoke implementation to get native API access
// from C# (this step is not necessary for C++.NET)
[DllImport("kernel32.dll",
CharSet = CharSet.Unicode,
SetLastError = true,
CallingConvention = CallingConvention.StdCall)]
static extern IntPtr CreateFile(
String InFileName,
UInt32 InDesiredAccess,
UInt32 InShareMode,
IntPtr InSecurityAttributes,
UInt32 InCreationDisposition,
UInt32 InFlagsAndAttributes,
IntPtr InTemplateFile);
// this is where we are intercepting all file accesses!
static IntPtr CreateFile_Hooked(
String InFileName,
UInt32 InDesiredAccess,
UInt32 InShareMode,
IntPtr InSecurityAttributes,
UInt32 InCreationDisposition,
UInt32 InFlagsAndAttributes,
IntPtr InTemplateFile)
{
try
{
Main This = (Main)HookRuntimeInfo.Callback;
lock (This.Queue)
{
This.Queue.Push(InFileName);
}
}
catch
{
}
// call original API...
return CreateFile(
InFileName,
InDesiredAccess,
InShareMode,
InSecurityAttributes,
InCreationDisposition,
InFlagsAndAttributes,
InTemplateFile);
}
}
}
即使这可能看起来很奇怪,接下来的部分将解释那里做了什么以及为什么。您可以将用户定义的目标进程 ID 作为唯一参数从命令行启动此应用程序。我建议使用“explorer.exe”的 PID,因为这会立即产生输出!只需在运行 FileMon 实用程序时浏览您的文件系统。
Command line utility-> FileMon.exe %PID%
也可以将所有内容输出到文件中,这可能更方便。
Command line utility-> FileMon.exe %PID% > “C:\MyLog.txt”
2 深入了解 Hook
既然您已经了解了 EasyHook 的基本思想和一些示例代码,我们应该开始探索幕后到底发生了什么。在本章中,您将学习如何利用 EasyHook API 的大部分功能,将库注入任何进程并 Hook 任何您想要的 API。
2.1 全局程序集缓存
目前,EasyHook 期望所有注入的程序集,包括其所有依赖项,都位于全局程序集缓存 (GAC) 中。这是因为 CLR 只会在相对于当前应用程序基目录和 GAC 的目录中搜索程序集,因此目标进程通常无法访问 EasyHook 或您的注入库。EasyHook 使用引用计数器来确保可以管理来自不同应用程序的相同程序集的多个安装。以下将注册所有 EasyHook 组件和注入所需的两个用户程序集到 GAC 中。第一个参数只是一个未解释的字符串,应该描述您的服务正在做什么。所有后续参数都应该是相对/绝对文件路径,指向所有应临时注册到 GAC 中的程序集。请注意,只接受强名称程序集。
Config.Register(
"A FileMon like demo application.",
"FileMon.exe",
"FileMonInject.dll");
可以保证,在所有常见情况下,如果注入进程终止,您的库将从 GAC 中移除。当然,也有一些罕见的例外情况;例如,如果您通过断开电源线来关闭您的 PC。在这种情况下,程序集将永远保留在 GAC 中,这在最终用户场景中并不是坏事,但在开发过程中确实如此。您可以使用随 Visual Studio 附带的 `Gacutil.exe` 来移除所有临时 GAC 程序集。
- 以管理员身份打开“Visual Studio 命令提示符”。
- 运行命令:`gacutil /uf EasyHook`。
- 为您需要从 GAC 中移除的每个程序集运行附加命令……
2.2 Windows Defender
注入有时会引发 Windows Defender 的抱怨。这不仅适用于 EasyHook,也适用于所有使用远程线程创建进行注入的库。
Windows Defender Real-Time Protection agent has detected changes.
Microsoft recommends you analyze the software that made these changes
for potential risks. You can use information about how these programs
operate to choose whether to allow them to run or remove them from your
computer. Allow changes only if you trust the program or the software
publisher. Windows Defender can't undo changes that you allow.
For more information please see the following:
Not Applicable
Scan ID: {44726E79-4262-454E-AFED-51A30D34BF67}
User: Lynn-PC\Lynn
Name: Unknown
ID:
Severity ID:
Category ID:
Path Found: process:pid:864;service:EasyHook64Svc;file:
D:\Projects\EasyHook 2.0.0.0\Debug\x64\EasyHook64Svc.exe
Alert Type: Unclassified software
Detection Type:
此类警告之后会立即出现信息,指出 Windows Defender 阻止了一次恶意尝试。我认为如果您使用 AuthentiCode 对 EasyHook 的所有可执行二进制文件进行签名,这种情况就会消失。这种阻塞只在注入到基本系统服务时发生。
2.3 注入 - 化繁为简
通常,库注入是任何 Hooking 库中最复杂的部分之一。但是,EasyHook 更进一步。它提供了三层注入抽象,而您的库是第四层。第一层是纯的、可重定位的汇编代码。它启动第二层,一个非托管 C++ 方法。汇编代码本身非常稳定。它提供了详尽的错误信息,并且能够在目标中不留下任何资源泄漏的情况下自行卸载。C++ 层启动托管注入加载器,并通过将注入进程的应用程序基目录作为第一个条目添加到目标的 `PATH` 变量中。这样,您就可以访问从注入进程中也可以访问的任何文件。托管注入加载器使用 .NET Reflection 和 .NET Remoting 在失败时提供详尽的错误报告,并在注入库中找到适当的入口点。它还负责优雅地移除 Hook 和清理资源。支持将同一个库多次加载到同一个目标中!
另一个复杂的部分在宿主端运行。它支持将库注入到其他终端会话、系统服务,甚至跨 WOW64 边界。对您而言,所有情况都将相同。EasyHook 将自动选择正确的注入过程。如果 EasyHook 注入成功,您可以 99% 确定您的库已成功加载和执行。如果失败,您可以 99% 确定目标中没有留下任何资源泄漏,并且它保持在稳定、可 Hook 的状态!几乎所有可能的故障都被捕获,如果看到目标因库注入而崩溃,那简直就像中了彩票一样!
请注意,Windows Vista 对其子系统服务具有高级安全性。它们在受保护的环境中运行,例如“受保护媒体路径”。使用 EasyHook 或任何其他用户模式库无法 Hook 此类服务。以下显示了我们正在讨论的 API 方法。
RemoteHooking.Inject(
Int32.Parse(args[0]),
"FileMonInject.dll", // 32-Bit version
"FileMonInject.dll", // 64-Bit version
ChannelName);
前四个参数是必需的。如果您只想 Hook 32 位或 64 位目标,可以将未使用的路径设置为 null。您可以指定一个文件路径,EasyHook 会自动将其转换为完全限定的程序集名称,或者一个部分程序集名称,例如“FileMonInject, PublicKeyToken = 3287453648abcdef”。目前,只有一个注入选项可以防止 EasyHook 将调试器附加到目标,但您只应在目标不喜欢附加调试器时设置此选项。EasyHook 会在注入完成之前将其分离,因此通常无需担心,并且通过使用目标符号地址而不是假设本地地址在目标中仍然有效,可以将注入稳定性提高几个数量级!
您可以传递任意数量的附加参数,但请注意,您只能传递可通过 GAC 访问的类型;否则,注入的库将无法反序列化参数列表。在这种情况下,异常将被重定向到宿主进程,您可以使用 `RemoteHooking.Inject` 周围的 try-catch
语句捕获它。这是它的一大优势!
注入库将自动获取对您在第四个参数之后指定的任何附加参数的访问权限。这样,您可以轻松地将通道名称传递给目标,以便注入库能够连接到您的宿主。
注意
请记住,CLR 只有在目标终止时才会卸载您的库。即使 EasyHook 提前释放了所有关联的资源,您也无法更改注入的 DLL,这意味着在目标终止之前,相应的 GAC 库无法更新。因此,如果您需要频繁更改注入库(在开发过程中),则应始终在每次调试会话后终止目标。这将确保没有应用程序依赖于该库,并且可以将其从 GAC 中移除。
2.3.1 创建一个已 Hook 的进程
有时,需要从一开始就 Hook 一个进程。这没什么大不了的,只需调用 `RemoteHooking.CreateAndInject` 而不是 `Inject`。这将先执行您的库主方法,再执行任何其他指令。您可以通过从注入库的 `Run` 方法调用 `RemoteHooking.WakeUpProcess` 来恢复新创建的进程。这仅在与 `CreateAndInject` 结合使用时才有意义;否则,它将什么都不做。
2.4 注入库入口点
所有注入的库都必须至少导出一个实现 `EasyHook.IEntryPoint` 接口的公共类。接口本身是空的,但它将您的类标识为入口点。以这种方式标记为入口点的类应导出一个实例构造函数和一个 `Run` 实例方法,其签名分别为 void Run(IContext, %ArgumentList%)
和 “.ctor(IContext, %ArgumentList%)”
。请注意,`%ArgumentList%` 是传递给 `RemoteHooking.Inject` 的附加参数的占位符。该列表从您传递给 `Inject` 的第五个参数开始,并将传递给构造函数和 `Run`。该列表不是以数组形式传递,而是以展开的参数列表形式传递。例如,如果您调用 `Inject(Target, Options, Path32, Path64, String, Int32, MemoryStream)`,那么 `%ArgumentList%` 将是 `String, Int32, MemoryStream`,并且您期望的 `Run` 签名为 void Run(IContext, String, Int32, MemoryStream)
。EasyHook 强制严格绑定,这意味着参数列表不会以任何方式进行类型转换。传递给 `Inject` 的类型应与 `Run` 签名中的类型完全相同。我希望这能解释清楚。接下来要提到的是,您应该避免使用静态字段或属性。只有当您确定不可能在同一个目标中同时存在两个库实例时,您才能安全地使用静态变量!
2.4.1 库构造函数
构造函数在 EasyHook 获得目标进程控制权后立即调用。您应该只连接到您的宿主并验证参数。此时,EasyHook 已经与宿主建立了工作连接,因此您未处理的所有异常都将自动重定向到宿主进程。一个常见的构造函数可能如下所示:
public class Main : EasyHook.IEntryPoint
{
FileMon.FileMonInterface Interface;
LocalHook CreateFileHook;
Stack<String> Queue = new Stack<String>();
public Main(RemoteHooking.IContext InContext, String InChannelName)
{
// connect to host...
Interface =
RemoteHooking.IpcConnectClient<FileMon.FileMonInterface>(InChannelName);
// validate connection...
Interface.Ping();
}
}
2.4.2 库 Run-方法
`Run` 方法可以作为应用程序入口点进行线程化。如果您从它返回,您的库将被卸载。但这并不是真的 ;-)。实际上,您的库会一直存在,直到 CLR 决定卸载它。这种行为可能会在未来的 EasyHook 版本中通过利用 CLR Hosting API 而改变,但目前我们只是不知道它!
与构造函数不同,您的 `Run` 方法没有异常重定向。如果您未处理任何异常,它将只是启动通常的卸载过程。在 EasyHook 的调试版本中,您会在事件日志中找到此类未处理的异常。您应该安装所有 Hook 并通知您的宿主成功,这可能如下所示:
public void Run(RemoteHooking.IContext InContext, String InChannelName)
{
// install hook...
try
{
CreateFileHook = LocalHook.Create(
LocalHook.GetProcAddress("kernel32.dll", "CreateFileW"),
new DCreateFile(CreateFile_Hooked),
this);
CreateFileHook.ThreadACL.SetExclusiveACL(new Int32[] {0});
}
catch(Exception ExtInfo)
{
Interface.ReportException(ExtInfo);
return;
}
Interface.IsInstalled(RemoteHooking.GetCurrentProcessId());
// wait for host process termination...
try
{
while (true)
{
Thread.Sleep(500);
// transmit newly monitored file accesses...
if (Queue.Count > 0)
{
String[] Package = null;
lock (Queue)
{
Package = Queue.ToArray();
Queue.Clear();
}
Interface.OnCreateFile(RemoteHooking.GetCurrentProcessId(), Package);
}
else
Interface.Ping();
}
}
catch
{
// NET Remoting will raise an exception if host is unreachable
}
}
循环只是将当前排队的文件访问发送到宿主进程。如果宿主进程终止,此类尝试会抛出异常,导致 CLR 从 `Run` 方法返回并自动卸载您的库!
2.5 注入辅助例程
在处理注入时,您会发现有几种方法很有用。要查询当前用户是否是管理员,您可以使用 `RemoteHooking.IsAdministrator`。请注意,如果您没有管理员权限,注入在大多数情况下都会失败!Vista 使用 UAC 来评估管理员权限,因此您应该阅读一些 MSDN 文章以了解如何利用它。
如果您已经是管理员,您可以使用 `RemoteHooking.ExecuteAsService
如果您想确定目标进程是否是 64 位,可以使用 `RemoteHooking.IsX64Process`。但请注意,您需要 `PROCESS_QUERY_INFORMATION` 访问权限才能完成调用。当然,它也适用于仅 32 位的 Windows 版本,例如 Windows 2000,在这种情况下始终返回 false。此外,还有 `RemoteHooking.GetCurrentProcessId` 和 `GetCurrentThreadId`,它们可能有助于在纯托管环境中查询真正的本机值!考虑到即将推出的 .NET 4.0,托管线程 ID 不一定映射到本机线程 ID。
2.6 如何安装 Hook
要安装 Hook,您至少需要传递两个参数。第一个是您要 Hook 的入口点地址,第二个是应将调用重定向到的委托。该委托应具有 `UnmanagedFunctionPointerAttribute`,并且与相应的 P/Invoke 实现具有完全相同的调用签名。最好的方法是在网上寻找一个经过充分测试的 P/Invoke 实现,并从中创建一个委托。托管 Hook 处理程序也必须匹配此签名,这由编译器自动强制执行……带有 `DllImportAttribute` 的 P/Invoke 实现可用于在处理程序中调用原始 API,这在大多数情况下是必要的。不要忘记大多数 API 在失败时都会设置 `SetLastError`。因此,如果您的代码不想执行调用,您应该将其设置为 `ERROR_ACCESS_DENIED` 或 `ERROR_INTERNAL_ERROR` 等。否则,外部代码可能会表现出意外行为!
第三个参数提供了一种将未解释的回调对象与 Hook 关联的方法。这正是处理程序中通过 `HookRuntimeInfo.Callback` 访问的对象。要卸载 Hook,只需移除对创建期间获得的对象的所有引用。为了防止它被卸载,您当然必须保留引用……这始终是延迟移除,因为您不知道 Hook 何时最终被移除,并且您的处理程序再也不会被调用。如果您想立即移除它,您必须像处理文件流等非托管资源一样调用 `LocalHook.Dispose`。以下代码片段是安装 Hook 的示例,该 Hook 将当前线程排除在拦截之外。
CreateFileHook = LocalHook.Create(
LocalHook.GetProcAddress("kernel32.dll", "CreateFileW"),
new DCreateFile(CreateFile_Hooked),
this);
CreateFileHook.ThreadACL.SetExclusiveACL(new Int32[] {0});
EasyHook 还提供了一种使用 `LocalHook.CreateUnmanaged` 安装纯非托管 Hook 的方法。您可以使用 C++.NET 编写它们,它允许您组合托管和非托管代码。但是,请记住您将无法访问 `HookRuntimeInformation` 类。您需要直接调用自 EasyHook 2.5 起可用的非托管 API,以从非托管代码访问运行时信息。但是,所有保护机制(参见下一段)仍将围绕您的非托管代码。一个空的非托管 Hook 比一个空的托管 Hook 快几个数量级。如果您的处理程序一旦获得执行,两者都以相同的速度运行。耗时的操作是从非托管环境切换到托管环境,反之亦然,这在使用纯非托管 Hook 时是不需要的!因此,您的处理程序将在大约 70 纳秒内被调用,而托管处理程序需要长达几微秒……在某些情况下,您将需要这种速度增益,这就是 EasyHook 提供它的原因。
2.7 如何编写 Hook 处理程序
到目前为止,没有什么复杂的,我希望你同意。但是,编写 Hook 处理程序是一件非常奇怪的事情。EasyHook 已经提供了几种机制,使得编写 Hook 处理程序更容易,或者说,根本上成为可能。
- 一个线程死锁屏障 (TDB),它将允许您和任何子调用从其处理程序内部再次调用 Hooked API。通常,这会导致死锁,因为处理程序会一遍又一遍地调用自身。EasyHook 将阻止此类循环!这也提供了您无需跟踪干净入口点的优势。
- 一个操作系统加载器锁保护,它将阻止您的处理程序在操作系统加载器锁中执行,如果托管处理程序代码尝试这样做,则会导致进程崩溃!
- 一个线程 ACL 模型,允许您将众所周知的专用线程(用于管理您的 Hooking 库,例如与您的宿主通信的线程)排除在拦截之外。请参阅“稳定 Hooking 指南”一章,了解差异以及为什么 TDB 不够!
- 一种通过名为 `HookRuntimeInfo` 的静态类提供 Hook 特定回调的机制。这样,您就可以访问库实例,而无需使用静态变量,例如。
- 此外,自 EasyHook 2.5 起,您可以生成调用堆栈跟踪、确定调用模块、处理程序返回地址、此返回地址的地址等。
如果没有上述某些机制,将托管代码用作 Hook 处理程序将根本不可能,而这正是 EasyHook 的独特之处。所有这些机制都非常稳定,并经过了数百个并发线程执行数十万个 Hook(在四核 CPU 上)的严格测试。最棒的是,所有这些机制在内核模式下也可用。
使用 Hook 处理程序,您可以简单地为 Hook 的 API 提供自己的实现。但是,您应该详细阅读和理解相关的 API 文档,以便为外部代码提供正确的行为。如果可能,您应该尽快处理拦截,并在注入库中协商访问或任何其他事项。只有在极少数情况下,您才应该以同步方式将调用重定向到宿主应用程序,因为这会严重减慢 Hook 的应用程序;例如,如果无法在库的知识范围内完成访问协商。在实际应用程序中,您应该将所有请求排队,并定期以数组形式传输它们,而不是每次调用。这可以像 FileMon 演示中所示那样完成。
请记住,如果您正在为 64 位或 AnyCPU 编译,则必须使用正确的类型替换。例如,`HANDLE` **不**映射到 `Int32`,而是映射到 `IntPtr`。在 32 位情况下,这不重要,但是当切换到 64 位时,句柄是 64 位宽的,就像 `IntPtr` 一样。相比之下,`DWORD` 始终是 32 位的,正如其名称所暗示的那样。以下是 FileMon 演示中使用的 Hook 处理程序示例:
static IntPtr CreateFile_Hooked(
String InFileName,
UInt32 InDesiredAccess,
UInt32 InShareMode,
IntPtr InSecurityAttributes,
UInt32 InCreationDisposition,
UInt32 InFlagsAndAttributes,
IntPtr InTemplateFile)
{
try
{
Main This = (Main)HookRuntimeInfo.Callback;
lock (This.Queue)
{
if (This.Queue.Count < 1000)
This.Queue.Push(InFileName);
}
}
catch
{
}
// call original API...
return CreateFile(
InFileName,
InDesiredAccess,
InShareMode,
InSecurityAttributes,
InCreationDisposition,
InFlagsAndAttributes,
InTemplateFile);
}
您可能对 1000 的队列限制感到好奇。问题在于,这两个演示应用程序并非设计用于处理大量拦截。我安装了 Tortoise SVN,这导致我的 explorer 产生了数十万个管道访问。令我惊讶的是,IPC 和注入库在处理如此大量的数据时没有问题,但是 `ProcessMonitor` 在尝试向数据网格添加大约 300,000 个条目时阻塞,CPU 使用率达到 100%。我猜测 FileMon 会做出同样的反应,因为它需要一段时间才能写入 300,000 行控制台。由于整个远程 Hooking 机制在这种高性能场景下似乎仍然稳定,所以能否处理它只取决于您的宿主应用程序。为了简单起见,这两个演示**不**能处理高性能场景!因此,如果您的宿主应用程序足够快,您可以安全地移除队列限制。
2.8 使用线程 ACL
EasyHook 管理一个全局 `ThreadACL`,也为每个 Hook 管理一个 ACL。此外,每个 ACL 都可以是包含式或排除式。这使您能够轻松地基于线程 ID 组合几乎任何类型的访问协商。默认情况下,EasyHook 设置一个空的全局排除式 ACL,它将授予所有线程访问权限,并为每个 Hook 设置一个空的包含式本地 ACL,它最终将拒绝所有线程访问。所有 Hook 都以虚拟挂起的方式安装,这意味着没有线程会通过访问协商。这是为了防止在您能够初始化可能的结构(例如 ACL)之前调用 Hook 处理程序。要为所有线程启用 Hook,只需将其本地 ACL 设置为空的排除式 ACL。要仅为当前线程启用它,只需设置一个包含式本地 ACL,其唯一条目为零。线程 ID 为零将**在**设置 ACL 之前自动替换为当前线程 ID(此协商稍后将使用您的线程 ID,并且不了解零)。以下是 `IsThreadIntercepted` 的伪代码:
if(ACLContains(&Unit.GlobalACL, CheckID))
{
if(ACLContains(LocalACL, CheckID))
{
if(LocalACL->IsExclusive)
return FALSE;
}
else
{
if(!LocalACL->IsExclusive)
return FALSE;
}
return !Unit.GlobalACL.IsExclusive;
}
else
{
if(ACLContains(LocalACL, CheckID))
{
if(LocalACL->IsExclusive)
return FALSE;
}
else
{
if(!LocalACL->IsExclusive)
return FALSE;
}
return Unit.GlobalACL.IsExclusive;
}
代码所做的不过是计算由全局和本地 ACL 表示的线程 ID 集合的交集。ACL 总是描述一组将被拦截的线程。ACL 可能是包含式或排除式的事实对此计算没有影响。它只是让您更容易定义一组线程的方式。一个排除式 ACL 以**所有**线程开始,您可以指定应从集合中移除的线程。一个包含式 ACL 以空线程集合开始,您可以手动指定此集合。
如果全局和本地拦截线程集合的交集包含给定的线程 ID,则方法返回 `TRUE`;否则返回 `FALSE`。
只需随意使用它们,并使用 `LocalHook.IsThreadIntercepted` 来检查您的 ACL 是否会提供预期的访问协商。
2.9 使用处理程序实用程序
EasyHook 暴露了一些调试例程,这些例程可能会在未来版本中进行扩展。它们通过 `EasyHook.LocalHook` 类静态可用。目前,它们解决了编写 Hook 处理程序时常见的以下问题:
- 将线程句柄转换回其线程 ID(需要句柄具有 `THREAD_QUERY_INFORMATION` 访问权限)。
- 将进程句柄转换回其进程 ID(需要句柄具有 `PROCESS_QUERY_INFORMATION` 访问权限)。
- 查询任何给定句柄的内核对象名称。通过这种方式,您可以将 `CreateFile` 获取的文件句柄转换回其文件名。即使句柄没有任何访问权限,这也有效!
与 EasyHook 2.0 相比,最新版本不需要调试器来使用任何处理程序实用程序。唯一一点是,仍然需要调试器来重定位 RIP 相对地址。默认情况下,调试器是禁用的。要获得 RIP 重定位支持(这对于 Windows API 通常不是必需的),只需在 Hook 安装之前调用 `LocalHook.EnableRIPRelocation`。
此外,EasyHook 2.5 暴露了 `HookRuntimeInformation` 类,它提供了处理程序特定的支持例程:
- 查询 `ReturnAddress`,查询 `AddressOfReturnAddress`,生成托管/非托管模块堆栈回溯,查询调用托管/非托管模块以及在 Hook 创建期间指定的回调。
- 设置适当的堆栈帧以允许抛出异常和自定义堆栈跟踪。EasyHook 在技术上会使此类堆栈跟踪不可能,但自 2.5 版本以来,可以为定义的代码段恢复堆栈映像以解决此问题。
2.10 IPC 辅助 API
任何目标-宿主场景的核心部分都是 IPC。有了 .NET Remoting,这变得非常出色。正如您在 FileMon 演示中看到的那样,只需两行代码即可在注入库和宿主之间建立稳定、快速且安全的 IPC 通道。当然,这只有通过 EasyHook 暴露的 IPC 辅助例程才能实现。使用原生 `IpcChannels`,代码会膨胀到三页 A4 纸,这仍然相当小。辅助例程将负责序列化设置和通道权限,以便您甚至可以将系统服务与在没有管理员权限的情况下运行的普通用户应用程序连接。它还提供生成随机端口名称的功能。应始终使用此服务,因为这是获得安全连接的唯一方法!如果您想提供自己的名称,您还必须指定允许访问该通道的适当知名 `SID`。在这种情况下,您应该始终指定内置的管理员组,因为所有管理员用户都可能使整个系统崩溃,因此您不必担心被管理员利用!
要使用随机端口名创建服务器,只需调用:
String ChannelName = null;
RemoteHooking.IpcCreateServer<FileMonInterface>(
ref ChannelName,
WellKnownObjectMode.SingleCall);
将生成的端口名传递给客户端并调用:
FileMon.FileMonInterface Interface =
RemoteHooking.IpcConnectClient<FileMon.FileMonInterface>(InChannelName);
从现在开始,您可以使用返回的底层 `MarshalByRefObject` 的客户端实例调用服务器,这些调用将自动重定向到服务器。这不是很棒吗?!但是请注意,这仅适用于实例成员!静态成员和字段将始终在本地处理……
2.11 稳定 Hooking 指南
即使 EasyHook 为 API Hooking 提供了新的维度,在开始编写自己的处理程序之前,您仍应了解一些事项。TDB 将保护您免受每个线程死锁的影响,但在 Hooking 过程中,您会经常遇到此处涵盖的多线程死锁。
场景 1
想象一下,您将重写 FileMon 演示,直接将拦截到的调用发送到目标,而无需经过任何异步队列。即使这看起来是正确的,因为 TDB 应该处理它,但实际上它存在一个大 bug。问题不在于您的代码本身,而在于您的代码调用的代码。而这几乎总是 Hook 处理程序中 bug 的来源。
问题出在 .NET Remoting 中。您在 Hook 安装之前就已经建立了连接,这就是为什么上述方法,即直接将拦截结果发送回宿主,可能最初会起作用。但有时,CLR 会使用 `CreateFile` 重新连接到宿主。这不会造成混乱,因为 TDB 会处理它,但显然,.NET Remoting 在一个排队的 worker item 中执行此操作。这将在另一个线程中调用 `CreateFile`,而拦截的线程正在等待它完成。但是,因为它在另一个线程中执行,所以它也再次被拦截。CLR 仍然没有连接,并且会一遍又一遍地启动整个过程,直到 worker item 队列已满并且没有更多可用线程为止。这就是目标将挂起的时间。
当然,我只是猜测,因为我不知道 .NET Remoting 的内部结构,但这种死锁会发生!有人可能会说,一个解决方案是在将拦截发送回宿主之前调用原始 API,也许确实如此,但请记住我们的 Hook 处理程序有多么简单,这个简单的死锁场景可以很容易地适用于任何您无法以这种方式解决的复杂场景。这只是为了向您展示基本思想……
场景 2
我们现在已经学到了教训,永远不会再犯同样的错误,是吗?假设我们使用一个专用线程将排队的拦截发送到我们的宿主。`Stack
现在,发生以下情况。为了将结果发送回我们的宿主,专用线程可能会在持有锁的同时再次调用 `CreateFile`。如果 .NET Remoting 使用相同的线程调用此 API,`Monitor::Enter` 的递归锁功能和 TDB 将发挥作用并防止任何死锁。但有时,.NET Remoting 会使用另一个线程,并且由于我们已经持有锁,它永远无法进入。当然,您可以通过在锁中填充一个临时缓冲区并在外部发送结果来再次解决这个问题,就像 FileMon 演示中那样。
一个通用解决方案
我们学到了什么?编写稳定的 Hook 处理程序不仅是一个好的 Hooking 库(如 EasyHook)的特性,也是 Windows API 编程经验和您自己的应用程序中可能出现问题的体现。在处理多线程代码时要小心,因为在这种情况下 TDB 不会完全保护您。不要调用内部使用或等待其他线程完成的 API 或 .NET 成员。如果您需要这样做,请使用专用线程,并将其排除在拦截之外。随着时间的推移,您将编写越来越稳定的 Hook 处理程序,并且不会担心一开始导致目标崩溃或挂起的事情。有了 EasyHook,您手中就拥有了一个强大的实用程序,其余的取决于您!
另一个您应该了解的副作用是,在处理 .NET Remoting 时,通常在宿主应用程序中实现共享类。这将导致 CLR 加载您的宿主程序集并初始化相关类中所有关联的静态变量。这就是为什么您不应该在与用作远程接口的类关联的类中初始化任何静态变量的原因。
2.12 展望未来
最新版本“EasyHook 2.5 Beta”目前已实现所有所需功能。如果您有任何遗漏,请随时提出新功能请求。我计划在未来两个月内发布第一个候选版本。请报告您发现的任何错误;否则,稳定的候选版本是不可能的!
目前,我正在为微软网站上提到的 ProcessMonitor 开发一个开源克隆。我的只会使用用户模式 Hook 来完成其任务,并且应该是一个如何编写 Hooking 应用程序的好例子。我计划在 2008 年 8 月下旬发布它。我没有时间整天测试 EasyHook。我将始终确保所有发布的演示和测试套件在所有支持的操作系统版本上都能正常工作,但这是我目前所能做的全部。不仅如此,在开源的情况下,客户就是测试人员。但是,您报告的任何错误都将很快得到修复!