作者:PI4net
0x01 基础概念
首先要说一些基础:
- CLR 和 托管代码(Manage Code)
- 中间语言 (IL)
- 元数据和PE文件
CLR 和 托管代码(Manage Code)
.NET Framework 提供了一个称为CLR(Common Language Runtime)的运行时环境,所以.NET 的程序,都是在CLR中运行的。
使用基于CLR的语言编译器开发的代码称为托管代码;托管代码具有许多优点,例如:跨语言集成、跨语言异常处理、增强的安全性、版本控制和部署支持、简化的组件交互模型、调试和分析服务等。
MS的一些语言,例如C#、VB、F#,都是在 CLR 中运行的。可以将CLR理解为他们的运行环境。
中间语言 (IL)
对.NET 高级语言编写的代码进行编译,就会得到一个由IL构成的二进制文件。类似于就jvm中的字节码。
IL构成的二进制文件在运行时,会交由CLR进行接管,并由CLR进行翻译(JIT),生成可以直接运行的机器码,最后进行执行。
IL有时也称为通用中间语言(CIL)或Microsoft中间语言(MSIL)。
元数据(Metadata)和PE文件
元数据是描述程序的二进制信息,存储在PE文件或内存中。当将基于dot NET编写的代码编译为PE文件时,元数据和IL代码将写入到PE文件中。
我们可以通过反编译来了解程序集或可执行文件中包含的Metadata和IL的秘密,打开ILDasm并加载实现准备的程序集,我们可以看到托管PE文件的相关内容:
.NET是基于面向对象的,所以元数据描述的主要目标就是面向对象的基本元素:类、类型、属性、方法、字段、参数、特性等,主要包括:
- 定义表,描述了源代码中定义的类型和成员信息,主要包括:TypeDef、MehodDef、FieldDef、ModuleDef、PropertyDef等。
- 引用表,描述了源代码中引用的类型和成员信息,引用元素可以是同一程序集的其他模块,也可以是不同程序集的模块,主要包括:AssemblyRef、TypeRef、ModuleRef、MethodsRef等。
- 指针表,使用指针表引用未知代码,主要包括:MethodPtr、FieldPtr、ParamPtr等。
- 堆,以stream的形式保存的信息堆,主要包括:#String、#Blob、#US、#GUIDe等。
.NET 程序编写运行的流程为:
这里只是简单描述了一些基础,还有一些重要的概念:应用程序域,程序集,JIT等,读者可以进行深入了解。
推荐:
0x02 History Review
开始进入主题。
我们的目的是在CLR中,对运行的方法进行注入、劫持。比如 function A 调用的时候,实际执行的功能确是 function B。
已经有人做过该方面的研究,也提出了几种可行的方案:
- 劫持 compileMethod
- Install trampoline
- 使用Profiling API
每种方案各有优劣,稍微介绍一下。
0x02-00 劫持 compileMethod
Jerry Wang 提出了一种 hook compileMethod的方法。
代码编译得到的PE文件在运行的时候,会调用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代码编译为本机代码。
JIT 处理操作根据 .NET 版本不同,分别位于 clrjit.dll 或者 mscorjit.dll 中,其中实现了一个名为getJit返回 ICorJitCompiler。
CLR 相关操作根据 .NET 版本不同,分别位于 clr.dll 或者 mscorjit.dll,调用 getJit 方法来取得 ICorJitCompiler, 然后调用compileMethod 方法将MSIL代码编译为本机代码。
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
#include "standardpch.h"
#include "icorjitcompiler.h"
#include "icorjitinfo.h"
interceptor_IEEMM* current_IEEMM = nullptr; // we want this to live beyond the scope of a single compileMethodCall
CorJitResult __stdcall interceptor_ICJC::compileMethod(ICorJitInfo* comp, /* IN */
struct CORINFO_METHOD_INFO* info, /* IN */
unsigned /* code:CorJitFlag */ flags, /* IN */
BYTE** nativeEntry, /* OUT */
ULONG* nativeSizeOfCode /* OUT */
)
{
interceptor_ICJI our_ICorJitInfo;
our_ICorJitInfo.original_ICorJitInfo = comp;
if (current_IEEMM == nullptr)
current_IEEMM = new interceptor_IEEMM();
CorJitResult temp =
original_ICorJitCompiler->compileMethod(&our_ICorJitInfo, info, flags, nativeEntry, nativeSizeOfCode);
return temp;
}
void interceptor_ICJC::clearCache()
{
original_ICorJitCompiler->clearCache();
}
BOOL interceptor_ICJC::isCacheCleanupRequired()
{
return original_ICorJitCompiler->isCacheCleanupRequired();
}
void interceptor_ICJC::ProcessShutdownWork(ICorStaticInfo* info)
{
original_ICorJitCompiler->ProcessShutdownWork(info);
}
void interceptor_ICJC::getVersionIdentifier(GUID* versionIdentifier /* OUT */)
{
original_ICorJitCompiler->getVersionIdentifier(versionIdentifier);
}
unsigned interceptor_ICJC::getMaxIntrinsicSIMDVectorLength(CORJIT_FLAGS cpuCompileFlags)
{
return original_ICorJitCompiler->getMaxIntrinsicSIMDVectorLength(cpuCompileFlags);
}
void interceptor_ICJC::setRealJit(ICorJitCompiler* realJitCompiler)
{
original_ICorJitCompiler->setRealJit(realJitCompiler);
}
这里思路很简单,hook compileMethod 方法,在进行编译MSIL代码之前,替换IL代码,便能更改函数功能或者改变函数执行流程。
Jerry Wang 便是这样做的:
// 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
);
但是这里有一个问题:
对于JIT已经编译过的方法,CLR不会调用上文中的 compileMethod 方法。
对于每个方法(method),内存中至少有一个 MethodDesc 结构,包含此方法的标志位(flags)、结构地址(slot address)、入口地址(entry address)等。
在一个方法被JIT编译之前,此结构会将入口地址设置成指向一个特殊的JMI trunk(prestub),调用它会触发JIT编译;当IL代码已被编译后,此结构会将入口地址修改为对应方法的JMI trunk,此时调用它就会直接跳转到编译后的本地代码。
所以经过 JIT 编译后的 MSIL 再次调用,会直接运行编译后的机器码,而不是再次进行编译执行。
这里附上《CLR via C#》的相关解释:
CLR为每个类型分配一个内部结构。在这个内部数据结构中,每个方法都有一个对应的记录项(entry)。每个记录项都含有一个地址,根据此地址即可找到方法的实现。对这个结构初始化时,CLR将每个记录项都设置成(指向)包含在CLR内部的一个未编档函数。我将该函数成为JITCompiler。
当首次调用某一方法时,JITCompiler方法会被调用。JITCompiler函数负责将方法的IL代码编译成本机CPU指令。JITCompiler函数被调用时,它知道要调用的是哪个方法,以及具体是什么类型定义了该方法。然后,JITCompiler会在定义(该类型的)程序集的元数据中查找被调用方法的IL。接着,JITCompiler验证IL代码,并将IL代码编译成本机CPU指令。本机CPU指令保存到动态分配的内存块中。然后,JITCompiler回到CLR为类型创建的内部数据结构,找到与被调用方法对应的那条记录,修改最初对JITCompiler的引用,使其指向内存块(其中包含了刚才编译好的本机CPU指令)的地址。最后,JITCompiler函数跳转到内存块中的代码。这些代码正是对应的方法的具体实现。代码执行完毕并返回时,会回到调用者的代码,并像往常一样继续执行。
我们劫持的是compileMethod, 如果将方法的 MethodDesc 结构,重置为为编译状态。那么便可以在方法调用的时候执行 compileMethod,此时我们便可以劫持函数内容。
要实现 MethodDesc 结构重置,有两种方法:直接修改内存,或者调用 MethodDesc::Reset :
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 是CLR内置结构,外部不能进行调用,但是我们可以解析PDB文件来得知CLR DLL中 MethodDesc::Reset() 方法的地址, 进行强行调用。
这样便能将以编译的方法,重置回未编译的状态。方法调用的时候便会进行compileMethod。
PS:感谢 ULYSSES,这个小哥翻译了Jerry Wang 的博文,我偷了个懒,截取了多处翻译。(笑)
0x02-01 Implementing a trampoline via the calli instruction
该方法是 Antonio ‘s4tan’ Parata提出来的,发表在phrack
他提出的修改策略为:
1. Install a trampoline at the beginning of the code. This
trampoline will call a dynamically defined method.
2. Define a dynamic method that will have a specific method signature.
3. Construct an array of objects that will contain the parameters
passed to the method.
4. Invoke a dispatcher function which will load our Assembly
and will finally call our code by passing a handle to the original
method and an array of objects representing the method parameters.
主要功能为:创建DynamicMethod来伪造有效的MethodDef标记,然后使用 calli 指令强行更改执行流程。
我也是受到这老哥的影响,摸索出了一套更稳定的实现方法,理论上有很多重叠的方法,在这里先不多说。如果对 trampoline 感兴趣,可以看博文: http://www.phrack.org/papers/dotnet_instrumentation.html
0x02-02 Profiling API
Profiling 是一种监视工具,CLR 在运行的时候会加载Profiling API 的 DLL.
Profiling API 通常被用于编写代码分析器,用来监视托管应用程序执行。
MS docs中介绍了Profiling API 的适用范围:
Supported Features:
The profiling API retrieves information about the following actions and events that occur in the CLR:
– CLR startup and shutdown events.
– Application domain creation and shutdown events.
– Assembly loading and unloading events.
– Module loading and unloading events.
– COM vtable creation and destruction events.
– Just-in-time (JIT) compilation and code-pitching events.
– Class loading and unloading events.
– Thread creation and destruction events.
– Function entry and exit events.
– Exceptions.
– Transitions between managed and unmanaged code execution.
– Transitions between different runtime contexts.
– Information about runtime suspensions.
– Information about the runtime memory heap and garbage collection activity.
– The profiling API can be called from any (non-managed) COM-compatible language.
The API is efficient with regard to CPU and memory consumption. Profiling does not involve changes to the profiled application that are significant enough to cause misleading results.
The profiling API is useful to both sampling and non-sampling profilers. A sampling profiler inspects the profile at regular clock ticks, say, at 5 milliseconds apart. A non-sampling profiler is informed of an event synchronously with the thread that causes the event.
其中有几个有用的方法,可以用于MSIL代码更改:
- ICorProfilerInfo::GetILFunctionBody Method
- ICorProfilerInfo::SetILFunctionBody Method
- JITCompilationStarted
SetILFunctionBody 的描述:
Replaces the body of the specified function in the specified module.
The SetILFunctionBody method replaces the relative virtual address of the function in the metadata so that it points to the new function body, and adjusts any internal data structures as required.
The SetILFunctionBody method can be called on only those functions that have never been compiled by a just-in-time (JIT) compiler.
Use the ICorProfilerInfo::GetILFunctionBodyAllocator method to allocate space for the new method to ensure that the buffer is compatible.
Profiling 因此具备一定的MSIL更改能力。替换流程很简单:
- 创建 DynamicMethod,使用GetILFunctionBody,将方法复制到 DynamicMethod 中
- 使用 ILGenerator 更改、增加IL代码
- 将修改了的 DynamicMethod 使用 SetILFunctionBody 映射到原先的方法上
这种方法很简单,要远比劫持 compileMethod 容易实现的多,而且还稳定。
but,使用 Profiling API 需要设置环境变量:
set COR_ENABLE_PROFILING = 1
set COR_PROFILER=”MyProfiler”
Profiling API 虽然稳定,但是易用性差一点。
0x03 捷径:反射
铺垫那么多,终于到正文了。其实我的思路和Profiling API、安装 trampoline 差不多。
核心方法都是,克隆方法到 DynamicMethod中进行更改,然后寻找办法,更改函数执行到我们修改过的DynamicMethod上。
在这里,我使用了反射。反射是C#强大的特性之一,可以使程序动态访问、检测和修改自身。
在使用windbg动态调试C#程序的时候,发现了一些有意思的方法。虽然微软没有给出相关文档,也不能直接从外部直接调用,但是借助反射,多了许多可能性。
这里分为两个部分来讲:
- MethodBase Repalce
- Dynamic Method Modify
0x03-00 MethodBase Repalce
前面提到过,JIT编译过,会将编译后的nativeEntry信息写到 MethodDesc 中,如果可以将这个地址进行替换,那么不就可以劫持函数执行了?
首先,我们需要获得方法对象。
获得方法对象的方法有很多,如果知道method name,我们可以使用反射(Reflection),如果不知道任何method name,可以使用StackTrace。通过调用关系,可以得到程序的函数信息。
MethodBase BaseInfo = typeof(className).GetMethod(“MethodName”);
new StackTrace(true).GetFrame(0).ToString()
接下来,我们就要获得method object 在内存中的位置,可以使用RuntimeMethodHandle GetFunctionPointer,它是指向此实例表示的方法的指针。或者,使用Methodbase Methodhandle.,都可以获取方法的内部元数据表示的句柄。
public static long GetMethodAddress(MethodBase method)
{
RuntimeMethodHandle runtimeMethodHandle = (RuntimeMethodHandle)method.MethodHandle;
RuntimeHelpers.PrepareMethod(runtimeMethodHandle);
return runtimeMethodHandle.GetFunctionPointer().ToInt64();
}
public static unsafe long GetMethodPtr(MethodBase method)
{
return (long)method.MethodHandle.Value.ToPointer();
}
之后我们要设置 VirtualProtect ,因为修改内存是危险的行为,所以,这些操作只能在unsafe function 环境下运行。
[DllImport(“kernel32.dll”)]
public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, Protection fNewProtect, out Protection lpflOldProtect);
之后就是替换两个函数的FunctionPointer,演示效果如下:
{% youtube xwib7zeJkew %}
0x03-01 Dynamic Method Modify
上一个例子是替换函数执行,接下来我们探讨下对现有函数修改。
我们可以借助Dynamic Method ,将现有的Method 的IL code ,经过修改,拷贝到Dynamic Method。这样就能实现,现有方法的功能注入。在方法调用的时候,会执行一些我们设定的操作。
但是Dynamic Method 没有 RuntimeMethodHandle,在这里需要对Dynamic Method 进行额外的处理。当得到 RuntimeMethodHandle 时,才能Replace MethodBase。
我们可以使用MethodBase.GetMethodBody().GetILAsByteArray获得IL code。 当然,微软提供一些操作码结构。
byte[] ilcodes =MethodInfo .GetMethodBody().GetILAsByteArray();
获取到函数的IL代码后,我们通过修改其中的OpCodes,进行对函数功能的更改:
这里简单做个实例:
public static void def_3()
{
int a = 1;
int b = 1;
if (a == b)
{
Console.WriteLine("a == b");
}
else
{
Console.WriteLine("a != b");
Console.WriteLine("success!!");
}
Console.Read();
}
我们定义了一个名为 def_3 的函数,其中定义了a和b,他们的值都是1,根据判断他们是否相等,执行不同的操作。
很明显,这个function 将输出(print) a等于b。接下来的演示,是更改这个函数的执行流程,将这个函数,输出a不等于b。
public static byte[] SettingILcode(byte[] old)
{
for (int i = 0; i <old.Length; i++)
{
if (old[i] == (byte)OpCodes.Ceq.Value)
{
old[i] = (byte)OpCodes.Cgt.Value;
}
}
return old;
}
比较两个数是否相等,用到的是Ceq。Cgt 的含义是,比较两个数大小。如果使用Cgt 替换 Ceq 。那么这个函数将变为:
public static void def_3()
{
int a = 1;
int b = 1;
if (a > b)
{
Console.WriteLine("a == b");
}
else
{
Console.WriteLine("a != b");
Console.WriteLine("success!!");
}
Console.Read();
}
通过修改一个Opcode ,可以修改整个函数的执行流程。这里我们修改了MSIL代码,控制了函数的执行。
因为元数据表和元数据Token,直接更改函数的MSIL代码是十分困难的。这里我们取个巧,对函数进行克隆,方法的MSIL拷贝到dynamicMethod中,在拷贝的过程中,就可以修改MSIL,进而鞥更改方法功能。
向dynamicMethod中输入IL code ,我们使用的是ILGenerator:
MS docs:
ILGenerator is used to generate method bodies for methods and constructors in dynamic assemblies and for standalone dynamic methods .
Use Emit Puts the specified instruction onto the Microsoft intermediate language (MSIL) stream followed by the metadata token for the given type.
但是,GetILAsByteArray() 返回的是IL 的字节数组,并不是 OpCodes。ILGenerator 处理的是操作码,所以我们需要 Byte -> OpCodes, 虽然微软给出了一些OpCodes的文档,也能得到OpCodes对应的Byte,但是实际识别操作码和操作数是十分繁琐的。在这里,我使用了Mono,只需要稍加更改,就可以使用mono 生成dynamicMethod。
我们无法直接获取动态函数的方法句柄(MethodHandle),如果不能获得MethodHandle,那么我们将不能对函数进行替换。但是其实在DynamicMethod 生成的时候,已经生成了句柄(Handle),我们通过GetMethodDescriptor,得到DynamicMethod的Handle信息。
实际上,GetMethodDescriptor并不是公开的函数,微软不允许直接调用。
但是我们可以使用反射,强行调用DynamicMethod::GetMethodDescriptor();
var noninternalInstance = BindingFlags.NonPublic | BindingFlags.Instance;
var GetMethodDescriptor = typeof(DynamicMethod).GetMethod("GetMethodDescriptor",noninternalInstance);
if (m_GetMethodDescriptor != null)
return (RuntimeMethodHandle)GetMethodDescriptor.Invoke(method, new object[0]);
至此,我们也得到了方法运行的指针,之后的操作流程和之前的 MethodBase Repalce 就一样了。
演示视频:
{% youtube cqrlMMqC1us %}
PS:前两天看到一个项目 Harmony ,和这个思路相同,但是工程化那么高,实在佩服。
0x04 Notes
动态更改IL代码,一共说了4种方法。但是真正意义更改方法内部IL的是第一种劫持 compileMethod,其他的方案,都不是在原有方法基础上更改的。
其他的三种方法都是对方法进行克隆,这种思路可以实现目的,但是函数在运行的时候,方法对应的“应用程序域“的环境已经改变。并不是严格意义上的对方法进行更改。
如果不更改应用程序域环境,在原有基础上进行MSIL的更改,需要更改元数据表和元数据标记,要做大量的内存操作,此时风险其实很大,稍有不慎,程序就崩溃了。
应用程序域相关的知识没有展开说,有兴趣的同学可以自行了解。
有什么用呢?
动态更改IL,应用场景有:数据安全、软件加固、外挂、反外挂等。
正常开发,基本上不怎么需要动态更改IL,更多的应用场景是在安全方面。起初想到的是外挂方面,毕竟基于 unity3d 的游戏还是挺多的。
在参加 CanSecWest 的时候和鹏博士聊了聊,提到了在数据安全方面有应用的可能。之后有时间,再来研究这一块吧。
发表评论
您还未登录,请先登录。
登录