CLR 注入:运行时方法替换






4.99/5 (43投票s)
在运行时将任何方法替换为另一个方法。已更新至 3.5 SP1。
引言
我一直对 CLR 的内部工作原理很感兴趣。其中一个特别令人感兴趣的部分是即时编译器(JIT)。今天,我们将探讨 JIT 如何编译 MSIL,并创建一个实用工具,使我们能够以编程方式在运行时将任何 JIT 编译的方法替换为另一个方法。我们还将创建一个调试实用工具,该工具将拦截 JIT 调用并将诊断信息打印到控制台。
即时编译器
Microsoft 中间语言或 MSIL(正确名称为公共中间语言 (CIL))是一种低级汇编类型的语言。所有 .NET 语言(C++/CLI 有一些例外)都编译为 MSIL。处理器无法直接运行 MSIL(未来可能会有类似 ARM Jazelle 的 .NET 技术)。即时编译器用于将 MSIL 转换为机器码。JIT 只会编译一个方法一次,CLR 会缓存 JIT 输出的机器码以供将来调用。
编译过程需要非常快,因为它发生在运行时。由于 MSIL 是一种低级语言,其操作码可以非常轻松地转换为特定于机器的操作码。编译过程本身使用一种称为 JITStub 的东西。JITStub 是一块机器码,每个方法都会有一个。JIT stub 最初包含调用 JIT 编译方法的代码。在方法被 JIT 编译后,stub 会被替换为直接调用 JIT 创建的机器码的代码。
000EFA60 E86488D979 call 79E882C9 // Before JIT. Call to JIT method
000EFA60 E97BC1CB00 jmp 00DABBE0 // After JIT. Jmp to JIT'ed assembly code
每个类都有一个方法表。方法表包含一个类的方法的所有 JITStub 的地址。JIT 编译时使用方法表来解析方法 JIT stub。已 JIT 编译的方法将不引用此表;JIT 创建的机器码将直接调用 stub 地址。下面是 SOS 的方法表输出。Entry 列包含 stub 地址。
MethodDesc Table
Entry MethodDesc JIT Name
79371278 7914b928 PreJIT System.Object.ToString()
7936b3b0 7914b930 PreJIT System.Object.Equals(System.Object)
7936b3d0 7914b948 PreJIT System.Object.GetHashCode()
793624d0 7914b950 PreJIT System.Object.Finalize()
000efa60 00d77ce0 JIT ReplaceExample.StaticClassB.A()
000efa70 00d77ce8 NONE ReplaceExample.StaticClassB.B()
可以在不实际调用方法的情况下,从托管代码调用 JIT。System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod
方法将强制 JIT 编译该方法。
调试
调试 JIT 过程可能非常困难。幸运的是,我们可以使用许多出色的工具。我们将简要介绍它们。
使用 SOS 进行调试
SOS 代表 Son of Strike(罢工之子)。它是 CLR 的一个调试扩展,您可以在 Visual Studio 或 WinDbg 中使用它。它是 .NET 框架附带的一个非常棒的工具。我曾多次使用它来调试没有安装 Visual Studio 的机器上的 .NET 异常。我们还可以使用它来定位和查看 CLR 使用的内存中的结构,查看汇编和 IL,以及许多其他事情。它是我编写本文的主要调试工具。SOS 仅在启用非托管调试时才有效。更多信息,请单击此处。
Rotor
Rotor 是 Microsoft 发布的一个开源(在 Microsoft 共享源许可证下发布)CLR。Rotor 与 Microsoft 发布的 CLR 不同,但它是一个完整的 CLR。代码量巨大,要找到所需内容可能非常困难。我们在 JIT Logger 中使用了一些 Rotor 的头文件。
JIT Logger
JITLogger 是一个将 JIT 调用记录到控制台的工具。它可以启用和禁用。我使用了 Daniel Pistelli 在他的 .NET Internals and Code Injection 文章中的代码来创建 JITLogger。下面是 JITLogger 的签名和一些示例输出。更多信息,请查看附件代码或阅读 Daniel 的出色 文章。
public class JitLogger
{
public static bool Enabled { get; set; }
public static int JitCompileCount { get; }
}
输出
JIT : 0xdd20a8 Program.StaticTests
JIT : 0xdd217b Program.TestStaticReplaceJited
JIT : 0x749c205c MethodUtil.ReplaceMethod
JIT : 0x749c2270 MethodUtil.MethodSignaturesEqual
JIT : 0x749c231c MethodUtil.GetMethodReturnType
JIT : 0x749c20e4 MethodUtil.GetMethodAddress
JIT : 0xdd23e6 StaticClassB.A
方法注入
我希望替换代码非常基础。我们将采用两个方法,一个源方法和一个目标方法,并将目标方法替换为源方法。下面是我们替换方法的签名
public static void ReplaceMethod(MethodBase source, MethodBase dest)
IL 注入
我最初想替换 IL,但遇到了一些问题。CLR 的行为似乎因生成模式和是否附加调试器而异。此外,JIT 调用会被缓存(stub 已替换)。我们需要让 CLR 以某种方式使 JIT 缓存失效。我设法让它生效了,但只在调试模式下,并且我必须为每个方法持久化一些状态才能让 CLR 重新 JIT 编译它。
我也尝试过使用 Daniel Pistelli 的挂钩方法,但遇到了一些问题。我想通过编程方式从托管代码替换方法。我认为可以挂钩 JIT,使用 RuntimeHelpers.PrepareMethod
来使方法被 JIT 编译,然后修改传递给我们挂钩方法的 CORINFO_METHOD_INFO
结构。在托管和非托管挂钩方法之间传递状态是一个问题。如果我们从挂钩方法调用任何托管方法,就会发生堆栈溢出。此外,使 JIT 缓存失效很麻烦,而且同样,我只能在调试模式下让它工作。
我尝试的另一种方法是使用非托管元数据 API。我会从元数据方法表中读取 RVA,使用 RVA 和模块基地址在内存中查找 IL 地址,然后直接覆盖它。这有问题,因为源 IL 的字节长度必须小于目标。这不仅仅是 IL。我们还有 tiny 或 fat IL 头,可能还有 SEH 结构等。方法调用后,一旦 JITStub 被替换,我们就遇到了与其他方法相同的问题。
在 IL 方法遇到瓶颈后,我决定尝试一种不同的方法。我们不替换 IL,而是替换 JIT 输出的汇编代码。通过这种方法,我们不必担心使缓存失效、IL 头、SEH 等问题。
JIT 注入后
我们的新方法将确保源方法和目标方法都经过编译,在内存中定位两者的参数表,并将目标参数表的 JITStub 地址替换为源的 JITStub 地址。我们可以任意多次替换方法,而不必担心方法被缓存。我们首先需要定位内存中的几个项。
我们将使用 RuntimeTypeHandle
和 RuntimeMethodHandle
来定位内存中的参数表和方法槽。RuntimeMethodHandle
指向内存中一个 8 字节的结构,称为 MethodDescription
。这是我们在使用 SOS !DumpMT -MD
命令时在 MethodDesc 列中看到的相同地址。此结构包含方法在参数表中的索引。然后,我们可以使用 RuntimeTypeHandle
来定位参数表本身。参数表从 RuntimeTypeHandle
地址偏移 40 字节开始。
动态方法的工作方式不同。我找不到太多文档,但我能够使用内存调试器找到 JITStub 地址。动态方法不公开其 RuntimeMethodHandle
,因此我们需要使用反射来获取它。我发现 JITStub 地址在运行时方法句柄地址偏移 24 字节处。
public static IntPtr GetMethodAddress(MethodBase method)
{
if ((method is DynamicMethod))
{
unsafe
{
byte* ptr = (byte*)GetDynamicMethodRuntimeHandle(method).ToPointer();
if (IntPtr.Size == 8)
{
ulong* address = (ulong*)ptr;
address += 6;
return new IntPtr(address);
}
else
{
uint* address = (uint*)ptr;
address += 6;
return new IntPtr(address);
}
}
}
RuntimeHelpers.PrepareMethod(method.MethodHandle);
unsafe
{
// Some dwords in the met
int skip = 10;
// Read the method index.
UInt64* location = (UInt64*)(method.MethodHandle.Value.ToPointer());
int index = (int)(((*location) >> 32) & 0xFF);
if (IntPtr.Size == 8)
{
// Get the method table
ulong* classStart = (ulong*)method.DeclaringType.TypeHandle.Value.ToPointer();
ulong* address = classStart + index + skip;
return new IntPtr(address);
}
else
{
// Get the method table
uint* classStart = (uint*)method.DeclaringType.TypeHandle.Value.ToPointer();
uint* address = classStart + index + skip;
return new IntPtr(address);
}
}
}
private static IntPtr GetDynamicMethodRuntimeHandle(MethodBase method)
{
if (method is DynamicMethod)
{
FieldInfo fieldInfo = typeof(DynamicMethod).GetField("m_method",
BindingFlags.NonPublic|BindingFlags.Instance);
return ((RuntimeMethodHandle)fieldInfo.GetValue(method)).Value;
}
return method.MethodHandle.Value;
}
获取 JITStub 地址的位置后,我们只需更改值。下面是我们的替换方法
public static void ReplaceMethod(IntPtr srcAdr, MethodBase dest)
{
IntPtr destAdr = GetMethodAddress(dest);
unsafe
{
if (IntPtr.Size == 8)
{
ulong* d = (ulong*)destAdr.ToPointer();
*d = *((ulong*)srcAdr.ToPointer());
}
else
{
uint* d = (uint*)destAdr.ToPointer();
*d = *((uint*)srcAdr.ToPointer());
}
}
}
public static void ReplaceMethod(MethodBase source, MethodBase dest)
{
if (!MethodSignaturesEqual(source, dest))
{
throw new ArgumentException("The method signatures are not the same.",
"source");
}
ReplaceMethod(GetMethodAddress(source), dest);
}
示例代码
在我们的示例代码中,我们将尝试几件事。我们将用另一个类的静态方法替换一个类的静态方法。对于实例方法,我们也将做同样的事情。我们还将用 DynamicMethod 替换静态方法。我们的一些测试方法在 Release 模式下被内联了。我必须向多个方法添加 MethodImpl
属性以防止内联。
如果我们单步执行代码,我们会注意到 Visual Studio 也会被欺骗。在方法被替换后,Visual Studio 将会进入新方法而不是旧方法。
下面是我们测试的输出
Enabling JIT debugging.
JIT : 0x10720a8 Program.StaticTests
JIT : 0x107217b Program.TestStaticReplaceJited
Replacing StaticClassA.A() with StaticClassB.A()
JIT : 0x71ac205c MethodUtil.ReplaceMethod
JIT : 0x71ac2270 MethodUtil.MethodSignaturesEqual
JIT : 0x71ac231c MethodUtil.GetMethodReturnType
JIT : 0x71ac20e4 MethodUtil.GetMethodAddress
JIT : 0x10723e6 StaticClassB.A
JIT : 0x71ac2094 MethodUtil.ReplaceMethod
JIT : 0x1072426 StaticClassA.A
Call StaticClassA.A() from a method that has already been jited
StaticClassA.A
Call StaticClassA.A() from a method that has not been jited
JIT : 0x1072172 Program.TestStaticReplace
StaticClassB.A
JIT : 0x1072190 Program.InstanceTests
JIT : 0x1072284 Program.TestInstanceReplaceJited
Replacing InstanceClassA.A() with InstanceClassB.A()
JIT : 0x10723c2 InstanceClassB.A
JIT : 0x1072402 InstanceClassA.A
Call InstanceClassA.A() from a method that has already been jited
JIT : 0x107241e InstanceClassA..ctor
InstanceClassA.A
Call InstanceClassA.A() from a method that has not been jited
JIT : 0x1072268 Program.TestInstanceReplace
InstanceClassB.A
JIT : 0x10722a0 Program.DynamicTests
JIT : 0x1072344 Program.CreateTestMethod
Created new dynamic metbod StaticClassA.C
JIT : 0x107232e Program.TestDynamicReplaceJited
Replacing StaticClassA.B() with dynamic StaticClassA.C()
JIT : 0x71ac2210 MethodUtil.GetDynamicMethodRuntimeHandle
JIT : 0x1072434 StaticClassA.B
Call StaticClassA.B() from a method that has already been jited
StaticClassA.B
Call StaticClassA.B() from a method that has not been jited
JIT : 0x1072325 Program.TestDynamicReplace
JIT : 0x10c318 DynamicClass.C
StaticClassA.C
结论
此代码的实际用途有限。如果您想修改正在使用的库但又无法访问源代码,不想反编译、重新编译或使用十六进制编辑器,那么这可能会对您有所帮助。也许可以创建一个 AOP 库,该库在运行时修改现有类型,而不是创建包装器或使用生成时方法。
此代码存在一些限制。从示例代码中我们可以看到,一旦方法被 JIT 编译,它将不再引用我们正在更改的参数表的地址。替换操作应在调用方法被 JIT 编译之前发生。这在 x86-64 机器上从未经过测试。Zapped 或 NGen-ed 程序集也不起作用。
我们需要记住,我们正在直接操作 CLR 内存,这并非预期用途。此代码可能无法在新版本的 .NET 框架上工作。此代码在 Vista x86 上使用 .NET 3.5 测试过,在您的机器上可能无法正常工作。
编写一个能够检测处理器功能并重新发出更优化的版本,从而利用 SIMD 等技术的类可能会很酷。我对 x86 汇编的了解还很有限。稍后我会看看是否能整理出一些东西。
更新
看来 .NET 2.0 SP2 中的内存布局发生了变化,我在安装 .NET 3.5 SP1 时被迫安装了它。我已更新代码以检测框架并相应地进行操作。如果您有任何问题,请告诉我:ziadelmalki@hotmail.com。
链接
- .NET Internals and Code Injection: http://www.ntcore.com/Files/netint_injection.htm
- SOS Debugging Extension: http://msdn.microsoft.com/en-us/library/bb190764.aspx
- SOS Cheat Sheat: http://geekswithblogs.net/.NETonMyMind/archive/2006/03/14/72262.aspx
- Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects: http://msdn.microsoft.com/en-us/magazine/cc163791.aspx
- Digging into interface calls in the .NET Framework: Stub-based dispatch: http://blogs.msdn.com/vancem/archive/2006/03/13/550529.aspx
- Debugging Rotor Jit Call: http://www.xwang.org/2009/02/debugging-rotor-jit-call/
- ARM Jazelle DBX (Direct Bytecode eXecution): http://www.arm.com/products/multimedia/java/jazelle.html