ILRewriting 入门






4.56/5 (15投票s)
运行时IL重写可用于向应用程序添加行为,例如日志记录,或将一个API的调用重定向到另一个API。本文及配套源代码解释了如何在运行时替换方法调用。
引言
本文旨在为运行时IL重写提供一个小型教程。我并不声称它是一个完整的教程,因为IL重写可能是一个庞大且有时困难的主题。目标读者是没有IL重写经验但有兴趣尝试的开发人员。
背景
我过去关于CLR宿主API和混合模式采样分析器的文章主要针对诊断和运行应用程序的测试。本文也源于测试领域。我曾看到Roy Osherove(单元测试大师)的视频,他谈到了某些模拟框架的实现。模拟框架用于单元测试中,以隔离类并消除对象依赖。其中一些框架通过使用IL重写来伪造和存根对象。
一个很棒的工具是微软研究院的Moles,它允许你覆盖System库中的返回值。假设你有一个遗留系统和一个日历实现需要测试,问题在于它的行为会因测试运行时间的不同而不同。通过IL重写,你可以在测试中覆盖System.DateTime.Now
。这样做的好处是你的测试将是确定性的,并且始终给出相同的结果。
代码重写
修改运行系统代码是本地二进制文件的旧做法
作者可以编写自修改代码。对于许可证检查,可以发布一个残缺的二进制文件,如果许可证检查成功,一些重要的代码可以从二进制文件隐藏的位置解密并复制到二进制文件中已被删除的位置。
绕过许可证检查或在电脑游戏中提供无限生命
有些人通过将条件跳转改为直接跳转来永久更改EXE。一些二进制EXE在磁盘上是加密的,所以这种方法并不总是可行,因此他们使用一个小加载器并在运行的系统上应用补丁。
向本地应用程序添加代码
必须找到一个免费的内存区域(又称代码洞),无论是磁盘上的还是运行进程的内存空间中的。为了将此代码添加到程序中,只需将函数A开头的指令复制到你的代码洞中,然后在函数A的开头插入一个对代码洞的调用。最后在代码洞中添加一个返回(ret),执行将从调用处继续。
IL重写的工作方式大致相同。事实上,它要容易得多。
- 无需代码洞。CLR可以直接分配免费空间。
- IL汇编器比本地汇编代码更易读,因为它可以经常被视为C#或VB.NET
- 元数据包含类和类型的完整类型信息。
通过ICorProfiler API进行重写
幸运的是,有一个分析器API,可以与CLR进行交互并接收通知。这是一个非托管API,这很遗憾,但又是必要的,否则分析器代码本身也会被分析。让我们来看看构建分析器所需的接口。
ICorProfilerCallback接口
你的分析器必须实现此接口才能从CLR接收通知。我们首先来看的第一个回调是Initialize
,它在启动时调用。
MIDL_INTERFACE("176FBED1-A55C-4796-98CA-A9DA0EF883E7")
ICorProfilerCallback : public IUnknown
{
public:
virtual HRESULT STDMETHODCALLTYPE Initialize(
/* [in] */ IUnknown *pICorProfilerInfoUnk) = 0;
}
我们得到的参数是指向实现ICorprofilerInfo
、IcorProfilerInfo2
和/或ICorProfilerInfo3
的对象的指针。你获得的对象的具体类型取决于运行的CLR版本。ICorprofilerInfo
和ICorprofilerInfo
是最重要的。ICorProfilerInfo3
接口在.NET 4.0中实现,并增加了附加和分离功能。ICorProfiler3
继承自ICorProfilerInfo2
,后者又继承自ICorPRofilerInfo
。所以如果你得到一个IcorPRofiler2
对象,就不需要再查询ICorprofilerInfo
对象了。因此,我们首先应该做的是查询ICorProfilerInfo2
。
ICorProfilerInfo2* m_corProfilerInfo2;
HRESULT hr = pICorProfilerInfoUnk->QueryInterface(IID_ICorProfilerInfo2, (LPVOID*)&m_corProfilerInfo2);
订阅事件
你可以通过设置事件掩码来告诉CLR你对哪些事件感兴趣。掩码是通过ORing在一起的一些枚举值构建的,并通过调用ICorProfilerInfo
对象上的SetEventMask
来设置。这只能进行一次,并且只能在Initialize
方法内进行。稍后从其他函数调用它将导致错误。
对于IL重写,推荐使用以下枚举值。
DWORD eventMask = COR_PRF_MONITOR_NONE;
eventMask |= COR_PRF_MONITOR_JIT_COMPILATION;
eventMask |= COR_PRF_MONITOR_MODULE_LOADS;
eventMask |= COR_PRF_DISABLE_INLINING;
eventMask |= COR_PRF_DISABLE_OPTIMIZATIONS;
m_corProfilerInfo->SetEventMask(eventMask);
JITCompilationStarted
关于IL重写,最重要的回调是JITCompilationStarted
。
MIDL_INTERFACE("8A8CC829-CCF2-49fe-BBAE-0F022228071A")
ICorProfilerCallback2 : public ICorProfilerCallback
{
public:
HRESULT ( STDMETHODCALLTYPE *JITCompilationStarted )(
ICorProfilerCallback2 * This,
/* [in] */ FunctionID functionId,
/* [in] */ BOOL fIsSafeToBlock);
}
该回调会在所有托管方法即将被JIT编译成本地代码时被调用。这是我们进行IL重写的机会窗口。
遵循的步骤
我们从JITCompilationStarted
回调中获得一个FunctionID。通过使用FunctionID作为ICorProfilerInfo::GetFunctionInfo
的参数,我们可以获得其ClassID
和ModuleID
。调用ICorProfilerInfo::GetModuleInfo
并传入ModuleID将返回其Module
名称和AssemblyID
。
IMetaDataImport接口
此接口用于在元数据中进行查找。例如,你可以遍历类中的所有方法,或者查找类的父类或接口。
IMetaDataEmit
此接口用于发射/生成新的模块、程序集、类、方法、字符串等。如果你有兴趣使用其他程序集中的方法,你将不得不为你将要调用它的模块生成一个mdMethodRef
。这有点像C中的前向声明或外部声明。外部程序集的加载将由CLR自动处理。请注意,它将在方法执行时加载,而不是在创建MethodRef
时加载。
内部结构
方法的IL代码包含一个头,描述了IL代码。
在其最简单的实现中
这个头只有1个字节。6位用于长度,2位用于标志。这种结构称为IMAGE_COR_ILMETHOD_TINY
。一个微小方法必须满足以下要求。
- 小方法 - IL代码最多63字节。
- 无异常处理
- 无局部变量
另一种结构是IMAGE_COR_ILMETHOD_FAT
。它是一个更复杂的头,包含堆栈大小、局部变量的类型信息以及有关子节的信息。使用异常处理会导致一个或多个额外的节。如果你添加了前导代码,你将不得不更新异常处理的开始和结束。只添加了前导代码,通过补偿新IL的大小可以轻松调整地址。在中间添加几个新的IL代码指令会更复杂。
添加前导代码
CodeProject上已经有一篇文章描述了如何为托管方法添加前导代码,名为Really Easy Logging using IL Rewriting and the .NET Profiling API。优点是它是一个简单且可工作的示例。作者所做的是动态分配一个字符串并创建一个mdMethodRef
来指向System.Console.WriteLine
。在前导代码中,他将字符串压入堆栈并调用mdMethodRef
。不幸的是,实际上并没有做多少“重写”。
我在IL重写领域的贡献是展示如何用外部程序集中的方法调用来替换现有调用。
替换现有方法调用
让我们开始吧!下面是SampleApp1.exe中FatDateNow
方法的IL代码。
"133002002200000001000011281400000A0A723B000070281300000A1200725B000070281500000A281200000A2A"
我们该如何开始?我们应该做的第一个练习是使用ILDasm打开托管应用程序。然后导航到方法并打开它。
.method public hidebysig instance void FatDateNow() cil managed
// SIG: 20 00 01
{
// Method begins at RVA 0x2130
// Code size 37 (0x25)
.maxstack 2
.locals init ([0] valuetype [mscorlib]System.DateTime dt)
IL_0000: /* 00 | */ nop
IL_0001: /* 28 | (0A)000014 */ call valuetype [mscorlib]System.DateTime [mscorlib]System.DateTime::get_Now()
IL_0006: /* 0A | */ stloc.0
IL_0007: /* 72 | (70)00003B */ ldstr "DateTime.Now : "
IL_000c: /* 28 | (0A)000013 */ call void [mscorlib]System.Console::Write(string)
IL_0011: /* 00 | */ nop
IL_0012: /* 12 | 00 */ ldloca.s dt
IL_0014: /* 72 | (70)00005B */ ldstr "HH:mm"
IL_0019: /* 28 | (0A)000015 */ call instance string [mscorlib]System.DateTime::ToString(string)
IL_001e: /* 28 | (0A)000012 */ call void [mscorlib]System.Console::WriteLine(string)
IL_0023: /* 00 | */ nop
IL_0024: /* 2A | */ ret
} // end of method Program::FatDateNow
"IL_00XX"开头的行是方法体。在此之前的所有内容都是来自头的Info。
我建议通过COR_ILMETHOD_TINY
和COR_ILMETHOD_FAT
(来自SDK中的corhlpr.h)来访问头,这两个结构包含特定字段的访问器方法。这样你就不必过多地进行位移操作了。
如果我们看左侧包含方法调用的行,我们可以看到进行方法调用的IL代码是0x28。它还需要一个名为token的参数,类型为mdMethodRef
。
如果你仔细看,mdMethodRef
的第一个字节在括号里。mdMethodRef
是一个编码的token,第一个字节引用了它所在的模块。数字的其余部分似乎只是该模块方法引用的序列号。这意味着,如果你想添加一个对已引用的方法的调用,可以重用一个mdMethodRef
。否则,你将不得不创建一个。幸运的是,如果你不关心查找,重复项也是可以接受的。
对其他程序集中的方法的引用必须是mdMethodRef
类型。如果你调用同一程序集内实现/定义的方法,可以使用mdMethodDef
。
查找现有的mdMethodRef
我添加了一个函数来查找mdTypeRef
(类/结构)和mdMethodRef
。在我的示例中,我调用它来查找System.DateTime.Now
的getter访问器。
HRESULT hr = MetadataHelper::ResolveMethodRef(
info, moduleId,
L"System.DateTime", L"get_Now",
&dateTimeTypeRef, &getNowMemberRef);
下面是函数的实现。
HRESULT MetadataHelper::ResolveMethodRef(
ICorProfilerInfo* info, ModuleID moduleID,
const WCHAR* typeName, const WCHAR* methodName,
mdTypeRef* outTypeRef, mdMemberRef* outMemberRef)
{
HRESULT hr = S_OK;
IMetaDataImport* pMetaDataImport = NULL;
*outTypeRef = mdTypeRefNil;
*outMemberRef = mdMemberRefNil;
hr = info->GetModuleMetaData(moduleID, ofRead, IID_IMetaDataImport,
(IUnknown** )&pMetaDataImport);
if (FAILED(hr))
{
return hr;
}
mdTypeRef typeRef = mdTypeRefNil;
hr = FindTypeRef(pMetaDataImport, typeName, &typeRef);
if (hr != S_OK)
goto cleanup;
mdMemberRef methodRef = mdMemberRefNil;
hr = FindMemberRef(pMetaDataImport, methodName, typeRef, &methodRef);
if (hr != S_OK)
goto cleanup;
*outTypeRef = typeRef;
*outMemberRef = methodRef;
cleanup:
pMetaDataImport->Release();
return hr;
}
FindTypeRef
和FindMemberRef
是简单地迭代所有类型和所有方法,直到找到我们要查找的token的方法。完整源代码包含在附件中。在此处占用不必要的空间。
创建新的mdMethodRef
const BYTE keyEMCAInterceptLib[] = { 0xca, 0xf2, 0x7b, 0x24, 0xca, 0xa5, 0xa1, 0x88 };
mdMemberRef newMemberRef = mdMemberRefNil;
MetadataHelper::DeclareZeroParamsFunctionReturningObject(info,
moduleId,
L"InterceptLib", keyEMCAInterceptLib,
keyEMCAInterceptLibSize,
L"InterceptLib.DebugLogger",
L"get_Now",
dateTimeTypeRef,
&newMemberRef);
创建新的mdMethodRef
的代码对于你创建它的方法来说是非常具体的。首先,它取决于参数的类型和返回值的类型。类型被编码成一个方法签名,该签名标识了方法。这类似于函数指针类型。将函数指针强制转换为不接受相同类型参数的指针是没有意义的。其次,如果使用了强命名,还必须提供程序集caf27b24caa5a188
的公共token。
下面是System.DateTime.Now
的签名。
BYTE rSig[] = {IMAGE_CEE_CS_CALLCONV_DEFAULT,
0, // Number of parameters
ELEMENT_TYPE_VALUETYPE, 0, 0, 0, 0, // Return value
0 // parameter list must end with 0
};
该方法不接受参数,但确实返回一个值。如果该值是原始类型,如int
或double
,则无需指定一个补充类型token。在这种情况下,返回类型是DateTime
,即一个类或结构。这被描述为ELEMENT_TYPE_VALUETYPE
,因为这对于CLR来说信息太少,类型必须后跟一个压缩的token引用(替换四个零)。函数CorSigCompressToken
在SDK的corhlpr.h中可用。
ULONG ulTokenLength = CorSigCompressToken(retType, &rSig[3]);
ULONG ulSigLength = 3 + ulTokenLength;
mdMemberRef memberRef = mdMemberRefNil;
Check(metaDataEmit->DefineMemberRef(typeRef, methodName, rSig, ulSigLength, &memberRef));
在示例中,我只编码了返回值,因为System.DateTime.Now
没有参数。如果你想调用一个带参数的函数,这些参数也需要被编码。
BYTE rSig[] = {IMAGE_CEE_CS_CALLCONV_DEFAULT,
2, // Number of parameters
ELEMENT_TYPE_VALUETYPE, 0, 0, 0, 0, // Return value
ELEMENT_TYPE_VALUETYPE, 0, 0, 0, 0, // Par 1
ELEMENT_TYPE_VALUETYPE, 0, 0, 0, 0, // Par 2
0 // parameter list must end with 0
};
现在我们可以把它们整合在一起了。
static const BYTE keyEMCAInterceptLib[] = { 0xca, 0xf2, 0x7b, 0x24, 0xca, 0xa5, 0xa1, 0x88 };
ULONG InterceptAPI::ReplaceDateTimeNowCalls(ICorProfilerInfo* info,
ModuleID moduleId, BYTE* ILBytes, ULONG ILBytesSize)
{
ULONG keyEMCAInterceptLibSize = sizeof(keyEMCAInterceptLib);
mdTypeRef dateTimeTypeRef = mdTypeRefNil;
mdMemberRef getNowMemberRef = mdMemberRefNil;
HRESULT hr = S_OK;
hr = MetadataHelper::ResolveMethodRef(info, moduleId,
L"System.DateTime", L"get_Now", &dateTimeTypeRef, &getNowMemberRef);
if (hr != S_OK)
return 0;
mdMemberRef newMemberRef = mdMemberRefNil;
MetadataHelper::DeclareZeroParamsFunctionReturningObject(info,
moduleId,
L"InterceptLib", keyEMCAInterceptLib,
keyEMCAInterceptLibSize,
L"InterceptLib.DebugLogger",
L"get_Now",
dateTimeTypeRef,
&newMemberRef);
ULONG count = OpCodeParser::ReplaceFunctionCall(ILBytes,
ILBytesSize, getNowMemberRef, newMemberRef);
return count;
下面是ReplaceFunctionCall
的实现。
ULONG OpCodeParser::ReplaceFunctionCall(BYTE* opCodeBytes, ULONG length,
mdMemberRef fromMemberRef, mdMemberRef toMemberRef)
{
ULONG count = 0;
ULONG index = 0;
while (index < length)
{
BYTE opCode = opCodeBytes[index];
if (IsFunctionCall(opCode))
{
int addressIndex = index + 1;
BYTE* address = opCodeBytes + addressIndex;
mdMemberRef* memberRefAddress = reinterpret_cast<mdMemberRef*>(address);
mdMemberRef memberRef = *memberRefAddress;
if (fromMemberRef == memberRef)
{
*memberRefAddress = toMemberRef;
count++;
}
}
ULONG opCodeSize = InstructionSize(opCodeBytes + index);
index += opCodeSize;
}
return count;
}
代码的作用是测试当前的操作码是否为函数调用(测试操作码是否等于0x28),如果是,则测试它是否是我们正在寻找的(fromMemberRef
),并用新的(toMemberRef
)替换它,否则我们跳到下一条指令。
InstructionSize
的实现最初可能会很棘手。指令可能有参数,因此大小不一。在《Expert .NET 2.0 IL Assembler》这本书的附录中,我找到了所有指令及其预期参数数量的信息。有了这些信息,我做了一个带有switch语句和一些if语句的函数,测试特定的字节码范围。这可能不是你见过的最漂亮的实现,但它在当时满足了我的目的。
最近我发现,我在书的附录中找到的信息也存在于.NET SDK中。在我的机器上,我发现在以下位置找到了该文件:“C:\Program Files (x86)\Microsoft SDKs\Windows\v7.0A\Include\opcode.def”。
// Canonical Name String Name Stack Behaviour Operand Params Length Byte(1) Byte(2)
// ---------------------------------------------------------------------------------------------
OPDEF(CEE_NOP, "nop", Pop0, Push0, InlineNone, IPrimitive, 1, 0xFF, 0x00, NEXT)
OPDEF(CEE_BREAK, "break", Pop0, Push0, InlineNone, IPrimitive, 1, 0xFF, 0x01, BREAK)
OPDEF(CEE_LDARG_0,"ldarg.0", Pop0, Push1, InlineNone, IMacro, 1, 0xFF, 0x02, NEXT)
将此文件包含在你的项目中,并做一个自制宏,应该可以将该文件转换为结构数组。这里有一个链接,说明有人已经这样做了Thoughts on writing an IL Disassembler。不幸的是,他这样做是为了一个公司,不能发布源代码。在将来,我可能也会自己做。
运行演示
演示由三个可执行文件组成。
- SampleApp1.exe - 打印当前时间和一个固定时间(18:15)。
- InterceptApp.exe - 接受一个文件名作为参数,启动一个带分析的托管应用程序。
- RunIt.bat - 使用环境变量启动SampleApp1.exe。(需要更新,它需要绝对文件名)。
在没有IL重写的情况下运行SampleApp1.exe将产生以下输出。
运行带有IL重写的SampleApp1.exe将拦截对System.DateTime.Now
的调用,也打印18:15(下午6:15)。
关注点
以下是一些我发现有用且有趣的网络资源。
- Expert 2.0 IL Assembler - 一本很棒的书,对我帮助很大。
- Cecil - 这是一个用于进行静态IL重写的超大型库。
- Rewrite MSIL code on the fly - MSDN杂志上的文章。
- Really Easy Logging using IL Rewriting and the .NET Profiling API.
历史
- 2012年9月4日,初次发布
- 2012年9月9日,添加了遗漏的操作码
- 2012年9月27日,对拼写进行了少量更正,添加了历史部分