概述

基于 IDA 对 .NET Framework 4.8 clr.dll(x64)的逆向分析,还原 RuntimeMethodHandle.GetFunctionPointer() 背后的完整决策链——MethodDesc::TryGetMultiCallableAddrOfCode 的 9 条分支,以及每类方法(FCall / 已 JIT / precode / 临时入口)分别如何拿到 entrypoint。

关键字:#编程语言/DotNet/CLR 逆向/ida hook methoddesc precode

背景

在 .NET RASP / Hook 框架中,核心需求是拿到一个方法的真实 native code 入口地址。最常用的 RuntimeMethodHandle.GetFunctionPointer() 不保证返回真实 JIT 代码——很多情况下返回的是 precode(跳板 stub)。要理解它的返回语义,必须深入 clr.dll 的决策函数 MethodDesc::TryGetMultiCallableAddrOfCode。

分析对象

  • clr.dll 4.8.4795.0(x64,clr.pdb 已入 IDA)
  • 配套复现工程:FunctionPointerDemo(net48/net9.0 双框架)

GetFunctionPointer 调用链

托管层   mi.MethodHandle.GetFunctionPointer()
              ↓ QCall
CLR 原生  RuntimeMethodHandle::GetFunctionPointer  (0x180066080)
              ↓
         MethodDesc::GetMultiCallableAddrOfCode   (0x18003ddd8)
              ↓ 失败则 FuncPtrStubs::GetFuncPtrStub 兜底
CLR 核心  MethodDesc::TryGetMultiCallableAddrOfCode  (0x18003de10)  ← 核心决策点

TryGetMultiCallableAddrOfCode 完整决策树

入参 accessFlags(GetFunctionPointer 传 CORINFO_ACCESS_UNMANAGED_CALLER_MAYBE|LDFTN=16)。决策顺序(每条分支 = 一种方法形态拿 entrypoint 的方式):

TryGetMultiCallableAddrOfCode(md, accessFlags)
│
├─① IsGenericMethodDefinition(md)          → 抛 InvalidOperationException(未实例化泛型不可调)
│
├─② IsWrapperStub(md) || IsEnCAddedMethod(md)
│      → GetOrCreatePrecode() → GetMethodEntryPoint()
│      【wrapper stub(unboxing/实例化 stub)与 EnC 新增方法:先确保有 precode,返回稳定入口】
│
├─③ accessFlags 无 UNMANAGED_CALLER 且 IsRemotingInterceptedViaVirtualDispatch(md)
│      → CRemotingServices::GetNonVirtualEntryPointForVirtualMethod(md)
│      【Remoting 拦截:虚分发被拦截的虚方法,返回非虚入口点(remoting 桩)】
│
├─④ Module::IsEditAndContinueEnabled(md 的模块)
│      → GetOrCreatePrecode() → GetMethodEntryPoint()
│      【EnC 启用的模块:必须用可回 patch 的 precode 入口,保证热更新时可改写】
│
├─⑤ (b6 & 7) == mcFCall(1)  ←★ 读 MethodDesc+6 低 3 位分类位
│      且 非remoting拦截 且 MethodTable flags 条件满足
│      → ECall::GetFCallImpl(md)                ← 直接返回 FCall 的原生实现地址!
│      【FCall 方法:绕过 precode 直达 ECall 表里的 C++ 实现】
│
├─⑥ 否则 IsPointingToNativeCode(md)          ← 读 b[3]&1(HasStableEntryPoint) 与 b[3]&2(HasNativeCodeSlot)
│      → GetNativeCode(md)                     ← 直接返回已 JIT 的真实代码
│      【★ 最理想分支:已 JIT 且无 precode 间接,entry == realCode】
│
├─⑦ b[3]&1 HasStableEntryPoint
│      → GetMethodEntryPoint(md)               ← 返回方法当前入口(可能是 precode)
│      【已 JIT 但仍带 precode 的方法】
│
├─⑧ MayHaveNativeCode(md)==0 或 IsRemotingInterceptedViaPrestub(md)
│      → GetOrCreatePrecode(md)
│      【★ MayHaveNativeCode 读 MethodDesc+6 的 0x20(mdcStatic) 位。
│         判定失败 = "不可能有 native code" → 只能给 precode(Hook 拿不到真实函数头的根因之一)】
│
└─⑨ md 所在 chunk 的 word >= 0
       → GetTemporaryEntryPoint(md)            ← 临时入口点(PrecodeFixupThunk 场景)
   否则 → return NULL

分支语义速记:

  • ⑤ FCall → 直达 C++ 实现
  • ⑥ 已 JIT 无 precode → 直达 native code(Hook 最理想)
  • ⑦ 已 JIT 有 precode → 稳定入口(可能 E8/E9 跳板,需偏移跟随)
  • ⑧⑨ 未 JIT / 异常场景 → precode / 临时入口(Hook 不能直接 patch 这里)

各被调函数语义(clr.dll 实测反编译)

函数地址功能
IsGenericMethodDefinition0x18000e19c是否泛型方法定义(未实例化)。是 → 直接抛 InvalidOperationException,Hook 不能挂未实例化泛型
IsWrapperStub0x180029228是否 unboxing stub 或 instantiating stub(泛型包装桩)。这类方法的”入口”语义是桩本身,须先 GetOrCreatePrecode
IsEnCAddedMethod0x18002ad0c是否 EnC(Edit and Continue)运行期新增方法
IsRemotingInterceptedViaVirtualDispatch0x18003dd64MayBeRemotingIntercepted && IsVtableMethod——remoting 透明代理场景的虚方法拦截
IsRemotingInterceptedViaPrestub0x18001123cMayBeRemotingIntercepted && !IsVtableMethod && !RequiresInstArg——非虚方法的 remoting 拦截走 prestub
MayBeRemotingIntercepted0x18003dd90开头 test [rcx+6],0x20(mdcStatic)→ 静态方法直接 return 0;再排除 Object ctor/finalizer
IsPointingToNativeCode0x18003df00读 b[3]&1(HasStableEntryPoint)+ b[3]&2(HasNativeCodeSlot)+ Precode::IsPointingTo——判断 entry 是否已指向真实 JIT 代码
MayHaveNativeCode0x180010b70综合分类位 + MethodTable flags(0xF0000/0x80000030)+ byte+6 的 0x20 位,判定”此方法可能存在 native code”。失败 → GetOrCreatePrecode
GetNativeCode0x1800103dc读 b[3]&8(HasNativeCodeSlot)→ 按 s_ClassificationSizeTable[分类] 定位代码槽指针,& ~1 清标志位。无 slot 返回 0
GetMethodEntryPoint0x180006c78读方法当前入口点槽:有 native code slot 走 ClassificationSizeTable 定位;否则从 MethodTable slot / chunk 间接解析
GetTemporaryEntryPoint0x18001df40沿 MethodDescChunk 的 TempEntryPoints 表找/分配临时入口(PrecodeFixupThunk 形态的来源之一)
GetOrCreatePrecode0x1801736dc有 TempEntry 就地升级为 precode(AllocMemTracker 分配),否则新分配 Precode 并回填 entrypoint
ECall::GetFCallImpl0x180011578查 ECall 表拿 FCall 的 C++ 实现地址(Console.WriteLine、String.Compare 这类内部方法不走 JIT)
CRemotingServices::GetNonVirtualEntryPointForVirtualMethod0x18003e364remoting 场景:虚方法被透明代理拦截时返回非虚入口

MethodDesc+6 字节位语义(net48 x64)

位含义实测佐证
& 7分类(mcIL=0/mcFCall=1/mcPInvoke=2/mcEEImpl=3/mcArray=4/mcInstantiated=5/mcDynamic=7)String.ToUpper=0x08
0x08mdcHasNonVtableSlot实例方法 0x08、静态方法 0x28
0x20mdcStatic(静态缓存位)MayBeRemotingIntercepted 读此位
0x80mdcRequiresInheritanceCheck继承/override 链方法置位

⚠️ 注意:lordmilko/DebugTools 的 mdcStatic=0x0080 是 16 位 flags 新定义,对 net48 x64 的 byte+6 不适用。CoreCLR 现代源码布局已重构(mdfStatic = 0x0080 在 m_wFlags),不能拿新源码套旧字节偏移。

实测验证要点

  1. PrepareMethod 与 InvocationFlags 无关:ReflectionInvocation::PrepareMethod(FCall 0x18065eca0)无条件走 PrepareMethodDesc → MethodCallGraphPreparer::Run 强制 JIT。m_invocationFlags 是 mscorlib 托管侧的反射调用准入缓存,native 侧零消费。
  2. md+8(int32)= 真实代码相对 MethodDesc 的偏移:realCode = md + ReadInt32(md+8) + 8——任何入口形态下都能跟随到真实代码。
  3. E8 跳板目标:未 JIT 方法的 entry E8 rel32 → clr.dll 共享 PrecodeFixupThunk;PrepareMethod 后回填为 E9 → 真实函数头。
  4. 扫描实验:21128 个 BCL 方法中 2171 个带 mdcRequiresInheritanceCheck(0x80),无手术 PrepareMethod 后仍 thunk 100 个(全为 async/Task/IL stub 类,by design)。

参考

  • CoreCLR 源码对照:src/coreclr/vm/method.cpp(TryGetMultiCallableAddrOfCode,main 分支行号 2245)
  • precode 结构:src/coreclr/vm/precode.h(StubPrecodeData/FixupPrecodeData)
  • 本地复现工程:Windows-API-Usage/.Net-Code/FunctionPointerDemo/(含 ReproMdcStatic / ReproMd8Probe / ReproSurgeryHunter 三个实验)
  • 关联工作文档:clr.dll逆向-PrepareMethod与mdcStatic位手术(Hook 框架视角的 mdcStatic 位手术分析)