Microsoft MVP성태의 닷넷 이야기
닷넷: 2329. C# - 실행 시에 메서드 가로채기 (.NET Framework 4.8) [링크 복사], [링크+제목 복사],
조회: 1733
글쓴 사람
정성태 (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)
13439정성태11/10/202311501닷넷: 2158. C# - 소켓 포트를 미리 시스템에 등록/예약해 사용하는 방법(Port Exclusion Ranges)파일 다운로드1
13438정성태11/9/202310966닷넷: 2157. C# - WinRT 기능을 이용해 윈도우에서 실행 중인 Media App 제어
13437정성태11/8/202311204닷넷: 2156. .NET 7 이상의 콘솔 프로그램을 (dockerfile 없이) 로컬 docker에 배포하는 방법
13436정성태11/7/202311291닷넷: 2155. C# - .NET 8 런타임부터 (Reflection 없이) 특성을 이용해 public이 아닌 멤버 호출 가능
13435정성태11/6/202310572닷넷: 2154. C# - 네이티브 자원을 포함한 관리 개체(예: 스레드)의 GC 정리
13434정성태11/1/202310508스크립트: 62. 파이썬 - class의 정적 함수를 동적으로 교체
13433정성태11/1/20239375스크립트: 61. 파이썬 - 함수 오버로딩 미지원
13432정성태10/31/202310167오류 유형: 878. 탐색기의 WSL 디렉터리 접근 시 "Attempt to access invalid address." 오류 발생
13431정성태10/31/202310711스크립트: 60. 파이썬 - 비동기 FastAPI 앱을 gunicorn으로 호스팅
13430정성태10/30/202310907닷넷: 2153. C# - 사용자가 빌드한 ICU dll 파일을 사용하는 방법
13429정성태10/27/202311047닷넷: 2152. Win32 Interop - C/C++ DLL로부터 이중 포인터 버퍼를 C#으로 받는 예제파일 다운로드1
13428정성태10/25/202311184닷넷: 2151. C# 12 - ref readonly 매개변수
13427정성태10/18/202310695닷넷: 2150. C# 12 - 정적 문맥에서 인스턴스 멤버에 대한 nameof 접근 허용(Allow nameof to always access instance members from static context)
13426정성태10/13/202311208스크립트: 59. 파이썬 - 비동기 호출 함수(run_until_complete, run_in_executor, create_task, run_in_threadpool)
13425정성태10/11/202311049닷넷: 2149. C# - PLinq의 Partitioner<T>를 이용한 사용자 정의 분할파일 다운로드1
13423정성태10/6/202310985스크립트: 58. 파이썬 - async/await 기본 사용법
13422정성태10/5/202310732닷넷: 2148. C# - async 유무에 따른 awaitable 메서드의 병렬 및 예외 처리 [1]
13421정성태10/4/202311000닷넷: 2147. C# - 비동기 메서드의 async 예약어 유무에 따른 차이
13420정성태9/26/202319239스크립트: 57. 파이썬 - UnboundLocalError: cannot access local variable '...' where it is not associated with a value
13419정성태9/25/202310836스크립트: 56. 파이썬 - RuntimeError: dictionary changed size during iteration
13418정성태9/25/202312462닷넷: 2146. C# - ConcurrentDictionary 자료 구조의 동기화 방식
13417정성태9/19/202311759닷넷: 2145. C# - 제네릭의 형식 매개변수에 속한 (매개변수를 가진) 생성자를 호출하는 방법
13416정성태9/19/202310366오류 유형: 877. redis-py - MISCONF Redis is configured to save RDB snapshots, ...
13415정성태9/18/202311793닷넷: 2144. C# 12 - 컬렉션 식(Collection Expressions) [2]
13414정성태9/16/202311133디버깅 기술: 193. Windbg - ThreadStatic 필드 값을 조사하는 방법
13413정성태9/14/202311953닷넷: 2143. C# - 시스템 Time Zone 변경 시 이벤트 알림을 받는 방법
... 16  17  18  19  [20]  21  22  23  24  25  26  27  28  29  30  ...