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

(시리즈 글이 4개 있습니다.)
닷넷: 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

닷넷: 2332. C# - (JetBrains Omea Reader 대상으로) 런타임 시에 메서드 가로채기
; https://www.sysnet.pe.kr/2/0/13924




C# - 실행 시에 메서드 가로채기 (.NET 5 ~ .NET 8)

(이번 글의 코드는 .NET 5, 6, 7, 8에서만 테스트되었습니다.)




지난 글에서 .NET Framework 환경에 대해 글을 썼는데요,

C# - 실행 시에 메서드 가로채기 (.NET Framework 4.8)
; https://www.sysnet.pe.kr/2/0/13909

이번에는 (테스트하기 귀찮으므로 .NET Core 1.x ~ 3.x는 제외하고) .NET 8로 한정해서 설명해 보겠습니다. (미리 언급하자면, .NET 9에서는 이 코드가 동작하지 않습니다.)

일단, 시작으로 지난 글의 소스 코드를 그대로 .NET 8 프로젝트로 가져와 실행해 보면 가로채기가 전혀 안 되는 것을 볼 수 있습니다. .NET Framework의 경우에는 메서드 호출을 "call [...]"로 JIT 컴파일 전/후 모두 간접 주소를 이용했는데요, .NET Core의 경우에는 "call ..." 또는 "jmp ..."로 offset 형식의 호출로 변경되면서 방식이 달라졌기 때문입니다. 게다가, MethodDesc에 보관했던 Jitted Address 값을 매번 사용하는 방식이 아닌, 최초 한 번만 그 값을 가져다가 재사용하므로 이후 호출에서는 MethodDesc로부터 구하지 않습니다. 이로 인해 가로채기가 되려면 반드시 "JIT 컴파일 전"에 이뤄져야 합니다.

따라서, 지난 글의 소스 코드에서 oldMethod 인자의 PrepareMethod 호출을 제거하고,

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); // 사실 이 코드는 .NET Framework에서도 필요 없는 작업이었습니다.
        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();
        }
    }

    // ...[생략]...
}

해당 메서드를 사용하기 전에 가로채기를 완료하면 됩니다.

// 가로채기를 먼저 한 다음,
{
    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();}

만약, 저 순서를 바꾸면 가로채기가 안 됩니다.




하지만, 저렇게 했는데도 여전히 "매개변수 없는 생성자"의 호출이 가로채기가 안 되는 것을 볼 수 있습니다. 재미있게도, .NET 5 ~ 8의 경우 (.NET Framework과는 달리) "매개변수 없는 생성자"도 MethodDesc의 구조체에 함수 포인터를 보관하는 방식으로 사용하기 때문입니다.

따라서, 그 부분의 소스 코드도 다음과 같이 변경해 주면 됩니다.

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

    IntPtr oldMethodPos = IntPtr.Zero;

#if !NET5_0_OR_GREATER
    if (oldMethod.IsConstructor && oldMethod.GetParameters().Length == 0)
    {
        oldMethodPos = FromMethodTable(oldMethod);
    }
    else
#endif
    {
        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();
    }
}

이제 다시 (x86/x64 상관없이) 실행해 보면,

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

internal class Program
{
    static void Main(string[] args)
    {
        // 아래의 코드를 주석 해제하면, 가로채기가 안 됩니다.
        //{
        //    TestClass testClass = new TestClass();
        //    testClass.TestMethod();
        //}

        {
            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;
    }

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

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

화면에는, 정상적으로 가로채기가 된 결과가 나옵니다.

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

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




만약, 한 번이라도 실행된 적이 있는 메서드에 대해 가로채기를 하고 싶다면 2가지 정도의 방법을 고려할 수 있습니다.

첫 번째로, 호출이 되는 "jmp ..." 명령어의 주소를 읽어 가로챌 대상 메서드의 주소로 offset 값을 계산하여 덮어쓰는 방법입니다. 물론, (원하는) 모든 호출 측 코드를 가로채야 하는 한다면 이는 매우 귀찮은 방법입니다.

두 번째로, "jmp ..." 대상 위치가 되는 Jitted Address의 초입 부분을 trampoline 기법으로 덮어쓰는 방법입니다. (이 과정에서 SharpDisasm을 쓰면 편리합니다.)

방법이야 그렇지만, 2가지 모두 이번 글의 방식에 비해서는 매우 귀찮은 작업임에는 틀림없습니다.




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







[최초 등록일: ]
[최종 수정일: 4/9/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)
11373정성태11/27/201727502오류 유형: 433. Raspberry Pi/Windows 다중 플랫폼 지원 컴파일 관련 오류 기록
11372정성태11/25/201729415사물인터넷: 13. 윈도우즈 사용자를 위한 라즈베리 파이 제로 W 모델을 설정하는 방법 [4]
11371정성태11/25/201723233오류 유형: 432. Hyper-V 가상 스위치 생성 시 Failed to connect Ethernet switch port 0x80070002 오류 발생
11370정성태11/25/201723908오류 유형: 431. Hyper-V의 Virtual Switch 생성 시 "External network" 목록에 특정 네트워크 어댑터 항목이 없는 경우
11369정성태11/25/201725255사물인터넷: 12. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 키보드 및 마우스로 쓰는 방법 (절대 좌표, 상대 좌표, 휠) [1]
11368정성태11/25/201730090.NET Framework: 699. UDP 브로드캐스트 주소 255.255.255.255와 192.168.0.255의 차이점과 이를 고려한 C# UDP 서버/클라이언트 예제 [2]파일 다운로드1
11367정성태11/25/201731067개발 환경 구성: 337. 윈도우 운영체제의 route 명령어 사용법
11366정성태11/25/201723245오류 유형: 430. 이벤트 로그 - Cryptographic Services failed while processing the OnIdentity() call in the System Writer Object.
11365정성태11/25/201723632오류 유형: 429. 이벤트 로그 - User Policy could not be updated successfully
11364정성태11/24/201727605사물인터넷: 11. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 마우스로 쓰는 방법 (절대 좌표) [2]
11363정성태11/23/201727869사물인터넷: 10. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 마우스 + 키보드로 쓰는 방법 (두 번째 이야기)
11362정성태11/22/201722406오류 유형: 428. 윈도우 업데이트 KB4048953 - 0x800705b4 [2]
11361정성태11/22/201725445오류 유형: 427. 이벤트 로그 - Filter Manager failed to attach to volume '\Device\HarddiskVolume??' 0xC03A001C
11360정성태11/22/201726662오류 유형: 426. 이벤트 로그 - The kernel power manager has initiated a shutdown transition.
11359정성태11/16/201725465오류 유형: 425. 윈도우 10 Version 1709 (OS Build 16299.64) 업그레이드 시 발생한 문제 2가지
11358정성태11/15/201731229사물인터넷: 9. Visual Studio 2017에서 Raspberry Pi C++ 응용 프로그램 제작 [1]
11357정성태11/15/201731577개발 환경 구성: 336. 윈도우 10 Bash 쉘에서 C++ 컴파일하는 방법
11356정성태11/15/201733357사물인터넷: 8. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 마우스 + 키보드로 쓰는 방법 [4]
11355정성태11/15/201727177사물인터넷: 7. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 마우스로 쓰는 방법 [2]파일 다운로드2
11354정성태11/14/201733097사물인터넷: 6. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 키보드로 쓰는 방법 [8]
11353정성태11/14/201729547사물인터넷: 5. Raspberry Pi Zero(OTG)를 다른 컴퓨터에 연결해 가상 이더넷 카드로 쓰는 방법 [1]
11352정성태11/14/201726421사물인터넷: 4. Samba를 이용해 윈도우와 Raspberry Pi간의 파일 교환 [1]
11351정성태11/7/201728523.NET Framework: 698. C# 컴파일러 대신 직접 구현하는 비동기(async/await) 코드 [6]파일 다운로드1
11350정성태11/1/201725091디버깅 기술: 108. windbg 분석 사례 - Redis 서버로의 호출을 기다리면서 hang 현상 발생
11349정성태10/31/201725921디버깅 기술: 107. windbg - x64 SOS 확장의 !clrstack 명령어가 출력하는 Child SP 값의 의미 [1]파일 다운로드1
11348정성태10/31/201721472디버깅 기술: 106. windbg - x64 역어셈블 코드에서 닷넷 메서드 호출의 인자를 확인하는 방법
... [106]  107  108  109  110  111  112  113  114  115  116  117  118  119  120  ...