【译】.NET CLR 注入:运行时修改IL代码

.NET CLR Injection: Modify IL Code during Run-time

原文 by 

翻译 by 

转载请保留作者信息与出处
Jerry Wang(CSDN博客地址)是一位经常在codeproject发表文章的国人大神。这篇文章所提出的动态修改IL代码确实很厉害,也很有实用价值。因此尝试进行简单翻译。(作者自己为什么不出中文版呢……)

运行时:程序运行的时候。为区别于.NET Runtime,后者将采用Runtime的写法(《CLR via C#》的译者也是采用的这种方法)

Introduction - 简介   

你可以下载示例(DEMO)程序(请在原文链接下载)尝试一下,它演示了在程序运行时对.NET方法进行动态修改。

  • 支持各种.NET版本,从2.0到4.5(译注:通过少量修改即可支持4.6)
  • 支持修改各种方法,包括动态方法和泛型方法
  • 支持Release编译的.NET进程   
  • 支持 x86 和 x64 程序   

在程序运行时修改.NET方法的MSIL代码,听上去就很酷,而且可以用在Hook、软件保护等多种领域。因此我想要做到这一点。但是有一个大难题——MSIL代码可能在我们去修改之前已经被JIT编译器编译成了本机(native)代码。另外.NET CLR的具体实现是没有文档说明的,而且每个版本都在变化,我们需要一种可靠、稳定、不依赖特定内存布局的办法来实现修改。

通过一周多的研究之后,我终于做到了!这是示例程序中的一个简单的方法:

protected string CompareOneAndTwo()
{
    int a = 1;
    int b = 2;
    if (a < b)
    {
        return "Number 1 is less than 2";
    }
    else
    {
        return "Number 1 is greater than 2 (O_o)";
    }
}

显然它会返回 "Number 1 is less than 2"。让我们把它修改成返回错误的结果: "Number 1 is greater than 2 (O_o)".

观察这个方法的MSIL代码。(译注:这里展示的IL代码是优化过的,即在编译时打开了“优化”开关。否则IL代码将会不同。)我们可以通过将操作码 Bge_S (大于等于则跳转)改为 Blt_S (小于则跳转)。这样这个if语句的逻辑就被改变了,代码将会跳转到我们预期的错误的结果。

你可以试一试示例程序,结果如下图。

这是用来替换IL的代码。我已经加了很多注释。

Hook .NET Method - Hook .NET方法

根据 ECMA-335 的说明,操作码 jmp  用来跳转到目标方法。不同于操作码 call ,当前方法的参数也会传送到目标方法——这就简单多了。

比如这个示例程序中的方法。

string TargetMethod(string a, string b)
{
      return a + "," + b;
}

要Hook上面的方法,我们首先声明一个具有同样参数和返回类型的方法。

string ReplaceMethod(string a, string b)
{
    return string.Format( "This method is hooked, a={0};b={1};", a, b);
}

然后,准备一段将要插入到 TargetMethod 的IL代码。

MethodInfo replaceMethod = type.GetMethod("ReplaceMethod", BindingFlags.NonPublic | BindingFlags.Instance);

byte[] ilCodes = new byte[5];
ilCodes[0] = (byte)OpCodes.Jmp.Value;
ilCodes[1] = (byte)(replaceMethod.MetadataToken & 0xFF);
ilCodes[2] = (byte)(replaceMethod.MetadataToken >> 8 & 0xFF);
ilCodes[3] = (byte)(replaceMethod.MetadataToken >> 16 & 0xFF);
ilCodes[4] = (byte)(replaceMethod.MetadataToken >> 24 & 0xFF);

第一个字节是操作码 jmp ,之后的操作数是目标方法的token。用这段IL代码替换 TargetMethod 的方法体。

MethodInfo targetMethod = type.GetMethod("TargetMethod", BindingFlags.NonPublic | BindingFlags.Instance);
InjectionHelper.UpdateILCodes(targetMethod, ilCodes);

于是 TargetMethod 成功被Hook。

Using the code - 如何使用代码

 InjectionHelper.cs 复制到你的项目。此类包含几个方法。

public static class InjectionHelper
{
    // 加载非托管库injection.dll。初始化过程会在单独的线程执行
    public static void Initialize()
 
    // 卸载非托管库injection.dll
    public static void Uninitialize()
 
    // 更新某个方法的IL代码
    public static void UpdateILCodes(MethodInfo method, byte[] ilCodes, int nMaxStack = -1) 
 
    // 直到初始化完成,此方法才会返回
    public static Status WaitForIntializationCompletion()
}

(译注:此处为2012版代码。作者于2014年更新的代码可能与上述代码不完全相同,但改动不大。另外你可以将InjectionHelper.cs封装成库方便使用

 InjectionHelper.Initialize 方法会从程序的当前目录加载非托管代码编写的 injection.dll 。所以你需要将相应的库放到正确的位置,或者修改代码更改加载位置。


文件名描述
Injection32.dll实现本文描述功能的非托管库 (x86) 
Injection64.dll实现本文描述功能的非托管库 (x64) 

 

Background  - 背景知识

Replace the IL code - 替换IL代码

首先来看看CLR和JIT是怎么工作的。

JIT的实现DLL(对于.NET 4以上是 clrjit.dll / 对于.NET 2.0-3.5是 mscorjit.dll )导出一个 _stdcall 的方法 getJit ,此方法返回一个 ICorJitCompiler 

CLR的实现DLL(对于.NET 4以上是 clr.dll / 对于.NET 2.0-3.5是 mscorwks.dll)调用 getJit 方法来取得 ICorJitCompiler  ,然后调用它的 compileMethod 方法来将MSIL代码编译为本机代码。

CorJitResult compileMethod(ICorJitInfo * pJitInfo, CORINFO_METHOD_INFO * pMethodInfo, 
   UINT nFlags, LPBYTE * pEntryAddress, ULONG * pSizeOfCode);

这里要做的很简单:找到 compileMethod 方法的地址,然后用 EasyHook 来Hook这个方法。

// JIT DLL中的ICorJitCompiler接口
class ICorJitCompiler 
{
public:
	typedef CorJitResult (__stdcall ICorJitCompiler::*PFN_compileMethod)(ICorJitInfo * pJitInfo, CORINFO_METHOD_INFO * pMethodInfo, UINT nFlags, LPBYTE * pEntryAddress, ULONG * pSizeOfCode);

	CorJitResult compileMethod(ICorJitInfo * pJitInfo, CORINFO_METHOD_INFO * pMethodInfo, UINT nFlags, LPBYTE * pEntryAddress, ULONG * pSizeOfCode)
	{
		return (this->*s_pfnComplieMethod)( pJitInfo, pMethodInfo, nFlags, pEntryAddress, pSizeOfCode);
	}
private:
	static PFN_compileMethod s_pfnComplieMethod;
};

// 保存原方法的地址
LPVOID pAddr = tPdbHelper.GetJitCompileMethodAddress();
LPVOID* pDest = (LPVOID*)&ICorJitCompiler::s_pfnComplieMethod;
*pDest = pAddr;

// 这是我们的compileMethod方法
CorJitResult __stdcall CInjection::compileMethod(ICorJitInfo * pJitInfo , CORINFO_METHOD_INFO * pCorMethodInfo , UINT nFlags , LPBYTE * pEntryAddress , ULONG * pSizeOfCode )
{
	ICorJitCompiler * pCorJitCompiler = (ICorJitCompiler *)this;
	
	// TODO: 在调用原来的compileMethod方法之前,将IL代码替换掉
	CorJitResult result = pCorJitCompiler->compileMethod( pJitInfo, pCorMethodInfo, nFlags, pEntryAddress, pSizeOfCode);
	return result;
}

// Hook JIT的compileMethod,用我们的方法来替换
NTSTATUS ntStatus = LhInstallHook( (PVOID&)ICorJitCompiler::s_pfnComplieMethod 
	, &(PVOID&)CInjection::compileMethod
	, NULL
	, &s_hHookCompileMethod
	);
 

Modify IL code for JIT-complied methods - 对于JIT已编译的方法,如何修改IL代码

终于谈到了这个问题。对于JIT已经编译过的方法,CLR不会调用上文中的 compileMethod 方法。为解决这个问题,我的想法是将CLR中的相关数据结构恢复到JIT编译之前的状态。这样,CLR就会再调用一次 compileMethod 方法,我们就能够替换对应的IL了。

因此我们需要稍微看一看CLR的实现。SSCLI(Shared Source Common Language Infrastructure是一个微软提供的很好的参照,但是它太旧了,我们无法直接使用它。


上面的图有点过时了,但是主要的结构还是一样的。对于.NET中的每个类(class),内存中至少有一个 MethodTable 结构。每个 MethodTable 对应一个 EEClass ,后者存储Runtime类型信息以供反射(Reflection)和其它用途使用。

对于每个方法(method),内存中至少有一个 MethodDesc 结构,包含此方法的标志位(flags)、结构地址(slot address)、入口地址(entry address)(译注:《CLR via C#》中将entry翻译为记录项)等。

在一个方法被JIT编译之前,此结构会将入口地址设置成指向一个特殊的JMI trunk(prestub),调用它会触发JIT编译;当IL代码已被编译后,此结构会将入口地址修改为对应方法的JMI trunk,此时调用它就会直接跳转到编译后的本地代码。

译注:这里附上《CLR via C#》的相关解释(中文版第四版P11):
CLR为每个类型分配一个内部结构。在这个内部数据结构中,每个方法都有一个对应的记录项(entry)。每个记录项都含有一个地址,根据此地址即可找到方法的实现。对这个结构初始化时,CLR将每个记录项都设置成(指向)包含在CLR内部的一个未编档函数。我将该函数成为JITCompiler
当首次调用某一方法时,JITCompiler方法会被调用。JITCompiler函数负责将方法的IL代码编译成本机CPU指令。JITCompiler函数被调用时,它知道要调用的是哪个方法,以及具体是什么类型定义了该方法。然后,JITCompiler会在定义(该类型的)程序集的元数据中查找被调用方法的IL。接着,JITCompiler验证IL代码,并将IL代码编译成本机CPU指令。本机CPU指令保存到动态分配的内存块中。然后,JITCompiler回到CLR为类型创建的内部数据结构,找到与被调用方法对应的那条记录,修改最初对JITCompiler的引用,使其指向内存块(其中包含了刚才编译好的本机CPU指令)的地址。最后,JITCompiler函数跳转到内存块中的代码。这些代码正是对应的方法的具体实现。代码执行完毕并返回时,会回到调用者的代码,并像往常一样继续执行。

要恢复这个数据结构,首先清空标志位(flags),然后将入口地址(entry address)重新修改为会触发JIT编译的地址。我在调试器中通过直接修改内存做到了。但是这样很麻烦,而且依赖于特定的内存布局,并且无法适用于不同的.NET版本。

幸运的是,后来我找到一个可靠的办法。我发现SSCLI源代码中有一个 MethodDesc::Reset 方法(vm/method.cpp)。  

void MethodDesc::Reset()
{
    CONTRACTL
    {
        THROWS;
        GC_NOTRIGGER;
    }
    CONTRACTL_END
 
    // This method is not thread-safe since we are updating
    // different pieces of data non-atomically.
    // Use this only if you can guarantee thread-safety somehow.

    _ASSERTE(IsEnCMethod() || // The process is frozen by the debugger
             IsDynamicMethod() || // These are used in a very restricted way
             GetLoaderModule()->IsReflection()); // Rental methods                                                                 

    // Reset any flags relevant to the old code
    ClearFlagsOnUpdate();
 
    if (HasPrecode())
    {
        GetPrecode()->Reset();
    }
    else
    {
        // We should go here only for the rental methods
        _ASSERTE(GetLoaderModule()->IsReflection());
 
        InterlockedUpdateFlags2(enum_flag2_HasStableEntryPoint | enum_flag2_HasPrecode, FALSE);
 
        *GetAddrOfSlotUnchecked() = GetTemporaryEntryPoint();
    }
 
    _ASSERTE(!HasNativeCode());
}

如你所见,这个方法就是我们需要的。所以我只需要调用此方法就可以将 MethodDesc 的状态恢复到JIT编译之前。

当然,我不能用SSCLI中的 MethodDesc ,而CLR中的 MethodDesc 是微软内部使用的,他的具体实现和布局除了微软自己之外,谁也不知道。

山重水复疑无路,柳暗花明又一村。

(译注:没错,作者写的就是这句话)

幸运的是,这个内部方法的地址存在于微软符号文件服务器(Microsoft Symbol Server)提供的PDB符号文件中,这就解决了我们的问题。我们可以通过解析PDB文件来得知CLR DLL中 Reset() 方法的地址。

现在最后一个未知要素在左边—— MethodDesc 的 this 指针。要取得它并不难,事实上,MethodBase.MethodHandle.Value == CORINFO_METHOD_HANDLE == MethodDesc 地址 ==  MethodDesc 的 this 指针。

这是我在非托管代码中定义的 MethodDesc 类。

class MethodDesc
{
	typedef void (MethodDesc::*PFN_Reset)(void);
	typedef BOOL (MethodDesc::*PFN_IsGenericMethodDefinition)(void);
	typedef ULONG (MethodDesc::*PFN_GetNumGenericMethodArgs)(void);
	typedef MethodDesc * (MethodDesc::*PFN_StripMethodInstantiation)(void);
	typedef BOOL (MethodDesc::*PFN_HasClassOrMethodInstantiation)(void);
	typedef BOOL (MethodDesc::*PFN_ContainsGenericVariables)(void);	
	typedef MethodDesc * (MethodDesc::*PFN_GetWrappedMethodDesc)(void);
	typedef AppDomain * (MethodDesc::*PFN_GetDomain)(void);
	typedef Module * (MethodDesc::*PFN_GetLoaderModule)(void);

public:
	void Reset(void) { (this->*s_pfnReset)(); }
	BOOL IsGenericMethodDefinition(void) { return (this->*s_pfnIsGenericMethodDefinition)(); }
	ULONG GetNumGenericMethodArgs(void) { return (this->*s_pfnGetNumGenericMethodArgs)(); }
	MethodDesc * StripMethodInstantiation(void) { return (this->*s_pfnStripMethodInstantiation)(); }
	BOOL HasClassOrMethodInstantiation(void)  { return (this->*s_pfnHasClassOrMethodInstantiation)(); }
	BOOL ContainsGenericVariables(void) { return (this->*s_pfnContainsGenericVariables)(); }
	MethodDesc * GetWrappedMethodDesc(void) { return (this->*s_pfnGetWrappedMethodDesc)(); }
	AppDomain * GetDomain(void) { return (this->*s_pfnGetDomain)(); }
	Module * GetLoaderModule(void) { return (this->*s_pfnGetLoaderModule)(); }
	
private:
	static PFN_Reset s_pfnReset;
	static PFN_IsGenericMethodDefinition s_pfnIsGenericMethodDefinition;
	static PFN_GetNumGenericMethodArgs s_pfnGetNumGenericMethodArgs;
	static PFN_StripMethodInstantiation s_pfnStripMethodInstantiation;
	static PFN_HasClassOrMethodInstantiation s_pfnHasClassOrMethodInstantiation;
	static PFN_ContainsGenericVariables s_pfnContainsGenericVariables;
	static PFN_GetWrappedMethodDesc s_pfnGetWrappedMethodDesc;
	static PFN_GetDomain s_pfnGetDomain;
	static PFN_GetLoaderModule s_pfnGetLoaderModule;
}; 

上面的static静态变量存储的是CLR DLL中 MethodDesc 类实现的内部方法的地址。当我的非托管DLL加载后,它们会被初始化。其它的公共成员就只是通过 this 指针和这些地址来调用这些内部方法。

现在,要调用Microsoft实现的内部方法就很容易了,就像这样:

MethodDesc * pMethodDesc = (MethodDesc*)pMethodHandle;
pMethodDesc->Reset();

Find internal methods' addresses from the PDB Symbol file - 从PDB符号文件中寻找内部方法的地址

内部方法的虚地址可以从PDB符号文件中获取。有了虚地址,我们就可以通过“虚地址+DLL基地址”来得到运行时此方法的实际地址。

Method Address = Method Virtual Address + base address of dll.  //方法实际地址 = 方法虚地址 + DLL基地址

在我之前的版本中(译注:指2012版),PDB文件是通过微软提供的symcheck.exe来下载到本地并解析的。

在现在的版本中,我做了一个web服务来在服务器上解析这些地址并直接返回给客户端,这应该会减少初始化所需要的时间。

(译注:但是至少我并不能连接上这个服务器。连接到作者的服务器不如微软的服务器那样可靠,虽然微软的服务器速度可能较慢,下载的PDB文件也相对比较大。)

将来,在收集了足够多的虚地址之后,不同版本的文件的虚地址可能会储存在DLL的资源中。在初始化过程中injection.dll会先在资源中查找是否存在对应文件版本的虚地址,如果没有此文件的版本,再请求web服务获取。这样,web服务就仅作为一个后备选择。

Reset the MethodDesc to pre-JITted status - 重置MethodDesc到JIT编译前的状态

现在,万事俱备。非托管DLL为托管代码导出了一个方法,接收IL代码和托管代码中获取到的 MethodBase.MethodHandle.Value 。

// 存储用于替换的IL代码的结构
typedef struct _ILCodeBuffer
{
	LPBYTE						pBuffer;
	DWORD						dwSize;
} ILCodeBuffer, *LPILCodeBuffer;


// 提供给托管代码调用的方法
BOOL CInjection::StartUpdateILCodes( MethodTable * pMethodTable
	, CORINFO_METHOD_HANDLE pMethodHandle
	, mdMethodDef md
	, LPBYTE pBuffer
	, DWORD dwSize
	)
{
	MethodDesc * pMethodDesc = (MethodDesc*)pMethodHandle;

	// 重置此MethodDesc
	pMethodDesc->Reset();

	ILCodeBuffer tILCodeBuffer;
	tILCodeBuffer.pBuffer = pBuffer;
	tILCodeBuffer.dwSize = dwSize;
	tILCodeBuffer.bIsGeneric = FALSE;

	// 存储将用于替换的IL代码
	s_mpILBuffers.insert( std::pair< CORINFO_METHOD_HANDLE, ILCodeBuffer>( pMethodHandle, tILCodeBuffer) );

	return TRUE;
}


上面的代码调用 Reset() 方法,之后将IL代码存储到一个map中,这个map将会在方法被编译时由 complieMethod 方法使用。

在 complieMethod 方法中就可以替换IL代码了。如下所示:

CorJitResult __stdcall CInjection::compileMethod(ICorJitInfo * pJitInfo
	, CORINFO_METHOD_INFO * pCorMethodInfo
	, UINT nFlags
	, LPBYTE * pEntryAddress
	, ULONG * pSizeOfCode
	)
{
	ICorJitCompiler * pCorJitCompiler = (ICorJitCompiler *)this;
	LPBYTE pOriginalILCode = pCorMethodInfo->ILCode;
	unsigned int nOriginalSize = pCorMethodInfo->ILCodeSize;

	
	ILCodeBuffer tILCodeBuffer = {0};

	MethodDesc * pMethodDesc = (MethodDesc*)pCorMethodInfo->ftn;

	// 找到将用于替换的代码
	std::map< CORINFO_METHOD_HANDLE, ILCodeBuffer>::iterator iter = s_mpILBuffers.find((CORINFO_METHOD_HANDLE)pMethodDesc);

	if( iter != s_mpILBuffers.end() )
	{
		tILCodeBuffer = iter->second;
		pCorMethodInfo->ILCode = tILCodeBuffer.pBuffer;
		pCorMethodInfo->ILCodeSize = tILCodeBuffer.dwSize;
	}

	CorJitResult result = pCorJitCompiler->compileMethod( pJitInfo, pCorMethodInfo, nFlags, pEntryAddress, pSizeOfCode);

	return result;
}  

Generic method - 泛型方法

一个泛型方法映射到内存中的一个 MethodDesc 。但是使用不同的类型作为参数调用泛型方法会导致CLR创建不同的方法定义。(这些实例化的方法可能会共享代码,参见下面泛型方法的实例化的相关说明)。

  1. shared generic method instantiations - 共享的泛型方法实例
  2. unshared generic method instantiations - 不共享的泛型方法实例
  3. instance methods in shared generic classes - 共享的泛型类中的实例方法
  4. instance methods in unshared generic classes - 不共享的泛型类中的实例方法
  5. static methods in shared generic classes - 共享的泛型类中的静态方法
  6. static methods in unshared generic classes  - 不共享的泛型类中的静态方法


 (译注:原文就是这样列举的,并没有给出链接或给予进一步解释。更多信息可参见《CLR via C#》中文第四版P243:代码爆炸)

下面是示例程序中定义的一个简单的泛型方法:


string GenericMethodToBeReplaced<T, K>(T t, K k)  

第一次调用 GenericMethodToBeReplaced<string, int>("11", 2) ,CLR会创建一个 InstantiatedMethodDesc 实例。(它是 MethodDesc 的子类,具有一个flag: mcInstantiated )此示例保存在对应程序模块(module)的 InstMethodHashTable 结构中。

若调用 GenericMethodToBeReplaced<long, int>(1, 2) 则会导致新建一个对应的 InstantiatedMethodDesc 实例。

因此,我们需要找到并重置此泛型方法的所有的 InstantiatedMethodDesc ,这样我们才能毫无遗漏地替换IL代码。

根据SSCLI的源代码vm/proftoeeinterfaceimpl.cpp,我们可以用到一个类: LoadedMethodDescIterator 。它的构造函数包含三个参数,通过指定的AppDomainModuleMethodToken寻找对应的实例方法。 

LoadedMethodDescIterator MDIter(ADIter.GetDomain(), pModule, methodId);
while(MDIter.Next())
{
    MethodDesc * pMD = MDIter.Current();
    if (pMD)
    {
        _ASSERTE(pMD->IsIL());
        pMD->SetRVA(rva);
    }
} 

通过CLR的PDB符号文件观察其内部方法,可以看到构造器、 Next 、 Current 等方法的地址都有。所以我们可以利用这个CLR中的类。

 

我们不知道 LoadedMethodDescIterator 实例的具体大小,不过这没关系。定义一个足够大的内存块来保存它就好了。

class LoadedMethodDescIterator
{
private:
	BYTE dummy[10240]; 
}; 

 从.NET2.0到.NET4.5, Next() 方法和构造器的定义都有了一些变化。

// .Net 2.0 & 4.0
LoadedMethodDescIterator(AppDomain * pAppDomain, Module *pModule,	mdMethodDef md)

// .Net 4.5
LoadedMethodDescIterator(AppDomain * pAppDomain, Module *pModule,	mdMethodDef md,enum AssemblyIterationMode mode)  
// .Net 2.0
BOOL LoadedMethodDescIterator::Next(void)
// .Net 4.0 / 4.5
BOOL LoadedMethodDescIterator::Next(CollectibleAssemblyHolder<DomainAssembly *> *)  

所以我们要检测当前的.NET Framework版本以调用正确的方法。主要的问题是,.NET4.5是.NET4.0的”就地升级“(in-place upgrade)。因此,在示例代码中,我是通过检测CLR文件的版本号来确定的。

(译注:这个检测方式是导致作者的程序没能支持.NET4.6的原因。请读者自行进行修改以支持.NET4.6)

// 检测CLR版本
BOOL DetermineDotNetVersion(void)
{
	WCHAR wszPath[MAX_PATH] = {0};
	::GetModuleFileNameW( g_hClrModule, wszPath, MAX_PATH);
	CStringW strPath(wszPath);
	int nIndex = strPath.ReverseFind('\\');
	if( nIndex <= 0 )
		return FALSE;
	nIndex++;
	CStringW strFilename = strPath.Mid( nIndex, strPath.GetLength() - nIndex);
	if( strFilename.CompareNoCase(L"mscorwks.dll") == 0 )
	{
		g_tDotNetVersion = DotNetVersion_20;
		return TRUE;
	}

	if( strFilename.CompareNoCase(L"clr.dll") == 0 )
	{
		VS_FIXEDFILEINFO tVerInfo = {0};
		if ( CUtility::GetFileVersion( wszPath, &tVerInfo) &&
			 tVerInfo.dwSignature == 0xfeef04bd)
		{
			int nMajor = HIWORD(tVerInfo.dwFileVersionMS);
			int nMinor = LOWORD(tVerInfo.dwFileVersionMS);
			int nBuildMajor = HIWORD(tVerInfo.dwFileVersionLS);
			int nBuildMinor = LOWORD(tVerInfo.dwFileVersionLS);

			if( nMajor == 4 && nMinor == 0 && nBuildMajor == 30319 )
			{
				if( nBuildMinor < 10000 )
					g_tDotNetVersion = DotNetVersion_40;
				else
					g_tDotNetVersion = DotNetVersion_45;
				return TRUE;
			}
		}
		return FALSE;
	}

	return FALSE;
} 

现在,我们可以定义一个 LoadedMethodDescIterator 类了。

enum AssemblyIterationMode { AssemblyIterationMode_Default = 0 };

class LoadedMethodDescIterator
{
	typedef void (LoadedMethodDescIterator::*PFN_LoadedMethodDescIteratorConstructor)(AppDomain * pAppDomain, Module *pModule,	mdMethodDef md);
	typedef void (LoadedMethodDescIterator::*PFN_LoadedMethodDescIteratorConstructor_v45)(AppDomain * pAppDomain, Module *pModule,	mdMethodDef md, AssemblyIterationMode mode);
	typedef void (LoadedMethodDescIterator::*PFN_Start)(AppDomain * pAppDomain, Module *pModule, mdMethodDef md);
	typedef BOOL (LoadedMethodDescIterator::*PFN_Next_v4)(LPVOID pParam);
	typedef BOOL (LoadedMethodDescIterator::*PFN_Next_v2)(void);
	typedef MethodDesc* (LoadedMethodDescIterator::*PFN_Current)(void);
public:
	LoadedMethodDescIterator(AppDomain * pAppDomain, Module *pModule, mdMethodDef md)
	{
		memset( dummy, 0, sizeof(dummy));
		memset( dummy2, 0, sizeof(dummy2));
		if( s_pfnConstructor )
			(this->*s_pfnConstructor)( pAppDomain, pModule, md);
		if( s_pfnConstructor_v45 )
			(this->*s_pfnConstructor_v45)( pAppDomain, pModule, md, AssemblyIterationMode_Default);
	}

	void Start(AppDomain * pAppDomain, Module *pModule, mdMethodDef md) 
	{ 
		(this->*s_pfnStart)( pAppDomain, pModule, md); 
	}
	BOOL Next() 
	{
		if( s_pfnNext_v4 )
			return (this->*s_pfnNext_v4)(dummy2); 

		if( s_pfnNext_v2 )
			return (this->*s_pfnNext_v2)(); 

		return FALSE;
	}
	MethodDesc* Current() { return (this->*s_pfnCurrent)(); }
private:
	// 我们不知道LoadedMethodDescIterator的具体大小,因此预留足够大的空间
BYTE dummy[10240]; // Next()函数在.NET4.0及以上用到的参数CollectibleAssemblyHolder<class DomainAssembly *>
BYTE dummy2[10240]; // 构造器 for .NET 2.0 & .NET 4.0 static PFN_LoadedMethodDescIteratorConstructor s_pfnConstructor; // 构造器 for .NET 4.5 static PFN_LoadedMethodDescIteratorConstructor_v45 s_pfnConstructor_v45; static PFN_Start s_pfnStart; static PFN_Next_v4 s_pfnNext_v4; static PFN_Next_v2 s_pfnNext_v2; static PFN_Current s_pfnCurrent; public: static void MatchAddress(PSYMBOL_INFOW pSymbolInfo) { LPVOID* pDest = NULL; if( wcscmp( L"LoadedMethodDescIterator::LoadedMethodDescIterator", pSymbolInfo->Name) == 0 ) { switch(g_tDotNetVersion) { case DotNetVersion_20: case DotNetVersion_40: pDest = (LPVOID*)&(LoadedMethodDescIterator::s_pfnConstructor); break; case DotNetVersion_45: pDest = (LPVOID*)&(LoadedMethodDescIterator::s_pfnConstructor_v45); break; default: ATLASSERT(FALSE); return; } } else if( wcscmp( L"LoadedMethodDescIterator::Next", pSymbolInfo->Name) == 0 ) { switch(g_tDotNetVersion) { case DotNetVersion_20: pDest = (LPVOID*)&(LoadedMethodDescIterator::s_pfnNext_v2); break; case DotNetVersion_40: case DotNetVersion_45: pDest = (LPVOID*)&(LoadedMethodDescIterator::s_pfnNext_v4); break; default: ATLASSERT(FALSE); return; } } else if( wcscmp( L"LoadedMethodDescIterator::Start", pSymbolInfo->Name) == 0 ) pDest = (LPVOID*)&(LoadedMethodDescIterator::s_pfnStart); else if( wcscmp( L"LoadedMethodDescIterator::Current", pSymbolInfo->Name) == 0 ) pDest = (LPVOID*)&(LoadedMethodDescIterator::s_pfnCurrent); if( pDest ) *pDest = (LPVOID)pSymbolInfo->Address; } };


最后,使用 LoadedMethodDescIterator 来重置所有已经实例化的泛型方法的 MethodDesc 。

// 找到此泛型方法的所有相关实例
Module * pModule = pMethodDesc->GetLoaderModule();
AppDomain * pAppDomain = pMethodDesc->GetDomain();
if( pModule )
{
	LoadedMethodDescIterator * pLoadedMethodDescIter = new LoadedMethodDescIterator( pAppDomain, pModule, md);
	while(pLoadedMethodDescIter->Next())
	{
		MethodDesc * pMD = pLoadedMethodDescIter->Current();
		if( pMD )
			pMD->Reset();
	}
	delete pLoadedMethodDescIter;
}
 

Points of interest - 值得注意之处  

Compilation optimization - 编译优化 

我发现,如果方法非常简单,IL代码只有几字节,那么此方法可能会采用内联(inline)的方式编译(直接嵌入到调用它的方法中)。在这种情况下,重置 MethodDesc 是没用的,因为根本就不会执行到那里。详情可参见 CEEInfo::canInline vm/jitinterface.cpp in SSCLI)。

Dynamic method - 动态方法

要修改动态方法的代码,必须十分小心。在其它类型的方法中填入不正确的IL代码只会导致 InvalidProgramException 异常;但是动态方法中的不正确的IL代码可能会导致CLR和整个进程崩溃!动态方法的IL代码也与其它类型方法的有些不同。最好从另一个动态方法中复制IL代码来修改。

Inject a running .Net process - 注入正在运行的.NET进程

要修改没有源代码的.NET进程,可以首先将你自己的.NET程序集通过EasyHookRhInjectLibrary)注入到目标进程,当你的程序集在目标进程中加载后(译注:需要确保是同一个AppDomain),调用 InjectionHelper.UpdateILCodes 来修改目标方法。更多关于EasyHook的信息请参阅它的文档(documentation)。


翻译 by Ulysses Wu。转载请注明。

2015.10.11 - 2015.10.19

评论 (2) -

  • 这个黑科技其实也有很大的局限性:你更改的代码只能依赖于现有的元数据,属于“带着镣铐跳舞”。
    举个例子:你要修改的目标程序集本来有调用过Console.WriteLine,那你更改的代码就可以调用这个方法;反之,若它从来没调用过MessageBox.Show,那么很有可能它的元数据里就没有记载这个方法引用(MemberRef),那么你更改的代码也就不可能调用这个方法。同理,由于加载字符串(ldstr)也是需要元数据的,因此你无法让修改的代码加载一个程序集里从来没出现过的字符串。
    要解决这个限制,就需要继续深挖这个Hook方案,想办法在运行时添加元数据。
    或者采用别的方案……这方面的黑科技我也在(漫无目的地)研究中。
    • 对了还有一点:原文末提到用EasyHook注入别的进程,这个有可能会不行。因为EasyHook默认倾向于把注入的代码放到单独的AppDomain中执行,而不是在同一个AppDomain。这样做的好处是注入的代码崩了不会把源程序搞崩。不过这样也就无法实现文中的操作了。所以如果你感兴趣,请尝试别的注入方案。

添加评论

Loading