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

비밀번호

댓글 작성자
 




[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13917정성태4/30/202525VS.NET IDE: 199. Directory.Build.props에 정의한 속성에 대해 Condition 제약으로 값을 변경하는 방법
13916정성태4/23/2025423디버깅 기술: 221. WinDbg 분석 사례 - ASP.NET HttpCookieCollection을 다중 스레드에서 사용할 경우 무한 루프 현상 - 두 번째 이야기
13915정성태4/13/20251635닷넷: 2331. C# - 실행 시에 메서드 가로채기 (.NET 9)파일 다운로드1
13914정성태4/11/20251963디버깅 기술: 220. windbg 분석 사례 - x86 ASP.NET 웹 응용 프로그램의 CPU 100% 현상 (4)
13913정성태4/10/20251186오류 유형: 950. Process Explorer - 64비트 윈도우에서 32비트 프로세스의 덤프를 뜰 때 "Error writing dump file: Access is denied." 오류
13912정성태4/9/2025848닷넷: 2330. C# - 실행 시에 메서드 가로채기 (.NET 5 ~ .NET 8)파일 다운로드1
13911정성태4/8/20251075오류 유형: 949. WinDbg - .NET Core/5+ 응용 프로그램 디버깅 시 sos 확장을 자동으로 로드하지 못하는 문제
13910정성태4/8/20251239디버깅 기술: 219. WinDbg - 명령어 내에서 환경 변수 사용법
13909정성태4/7/20251709닷넷: 2329. C# - 실행 시에 메서드 가로채기 (.NET Framework 4.8)파일 다운로드1
13908정성태4/2/20252105닷넷: 2328. C# - MailKit: SMTP, POP3, IMAP 지원 라이브러리
13907정성태3/29/20251889VS.NET IDE: 198. (OneDrive, Dropbox 등의 공유 디렉터리에 있는) C# 프로젝트의 출력 경로 변경하기
13906정성태3/27/20252130닷넷: 2327. C# - 초기화되지 않은 메모리에 접근하는 버그?파일 다운로드1
13905정성태3/26/20252167Windows: 281. C++ - Windows / Critical Section의 안정화를 위해 도입된 "Keyed Event"파일 다운로드1
13904정성태3/25/20251866디버깅 기술: 218. Windbg로 살펴보는 Win32 Critical Section파일 다운로드1
13903정성태3/24/20251506VS.NET IDE: 197. (OneDrive, Dropbox 등의 공유 디렉터리에 있는) C++ 프로젝트의 출력 경로 변경하기
13902정성태3/24/20251714개발 환경 구성: 742. Oracle - 테스트용 hr 계정 및 데이터 생성파일 다운로드1
13901정성태3/9/20252093Windows: 280. Hyper-V의 3가지 Thread Scheduler (Classic, Core, Root)
13900정성태3/8/20252330스크립트: 72. 파이썬 - SQLAlchemy + oracledb 연동
13899정성태3/7/20251789스크립트: 71. 파이썬 - asyncio의 ContextVar 전달
13898정성태3/5/20252108오류 유형: 948. Visual Studio - Proxy Authentication Required: dotnetfeed.blob.core.windows.net
13897정성태3/5/20252336닷넷: 2326. C# - PowerShell과 연동하는 방법 (두 번째 이야기)파일 다운로드1
13896정성태3/5/20252161Windows: 279. Hyper-V Manager - VM 목록의 CPU Usage 항목이 항상 0%로 나오는 문제
13895정성태3/4/20252203Linux: 117. eBPF / bpf2go - Map에 추가된 요소의 개수를 확인하는 방법
13894정성태2/28/20252230Linux: 116. eBPF / bpf2go - BTF Style Maps 정의 구문과 데이터 정렬 문제
13893정성태2/27/20252179Linux: 115. eBPF (bpf2go) - ARRAY / HASH map 기본 사용법
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...