Microsoft MVP성태의 닷넷 이야기
닷넷: 2329. C# - 실행 시에 메서드 가로채기 (.NET Framework 4.8) [링크 복사], [링크+제목 복사],
조회: 1730
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일

(시리즈 글이 3개 있습니다.)
닷넷: 2329. C# - 실행 시에 메서드 가로채기 (.NET Framework 4.8)
; https://www.sysnet.pe.kr/2/0/13909

닷넷: 2330. C# - 실행 시에 메서드 가로채기 (.NET 5 ~ .NET 8)
; https://www.sysnet.pe.kr/2/0/13912

닷넷: 2331. C# - 실행 시에 메서드 가로채기 (.NET 9)
; https://www.sysnet.pe.kr/2/0/13915




C# - 실행 시에 메서드 가로채기 (.NET Framework 4.8)

(2025-04-08 업데이트: 소스코드의 x64 지원을 추가합니다.)




오랜만에 지난 코드를 한번 실행했더니,

.NET Framework: 900. 실행 시에 메서드 가로채기 - CLR Injection: Runtime Method Replacer 개선 - 네 번째 이야기(Monitor.Enter 후킹)
; https://www.sysnet.pe.kr/2/0/12165

정상적으로 동작하지 않는군요. ^^ 아마도 그동안 Tiered Compilation 등이 도입되면서 JIT 컴파일러와 함께 관련 구조체들이 변경된 것 같습니다. 그래서 이에 대해 다시 정리해 볼 필요가 생겼는데요, 단지 테스트 환경을 .x86 + .NET Framework 4.8로 제한해서 다룰 것입니다.




분석을 위해 간단한 예제를 하나 만드는 것으로 시작하겠습니다. ^^

using System;
using System.Reflection;
using System.Runtime.CompilerServices;

// 설명의 편의를 위해 x86 + Debug + .NET Framework 4.8 환경에서 실행

internal class Program
{
    static void Main(string[] args)
    {
        TestClass thisInstance = new TestClass();
        TestClass thisInstance2 = new TestClass("test");
        thisInstance.TestMethod();
        TestClass.TestMethod2();

        Console.ReadLine(); // 이 시점에 Windbg로 연결
    }
}

public class TestClass
{
    string _name = "TestClass";

    public TestClass() { Console.WriteLine("TestClass constructor"); }

    public TestClass(string name) { Console.WriteLine("TestClass constructor2"); _name = name; }

    public void TestMethod() { Console.WriteLine("Instance TestMethod"); }

    public static void TestMethod2() { Console.WriteLine("Static TestMethod2"); }
}

예제에서 제공한 TestClass에는 2개의 생성자와 2개의 (instance, static) 멤버 메서드가 있는데요, 실행 후, Console.ReadLine 시점에 WinDbg를 연결해 각각의 메서드 호출 코드를 살펴보면,

0:003> !name2ee ConsoleApp1!Program.Main
Module: 01292ce4 (ConsoleApp1.exe)
Token: 0x06000001
MethodDesc: 0129308c
Name: Program.Main(System.String[])
JITTED Code Address: 05130070

0:003> !U 05130070
Normal JIT generated code
Program.Main(System.String[])
Begin 05130070, size 27b
...[생략]...
051300b2 8945a8          mov     dword ptr [ebp-58h],eax
051300b5 8b4da8          mov     ecx,dword ptr [ebp-58h]
051300b8 ff1570312901    call    dword ptr ds:[1293170h] (TestClass..ctor(), mdToken: 06000003)
051300be 8b45a8          mov     eax,dword ptr [ebp-58h]
051300c1 8945b4          mov     dword ptr [ebp-4Ch],eax
051300c4 b938312901      mov     ecx,1293138h (MT: TestClass)
051300c9 e84e1f10fc      call    CORINFO_HELP_NEWSFAST (0123201c)
051300ce 8945a4          mov     dword ptr [ebp-5Ch],eax
051300d1 8b1530200304    mov     edx,dword ptr ds:[4032030h] ("test")
051300d7 8b4da4          mov     ecx,dword ptr [ebp-5Ch]
051300da ff1510312901    call    dword ptr ds:[1293110h] (TestClass..ctor(System.String), mdToken: 06000004)
051300e0 8b45a4          mov     eax,dword ptr [ebp-5Ch]
051300e3 8945b0          mov     dword ptr [ebp-50h],eax
051300e6 8b4db4          mov     ecx,dword ptr [ebp-4Ch]
051300e9 3909            cmp     dword ptr [ecx],ecx
051300eb ff151c312901    call    dword ptr ds:[129311Ch] (TestClass.TestMethod(), mdToken: 06000005)
051300f1 90              nop
051300f2 ff1528312901    call    dword ptr ds:[1293128h] (TestClass.TestMethod2(), mdToken: 06000006)
051300f8 90              nop
...[생략]...

이렇게 정리됩니다.

call ds:[1293170h]: TestClass..ctor()
call ds:[1293110h]: TestClass..ctor(System.String)
call ds:[129311Ch]: TestClass.TestMethod()
call ds:[1293128h]: TestClass.TestMethod2()

일단은 현재 단계까지는 지난번 글과 상황은 같습니다.

Windbg 환경에서 확인해 본 .NET 메서드 JIT 컴파일 전과 후
; https://www.sysnet.pe.kr/2/0/1023

하지만, call [...]에 나온 주소들의 위치가 해당 메서드의 종류에 따라 달라지는 차이가 생겼습니다. 어떻게 달라졌는지 알아내 볼까요? ^^ 이를 위해 각각의 메서드에 대한 MethodDesc를 구하고,

0:003> !name2ee ConsoleApp1!TestClass
Module: 01292ce4 (ConsoleApp1.exe)
Token: 0x02000003
MethodTable: 01293138
EEClass: 01291394
Name: TestClass

0:003> !dumpmt -md 0x1293138
EEClass: 01291394
Module: 01292ce4
Name: TestClass
mdToken: 02000003  (C:\temp\builds\ConsoleApp1\AnyCPU\Debug\ConsoleApp1.exe)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 8
--------------------------------------
MethodDesc Table
   Entry MethodDesc      JIT Name
5be32bb0   5bca5698   PreJIT System.Object.ToString()
5be32bd0   5bca56a0   PreJIT System.Object.Equals(System.Object)
5be32c40   5bca56d0   PreJIT System.Object.GetHashCode()
5bea4800   5bca56f4   PreJIT System.Object.Finalize()
05130318   01293100      JIT TestClass..ctor()
05130370   01293108      JIT TestClass..ctor(System.String)
051303d8   01293114      JIT TestClass.TestMethod()
05130410   01293120      JIT TestClass.TestMethod2()

개별 메서드에 대한 MethodDesc 구조체의 값을 확인해 보면 이렇게 나옵니다.

// 05130318   01293100      JIT TestClass..ctor()
0:003> dd 01293100 L3
01293100  31000003 00000004 71020004

// 05130370   01293108      JIT TestClass..ctor(System.String)
0:003> dd 01293108 L3
01293108  71020004 00000005 05130370

// 051303d8   01293114      JIT TestClass.TestMethod()
0:003> dd 01293114 L3
01293114  71050005 20000006 051303d8

// 05130410   01293120      JIT TestClass.TestMethod2()
0:003> dd 01293120 L3
01293120  71080006 00200007 05130410

// 위의 경우 32비트라서 12바이트를 출력하고, 64비트인 경우라면 3번째 필드가 포인터 크기이므로 16바이트를 확인
// 0:007> dq 0x7ffaab0f51c0 L2
// 00007ffa`ab0f51c0  0b280005`01023009 00007ffa`ab0e99f8

보는 바와 같이, 재미있게도 매개변수 없는 생성자를 제외한 다른 메서드가 모두 MethodDesc의 3번째 DWORD 위치에 (Entry에 해당하는) Jitted Address를 담고 있습니다. 또한, 당연하게도 이전에 !U ... 명령어로 출력된 call [...] 주소였던 [1293110h], [129311Ch], [1293128h] 값들이 바로 MethodDesc의 3번째 DWORD 위치를 가리키고 있습니다.

그렇다면, "매개변수 없는 생성자"의 Jitted Address를 담고 있던 "call ds:[1293170h]" 주소는 어떤 위치였을까요? 역시나 이것도 이리저리 뒤지다 보니, (MethodDesc가 아닌) MethodTable의 특정 위치라는 것이 나옵니다.

0:003> dd 0x1293138 L0n20
01293138  00080000 0000000c 00050011 00000004
01293148  5beddb94 01292ce4 01293174 01291394
01293158  00000000 00000000 5be32bb0 5be32bd0
01293168  5be32c40 5bea4800 05130318 00000080 // 1293170h 위치의 값
01293178  04032004 012931fc 00000005 00000000

이것을 구하는 수식은 "CLR Injection: Runtime Method Replacer" 글에서 찾을 수 있는데요,

{
    // Some dwords in the met
    int skip = 10;

    // Read the method index.
    UInt64* location = (UInt64*)(method.MethodHandle.Value.ToPointer());
    int index = (int)(((*location) >> 32) & 0xFF);

    if (IntPtr.Size == 8)
    {
        // Get the method table
        ulong* classStart = (ulong*)method.DeclaringType.TypeHandle.Value.ToPointer();
        ulong* address = classStart + index + skip;
        return new IntPtr(address);
    }
    else
    {
        // Get the method table
        uint* classStart = (uint*)method.DeclaringType.TypeHandle.Value.ToPointer();
        uint* address = classStart + index + skip;
        return new IntPtr(address);
    }
}

그러니까 MethodTable의 처음 10번째 DWORD는 건너 뛰고, 해당 MethodDesc에 명시된 index 위치인 것입니다. (다시 말하면, 유일하게 "매개변수 없는 생성자"에 대해서만 "CLR Injection: Runtime Method Replacer" 글의 내용이 여전히 적용될 수 있는 것입니다.)

위의 원칙을 따라 "매개변수 없는 생성자"의 Jitted Address 위치를 다음과 같이 구할 수 있습니다.

// 32비트 .NET Framework

0:003> ? (qwo(01293100) >> 0n32) & 0xFF
Evaluate expression: 4 = 00000004

0:003> dd 0x1293138+((4 + 0n10)*4) L1
01293170  05130318

정확하죠? ^^




끝이군요, 이제 위에서 설명한 내용대로 가로채기를 위한 클래스를 다음과 같이 구현할 수 있고,

using System;
using System.Reflection;
using System.Runtime.CompilerServices;

public class ReplaceMethod
{
    public unsafe static void Replace(MethodBase oldMethod, MethodInfo newMethod)
    {
        RuntimeHelpers.PrepareMethod(oldMethod.MethodHandle);
        RuntimeHelpers.PrepareMethod(newMethod.MethodHandle);

        IntPtr oldMethodPos = IntPtr.Zero;

        if (oldMethod.IsConstructor && oldMethod.GetParameters().Length == 0)
        {
            oldMethodPos = FromMethodTable(oldMethod);
        }
        else
        {
            oldMethodPos = FromMethodDesc(oldMethod);
        }

        if (oldMethodPos == IntPtr.Zero)
        {
            throw new InvalidOperationException("Failed to get method position.");
        }

        IntPtr newFuncAddr = newMethod.MethodHandle.GetFunctionPointer();
        
        if (IntPtr.Size == 8)
        {
            ulong* ptr = (ulong*)oldMethodPos;
            *ptr = (ulong)newFuncAddr.ToInt64();
        }
        else
        {
            uint* ptr = (uint*)oldMethodPos;
            *ptr = (uint)newFuncAddr.ToInt32();
        }
    }

    private unsafe static IntPtr FromMethodDesc(MethodBase oldMethod)
    {
        IntPtr methodDescPtr = oldMethod.MethodHandle.Value;

        if (IntPtr.Size == 8)
        {
            ulong* ptr = (ulong*)methodDescPtr;
            ptr += 1; // Skip the first two DWORDs
            return new IntPtr(ptr);
        }
        else
        {
            uint* ptr = (uint*)methodDescPtr;
            ptr += 2; // Skip the first two DWORDs
            return new IntPtr(ptr);
        }
    }

    private static unsafe IntPtr FromMethodTable(MethodBase oldMethod)
    {
        int index = GetMethodIndex(oldMethod);

        IntPtr? methodTablePtr = oldMethod?.DeclaringType?.TypeHandle.Value;
        if (methodTablePtr == null)
        {
            throw new InvalidOperationException("Failed to get method table pointer.");
        }

        if (IntPtr.Size == 8)
        {
            ulong* ptr = (ulong*)methodTablePtr.Value;
            ptr += 8; // Skip the first 8 QWORDs
            ptr += index; // move to the method
            return new IntPtr(ptr);
        }
        else
        {
            uint* ptr = (uint*)methodTablePtr.Value;
            ptr += 10; // Skip the first 10 DWORDs
            ptr += index; // move to the method
            return new IntPtr(ptr);
        }
    }

    private static unsafe int GetMethodIndex(MethodBase oldMethod)
    {
        IntPtr methodDescPtr = oldMethod.MethodHandle.Value;
        ulong value = *(ulong*)methodDescPtr;

        return (int)(value >> 32 & 0xFF);
    }
}

아래와 같이 생성자와 인스턴스 메서드를 가로채는 예제를 만들어 보면,

using System;
using System.Reflection;
using System.Runtime.CompilerServices;

internal class Program
{
    static void Main(string[] args)
    {
        {
            MethodInfo instanceMethod = typeof(TestClass).GetMethod("TestMethod");
            MethodInfo newInstanceMethod = typeof(Program).GetMethod("MyMethod");
            ReplaceMethod.Replace(instanceMethod, newInstanceMethod);
        }


        {
            MethodBase ctorMethod = typeof(TestClass).GetConstructor(new Type[] { });
            MethodInfo newCtorMethod = typeof(Program).GetMethod("MyCtor");
            ReplaceMethod.Replace(ctorMethod, newCtorMethod);
        }

        TestClass testClass = new TestClass();
        testClass.TestMethod();
    }

    public void MyMethod()
    {
        Console.WriteLine("MyMethod called! (replaced)");
    }

    public void MyCtor()
    {
        Console.WriteLine("MyCtor called! (replaced)");
    }
}


public class TestClass
{
    string _name = "TestClass";

    public TestClass()
    {
        Console.WriteLine("TestClass constructor");
    }

    public TestClass(string name)
    {
        Console.WriteLine("TestClass constructor2");
        _name = name;
    }

    public void TestMethod()
    {
        Console.WriteLine("Instance TestMethod");
    }

    public static void TestMethod2()
    {
        Console.WriteLine("Static TestMethod2");
    }
}

이런 출력 결과를 얻을 수 있습니다.

MyCtor called! (replaced)
MyMethod called! (replaced)

/* 만약 Replace 호출을 하지 않았다면 이렇게 출력
TestClass constructor
Instance TestMethod
*/




참고로, 위의 코드를 Release 환경으로 실행하면 TestMethod의 호출을 가로채지 못한 결과가 나옵니다.

MyCtor called! (replaced)
Instance TestMethod

왜냐하면 해당 메서드가 너무 간단해서 inline 처리됐기 때문인데요, 따라서 Release로 빌드할 때는 NoInlining 옵션을 주어야 합니다.

[MethodImpl(MethodImplOptions.NoInlining)]
public void TestMethod()
{
    Console.WriteLine("Instance TestMethod");
}

마지막으로, 위에서 잠깐 살펴본 MethodDesc의 구조체 값을 보면,

// 05130318   01293100      JIT TestClass..ctor()
0:003> dd 01293100 L3
01293100  31000003 00000004 71020004

// 05130370   01293108      JIT TestClass..ctor(System.String)
0:003> dd 01293108 L3
01293108  71020004 00000005 05130370

매개변수 없는 생성자의 경우 MethodDesc가 (의미 있는 정보라고 판단했을 때) 8바이트, 그 외의 경우에는 12바이트로 차이가 나고 있습니다. 이것 역시 과거에 살펴봤던,

C# 코드로 접근하는 MethodDesc, MethodTable
; https://www.sysnet.pe.kr/2/0/12142

MethodDesc의 다양한 유형(IL, Instantiated, FCall, NDirect, EEImpl, Array, ComInterop, Dynamic)에 따라 정의된 필드와 부합하지 않습니다. 즉, 12바이트를 차지하는 MethodDesc가 과거에는 없었는데 현재는 존재하는 것입니다.

혹시나, 이와 관련해 더 궁금한 점이 있다면 공식 소스코드를 분석해 보시면 좋을 것입니다. ^^

src/coreclr/vm/method.hpp
; https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/method.hpp

src/coreclr/vm/method.cpp
; https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/method.cpp




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 4/12/2025]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




... 16  17  18  [19]  20  21  22  23  24  25  26  27  28  29  30  ...
NoWriterDateCnt.TitleFile(s)
13464정성태11/28/202310354닷넷: 2174. C# - .NET 7부터 UnmanagedCallersOnly 함수 export 기능을 AOT 빌드에 통합파일 다운로드1
13463정성태11/27/20239723오류 유형: 881. Visual Studio - NU1605: Warning As Error: Detected package downgrade
13462정성태11/27/202310312오류 유형: 880. Visual Studio - error CS0246: The type or namespace name '...' could not be found
13461정성태11/26/202310107닷넷: 2173. .NET Core 3/5+ 기반의 COM Server를 registry 등록 없이 사용하는 방법파일 다운로드1
13460정성태11/26/202310151닷넷: 2172. .NET 6+ 기반의 COM Server 내에 Type Library를 내장하는 방법파일 다운로드1
13459정성태11/26/202310578닷넷: 2171. .NET Core 3/5+ 기반의 COM Server를 기존의 regasm처럼 등록하는 방법파일 다운로드1
13458정성태11/26/202310659닷넷: 2170. .NET Core/5+ 기반의 COM Server를 tlb 파일을 생성하는 방법(tlbexp)
13457정성태11/25/202310324VS.NET IDE: 187. Visual Studio - 16.9 버전부터 추가된 "Display inline type hints" 옵션
13456정성태11/25/202311045닷넷: 2169. C# - OpenAI를 사용해 PDF 데이터를 대상으로 OpenAI 챗봇 작성 [1]파일 다운로드1
13455정성태11/25/202310681닷넷: 2168. C# - Azure.AI.OpenAI 패키지로 OpenAI 사용파일 다운로드1
13454정성태11/23/202311036닷넷: 2167. C# - Qdrant Vector DB를 이용한 Embedding 벡터 값 보관/조회 (Azure OpenAI) [1]파일 다운로드1
13453정성태11/23/20239625오류 유형: 879. docker desktop 설치 시 "Invalid JSON string. (Exception from HRESULT: 0x83750007)"
13452정성태11/22/202310165닷넷: 2166. C# - Azure OpenAI API를 이용해 사용자가 제공하는 정보를 대상으로 검색하는 방법파일 다운로드1
13451정성태11/21/202310251닷넷: 2165. C# - Azure OpenAI API를 이용해 ChatGPT처럼 동작하는 콘솔 응용 프로그램 제작파일 다운로드1
13450정성태11/21/202310054닷넷: 2164. C# - Octokit을 이용한 GitHub Issue 검색파일 다운로드1
13449정성태11/21/202310517개발 환경 구성: 688. Azure OpenAI 서비스 신청 방법
13448정성태11/20/202310268닷넷: 2163. .NET 8 - Dynamic PGO를 결합한 성능 향상파일 다운로드1
13447정성태11/16/202310616닷넷: 2162. ASP.NET Core 웹 사이트의 SSL 설정을 코드로 하는 방법
13446정성태11/16/202310543닷넷: 2161. .NET Conf 2023 - Day 1 Blazor 개요 정리
13445정성태11/15/202311539Linux: 62. 리눅스/WSL에서 CA 인증서를 저장하는 방법
13444정성태11/15/202310753닷넷: 2160. C# 12 - Experimental 특성 지원
13443정성태11/14/202310463개발 환경 구성: 687. OpenSSL로 생성한 사용자 인증서를 ASP.NET Core 웹 사이트에 적용하는 방법
13442정성태11/13/202310146개발 환경 구성: 686. 비주얼 스튜디오로 실행한 ASP.NET Core 사이트를 WSL 2 인스턴스에서 https로 접속하는 방법
13441정성태11/12/202310760닷넷: 2159. C# - ASP.NET Core 프로젝트에서 서버 Socket을 직접 생성하는 방법파일 다운로드1
13440정성태11/11/20239623Windows: 253. 소켓 Listen 시 방화벽의 Public/Private 제어 기능이 비활성화된 경우
13439정성태11/10/202311501닷넷: 2158. C# - 소켓 포트를 미리 시스템에 등록/예약해 사용하는 방법(Port Exclusion Ranges)파일 다운로드1
... 16  17  18  [19]  20  21  22  23  24  25  26  27  28  29  30  ...