Microsoft MVP성태의 닷넷 이야기
닷넷: 2329. C# - 실행 시에 메서드 가로채기 (.NET Framework 4.8) [링크 복사], [링크+제목 복사],
조회: 1791
글쓴 사람
정성태 (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

비밀번호

댓글 작성자
 




... 76  77  78  79  [80]  81  82  83  84  85  86  87  88  89  90  ...
NoWriterDateCnt.TitleFile(s)
11931정성태6/6/201919221개발 환경 구성: 441. C# - CairoSharp/GtkSharp 사용을 위한 프로젝트 구성 방법
11930정성태6/5/201919769.NET Framework: 842. .NET Reflection을 대체할 System.Reflection.Metadata 소개 [1]
11929정성태6/5/201919304.NET Framework: 841. Windows Forms/C# - 클립보드에 RTF 텍스트를 복사 및 확인하는 방법 [1]
11928정성태6/5/201918070오류 유형: 543. PowerShell 확장 설치 시 "Catalog file '[...].cat' is not found in the contents of the module" 오류 발생
11927정성태6/5/201919245스크립트: 15. PowerShell ISE의 스크립트를 복사 후 PPT/Word에 붙여 넣으면 한글이 깨지는 문제 [1]
11926정성태6/4/201919878오류 유형: 542. Visual Studio - pointer to incomplete class type is not allowed
11925정성태6/4/201919618VC++: 131. Visual C++ - uuid 확장 속성과 __uuidof 확장 연산자파일 다운로드1
11924정성태5/30/201921256Math: 57. C# - 해석학적 방법을 이용한 최소 자승법 [1]파일 다운로드1
11923정성태5/30/201920938Math: 56. C# - 그래프 그리기로 알아보는 경사 하강법의 최소/최댓값 구하기파일 다운로드1
11922정성태5/29/201918483.NET Framework: 840. ML.NET 데이터 정규화파일 다운로드1
11921정성태5/28/201924319Math: 55. C# - 다항식을 위한 최소 자승법(Least Squares Method)파일 다운로드1
11920정성태5/28/201915997.NET Framework: 839. C# - PLplot 색상 제어
11919정성태5/27/201920224Math: 54. C# - 최소 자승법의 1차 함수에 대한 매개변수를 단순 for 문으로 구하는 방법 [1]파일 다운로드1
11918정성태5/25/201921096Math: 53. C# - 행렬식을 이용한 최소 자승법(LSM: Least Square Method)파일 다운로드1
11917정성태5/24/201922050Math: 52. MathNet을 이용한 간단한 통계 정보 처리 - 분산/표준편차파일 다운로드1
11916정성태5/24/201919901Math: 51. MathNET + OxyPlot을 이용한 간단한 통계 정보 처리 - Histogram파일 다운로드1
11915정성태5/24/201923016Linux: 11. 리눅스의 환경 변수 관련 함수 정리 - putenv, setenv, unsetenv
11914정성태5/24/201921931Linux: 10. 윈도우의 GetTickCount와 리눅스의 clock_gettime파일 다운로드1
11913정성태5/23/201918725.NET Framework: 838. C# - 숫자형 타입의 bit(2진) 문자열, 16진수 문자열 구하는 방법파일 다운로드1
11912정성태5/23/201918647VS.NET IDE: 137. Visual Studio 2019 버전 16.1부터 리눅스 C/C++ 프로젝트에 추가된 WSL 지원
11911정성태5/23/201917442VS.NET IDE: 136. Visual Studio 2019 - 리눅스 C/C++ 프로젝트에 인텔리센스가 동작하지 않는 경우
11910정성태5/23/201927111Math: 50. C# - MathNet.Numerics의 Matrix(행렬) 연산 [1]파일 다운로드1
11909정성태5/22/201921092.NET Framework: 837. C# - PLplot 사용 예제 [1]파일 다운로드1
11908정성태5/22/201919799.NET Framework: 836. C# - Python range 함수 구현파일 다운로드1
11907정성태5/22/201916564오류 유형: 541. msbuild - MSB4024 The imported project file "...targets" could not be loaded
11906정성태5/21/201916776.NET Framework: 835. .NET Core/C# - 리눅스 syslog에 로그 남기는 방법
... 76  77  78  79  [80]  81  82  83  84  85  86  87  88  89  90  ...