DotNetDetour - 万能的开源 .NET 代码 Hook 神器

e161baa2f0f2a6a4378d205c244d0fac.png

73272a5d7b98e8d1c6859fa540611d8b.gif

f1697a2e375e8836d8ae78254a42277c.png

欢迎来到 Dotnet 工具箱!在这里,你可以发现各种令人惊喜的开源项目!

DotNetDetour

DotNetDetour是一个用于.net方法hook的类库

  • • 支持32bit和64bit的.net程序

  • • 支持静态方法,实例方法、属性方法、泛型类型的方法、泛型方法的hook

  • • 支持.net基础类库方法的hook

  • • 无任何性能影响,无需知道和改动被hook的方法源码

基础示例

1.git clone本项目最新源码使用;或者NuGet安装(可能未及时更新):Install-Package DotNetDetour, 或者:Install-Package kissstudio.DotNetDetour。

2.参考以下例子实现IMethodHook接口,使用特性标记要Hook的方法

namespace Test.Solid {
    //假设有一个已存在的类(并且无法修改源码,如.Net框架的方法)
    public class SolidClass{
        public string Run(string msg){
            return msg+"(run)";
        }
    }
}

namespace Test{
    //我们自行实现一个类来修改Run方法的行为,此类用IMethodHook接口修饰
    public class MyClass:IMethodHook{
        //我们实现一个新Run方法,并标记为HookMethod,覆盖SolidClass中的Run方法
        [HookMethod("Test.Solid.SolidClass")]
        public string Run(string msg){
            return "Hook " + Run_Original(msg);
        }
        
        //实现一个占位方法,此方法代表被Hook覆盖的原始方法
        [OriginalMethod]
        public string Run_Original(string msg){
            return null; //这里写什么无所谓,能编译过即可
        }
    }
}

3.在程序中执行安装操作(只需运行一次即可),最佳运行时机:必须在被Hook方法被调用前执行,最好程序启动时运行一次即可。

MethodHook.Install();

4.当执行到被Hook的方法时,该调用将被转到我们的Hook方法执行:

var msg=new SolidClass().Run("Hello World!");

//Hook Hello World!(run)

Hook场景

普通方法Hook

静态和非静态的普通方法Hook操作都是一模一样的,两步到位:新建一个类实现IMethodHook接口,编写普通Hook方法,用HookMethod特性标记此方法,有无static修饰、返回值类型(仅针对引用性质的类型,非int等值类型)不同都不影响,但参数签名要和被Hook的原始方法一致,值类型和引用类型尽量不要混用。

第一步:新建一个类实现IMethodHook接口

我们编写的Hook方法所在的类需要实现IMethodHook接口,此接口是一个空接口,用于快速的查找Hook方法。

或者使用IMethodHookWithSet接口(算Plus版吧),此接口带一个HookMethod(MethodBase method)方法,这个类每成功进行一个Hook的初始化,就会传入被Hook的原始方法(可判断方法名称来确定是初始化的哪个方法),这个方法可用于获取方法所在的类(如:私有类型),可用于简化后续的反射操作;注意:此方法应当当做静态方法来进行编码。

第二步:编写Hook方法,用HookMethod特性标记

HookMethod(type,targetMethodName,originalMethodName) ,type参数支持:Type类型对象、类型完全限定名。如果能直接获取到类型对象,就使用Type类型对象;否则必须使用此类型的完全限定名(如:私有类型),如:System.Int32System.Collections.Generic.List`1[[System.String]]

[HookMethod("Namespace.xxx.MyClass", "TargetMethodName", "OriginalMethodName")]
public string MyMethod(string param){...}

[HookMethod(typeof(MyClass))]
public string MyMethod(string param){...}

如果我们的方法名称和被Hook的目标方法名称一致,无需提供targetMethodName参数。

如果我们提供目标原始方法的占位方法OriginalMethod,并且名称为目标原始方法名称 + _Original,或者当前类内只有一个Hook方法,无需提供originalMethodName参数。

注意:方法参数

参数签名要和被Hook的原始方法一致,如果不一致将导致无法找到原始方法(原因:存在重载方法无法确认是哪个的问题)。

如果存在我们无法使用的参数类型的时候(如:私有类型),我们可以用object等其他引用类型代替此类型(注意不要用值类型,否则可能出现内存访问错误),并把此参数用RememberType进行标记:

//目标方法:
public string SolidMethod(MyClass data, int code){...}

//我们的Hook方法:
public string MyMethod([RememberType("Namespace.xxx.MyClass")]object data, int code){...}

可选:提供OriginalMethod特性标记的原始方法

如果我们还想调用被Hook的原始方法,我们可以提供一个占位方法,此方法用OriginalMethod进行标记即可。此方法只起到代表原始方法的作用,不需要可以不提供,要求:参数签名必须和我们写的Hook方法一致(原因:存在重载方法无法确认是哪个的问题)。

此方法默认名称格式为目标原始方法名称 + _Original,不使用这个名称也可以,但如果使用其他名称并且当前类中有多个Hook方法,必须在Hook方法HookMethod特性中进行设置originalMethodName进行关联。

[OriginalMethod]
public string SolidMethod_Original(object data, int code){

可选:给我们的Hook方法传递参数

我们编写Hook方法是在被Hook的原始方法被调用时才会执行的,我们可能无法修改调用过程的参数(如果是能修改方法的话就跳过此节),虽然我们编写的Hook方法可以是非静态方法,但我们应当把它当静态方法来看待,虽然可以用属性字段(非静态的也当做静态)之类的给我们的Hook方法传递数据,但如果遇到并发,是不可靠的。

我们可以通过当前线程相关的上下文来传递数据,比如:HttpContextCallContextAsyncLocalThreadLoacl。推荐使用CallContext.LogicalSetData来传递数据,如果可以用HttpContext就更好了(底层也是用CallContext.HostContext来实现的)。ThreadLoacl只能当前线程用,遇到异步、多线程就不行了。AsyncLocal当然是最好的,但稍微低些版本的.Net Framework还没有这个。

[HookMethod("Namespace.xxx.MyClass", "TargetMethodName", "OriginalMethodName")]
public string MyMethod(string param){
    if (CallContext.LogicalGetData("key") == (object)"value") {
        //执行特定Hook代码
        return;
    }
    //执行其他Hook代码
    ...
}

//调用
CallContext.LogicalSetData("key", "value");
new MyClass().MyMethod("");
CallContext.LogicalSetData("key", null);

注:虽然大部分多线程、异步环境下调用上下文是会被正确复制传递的,但如果哪里使用了ConfigeAwait(false)或者其他影响上下文的操作(定时回调、部分异步IO回调好像也没有传递),当我们的Hook方法执行时,可能上下文数据并没有传递进来。

异步方法Hook

异步方法的Hook方法需要用async来修饰、返回Task类型,其他和普通方法Hook没有区别。

小提醒:不要在存在SynchronizationContext(如:HttpContext、UI线程)的线程环境中直接在同步方法中调用异步方法,真发生异步行为时100%死锁,可以强制关闭SynchronizationContext来规避此种问题,但会引发一系列问题。如果使用过程中发生死锁,跟我们进行的Hook操作没有关系

[HookMethod(typeof(MyClass))]
public async Task<int> MyMethodAsync() {...}

//异步环境调用
val=await new MyClass().MyMethodAsync();

//同步环境调用
var bak = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);
try {
    val=new MyClass().MyMethodAsync().Result;
} finally {
    SynchronizationContext.SetSynchronizationContext(bak);
}

属性Hook

属性其实是get_xxx()名称的普通方法,比如MyProperty属性Hook get_MyProperty()这个普通方法即可。

[HookMethod("Namespace.xxx.MyClass")]
public string get_MyProperty(){...}

[OriginalMethod]
public string get_MyProperty_Original(){...}

或者在get块上方进行标记,规则和普通方法一致:

public string MyProperty{
    [HookMethod("Namespace.xxx.MyClass")]
    get{ ... }
}

public string MyProperty_Original{
    [OriginalMethod]
    get{ ... }
}

注:Hook属性时有可能能成功设置此Hook,但不一定会执行我们的代码,可能是编译过程中优化了整个调用过程,跳过了部分属性方法,直接返回了最深层次的调用值,如下面这种类似的属性获取方式:

int A{get{return B;}}
int B{get{return C;}}
int C{get{return 123;}}

我们Hook A属性,能成功设置Hook方法,但我们调用A属性时,并不会执行我们的Hook方法。换B也不行,只有Hook C才行。也许是编译的时候把A、B的调用直接优化成了对C的调用,我们只需要对最深层次的属性调用进行Hook就能避免此问题。(这个只是演示可能会出现的问题,我们自己特意写代码去测试并不能复现)。


构造方法Hook

我们编写个返回值为void、方法名称为类名称的普通方法即可实现。如果方法名称无法使用类名称时,需在HookMethod中设置targetMethodName.ctor。其他规则和普通方法一致。

[HookMethod("Namespace.xxx.MyClass")]
public void MyClass(string param) {
    ...
    MyClass_Original(param);//可选调用自身实例化方法
    ...
}

[OriginalMethod]
public void MyClass_Original(string param) {}

泛型类的方法Hook

形如class MyClass<T>{ T MyMethod(T param, object param2){...} }这种泛型,对里面的方法进行Hook。泛型类中方法的Hook和普通方法Hook没有多大区别,只是在提供HookMethod特性的type参数时需要对类型具体化,比如调用的地方使用的是int类型,那么我们就Hook int类型的此类:typeof(MyClass<int>)Namespace.xxx.MyClass&#96;1[[System.Int32]],其他和普通方法规则相同。

由于存在引用类型值类型两种类型,并且表现不一致,我们在具体化时要分开对待。

值类型泛型参数

每种使用到的值类型泛型参数的具体类型都需要单独实现Hook,intbool等为值类型都要单独实现,如int类型写法:

[HookMethod("Namespace.xxx.MyClass`1[[System.Int32]]")]
public int MyMethod(int param, object param2) {

引用类型泛型参数

每种使用到引用类型参数的具体类型都共用一个Hook,注意是:同一个泛型类中的同一个方法只能用一个相同方法进行Hookstring普通object等都是引用类型都共用一个Hook,如string类型写法:

[HookMethod("Namespace.xxx.MyClass`1[[System.Object]]")]
public object MyMethod(object param, object param2) {
    if(param is string){
        ... //string 类型实现代码
    } else if(param is xxxx){
        ... //其他引用类型实现代码
    }

 

实现原理

1.为何想做这个

说到hook大家都应该不陌生,就是改变函数的执行流程,让本应该执行的函数跑到另一个函数中执行,这是个很有用也很有趣的功能(例如获取函数参数信息,改变函数执行流程,计算函数执行时间等等),杀软中主防的原理就是hook,通过hook拦截函数获取参数信息来判断是否是危险行为,但这类程序大多是C++的,一直以来我都想实现可以hook .net函数的库,网上搜索了很多,但都不理想,所以想自己实现一个。

2.实现原理

我采用的是inline hook的方式,因为我对.net虚拟机以及一些内部的结构并不是很熟悉,并且有些东西的确找不到任何文档,所以就采用原生代码的inline hook的方式来实现。

首先说一下inline hook的基本原理,它是通过修改函数的前5字节指令为jmp xxxxxxxx来实现的,例如一个C#方法:

用windbg调试查看方法信息:

8d10c411aa037c8aa2c6ea39de100c23.png

查看已经jit了的原生代码:

40ed80b0709c5df70e42fa767fb022c7.png

这里的地址(0x008c0640)可以通过MethodInfo.MethodHandle.GetFunctionPointer().ToPointer()方法获取。

到了这里,我们就知道了修改从push ebp开始的5个字节为jmp跳转指令,跳入我们自己的函数就可以达到hook的目的,但执行到我们的函数后,如果我们并不是要拦截执行流程,那么我们最终是需要再调用原函数的,但原函数已经被修改了,这会想到的办法就是恢复那修改的5字节指令,但这又会引发另一个问题,就是当我们恢复时,正好另一个线程调用到这个函数,那么程序将会崩溃,或者说漏掉一次函数调用,修改时暂停其他线程并等待正跑在其中的CPU执行完这5字节再去恢复指令也许是个不错的办法,但感觉并不容易实现,而且影响性能,所以我放弃了这种办法。

那么如何才能调用修改前的函数呢,我首先想到是C中写裸函数的方式,即自己用汇编拼出来一个原函数再执行:

原函数前5字节指令+jmp跳转指令

但其实这也是不可行的,聪明的人已经发现,图中所示的函数的前5字节并不是一个完整的汇编指令,不同的函数,长度都不一样,.net的函数并不像某些原生函数那样,会预留mov edi,edi这样的正好5字节的指令,我先想到的是复制函数的所有汇编指令生成新的函数,但这样也会出问题,因为像E8,E9这样的相对跳转指令,如果指令地址变了,那么跳转的位置也就变了,程序就会崩溃,所以这也不可行。

到了这里,我有些不耐烦了,毕竟我是要hook所有函数的,而不是某个固定的函数,而函数入口的指令又不相同,这可怎么办,难道我需要计算出大于等于5字节的最小完整汇编指令长度

按照这个思路,最终找到了一个用C写的反汇编库(BlackBone),其中提供了类似的方法,我稍作了修改后试用了下,的确不错,可以准确求出汇编指令长度,例如

push ebp
mov ebp,esp
mov eax,dword ptr ds:[33F22ACh]

求出值是9,这样我根据求出的值动态拼接一个函数出来即可,哈哈,到了这里,感觉实现的差不多了,但没想到64位下又给了我当头一棒,之前的原函数指令可以写成:

大于等于5字节的最小完整汇编指令+jmp跳转指令 即可构成我们的原函数

但我们知道,C#中要想执行汇编,是需要用Marshal.AllocHGlobal来分配非托管空间的,而这样分配的地址与我们要跳转到的原函数的地址在64位下是超过2GB地址范围的,一般的跳转指令是无法实现的,所以想到了用ret指令实现,而64位地址又不能直接push,所以最后写出如下汇编:

push rax
mov rax,target_addr
push rax
mov rax,qword ptr ss:[rsp+8]
ret 8

由于某些C#函数竟然第一行就是修改rax寄存器的值,所以只能是先保存rax,推入堆栈后再恢复,这里汇编操作就方便多了,之前实现另一个东西,用到IL指令,但发现只有dup这种复制栈顶元素的指令,却没有获取堆栈中某个非栈顶元素值的指令,所以说还是汇编灵活啊,想怎么写就怎么写,啥都能实现。

最后就是这个原函数的调用过程了,因为是动态拼接的函数,所以想到的就是用Marshal.GetDelegateForFunctionPointer转成委托来执行,后来发现不对,因为我虽然拼接的是汇编,而这个汇编是C#方法jit后的汇编,这个并不是C方法编译后的汇编,通过把非托管指针转换为委托的方式运行函数是会添加很多不需要的操作的,例如托管类型与非托管类型的转换,但我拼接出的函数是不需要这些过程的,这个怎么办,看来只能用调用C#普通函数的方式调用,这个怎么实现呢,其实很好办,只需写一个空壳函数,然后修改这个函数的方法表中的原生指令指针即可,具体方法如下:

*((ulong*)((uint*)method.MethodHandle.Value.ToPointer() + 2)) = (ulong)ptr;

method是空壳函数的MethodInfo, ptr是动态拼接的原函数的地址

好,到了这里就基本完成核心功能了,最不好处理的就是这个原函数调用,我的完整的64位原函数指令拼接就实现了,代码很少,如下所示:

byte[] jmp_inst =
{
    0x50,                                              //push rax
    0x48,0xB8,0x90,0x90,0x90,0x90,0x90,0x90,0x90,0x90, //mov rax,target_addr
    0x50,                                              //push rax
    0x48,0x8B,0x44,0x24,0x08,                          //mov rax,qword ptr ss:[rsp+8]
    0xC2,0x08,0x00                                     //ret 8
};

protected override void CreateOriginalMethod(MethodInfo method)
{
    uint oldProtect;
    var needSize = NativeAPI.SizeofMin5Byte(srcPtr);
    byte[] src_instr = new byte[needSize];
    for (int i = 0; i < needSize; i++)
    {
        src_instr[i] = srcPtr[i];
    }
    fixed (byte* p = &jmp_inst[3])
    {
        *((ulong*)p) = (ulong)(srcPtr + needSize);
    }
    var totalLength = src_instr.Length + jmp_inst.Length;
    IntPtr ptr = Marshal.AllocHGlobal(totalLength);
    Marshal.Copy(src_instr, 0, ptr, src_instr.Length);
    Marshal.Copy(jmp_inst, 0, ptr + src_instr.Length, jmp_inst.Length);
    NativeAPI.VirtualProtect(ptr, (uint)totalLength, Protection.PAGE_EXECUTE_READWRITE, out oldProtect);
    RuntimeHelpers.PrepareMethod(method.MethodHandle);
    *((ulong*)((uint*)method.MethodHandle.Value.ToPointer() + 2)) = (ulong)ptr;
}

3.类库开发所用到的语言 之前我说,我的这个库是完全用C#实现的,但其中的确用到了一个C写的反汇编库,于是我用C#把那个库重写了一遍,说来也简单,C的代码粘过来,C#启用unsafe代码,改了10分钟就好了,真心是非常方便,毕竟C#是支持指针和结构体的,而且基础类型非常丰富,这里得给C#点个赞!

-

技术群:添加小编微信并备注进群

小编微信:mm1552923   

公众号:dotNet编程大全    

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值