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

비밀번호

댓글 작성자
 




... 106  107  [108]  109  110  111  112  113  114  115  116  117  118  119  120  ...
NoWriterDateCnt.TitleFile(s)
11224정성태6/13/201718206.NET Framework: 661. Json.NET의 DeserializeObject 수행 시 속성 이름을 동적으로 바꾸는 방법파일 다운로드1
11223정성태6/12/201716838개발 환경 구성: 318. WCF Service Application과 WCFTestClient.exe
11222정성태6/10/201720564오류 유형: 399. WCF - A property with the name 'UriTemplateMatchResults' already exists.파일 다운로드1
11221정성태6/10/201717557오류 유형: 398. Fakes - Assembly 'Jennifer5.Fakes' with identity '[...].Fakes, [...]' uses '[...]' which has a higher version than referenced assembly '[...]' with identity '[...]'
11220정성태6/10/201722920.NET Framework: 660. Shallow Copy와 Deep Copy [1]파일 다운로드2
11219정성태6/7/201718237.NET Framework: 659. 닷넷 - TypeForwardedFrom / TypeForwardedTo 특성의 사용법
11218정성태6/1/201721068개발 환경 구성: 317. Hyper-V 내의 VM에서 다시 Hyper-V를 설치: Nested Virtualization
11217정성태6/1/201716942오류 유형: 397. initerrlog: Could not open error log file 'C:\...\MSSQL12.MSSQLSERVER\MSSQL\Log\ERRORLOG'
11216정성태6/1/201719051오류 유형: 396. Activation context generation failed
11215정성태6/1/201720005오류 유형: 395. 관리 콘솔을 실행하면 "This app has been blocked for your protection" 오류 발생 [1]
11214정성태6/1/201717708오류 유형: 394. MSDTC 서비스 시작 시 -1073737712(0xC0001010) 오류와 함께 종료되는 문제 [1]
11213정성태5/26/201722502오류 유형: 393. TFS - The underlying connection was closed: Could not establish trust relationship for the SSL/TLS secure channel.
11212정성태5/26/201721838오류 유형: 392. Windows Server 2016에 KB4019472 업데이트가 실패하는 경우
11211정성태5/26/201720865오류 유형: 391. BeginInvoke에 전달한 람다 함수에 CS1660 에러가 발생하는 경우
11210정성태5/25/201721315기타: 65. ActiveX 없는 전자 메일에 사용된 "개인정보 보호를 위해 암호화된 보안메일"의 암호화 방법
11209정성태5/25/201768254Windows: 143. Windows 10의 Recovery 파티션을 삭제 및 새로 생성하는 방법 [16]
11208정성태5/25/201727983오류 유형: 390. diskpart의 set id 명령어에서 "The specified type is not in the correct format." 오류 발생
11207정성태5/24/201728311Windows: 142. Windows 10의 복구 콘솔로 부팅하는 방법
11206정성태5/24/201721594오류 유형: 389. DISM.exe - The specified image in the specified wim is already mounted for read/write access.
11205정성태5/24/201721275.NET Framework: 658. C#의 tail call 구현은? [1]
11204정성태5/22/201730813개발 환경 구성: 316. 간단하게 살펴보는 Docker for Windows [7]
11203정성태5/19/201718744오류 유형: 388. docker - Host does not exist: "default"
11202정성태5/19/201719810오류 유형: 387. WPF - There is no registered CultureInfo with the IetfLanguageTag 'ug'.
11201정성태5/16/201722574오류 유형: 386. WPF - .NET 3.5 이하에서 TextBox에 한글 입력 시 TextChanged 이벤트의 비정상 종료 문제 [1]파일 다운로드1
11200정성태5/16/201719375오류 유형: 385. WPF - 폰트가 없어 System.IO.FileNotFoundException 예외가 발생하는 경우
11199정성태5/16/201721183.NET Framework: 657. CultureInfo.GetCultures가 반환하는 값
... 106  107  [108]  109  110  111  112  113  114  115  116  117  118  119  120  ...