상황별 GetFunctionPointer 반환값 정리 - x86
우선, 이 글을 읽기 전에 다음의 글을 읽어보시면 도움이 됩니다.
실행 시에 메서드 가로채기 - CLR Injection: Runtime Method Replacer 개선
; https://www.sysnet.pe.kr/2/0/942
Windbg 환경에서 확인해 본 .NET 메서드 JIT 컴파일 전과 후
; https://www.sysnet.pe.kr/2/0/1023
간단히 설명하자면, RuntimeMethodHandle 타입에서 제공되는 GetFunctionPointer 메서드를 이용하면 해당 함수의 주소를 알 수가 있습니다. 예를 들어, 아래와 같은 식입니다.
=== 현재 호출 중인 함수의 주소를 알고 싶다면? ====
void Test()
{
StackFrame st = new StackFrame(0);
IntPtr ptr = st.GetMethod().MethodHandle.GetFunctionPointer();
}
=== 특정 메서드의 함수 주소를 알고 싶다면? ====
MethodBase func = typeof([...Type...]).GetMethod("[...MethodName...]", ...{예: BindingFlags.Static | BindingFlags.NonPublic }...);
IntPtr pMain = func.MethodHandle.GetFunctionPointer();
하지만, 이 값은 다양한 상황에서 약간의 차이를 보여주는데 개별 경우의 수에 따라 어떻게 다른지 알아보는 것이 이번 글의 목적입니다.
이후 설명하는 내용은 별다른 이야기가 없는 경우 기본적으로 다음과 같은 환경임을 가정합니다.
- NET 4.0 Console Application
- Debug Build
- 체크 해제 - Enable the Visual Studio hosting process
- 체크 - Enable unmanaged code debugging
- 심벌 서버 지정 - 마이크로소프트 공용 PDB 심벌 파일 배포 서버
EXE 어셈블리의 Main 메서드
Main 메서드는 진입 함수이기 때문에 GetFunctionPointer를 이용하여 JIT 컴파일 이전 단계의 값을 알아낼 수는 없습니다. 따라서 JIT 이후의 값만 테스트 해볼 수 있는데요. Main 메서드에 대한 GetFunctionPointer 출력 결과는 실제 JIT 컴파일 된 기계어 코드의 주소가 출력되는 아주 표준적인 동작을 보여줍니다. 확인 과정은 다음과 같습니다.
static void Main(string[] args)
{
ShowMainFunc();
}
static void ShowMainFunc()
{
MethodBase func = typeof(Program).GetMethod("Main", BindingFlags.Static | BindingFlags.NonPublic);
IntPtr pMain = func.MethodHandle.GetFunctionPointer();
ClassLibrary1.Class1.OutputFunctionAddress("Main", pMain);
}
=== 출력 결과 ===
Main Function == 68d850
"Immediate Window"에서 sos.dll을 이용하여 확인해 보면 정확히 일치하는 것을 확인할 수 있습니다.
!load "C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\SOS.dll"
extension c:\windows\microsoft.net\framework\v4.0.30319\sos.dll loaded
!name2ee ConsoleApplication1.exe!ConsoleApplication1.Program.Main
PDB symbol for clr.dll not loaded
Module: 00152e9c
Assembly: ConsoleApplication1.exe
Token: 1eb2701706000001
MethodDesc: 001533f0
Name: ConsoleApplication1.Program.Main(System.String[])
JITTED Code Address: 0068d850
같은 어셈블리에 정의된 메서드의 JIT 이전
아래와 같이 테스트 코드를 작성하고, GetFunctionPointer 값을 확인해 볼 수 있습니다.
static void Main(string[] args)
{
Program pg = new Program();
ShowTestFunc("Test1 - Before JITting", "Test1");
pg.Test1();
}
private void Test1()
{
Console.WriteLine("Test1 called!");
}
static void ShowTestFunc(string text, string methodName)
{
MethodBase func = typeof(Program).GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
IntPtr pFunc = func.MethodHandle.GetFunctionPointer();
ClassLibrary1.Class1.OutputFunctionAddress(text, pFunc);
}
=== 출력 결과 ===
Test1 - Before JITting Function == 2ac070
반면에, .NET Disassembly 창에서 값을 확인해 보면,
22: pg.Test1();
00000053 8B 4D F8 mov ecx,dword ptr [ebp-8]
00000056 39 09 cmp dword ptr [ecx],ecx
00000058 FF 15 04 34 2A 00 call dword ptr ds:[002A3404h]
0000005e 90 nop
0x2ac015 값인데 0x2ac070과는 5B 값만큼의 차이가 납니다. 즉, JIT 되기 이전의 메서드에 대해서는 GetFunctionPointer가 가리키는 값이 Stub 주솟값이 아니라는 것을 알 수 있습니다.
같은 어셈블리에 정의된 메서드의 JIT 이후
이 경우에는 Main 메서드에서 확인한 것처럼 GetFunctionPointer 반환값과 JIT 컴파일된 메서드의 주솟값이 일치합니다.
다른 어셈블리에 정의된 메서드의 JIT 이전
이 경우에도 "같은 어셈블리에 정의된 메서드의 JIT 이전"에서 살펴본 대로 결과가 다르게 나옵니다. 단지 그때는 0x5B만큼의 차이가 났지만 이번에는 0x33만큼의 차이가 납니다. 사실 0x5B, 0x33이라는 절대적인 값의 차이는 의미가 없습니다. 왜냐하면 해당 클래스에 정의된 몇 번째의 메서드를 비교했느냐에도 값이 달라질 수 있기 때문에, 단지 의미가 있다면 어쨌든 'offset 값이 다르다' 는 정도가 될 것입니다.
다른 어셈블리에 정의된 메서드의 JIT 이후
이 경우에도 Main 메서드에서 확인한 것처럼 GetFunctionPointer 반환값과 JIT 컴파일 된 메서드의 주소값이 일치합니다.
NGen 된 BCL(Base Class Library) 메서드
다음은, 예제 코드와 결과입니다.
public static void ShowCreateCommandAddress()
{
MethodBase func = typeof(SqlConnection).GetMethod("CreateCommand", BindingFlags.Instance | BindingFlags.Public);
IntPtr pOld = func.MethodHandle.GetFunctionPointer();
OutputFunctionAddress("CreateCommand", pOld);
}
=== 출력 결과 ===
CreateCommand Function == 5114fd34
위의 코드에 사용된 SqlConnection 타입을 담은 System.Data 어셈블리가 NGen 된 이미지 버전으로 사용되었음을 어떻게 알 수 있을까요? Visual Studio 의 디버그 상태에서 "Ctrl + D, M"으로 Modules 창을 띄우면 해당 어셈블리에 대해서 System.Data.ni.dll이 목록에 포함되어 있다는 사실로 알 수 있습니다.
재미있는 점은, 특별히 DLL 로딩 주소의 충돌이 발생하지 않는 한 이미 기계어 코드로 출력된 상태이기 때문에, EXE 프로그램을 재실행하는 경우에도 JIT 컴파일 된 주솟값이 바뀌지 않습니다.
참고로, 이번 경우에는 지난 이야기에서도 한번 다룬 적이 있는데요.
Visual Studio의 .NET Disassembly 창의 call 호출에 사용되는 주소의 의미는?
; https://www.sysnet.pe.kr/2/0/1019
그때도 언급했지만, GetFunctionPointer 반환값(위에서는 0x5114fd34)은 메서드의 JIT 컴파일된 주소값이 아닙니다. 이에 대해서 다시 한번 sos.dll을 이용해서 알아보겠습니다.
!load "C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\SOS.dll"
extension c:\windows\microsoft.net\framework\v4.0.30319\sos.dll loaded
!name2ee System.Data.dll!System.Data.SqlClient.SqlConnection.CreateCommand
PDB symbol for clr.dll not loaded
Module: 510b1000
Assembly: System.Data.dll
Token: 4ce43204060024c9
MethodDesc: 510bbb38
Name: System.Data.SqlClient.SqlConnection.CreateCommand()
JITTED Code Address: 51459370
위의 결과에서처럼 실제 "System.Data.SqlClient.SqlConnection.CreateCommand" 메서드의 body가 JIT 컴파일 되어 기계어로 출력된 주소는 "0x51459370"이고, 역어셈블을 해보면 본래의 메서드임을 알 수 있습니다.
!u 51459370
preJIT generated code
System.Data.SqlClient.SqlConnection.CreateCommand()
Begin 51459370, size 1f
>>> 51459370 57 push edi
51459371 56 push esi
51459372 8BF9 mov edi,ecx
51459374 B9B0FC1851 mov ecx,5118FCB0h (MT: System.Data.SqlClient.SqlCommand)
51459379 E81AF2CEFF call 51148598 (?gpViaConns@@3PAPAVVia@@A)
5145937E 8BF0 mov esi,eax
51459380 57 push edi
51459381 8BCE mov ecx,esi
51459383 33D2 xor edx,edx
51459385 E86205CFFF call 511498EC (System.Data.SqlClient.SqlCommand..ctor(System.String, System.Data.SqlClient.SqlConnection), mdToken: 060023e2)
5145938A 8BC6 mov eax,esi
5145938C 5E pop esi
5145938D 5F pop edi
5145938E C3 ret
그렇다고 GetFunctionPointer 반환값(위에서는 0x5114fd34)이 전혀 이상한 값이 나온 것은 아닙니다. 왜냐하면 역어셈블을 해보면 다음과 같이 jmp 구문이 나오기 때문입니다.
!u 5114fd34
Unmanaged code
5114FD34 B838BB0B51 mov eax,510BBB38h
5114FD39 90 nop
5114FD3A E8418AFFFF call 51148780
5114FD3F E92C963000 jmp 51459370
5114FD44 B854BB0B51 mov eax,510BBB54h
5114FD49 90 nop
5114FD4A E8318AFFFF call 51148780
5114FD4F E9248AFFFF jmp 51148778
5114FD54 B8F0BB0B51 mov eax,510BBBF0h
5114FD59 90 nop
실제로 코드에서 SqlConnection.CreateCommand를 사용한 경우, 그 호출을 포함한 메서드가 JIT 컴파일 될 때 기록되는 call에 사용되는 주소는 GetFunctionPointer 반환값과 일치합니다. 즉, JIT 컴파일 된 메서드의 기계어 코드에 대한 절대 주솟값을 직접 코드에서 다루지 않고 경유하는 지점을 별도로 두어 사용하는 것입니다.
NGen 되지 않은 BCL메서드의 JIT 이전 (또는, NGen 없이 GAC에 등록만 된 어셈블리)
NGen 되지 않은 BCL 함수라? 그게 어떻게 가능할까요? 바로 .NET Profiler를 이용하면 가능합니다. .NET Profiler의 옵션 중에서 COR_PRF_USE_PROFILE_IMAGES를 지정하게 되면 NGen 모듈을 사용하지 않는다고 이전에 설명을 했었지요. ^^
닷넷 프로파일러 - IL 코드 재작성
; https://www.sysnet.pe.kr/2/0/767
그렇게 해서 동일하게 SqlConnection.CreateCommand 메서드의 JIT 단계 이전에 GetFunctionPointer 값을 출력해 보니 0x30ce70이 나왔습니다. 물론, sos.dll로 확인해 보면,
!name2ee System.Data.dll!System.Data.SqlClient.SqlConnection.CreateCommand
PDB symbol for clr.dll not loaded
Module: 00303bc0
Assembly: System.Data.dll
Token: 2a469854060024c9
MethodDesc: 00b499a4
Name: System.Data.SqlClient.SqlConnection.CreateCommand()
Not JITTED yet. Use !bpmd -md 00b499a4 to break on run.
정말로 JIT된 상태가 아니라고 나옵니다. 그런데, GetFunctionPointer 반환값(0x30ce70)이 재미있습니다. 역어셈블을 해보면 다음과 같이 정확한 stub 코드의 주솟값임을 알 수 있습니다.
!u 0x30ce70
Unmanaged code
0030CE70 B8A499B400 mov eax,0B499A4h
0030CE75 90 nop
0030CE76 E865620970 call 703A30E0
0030CE7B E9D039FAFF jmp 002B0850
0030CE80 0000 add byte ptr [eax],al
0030CE82 0000 add byte ptr [eax],al
0030CE84 0000 add byte ptr [eax],al
0030CE86 0000 add byte ptr [eax],al
0030CE88 0000 add byte ptr [eax],al
0030CE8A 0000 add byte ptr [eax],al
JIT 전 상태이니, 실제로 SqlConnection.CreateCommand를 호출하는 코드를 .NET Disassembly로 확인해 보면 다음과 같습니다.
private static void CallFunc()
{
SqlConnection con = new SqlConnection();
38: con.CreateCommand();
00000035 8B 4D FC mov ecx,dword ptr [ebp-4]
00000038 39 09 cmp dword ptr [ecx],ecx
0000003a E8 41 13 76 FD call FD761380
0000003f 90 nop
오호~~~ call FF가 아니라 call E8 기계어 코드에 직접적인 변위값을 받고 있습니다. FD761380 주소가 어디인지 계산하기 위해서는 예전에 살펴봤던 그 방법을 사용해야 합니다.
Visual Studio의 .NET Disassembly 창의 call 호출에 사용되는 주소의 의미는?
; https://www.sysnet.pe.kr/2/0/1019
!name2ee ClassLibrary1.dll!ClassLibrary1.Class1.CallFunc
Module: 003034f4
Assembly: ClassLibrary1.dll
Token: 2a449f8406000003
MethodDesc: 003039d8
Name: ClassLibrary1.Class1.CallFunc()
JITTED Code Address: 02babaf0
!u 02babaf0
Normal JIT generated code
ClassLibrary1.Class1.CallFunc()
Begin 02babaf0, size 45
>>> 02BABAF0 55 push ebp
02BABAF1 8BEC mov ebp,esp
02BABAF3 83EC08 sub esp,8
02BABAF6 833D9437300000 cmp dword ptr ds:[00303794h],0
02BABAFD 7405 je 02BABB04
02BABAFF E817A9AA6D call 7065641B (JitHelp: CORINFO_HELP_DBG_IS_JUST_MY_CODE)
02BABB04 33D2 xor edx,edx
02BABB06 8955FC mov dword ptr [ebp-4],edx
02BABB09 90 nop
02BABB0A B9E09CB400 mov ecx,0B49CE0h (MT: System.Data.SqlClient.SqlConnection)
02BABB0F E83E72816D call 703C2D52 (JitHelp: CORINFO_HELP_NEW_CROSSCONTEXT)
02BABB14 8945F8 mov dword ptr [ebp-8],eax
02BABB17 8B4DF8 mov ecx,dword ptr [ebp-8]
02BABB1A E8795EE5FF call 02A01998 (System.Data.SqlClient.SqlConnection..ctor(), mdToken: 060024ec)
02BABB1F 8B45F8 mov eax,dword ptr [ebp-8]
02BABB22 8945FC mov dword ptr [ebp-4],eax
02BABB25 8B4DFC mov ecx,dword ptr [ebp-4]
02BABB28 3909 cmp dword ptr [ecx],ecx
02BABB2A E8411376FD call 0030CE70 (System.Data.SqlClient.SqlConnection.CreateCommand(), mdToken: 060024c9)
02BABB2F 90 nop
02BABB30 90 nop
02BABB31 8BE5 mov esp,ebp
02BABB33 5D pop ebp
02BABB34 C3 ret
정말 맞는지 그때처럼 계산해 보면,
02BABB2F + FD761380 ==> 4byte값만 취하면 ==> 0x0030ceaf - (.NET Disassembly 창 변위) 0x3f == 0030CE70
정확하군요. ^^ 게다가 0x30ce70 값은 GetFunctionPointer로 반환받은 값과 동일합니다.
정리해 보면, NGen되지 않은 BCL 메서드의 경우 GetFunctionPointer 값은 Stub 코드 위치를 정확하게 가리키고 있다는 차이가 있습니다.
NGen 되지 않은 BCL 메서드의 JIT 이후
이것 또한 재미있습니다. NGen되기 이전에 GetFunctionPointer 값이 0030CE70이었는데, NGen 후에도 그 값은 그대로 변하지 않았습니다. 물론, JIT 컴파일은 되었습니다.
!name2ee System.Data.dll!System.Data.SqlClient.SqlConnection.CreateCommand
Module: 00303bc0
Assembly: System.Data.dll
Token: 2d289f9c060024c9
MethodDesc: 00b499a4
Name: System.Data.SqlClient.SqlConnection.CreateCommand()
JITTED Code Address: 05f1c280
더욱 재미있는 점이 하나 있다면, JIT 이전/이후의 값에 대한 패치를 "데이터"영역이 아닌, 코드 영역에서 직접 수정한다는 것입니다.
이 때문에 JIT 이전의 GetFunctionPointer 값으로 반환된 stub 코드의 역어셈블 값에서 jmp 코드의 오퍼랜드 값이 "JITTED Code Address"로 바뀌었습니다.
!u 0x30ce70
Unmanaged code
0030CE70 B8A499B400 mov eax,0B499A4h
0030CE75 90 nop
0030CE76 E865620970 call 703A30E0
0030CE7B E900F4C005 jmp 05F1C280 <== JIT 이전값: jmp 002B0850
0030CE80 00B000EB7CB0 add byte ptr [eax+B07CEB00h],dh
0030CE86 03EB add ebp,ebx
0030CE88 78B0 js 0030CE3A
0030CE8A 06 push es
0030CE8B EB74 jmp 0030CF01
0030CE8D B009 mov al,9
이 외에 더 알아봐야 할 경우의 수가 있을까요? (있다면, 다시 추가하면 될 것이고.)
이러한 일련의 검사를 통해 알 수 있는 중요한 사실은, 적어도 JIT 컴파일 이후의 GetFunctionPointer 반환값은 안전하게 해당 메서드를 call 할 수 있는 주소를 포함하고 있다는 점입니다. (너무 당연한 결론이죠? ^^;)
혹시 아래의 글 기억나세요?
실행 시에 메서드 가로채기 - CLR Injection: Runtime Method Replacer 개선
; https://www.sysnet.pe.kr/2/0/942
원래의 "Runtime Method Replacer" 소스 코드에서는 JIT 컴파일 된 함수의 주소를 (.NET 버전별로 바뀔 가능성이 있는) offset 연산을 통해 계산되었지만, 제가 개선한 소스 코드에서는 그 부분을 GetFunctionPointer 반환값으로 대체했기 때문에 .NET 버전에 상관없이 안정적으로 동작하는 소스 코드가 얻어진 것입니다.
자, 여기서 '메서드 치환'에 대해서 잠깐 생각해 보면, 이를 위해서는 '치환되어질 원본 메서드의 주소'값과 '치환될 새로운 메서드의 주소'값을 알아야만 가능합니다.
여전히 "Runtime Method Replacer"는 '치환되어질 원본 메서드의 주소'값에 대해서는 불안정한 offset 값 계산을 하고 있으며 그 때문에 다양한 메서드의 변종(? - 예를 들어 Ngen된 BCL 함수의 경우)에 대해서는 치환하지 못하는 단점이 있습니다.
사실, 제가 이번 글에서 시도했던 작업의 목표는 GetFunctionPointer 반환값의 정합성을 확인하기 위한 것은 아니었고, 어떤 경우에서든지 안정적으로 '치환되어질 원본 메서드의 주소' 값을 찾는 것이 목표였는데 아쉽게도 달성하지 못한 것 같습니다. 대신에 ^^ 정리해 놓은 것이 아까워서 이렇게 다른 제목으로 글을 쓰게 된 것입니다.
혹시나 닷넷 메서드 가로채기에 관심있으신 분들은 위의 글을 읽어보시고 저와 같은 시행착오를 겪지 않기를 바랍니다. ^^
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]