windbg - x86 메모리 덤프 분석 시 닷넷 메서드의 호출 인자 값 확인
x64 관련해서는 기록해 둔 것이 있는데,
x64 콜 스택 인자 추적과 windbg의 Child-SP, RetAddr, Args to Child 값 확인
; https://www.sysnet.pe.kr/2/0/10832
windbg - x64 역어셈블 코드에서 닷넷 메서드 호출의 인자를 확인하는 방법
; https://www.sysnet.pe.kr/2/0/11348
windbg - x64 SOS 확장의 !clrstack 명령어가 출력하는 Child SP 값의 의미
; https://www.sysnet.pe.kr/2/0/11349
x86이 없군요. ^^
예를 들면서 해볼까요? ^^ C# 프로그램을 작성하다 보면 EventWaitHandle 타입으로 WaitOne을 호출하며 대기하는 경우가 있습니다.
using System.Threading;
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
ExitOrSleep(1000 * 50);
}
private static void ExitOrSleep(int timeout)
{
EventWaitHandle ewh = new EventWaitHandle(false, EventResetMode.ManualReset);
ewh.WaitOne(timeout, false);
}
}
}
바로 이 WaitOne을 호출했을 때 메모리 덤프를 뜬 것을 분석 시 timeout으로 주어진 시간을 찾는다고 가정해 봅니다.
다음은 WaitOne 호출에 따른 콜 스택을 보여줍니다.
0:000> kv
# ChildEBP RetAddr Args to Child
00 001beb64 77061293 00000001 001bef10 00000000 ntdll!NtWaitForMultipleObjects+0xc (FPO: [5,0,0])
01 001becf8 72a0ff96 00000001 001bef10 00000001 KERNELBASE!WaitForMultipleObjectsEx+0x103 (FPO: [SEH])
02 001bed48 72a0fcd8 00000001 0000c350 00000001 clr!WaitForMultipleObjectsEx_SO_TOLERANT+0x3c (FPO: [Non-Fpo])
03 001bedd4 72a0fdc9 00000001 001bef10 00000001 clr!Thread::DoAppropriateWaitWorker+0x237 (FPO: [5,25,0])
04 001bee40 72a1011f 00000001 001bef10 00000001 clr!Thread::DoAppropriateWait+0x64 (FPO: [Non-Fpo])
05 001bef48 70df9911 00000000 00000000 045e247c clr!WaitHandleNative::CorWaitOneNative+0x163 (FPO: [Non-Fpo])
06 001bef5c 70df98d8 00000000 0000c350 00000000 mscorlib_ni+0x429911
07 001bef78 02990474 00000000 00000000 001bef94 mscorlib_ni+0x4298d8
WARNING: Frame IP not in any known module. Following frames may be wrong.
08 001bef88 7296eb16 0275e020 001befe8 72976e84 0x2990474
09 001bef94 72976e84 001bf024 001befd8 72b07020 clr!CallDescrWorkerInternal+0x34
0a 001befe8 729782f4 00000000 045e2440 00000000 clr!CallDescrWorkerWithHandler+0x6b (FPO: [Non-Fpo])
0b 001bf050 72a2d34c 001bf144 57a56079 02704d0c clr!MethodDescCallSite::CallTargetWorker+0x16a (FPO: [Non-Fpo])
0c 001bf17c 72a2d2b0 001bf1a0 00000000 57a56095 clr!RunMain+0x1ad (FPO: [Non-Fpo])
0d 001bf3f0 72a2da26 00000000 57a56505 00020000 clr!Assembly::ExecuteMainMethod+0x124 (FPO: [1,149,0])
0e 001bf8e8 72a2dac9 57a56845 00000000 00000000 clr!SystemDomain::ExecuteMainMethod+0x631 (FPO: [0,311,0])
0f 001bf940 72a2d426 57a56885 00000000 72a50320 clr!ExecuteEXE+0x4c (FPO: [Non-Fpo])
10 001bf980 72a5033c 57a568b9 00000000 72a50320 clr!_CorExeMainInternal+0xdc (FPO: [Non-Fpo])
11 001bf9bc 737dd91b a2f79cdd 739e4df0 737dd8a0 clr!_CorExeMain+0x4d (FPO: [Non-Fpo])
12 001bf9fc 739de879 739e4df0 737d0000 2bf7ae78 mscoreei!_CorExeMain+0x10e (FPO: [0,10,4])
13 001bfa10 739e4df8 739e4df0 77188654 003a9000 mscoree!ShellShim__CorExeMain+0xa9 (FPO: [Non-Fpo])
14 001bfa18 77188654 003a9000 77188630 dc4d9cdf mscoree!_CorExeMain_Exported+0x8 (FPO: [0,0,4])
15 001bfa2c 77594a77 003a9000 b6694afe 00000000 kernel32!BaseThreadInitThunk+0x24 (FPO: [Non-Fpo])
16 001bfa74 77594a47 ffffffff 775b9eee 00000000 ntdll!__RtlUserThreadStart+0x2f (FPO: [SEH])
17 001bfa84 00000000 739e4df0 003a9000 00000000 ntdll!_RtlUserThreadStart+0x1b (FPO: [Non-Fpo])
x86의 경우에는 WIN32 API가 따르는 기본 호출 규약인 __stdcall이 ecx, edx 등을 통한 레지스터를 이용하지 않기 때문에 스택에 값이 잘 남게 됩니다. 이 때문에 kv 명령어로 "Args to Child" 항목을 출력하면 넘겨진 인자 값을 쉽게 확인하는 수 있는 경우가 많습니다. 가령 이번 예제에서 50,000 (0xc350) 값을 WaitOne에 전달했는데 위에서 보는 바와 같이 2개의 호출 스택에서 그 값을 직접 확인할 수 있습니다. (실제로 x86 덤프에서 WaitOne에 전달한 시간을 알고 싶다면 clr!WaitForMultipleObjectsEx_SO_TOLERANT 함수의 "Args to Child"에 나타난 2번째 인자를 확인하면 거의 잘 맞을 것입니다.)
이런 찍는 방법 말고 ^^ 정식으로 한번 파고들어 보겠습니다. 우선, Native 호출 스택은 낯설을 테니 닷넷 호출 스택으로 출발해 보겠습니다.
0:000> !clrstack
OS Thread Id: 0x92ec (0)
Child SP IP Call Site
001bee74 7759ed3c [HelperMethodFrame_1OBJ: 001bee74] System.Threading.WaitHandle.WaitOneNative(System.Runtime.InteropServices.SafeHandle, UInt32, Boolean, Boolean)
001bef58 70df9911 System.Threading.WaitHandle.InternalWaitOne(System.Runtime.InteropServices.SafeHandle, Int64, Boolean, Boolean)
001bef70 70df98d8 System.Threading.WaitHandle.WaitOne(Int32, Boolean)
001bef84 02990474 ConsoleApp1.Program.Main(System.String[]) [E:\ConsoleApp1\ConsoleApp1\Program.cs @ 16]
001bf0f8 7296eb16 [GCFrame: 001bf0f8]
Main 함수에서 WaitOne을 호출하고 있는데 이때 Main 함수의 IP 주솟값이 02990474입니다. 이것은 WaitOne 호출이 반환되면 시작할 IP 주소를 의미하는데 따라서 WaitOne을 호출할 당시의 명령어를 확인하고 싶다면 다음과 같은 식으로 할 수 있습니다.
0:000> uf 02990474 -20
02990454 0470 add al,70h
02990456 8bf0 mov esi,eax
02990458 6a01 push 1
0299045a 8bce mov ecx,esi
0299045c 33d2 xor edx,edx
0299045e e8c9ec356e call mscorlib_ni+0x31f12c (70cef12c)
02990463 6a00 push 0
02990465 8bce mov ecx,esi
02990467 ba50c30000 mov edx,0C350h
0299046c 8b01 mov eax,dword ptr [ecx]
0299046e 8b402c mov eax,dword ptr [eax+2Ch]
02990471 ff5004 call dword ptr [eax+4]
02990474 5e pop esi
02990475 5d pop ebp
02990476 c3 ret
이를 기반으로 WaitOne의 signature와 함께,
public virtual bool WaitOne(int millisecondsTimeout, bool exitContext);
인자 값을 다음과 같이 유추할 수 있습니다.
mov ecx, esi = EventWaitHandle 인스턴스의 주솟값
mov edx, 0C350h = millisecondsTimeout
push 0 = exitContext
그런데 벌써 시간 값이 0x0c350 상수로 나왔습니다. 그럼 여기서 끝낼 수도 있겠지만 대개의 경우 변숫값으로도 전달하는 경우도 많기 때문에 그런 상황을 가정하고 좀 더 파헤쳐 보겠습니다. 일단, 이번 함수 호출에서는 스택 상으로 인자 값을 확인할 방법이 없습니다. 왜냐하면 millisecondsTimeout 값이 volatile 레지스터인 edx로 전달되기 때문에 현재 CPU 상태에서 edx의 값이 다른 값으로 덮어써졌을 확률이 높습니다.
다음 함수로 넘어가 이번엔 WaitOne에서 InternalWaitOne 함수를 호출하는 상황을 보겠습니다.
0:000> uf 70df98d8 -20
mscorlib_ni+0x4298b8:
70df98b8 0f8c429ac000 jl mscorlib_ni+0x1033300 (71a03300) Branch
mscorlib_ni+0x4298be:
70df98be 8b7108 mov esi,dword ptr [ecx+8]
70df98c1 8bc2 mov eax,edx
70df98c3 c1f81f sar eax,1Fh
70df98c6 50 push eax
70df98c7 52 push edx
70df98c8 0fb65110 movzx edx,byte ptr [ecx+10h]
70df98cc 0fb64508 movzx eax,byte ptr [ebp+8]
70df98d0 50 push eax
70df98d1 8bce mov ecx,esi
70df98d3 e818000000 call mscorlib_ni+0x4298f0 (70df98f0)
70df98d8 25ff000000 and eax,0FFh
70df98dd 59 pop ecx
70df98de 5e pop esi
70df98df 5d pop ebp
70df98e0 c20400 ret 4
...[생략]...
현재 "call mscorlib_ni+0x4298f0 (70df98f0)" 호출을 한 상태이고 해당 호출이 완료되면 70df98d8의 "and eax, 0FFh"를 실행할 예정입니다. 즉, 70df98f0 주소의 함수는 InternalWaitOne입니다. .NET Reflector로 InternalWaitOne의 signature를 보면,
internal static bool InternalWaitOne(SafeHandle waitableSafeHandle, long millisecondsTimeout, bool hasThreadAffinity, bool exitContext)
4개의 인자를 전달받고 있습니다. 그런데... 이상하군요. 위의 코드에 보면 push eax, push edx,..., push eax로 총 3번의 push와 함께 ecx, edx에 값을 설정하고 있습니다. 그렇다면 5개의 인자여야 하는데... 음... 여기서 해석이 안되는군요. (혹시 아시는 분은 덧글 부탁드립니다.) 하지만 일단 4개라 가정하고 어셈블리 코드에서 전달되는 인자의 값을 대략 다음과 같이 유추할 수 있습니다.
push eax
; hasThreadAffinity
push edx
; exitContext
push eax
; ?????
mov ecx, esi
; waitableSafeHandle
movzx edx, byte ptr [ecx + 10h]
; millisecondsTimeout
우리가 알고 싶은 [ecx + 10h]는 이번에도 스택에 저장되어 있지 않으므로 값을 알아낼 수 없습니다. (ecx + 10h과 같은 연산인 걸로 봐서 아마도 ecx가 스택의 어느 지점을 가리키는 것이 아닌가 생각되지만 이를 알아내기 위해서는 70df98d8 단계까지의 코드를 해석해야 하므로 분석이 더뎌질 수 있습니다.) 따라서 이번에도 다시 InternalWaitOne으로 넘어갑니다.
0:000> uf 70df9911 -21
mscorlib_ni+0x4298f0:
70df98f0 55 push ebp
70df98f1 8bec mov ebp,esp
70df98f3 56 push esi
70df98f4 90 nop
70df98f5 85c9 test ecx,ecx
70df98f7 0f84439ac000 je mscorlib_ni+0x1033340 (71a03340) Branch
mscorlib_ni+0x4298fd:
70df98fd 81e2ff000000 and edx,0FFh
70df9903 52 push edx
70df9904 0fb64508 movzx eax,byte ptr [ebp+8]
70df9908 50 push eax
70df9909 8b550c mov edx,dword ptr [ebp+0Ch]
70df990c e83f16efff call mscorlib_ni+0x31af50 (70ceaf50)
70df9911 8bf0 mov esi,eax
70df9913 b901000000 mov ecx,1
70df9918 ba9b000000 mov edx,9Bh
70df991d e81607efff call mscorlib_ni+0x31a038 (70cea038)
70df9922 80b8380d000000 cmp byte ptr [eax+0D38h],0
70df9929 0f853f9ac000 jne mscorlib_ni+0x103336e (71a0336e) Branch
InternalWaitOne에서 호출하는 System.Threading.WaitHandle.WaitOneNative의 signature는 InternalWaitOne과 동일합니다.
[MethodImpl(MethodImplOptions.InternalCall), SecurityCritical]
private static extern int WaitOneNative(SafeHandle waitableSafeHandle, uint millisecondsTimeout, bool hasThreadAffinity, bool exitContext);
인자 전달을 위한 어셈블리 코드 해석은 이렇게 되고,
ecx
; InternalWaitOne에서 넘긴 ecx를 그대로 전달
mov edx, dword ptr [ebp + 0ch]
; millisecondsTimeout
and edx,0FFh
push edx
; hasThreadAffinity
movzx eax,byte ptr [ebp+8]
push eax
; exitContext
드디어 나왔습니다. 이번에는 ebp + 0ch에 millisecondsTimeout 값이 담겨 있습니다. 따라서 InternalWaitOne 호출 당시의 ebp 값을 알아내면 됩니다. 우리가 살펴본 InternalWaitOne에 대해 !clrstack에서 살펴보면 IP 주솟값을 얻을 수 있었는데요.
0:000> !clrstack
OS Thread Id: 0x92ec (0)
Child SP IP Call Site
001bee74 7759ed3c [HelperMethodFrame_1OBJ: 001bee74] System.Threading.WaitHandle.WaitOneNative(System.Runtime.InteropServices.SafeHandle, UInt32, Boolean, Boolean)
001bef58 70df9911 System.Threading.WaitHandle.InternalWaitOne(System.Runtime.InteropServices.SafeHandle, Int64, Boolean, Boolean)
001bef70 70df98d8 System.Threading.WaitHandle.WaitOne(Int32, Boolean)
001bef84 02990474 ConsoleApp1.Program.Main(System.String[]) [E:\ConsoleApp1\ConsoleApp1\Program.cs @ 16]
001bf0f8 7296eb16 [GCFrame: 001bf0f8]
전에 설명한 대로 70df9911 값은 InternalWaitOne이 WaitOneNative를 호출하고 돌아왔을 때 실행을 계속할 주소였습니다. 이를 염두에 두고 kv 호출 스택을 보면,
0:000> kv
# ChildEBP RetAddr Args to Child
00 001beb64 77061293 00000001 001bef10 00000000 ntdll!NtWaitForMultipleObjects+0xc (FPO: [5,0,0])
01 001becf8 72a0ff96 00000001 001bef10 00000001 KERNELBASE!WaitForMultipleObjectsEx+0x103 (FPO: [SEH])
02 001bed48 72a0fcd8 00000001 0000c350 00000001 clr!WaitForMultipleObjectsEx_SO_TOLERANT+0x3c (FPO: [Non-Fpo])
03 001bedd4 72a0fdc9 00000001 001bef10 00000001 clr!Thread::DoAppropriateWaitWorker+0x237 (FPO: [5,25,0])
04 001bee40 72a1011f 00000001 001bef10 00000001 clr!Thread::DoAppropriateWait+0x64 (FPO: [Non-Fpo])
05 001bef48 70df9911 00000000 00000000 045e247c clr!WaitHandleNative::CorWaitOneNative+0x163 (FPO: [Non-Fpo])
06 001bef5c 70df98d8 00000000 0000c350 00000000 mscorlib_ni+0x429911
07 001bef78 02990474 00000000 00000000 001bef94 mscorlib_ni+0x4298d8
WARNING: Frame IP not in any known module. Following frames may be wrong.
...[생략]...
16 001bfa74 77594a47 ffffffff 775b9eee 00000000 ntdll!__RtlUserThreadStart+0x2f (FPO: [SEH])
17 001bfa84 00000000 739e4df0 003a9000 00000000 ntdll!_RtlUserThreadStart+0x1b (FPO: [Non-Fpo])
70df9911 주솟값을 RetAddr로 표기한 WaitHandleNative::CorWaitOneNative 호출을 확인할 수 있습니다. 그 함수의 RetAddr라고 되어 있으니 그렇다면 그 아래의 "mscorlib_ni+0x429911" 호출이 InternalWaitOne에 해당합니다. (.NET Managed 함수 호출은 k 명령어에서 저런 식으로 이름이 보입니다.)
따라서 "mscorlib_ni+0x429911"의 ChildEBP로 나온 001bef5c 값이 InternalWaitOne 함수가 호출될 당시의 EBP 값이 됩니다. 그리하여 [ebp + 0ch]의 값은,
mov edx, dword ptr [ebp + 0ch]
다음과 같이 구할 수 있습니다.
0:000> ? 001bef5c + c
Evaluate expression: 1830760 = 001bef68
0:000> dd 001bef68
001bef68 0000c350 00000000 70e798a4 045e244c
001bef78 001bef88 02990474 00000000 00000000
001bef88 001bef94 7296eb16 0275e020 001befe8
001bef98 72976e84 001bf024 001befd8 72b07020
001befa8 001bf0f8 72976e3d 57a57eed 001bf14c
001befb8 001bf0b8 001bf06c 729d432a 001bf024
001befc8 00000000 57a57eed 001befa0 001bf0b8
001befd8 001bf16c 72b05370 2529ff9d 00000001
원하던 바로 그 값(0000c350)입니다.
위에서 EBP 레지스터의 값을 k 명령어의 ChildEBP를 통해서 구하긴 했지만 돌이켜 보면 !clrstack의 "Child SP"로도 구할 수 있습니다.
0:000> !clrstack
OS Thread Id: 0x92ec (0)
Child SP IP Call Site
001bee74 7759ed3c [HelperMethodFrame_1OBJ: 001bee74] System.Threading.WaitHandle.WaitOneNative(System.Runtime.InteropServices.SafeHandle, UInt32, Boolean, Boolean)
001bef58 70df9911 System.Threading.WaitHandle.InternalWaitOne(System.Runtime.InteropServices.SafeHandle, Int64, Boolean, Boolean)
001bef70 70df98d8 System.Threading.WaitHandle.WaitOne(Int32, Boolean)
001bef84 02990474 ConsoleApp1.Program.Main(System.String[]) [E:\ConsoleApp1\ConsoleApp1\Program.cs @ 16]
001bf0f8 7296eb16 [GCFrame: 001bf0f8]
InternalWaitOne의 Child SP가 001bef58인 것은 부모 함수(WaitOne)가 InternalWaitOne을 call한 시점의 SP를 의미합니다. 따라서 call 이후 prologue로 "push ebp", "mov ebp, esp"로 구성되는 EBP 프레임 레지스터의 값은 Child SP 값의 +4가 됩니다. 즉, 001bef58 + 4 = 001bef5c로 결국 ChildEBP의 값과 같게 됩니다.
(그런데 좀 이상하군요. x64에서는 Child SP의 의미가 prologue가 모두 반영된 코드인데 x86에서는 프레임 스택 구성 전의 값을 가리키고 있다는 것이.)
사실 .NET 메서드들은 __clrcall 호출 규약을 따르기 때문에 ecx, edx의 활용으로 인자 추적이 살짝 어렵습니다. 따라서 쉽게 찾으려면 .NET 메서드에서 Win32 API 등의 __stdcall 호출 규약을 따르는 시점부터 찾는 것이 좋습니다. 그 외에, primitive 타입이 아닌 객체라면 !dso 명령어를 통해 출력되는 결과로부터 유추하는 것이 더 빠릅니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]