Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 12개 있습니다.)

x64 콜 스택 인자 추적과 windbg의 Child-SP, RetAddr, Args to Child 값 확인

.NET Thread.Sleep 함수를 호출한 경우로 예를 들어볼까요? ^^

Thread.Sleep(60 * 1000);

.NET Reflector로 확인해 보면 내부적으로 SleepInternal로 내려가고,

// ================ Thread.cs

[MethodImplAttribute(MethodImplOptions.InternalCall)]
private static extern void SleepInternal(int millisecondsTimeout);

public static void Sleep(int millisecondsTimeout)
{
    SleepInternal(millisecondsTimeout);
}

sscli를 참고 삼아 추적해 가면, .\sscli\clr\src\vm\ecall.cpp 파일에서 ThreadNative::Sleep으로 연결되는 것을 볼 수 있습니다.

FCFuncStart(gThreadFuncs)
    ...[생략]...
    FCFuncElement("SleepInternal", ThreadNative::Sleep)
    ...[생략]...
FCFuncEnd()

ThreadNative::Sleep 정의는 .\sscli\clr\src\vm\comsynchronizable.cpp에 있고,

FCIMPL1(void, ThreadNative::Sleep, INT32 iTime)
{
    ...[생략]...

    GetThread()->UserSleep(iTime);

    ..[생략]...
}
FCIMPLEND

GetThread()->UserSleep 메서드는 .\sscli\clr\src\vm\threads.cpp 파일에 있습니다.

// Implementation of Thread.Sleep().
void Thread::UserSleep(INT32 time)
{
    ...[생략]...

    res = ClrSleepEx (time, TRUE);

    ...[생략]...
}

겨우 Sleep 하나에 길게도 내려가는군요. ^^; 계속해서 ClrSleepEx의 정의를 .\sscli\clr\src\vm\hosting.h에서 찾을 수 있고,

#define ClrSleepEx EESleepEx

EESleepEx 함수의 실제 정의는 .\sscli\clr\src\utilcode\clrhost.cpp에서 확인할 수 있습니다.

DWORD ClrSleepEx(DWORD dwMilliseconds, BOOL bAlertable)
{
    WRAPPER_CONTRACT;

    return GetExecutionEngine()->ClrSleepEx(dwMilliseconds, bAlertable);
}

자, 마지막입니다. ^^ GetExecutionEngine()->ClrSleepEx의 정의는 .\sscli\clr\src\utilcode\hostimpl.cpp 파일에 있고,

DWORD STDMETHODCALLTYPE UtilExecutionEngine::ClrSleepEx(DWORD dwMilliseconds, BOOL bAlertable)
{
    return SleepEx (dwMilliseconds, bAlertable);    
}

이제서야 Win32 API의 SleepEx가 불리는 것을 볼 수 있습니다. ^^

이후부터는 windbg를 통해 확인할 수 있습니다.

0:035> kb
RetAddr           : Args to Child                                                           : Call Site
00007ffb`ba62121a : 000000ca`9bbad108 000000ca`9bbad110 000000c6`161889c0 00007ffb`4686d129 : ntdll!NtDelayExecution+0xa
00007ffb`a5152e73 : 00000000`00000000 00000000`00000001 00000000`00000000 00000000`00000000 : KERNELBASE!SleepEx+0xa2
00007ffb`a515da6c : 000000ca`9ad04f70 00000000`0000ea60 000000c6`161889c0 00007ffb`45fe5db2 : clr!EESleepEx+0x24
00007ffb`a515db71 : 06000000`00000001 00000000`008363dd 04000000`00000001 00007ffb`4686d705 : clr!Thread::UserSleep+0xa5
00007ffb`4615e43f : 000000ca`0000ea60 000000ca`9b8307a0 000000c6`96a05d28 000000ca`9bbad4b0 : clr!ThreadNative::Sleep+0xad
00007ffb`47072761 : 000000c6`0000ea60 000000c8`16187508 00007ffb`00000000 000000ca`99ad1ca3 : 0x00007ffb`4615e43f
00007ffb`467b9762 : 000000c6`961fa428 000000c6`961fa428 000000c6`961ae3d0 000000ca`9b8307a0 : 0x00007ffb`47072761
00007ffb`467b9258 : 000000c6`961fe978 000000c6`961fa428 000000c6`961ae3d0 00000000`00000000 : 0x00007ffb`467b9762

EESleepEx에서 Win32 API인 SleepEx가 불렸고 거기서 다시 NtDelayExecution이 불립니다. windbg로 NtDelayExecution을 살펴보면,

ntdll!NtDelayExecution:
00007ffb`bd1b1500 4c8bd1          mov     r10,rcx
00007ffb`bd1b1503 b833000000      mov     eax,33h
00007ffb`bd1b1508 0f05            syscall
00007ffb`bd1b150a c3              ret

커널 호출로 넘어가는 syscall로 마무리되고 있습니다.




x64에서 콜스택을 역으로 추적하려면 다음의 2가지 특성을 알고 있어야 합니다.

  • x86에서는 EBP 레지스터를 통해 스택 프레임을 형성했던 것과는 달리 x64에서는 EBP(RBP) 레지스터가 범용으로 바뀌었음
  • 대신 ESP(RSP) 레지스터는 함수의 진입점에서 한번만 바뀌고, 함수의 반환점에서 호출 이전의 값으로 복원됨.

자, 그럼 이제 windbg에서 NtDelayExecution으로부터 역으로 콜스택을 추적해 보겠습니다. 우선, 위에서 보여준 NtDelayExecution를 코드를 다시 보겠습니다.

ntdll!NtDelayExecution:
00007ffb`bd1b1500 4c8bd1          mov     r10,rcx
00007ffb`bd1b1503 b833000000      mov     eax,33h
00007ffb`bd1b1508 0f05            syscall
00007ffb`bd1b150a c3              ret

스택 관련 명령어가 없으므로 NtDelayExecution의 RSP는 변경되지 않은 상태이므로 현재의 RSP에는 NtDelayExecution의 ret 명령어로 돌아갈 상위 SleepEx로의 반환 주소를 가리키고 있습니다. 확인을 위해 windbg에서 rsp 레지스터의 값을 확인해 보았고,

rsp == 000000ca`9bbad048

000000ca`9bbad048 주소의 값을 덤프해 보니 다음과 같습니다.

[000000ca`9bbad048 주소의 메모리 값] ba62121a 00007ffb 9bbad108 000000ca 9bbad110 000000ca 161889c0 000000c6

즉, "ba62121a 00007ffb" 8바이트 값이 NtDelayExecution의 ret 코드로 인해 꺼내질 SleepEx로의 반환 주소입니다. 또한 이 값은 kb 명령어로도 구할 수 있습니다.

0:035> kb
RetAddr           : Args to Child                                                           : Call Site
00007ffb`ba62121a : 000000ca`9bbad108 000000ca`9bbad110 000000c6`161889c0 00007ffb`4686d129 : ntdll!NtDelayExecution+0xa
00007ffb`a5152e73 : 00000000`00000000 00000000`00000001 00000000`00000000 00000000`00000000 : KERNELBASE!SleepEx+0xa2
00007ffb`a515da6c : 000000ca`9ad04f70 00000000`0000ea60 000000c6`161889c0 00007ffb`45fe5db2 : clr!EESleepEx+0x24
00007ffb`a515db71 : 06000000`00000001 00000000`008363dd 04000000`00000001 00007ffb`4686d705 : clr!Thread::UserSleep+0xa5
00007ffb`4615e43f : 000000ca`0000ea60 000000ca`9b8307a0 000000c6`96a05d28 000000ca`9bbad4b0 : clr!ThreadNative::Sleep+0xad
00007ffb`47072761 : 000000c6`0000ea60 000000c8`16187508 00007ffb`00000000 000000ca`99ad1ca3 : 0x00007ffb`4615e43f
00007ffb`467b9762 : 000000c6`961fa428 000000c6`961fa428 000000c6`961ae3d0 000000ca`9b8307a0 : 0x00007ffb`47072761
00007ffb`467b9258 : 000000c6`961fe978 000000c6`961fa428 000000c6`961ae3d0 00000000`00000000 : 0x00007ffb`467b9762

이어서 SleepEx 코드를 보면, 예상했던 대로 00007ffb`ba62121a 주소가 NtDelayExecution 호출 바로 다음에 있는 것을 확인할 수 있습니다.

KERNELBASE!SleepEx:
00007ffb`ba621170 4c8bdc          mov     r11,rsp
00007ffb`ba621173 49895b08        mov     qword ptr [r11+8],rbx
00007ffb`ba621177 89542410        mov     dword ptr [rsp+10h],edx
00007ffb`ba62117b 56              push    rsi
00007ffb`ba62117c 57              push    rdi
00007ffb`ba62117d 4156            push    r14
00007ffb`ba62117f 4881ec80000000  sub     rsp,80h
00007ffb`ba621186 8bda            mov     ebx,edx
00007ffb`ba621188 8bf1            mov     esi,ecx
00007ffb`ba62118a 49c7439848000000 mov     qword ptr [r11-68h],48h
00007ffb`ba621192 c744243801000000 mov     dword ptr [rsp+38h],1
00007ffb`ba62119a 33c0            xor     eax,eax
00007ffb`ba62119c 498943a8        mov     qword ptr [r11-58h],rax
00007ffb`ba6211a0 498943b0        mov     qword ptr [r11-50h],rax
00007ffb`ba6211a4 498943b8        mov     qword ptr [r11-48h],rax
00007ffb`ba6211a8 498943c0        mov     qword ptr [r11-40h],rax
...[생략]...
00007ffb`ba621208 0f853a190900    jne     KERNELBASE!SleepEx+0xc6 (00007ffb`ba6b2b48)
00007ffb`ba62120e 498bd6          mov     rdx,r14 // r14 == ca9bbad070
00007ffb`ba621211 0fb6cb          movzx   ecx,bl
00007ffb`ba621214 ff15de8b1000    call    qword ptr [KERNELBASE!_imp_NtDelayExecution (00007ffb`ba729df8)]
00007ffb`ba62121a 8bf0            mov     esi,eax // _imp_NtDelayExecution 실행 후 돌아오는 주소
00007ffb`ba62121c 898424b0000000  mov     dword ptr [rsp+0B0h],eax
00007ffb`ba621223 85db            test    ebx,ebx
00007ffb`ba621225 0f85a2bb0000    jne     KERNELBASE!SleepEx+0xaf (00007ffb`ba62cdcd)
00007ffb`ba62122b 488b8424b8000000 mov     rax,qword ptr [rsp+0B8h]
00007ffb`ba621233 4885c0          test    rax,rax
00007ffb`ba621236 0f8523190900    jne     KERNELBASE!SleepEx+0x12d (00007ffb`ba6b2b5f)
00007ffb`ba62123c 85db            test    ebx,ebx
00007ffb`ba62123e 0f859abb0000    jne     KERNELBASE!SleepEx+0x120 (00007ffb`ba62cdde)
00007ffb`ba621244 b8c0000000      mov     eax,0C0h
00007ffb`ba621249 3bf0            cmp     esi,eax
00007ffb`ba62124b 7402            je      KERNELBASE!SleepEx+0xf2 (00007ffb`ba62124f)
00007ffb`ba62124d 8bc7            mov     eax,edi
00007ffb`ba62124f 488b9c24a0000000 mov     rbx,qword ptr [rsp+0A0h]
00007ffb`ba621257 4881c480000000  add     rsp,80h
00007ffb`ba62125e 415e            pop     r14
00007ffb`ba621260 5f              pop     rdi
00007ffb`ba621261 5e              pop     rsi
00007ffb`ba621262 c3              ret

그 외에, SleepEx 코드를 보면 이전에 말했던 대로 진입점과 반환점 이외에는 push/pop 스택 명령어 및 rsp 레지스터에 대한 sub/add 명령어가 없는 것을 확인할 수 있습니다.

KERNELBASE!SleepEx:
00007ffb`ba621170 4c8bdc          mov     r11,rsp
00007ffb`ba621173 49895b08        mov     qword ptr [r11+8],rbx
00007ffb`ba621177 89542410        mov     dword ptr [rsp+10h],edx
00007ffb`ba62117b 56              push    rsi
00007ffb`ba62117c 57              push    rdi
00007ffb`ba62117d 4156            push    r14
00007ffb`ba62117f 4881ec80000000  sub     rsp,80h
00007ffb`ba621186 8bda            mov     ebx,edx
...[생략]...
00007ffb`ba621214 ff15de8b1000    call    qword ptr [KERNELBASE!_imp_NtDelayExecution (00007ffb`ba729df8)]
00007ffb`ba62121a 8bf0            mov     esi,eax
00007ffb`ba62121c 898424b0000000  mov     dword ptr [rsp+0B0h],eax
...[생략]...
00007ffb`ba62124f 488b9c24a0000000 mov     rbx,qword ptr [rsp+0A0h]
00007ffb`ba621257 4881c480000000  add     rsp,80h
00007ffb`ba62125e 415e            pop     r14
00007ffb`ba621260 5f              pop     rdi
00007ffb`ba621261 5e              pop     rsi
00007ffb`ba621262 c3              ret

이런 성질 덕분에 콜스택을 추적하는 것이 가능합니다. 위의 코드에서 SleepEx 진입점을 보면,

push    rsi
push    rdi
push    r14
sub     rsp,80h

총 0x80 + (8 * 3)개의 스택이 점유된 것을 알 수 있습니다. 이전의 RSP 레지스터에 대한 메모리 덤프를 좀 더 많이 해보면,

000000ca`9bbad048 ba62121a 00007ffb 9bbad108 000000ca 9bbad110 000000ca 161889c0 000000c6
000000ca`9bbad068 4686d129 00007ffb dc3cba00 ffffffff 9bbad070 000000ca 00000048 00000000
000000ca`9bbad088 00000001 000000ca 00000000 00000000 00000000 00000000 00000030 00000000
000000ca`9bbad0a8 ffffffff ffffffff ffffffff ffffffff ba62cdbf 00007ffb 00000000 00000000
000000ca`9bbad0c8 00000015 000000c6 a515dac4 00007ffb 0000ea60 00000000 008363dd 00000000
000000ca`9bbad0e8 a5152e73 00007ffb 00000000 00000000 00000001 00000000 00000000 00000000
000000ca`9bbad108 00000000 00000000 9ad04f70 000000ca a515da6c 00007ffb 9ad04f70 000000ca
000000ca`9bbad128 0000ea60 00000000 161889c0 000000c6 45fe5db2 00007ffb fffffffe ffffffff
000000ca`9bbad148 9ad04f70 000000ca 00000001 000000ca 96a31838 000000c6 00000000 00000000

현재 NtDelayExecution 함수가 실행되고 있는 상태이므로 ba62121a 00007ffb 값은 이전에 살펴본 것처럼 SleepEx로의 반환 주소입니다. 따라서 ret 코드 이후 RSP는 (000000ca`9bbad048 + 8) 주소로 이동합니다. 이를 다시 말하면 NtDelayExecution 함수를 호출(call)하기 전에는 RSP의 값이 (000000ca`9bbad048 + 8) 상태였다는 것을 의미합니다.

따라서, (000000ca`9bbad048 + 8) 위치로부터 0x80 (8바이트 16개 메모리 분량)까지는 SleepEx의 초기 "sub rsp, 80h"로 인해 점유된 공간이고,

000000ca`9bbad048 ba62121a 00007ffb 9bbad108 000000ca 9bbad110 000000ca 161889c0 000000c6
000000ca`9bbad068 4686d129 00007ffb dc3cba00 ffffffff 9bbad070 000000ca 00000048 00000000
000000ca`9bbad088 00000001 000000ca 00000000 00000000 00000000 00000000 00000030 00000000
000000ca`9bbad0a8 ffffffff ffffffff ffffffff ffffffff ba62cdbf 00007ffb 00000000 00000000
000000ca`9bbad0c8 00000015 000000c6 a515dac4 00007ffb 0000ea60 00000000 008363dd 00000000
000000ca`9bbad0e8 a5152e73 00007ffb 00000000 00000000 00000001 00000000 00000000 00000000
000000ca`9bbad108 00000000 00000000 9ad04f70 000000ca a515da6c 00007ffb 9ad04f70 000000ca
000000ca`9bbad128 0000ea60 00000000 161889c0 000000c6 45fe5db2 00007ffb fffffffe ffffffff
000000ca`9bbad148 9ad04f70 000000ca 00000001 000000ca 96a31838 000000c6 00000000 00000000

다시 이후의 8바이트 * 3개의 영역은 각각 push rsi, push rdi, push r14으로 점유된 공간입니다.

000000ca`9bbad048 ba62121a 00007ffb 9bbad108 000000ca 9bbad110 000000ca 161889c0 000000c6
000000ca`9bbad068 4686d129 00007ffb dc3cba00 ffffffff 9bbad070 000000ca 00000048 00000000
000000ca`9bbad088 00000001 000000ca 00000000 00000000 00000000 00000000 00000030 00000000
000000ca`9bbad0a8 ffffffff ffffffff ffffffff ffffffff ba62cdbf 00007ffb 00000000 00000000
000000ca`9bbad0c8 00000015 000000c6 a515dac4 00007ffb 0000ea60 00000000 008363dd 00000000
000000ca`9bbad0e8 a5152e73 00007ffb 00000000 00000000 00000001 00000000 00000000 00000000
000000ca`9bbad108 00000000 00000000 9ad04f70 000000ca a515da6c 00007ffb 9ad04f70 000000ca
000000ca`9bbad128 0000ea60 00000000 161889c0 000000c6 45fe5db2 00007ffb fffffffe ffffffff
000000ca`9bbad148 9ad04f70 000000ca 00000001 000000ca 96a31838 000000c6 00000000 00000000

결국 이 스택 영역(0x80 + 8 * 3)은 SleepEx의 반환점에서 다음의 명령어로 정리가 됩니다.

add     rsp,80h
pop     r14
pop     rdi
pop     rsi

그렇다면 이제 자연스럽게 SleepEx의 ret으로 인해 반환될 주소가 "a5152e73 00007ffb"임을 알 수 있습니다.

000000ca`9bbad048 ba62121a 00007ffb 9bbad108 000000ca 9bbad110 000000ca 161889c0 000000c6
000000ca`9bbad068 4686d129 00007ffb dc3cba00 ffffffff 9bbad070 000000ca 00000048 00000000
000000ca`9bbad088 00000001 000000ca 00000000 00000000 00000000 00000000 00000030 00000000
000000ca`9bbad0a8 ffffffff ffffffff ffffffff ffffffff ba62cdbf 00007ffb 00000000 00000000
000000ca`9bbad0c8 00000015 000000c6 a515dac4 00007ffb 0000ea60 00000000 008363dd 00000000
000000ca`9bbad0e8 a5152e73 00007ffb 00000000 00000000 00000001 00000000 00000000 00000000
000000ca`9bbad108 00000000 00000000 9ad04f70 000000ca a515da6c 00007ffb 9ad04f70 000000ca
000000ca`9bbad128 0000ea60 00000000 161889c0 000000c6 45fe5db2 00007ffb fffffffe ffffffff
000000ca`9bbad148 9ad04f70 000000ca 00000001 000000ca 96a31838 000000c6 00000000 00000000

이렇게 반복하면 x64 프로그램에서 현재의 RSP 레지스터 값만으로 호출 스택을 차례로 알아낼 수 있습니다.




실습을 위해 ^^ SleepEx도 한번 해볼까요?

SleepEx의 ret 으로 인해 돌아갈 주소는 "00007ffb`a5152e73"이고 당연히 EESleepEx 함수 내의 _imp_SleepEx 호출 이후의 주소에 해당합니다.

clr!EESleepEx:
00007ffb`a5152e50 53              push    rbx
00007ffb`a5152e51 4883ec20        sub     rsp,20h
00007ffb`a5152e55 448bc9          mov     r9d,ecx
00007ffb`a5152e58 33db            xor     ebx,ebx
00007ffb`a5152e5a 488b0d8f588e00  mov     rcx,qword ptr [clr!CorHost2::m_HostTaskManager (00007ffb`a5a386f0)]
00007ffb`a5152e61 4885c9          test    rcx,rcx
00007ffb`a5152e64 0f85aec13700    jne     clr!EESleepEx+0x37c1c8 (00007ffb`a54cf018)
00007ffb`a5152e6a 418bc9          mov     ecx,r9d
00007ffb`a5152e6d ff1535f86b00    call    qword ptr [clr!_imp_SleepEx (00007ffb`a58126a8)]
00007ffb`a5152e73 8bd8            mov     ebx,eax // 00007ffb`a5152e73 여기를 가리킴.
00007ffb`a5152e75 8bc3            mov     eax,ebx
00007ffb`a5152e77 4883c420        add     rsp,20h
00007ffb`a5152e7b 5b              pop     rbx
00007ffb`a5152e7c c3              ret
00007ffb`a5152e7d 90              nop
00007ffb`a5152e7e 90              nop

다시 말씀드리지만 함수의 진입점과 반환점 이외의 곳에서는 스택 조작 명령어가 x64에서는 없습니다. 이로 인해 처음과 마지막의 스택 조작 명령어는 쌍을 이루게 되고 그 중 하나만 확인해도 그 함수가 예약한 스택의 총 크기를 알 수 있습니다. 위의 EESleepEx의 경우에도 다음과 같이 스택 조작 명령어를 처음/끝에서 확인할 수 있고,

push    rbx
sub     rsp,20h
...[생략]...
add     rsp,20h
pop     rbx

따라서 EESleepEx는 0x20(8바이트로 4개) + 8의 스택 공간을 사용하고 있는 것입니다. 이전의 RSP 덤프 메모리에 이어서 값을 확인해 보면, "sub rsp, 20h"로 인해 점유된 공간이 있고,

000000ca`9bbad0e8 a5152e73 00007ffb 00000000 00000000 00000001 00000000 00000000 00000000
000000ca`9bbad108 00000000 00000000 9ad04f70 000000ca a515da6c 00007ffb 9ad04f70 000000ca
000000ca`9bbad128 0000ea60 00000000 161889c0 000000c6 45fe5db2 00007ffb fffffffe ffffffff
000000ca`9bbad148 9ad04f70 000000ca 00000001 000000ca 96a31838 000000c6 00000000 00000000
000000ca`9bbad168 0000ea60 00000000 9bbad310 000000ca a515db71 00007ffb 00000001 06000000

"push rbx" 공간까지 제외하고 나면,

000000ca`9bbad0e8 a5152e73 00007ffb 00000000 00000000 00000001 00000000 00000000 00000000
000000ca`9bbad108 00000000 00000000 9ad04f70 000000ca a515da6c 00007ffb 9ad04f70 000000ca
000000ca`9bbad128 0000ea60 00000000 161889c0 000000c6 45fe5db2 00007ffb fffffffe ffffffff
000000ca`9bbad148 9ad04f70 000000ca 00000001 000000ca 96a31838 000000c6 00000000 00000000
000000ca`9bbad168 0000ea60 00000000 9bbad310 000000ca a515db71 00007ffb 00000001 06000000

EESleepEx 실행 후 돌아갈 주소가 구해집니다.

000000ca`9bbad0e8 a5152e73 00007ffb 00000000 00000000 00000001 00000000 00000000 00000000
000000ca`9bbad108 00000000 00000000 9ad04f70 000000ca a515da6c 00007ffb 9ad04f70 000000ca
000000ca`9bbad128 0000ea60 00000000 161889c0 000000c6 45fe5db2 00007ffb fffffffe ffffffff
000000ca`9bbad148 9ad04f70 000000ca 00000001 000000ca 96a31838 000000c6 00000000 00000000
000000ca`9bbad168 0000ea60 00000000 9bbad310 000000ca a515db71 00007ffb 00000001 06000000

이 정도면, RSP 값을 이용해 콜 스택을 추적하는 방법을 확실히 아셨겠지요! ^^




콜스택 추적과 함께 중요한 점이 있다면 바로 인자(argument) 값에 대한 추적입니다. 아시는 바와 같이 x64 ABI(응용 프로그램 이진 인터페이스)에서 처음 4개의 인수는 레지스터 RCX, RDX, R8 및 R9에 전달됩니다. 하지만, rcx, rdx, r8, r9 레지스터는 해당 함수 내에서 재사용할 수 있으므로 이런 경우 백업을 위해 무조건 rsp + (8 * 4 == 0x20)개 영역의 스택을 확보하게 됩니다. 따라서, 거의 대부분의 x64 플랫폼의 함수 호출에서는 진입점에서 "sub rsp, ??h" 코드가 0x20 이상의 값을 가지게 됩니다.

확인 차원에서 ThreadNative::Sleep의 코드를 보겠습니다.

clr!ThreadNative::Sleep:
00007ffb`a515dac4 488bc4          mov     rax,rsp
00007ffb`a515dac7 894808          mov     dword ptr [rax+8],ecx
00007ffb`a515daca 4154            push    r12
00007ffb`a515dacc 4156            push    r14
00007ffb`a515dace 4157            push    r15
00007ffb`a515dad0 4881ec40010000  sub     rsp,140h
00007ffb`a515dad7 48c7442448feffffff mov   qword ptr [rsp+48h],0FFFFFFFFFFFFFFFEh
00007ffb`a515dae0 48895810        mov     qword ptr [rax+10h],rbx

Sleep 함수는 1개의 인자를 받으므로 rcx에 전달되었고, 이 값을 rax == rsp, [rax + 8] 위치에 보관하고 있습니다. 위의 코드에서 또 한가지 재미있는 점이 있다면, rcx에 전달된 인자를 스택에 백업하는 "move dword ptr [rax + 8], ecx" 코드에서 ecx가 쓰였다는 것입니다. 물론, Sleep의 인자 형이 int 4byte이기 때문에 ecx가 사용되어도 무방하지만 이로 인해 rcx 8바이트 중 상위 4바이트는 삭제되지 않고 보관되어 있기 때문에 [rax + 8] 위치의 값 전체를 인자 값으로 여겨서는 안된다는 점입니다.

실제로 windbg에서 kb 명령으로 확인하는 경우,

0:035> kb
RetAddr           : Args to Child                                                           : Call Site
00007ffb`ba62121a : 000000ca`9bbad108 000000ca`9bbad110 000000c6`161889c0 00007ffb`4686d129 : ntdll!NtDelayExecution+0xa
00007ffb`a5152e73 : 00000000`00000000 00000000`00000001 00000000`00000000 00000000`00000000 : KERNELBASE!SleepEx+0xa2
00007ffb`a515da6c : 000000ca`9ad04f70 00000000`0000ea60 000000c6`161889c0 00007ffb`45fe5db2 : clr!EESleepEx+0x24
00007ffb`a515db71 : 06000000`00000001 00000000`008363dd 04000000`00000001 00007ffb`4686d705 : clr!Thread::UserSleep+0xa5
00007ffb`4615e43f : 000000ca`0000ea60 000000ca`9b8307a0 000000c6`96a05d28 000000ca`9bbad4b0 : clr!ThreadNative::Sleep+0xad
00007ffb`47072761 : 000000c6`0000ea60 000000c8`16187508 00007ffb`00000000 000000ca`99ad1ca3 : 0x00007ffb`4615e43f
00007ffb`467b9762 : 000000c6`961fa428 000000c6`961fa428 000000c6`961ae3d0 000000ca`9b8307a0 : 0x00007ffb`47072761
00007ffb`467b9258 : 000000c6`961fe978 000000c6`961fa428 000000c6`961ae3d0 00000000`00000000 : 0x00007ffb`467b9762
...[생략]...

"Args to Child" 컬럼에 대해 ThreadNative::Sleep의 첫 번째 인자로 보여지고 있는 "000000ca 0000ea60" 값을 그 함수의 실제 첫 번째 값이라고 여기면 프로그램의 진행을 잘못 해석하게 됩니다. 일례로 예제 코드에서는 Thread.Sleep에 60초에 해당하는 60000 값을 전달했기 때문에 "ea60" 값이어야 하는데 "ca0000ea60"으로 해석해 버리면 "867583453792"라는 어마어마한 값이 되어버립니다.

x64에서의 인자 해석이 어려운 첫 번째 사항이 바로 이것입니다. windbg의 출력값만 믿어서는 안되고 실제 해당 기계어의 진입점 코드를 봐야만 올바른 값을 얻을 수 있다는 것입니다.

아쉽게도 "두 번째 어려움"까지 있는데, 심지어 windbg의 kb 명령어로 출력된 "Args to Child" 값 전체를 믿을 수 없다는 것입니다. 왜냐하면 4개의 인자 rcx, rdx, r8, r9에 대한 백업용 스택 공간 확보는 의무적이긴하지만 거기에 인자 값을 백업하는 것은 의무가 아니기 때문입니다. 따라서, rcx, rdx, r8, r9 레지스터 값이 다른 목적으로 사용되지 않는 한 백업 코드는 늘 있다고 볼 수 없습니다. 실제로 kb로 출력된 값을 보면 최초 Thread.Sleep에 전달했던 60000(0xea60)값이 NtDelayExecution까지의 "Args to Child" 목록에 항상 나타나고 있지 않음을 알 수 있습니다.

0:035> kb
RetAddr           : Args to Child                                                           : Call Site
00007ffb`ba62121a : 000000ca`9bbad108 000000ca`9bbad110 000000c6`161889c0 00007ffb`4686d129 : ntdll!NtDelayExecution+0xa
00007ffb`a5152e73 : 00000000`00000000 00000000`00000001 00000000`00000000 00000000`00000000 : KERNELBASE!SleepEx+0xa2
00007ffb`a515da6c : 000000ca`9ad04f70 00000000`0000ea60 000000c6`161889c0 00007ffb`45fe5db2 : clr!EESleepEx+0x24
00007ffb`a515db71 : 06000000`00000001 00000000`008363dd 04000000`00000001 00007ffb`4686d705 : clr!Thread::UserSleep+0xa5
00007ffb`4615e43f : 000000ca`0000ea60 000000ca`9b8307a0 000000c6`96a05d28 000000ca`9bbad4b0 : clr!ThreadNative::Sleep+0xad

왜냐하면, 해당 함수에서 rcx, rdx, r8, r9의 내용이 변하지 않는다면 굳이 성능 저하를 발생시키는 백업 작업을 하진 않기 때문입니다. 정리해 보면, windbg의 "Args to Child"는 백업용 스택 공간의 값을 '무조건' 보여주는 것일 뿐 실제 인자가 아닐 수 있습니다. 결국 운이 좋다면 "Args to Child"에 백업된 인자 값을 확인할 수 있겠지만 그렇지 않다면 어셈블리 코드를 따라가며 값을 확인해야 합니다.

물론, 가장 상위의 함수 호출에서 BP(Breakpoint)가 잡힌 상태라면 현재 레지스터의 rcx, rdx, r8, r9를 통해 그나마 쉽게 인자 값을 확인할 수 있지만 그 부모의 함수에 전달된 인자 값을 확인하려면 어셈블리 코드를 확인해야 하는 경우가 발생할 수밖에 없습니다.




rcx, rdx, r8, r9에 대한 백업 용도로 확보되는 공간을 "Parameter Homing Space"라고 합니다. 백업이 된다고 가정하고, 스택에 보관되는 정형화된 패턴은 다음과 같습니다.

// http://www.osronline.com/showthread.cfm?link=230224

module!callee:
mov     qword ptr [rsp+08h],rcx
mov     qword ptr [rsp+10h],rdx
mov     qword ptr [rsp+18h],r8
mov     qword ptr [rsp+20h],r9

add rsp, ??h

참고로, [rsp + 0h] 영역에는 해당 함수를 호출한 부모의 반환 주소값이 들어가 있기 때문에 함수의 진입점에서는 [rsp + 08h] 이후부터 인자 값이 위치하게 됩니다.

따라서, 인자 값을 확인하는 패턴도 다음과 같이 정해집니다.

[rsp+08h] : 호출 함수에 전달한 파라미터 1을 저장
[rsp+10h] : 호출 함수에 전달한 파라미터 2를 저장
[rsp+18h] : 호출 함수에 전달한 파라미터 3을 저장
[rsp+20h] : 호출 함수에 전달한 파라미터 4를 저장

하지만 여기서 문제가 있습니다. 위에서 보는 것처럼, 일련의 "mov ..."가 있은 후 "add rsp, ??h"로 인해 RSP 주소가 변경되었으므로 일반적인 디버깅 상황에서는 [rsp + 08h]와 같은 수식을 쓸 수 없습니다. 당연히 부모 함수의 경우에도 rsp가 한참 변경된 상태이므로 쓸 수 없습니다.

따라서, 위의 수식을 정상적으로 사용하려면 콜스택을 확인하는 방법을 동원해 해당 함수가 호출된 시점의 RSP 값을 알아내야 합니다. 물론, 이 작업은 매우 번거로운데요. 다행히 windbg에서 kv 명령어를 통해 호출 당시의 RSP 값을 구할 수 있습니다.

0:035> kv
Child-SP          RetAddr           : Args to Child                                                           : Call Site
000000ca`9bbad048 00007ffb`ba62121a : 000000ca`9bbad108 000000ca`9bbad110 000000c6`161889c0 00007ffb`4686d129 : ntdll!NtDelayExecution+0xa
000000ca`9bbad050 00007ffb`a5152e73 : 00000000`00000000 00000000`00000001 00000000`00000000 00000000`00000000 : KERNELBASE!SleepEx+0xa2
000000ca`9bbad0f0 00007ffb`a515da6c : 000000ca`9ad04f70 00000000`0000ea60 000000c6`161889c0 00007ffb`45fe5db2 : clr!EESleepEx+0x24
000000ca`9bbad120 00007ffb`a515db71 : 06000000`00000001 00000000`008363dd 04000000`00000001 00007ffb`4686d705 : clr!Thread::UserSleep+0xa5
000000ca`9bbad180 00007ffb`4615e43f : 000000ca`0000ea60 000000ca`9b8307a0 000000c6`96a05d28 000000ca`9bbad4b0 : clr!ThreadNative::Sleep+0xad
000000ca`9bbad2e0 00007ffb`47072761 : 000000c6`0000ea60 000000c8`16187508 00007ffb`00000000 000000ca`99ad1ca3 : 0x00007ffb`4615e43f
000000ca`9bbad320 00007ffb`467b9762 : 000000c6`961fa428 000000c6`961fa428 000000c6`961ae3d0 000000ca`9b8307a0 : 0x00007ffb`47072761
000000ca`9bbad4d0 00007ffb`467b9258 : 000000c6`961fe978 000000c6`961fa428 000000c6`961ae3d0 00000000`00000000 : 0x00007ffb`467b9762
...[생략]...

"Child-SP"라는 컬럼명에서 알 수 있듯이 해당 값은 부모 함수에서 현재의 함수를 호출할 당시의 RSP 값입니다. 예를 들어 NtDelayExecution 항목을 보면,

Child-SP          RetAddr           : Args to Child                                                           
000000ca`9bbad048 00007ffb`ba62121a : 000000ca`9bbad108 000000ca`9bbad110 000000c6`161889c0 00007ffb`4686d129 : ntdll!NtDelayExecution+0xa

"000000ca`9bbad048" 값이 NtDelayExecution 실행 중의 RSP 값이 아니고, 그 부모 함수인 KERNELBASE!SleepEx가 ntdll!NtDelayExecution 함수를 call 했을 때의 RSP 값입니다. 여기서 중요한 것은 call을 이미 한 상태의 RSP 값이기 때문에 '반환주소' 8바이트가 이미 고려되었으므로 인자 값을 알아내는 패턴이 다음과 같이 바뀝니다.

[rsp+00h] : 호출 함수에 전달할 파라미터 1을 저장
[rsp+08h] : 호출 함수에 전달할 파라미터 2를 저장
[rsp+10h] : 호출 함수에 전달할 파라미터 3을 저장
[rsp+18h] : 호출 함수에 전달할 파라미터 4를 저장

개인적으로는 위의 표현은 좀 혼란스럽다고 봅니다. 왜냐하면 실제 어셈블리 코드에서는 [rsp + 08h]이 첫 번째 인자를 가리키기 때문인데요. 그래서 이를 명확하게 하기 위해 rsp 대신 "childsp"를 쓰는 것이 나을 것 같습니다.

[ChildSP+00h] : 호출 함수에 전달할 파라미터 1을 저장
[ChildSP+08h] : 호출 함수에 전달할 파라미터 2를 저장
[ChildSP+10h] : 호출 함수에 전달할 파라미터 3을 저장
[ChildSP+18h] : 호출 함수에 전달할 파라미터 4를 저장

실제로 KERNELBASE!SleepEx의 코드를 보면,

KERNELBASE!SleepEx:
00007ffb`ba621170 4c8bdc          mov     r11,rsp
00007ffb`ba621173 49895b08        mov     qword ptr [r11+8],rbx   ; <== 첫 번째 파라미터 저장 == 00000000`00000000
00007ffb`ba621177 89542410        mov     dword ptr [rsp+10h],edx ; <== 두 번째 파라미터 저장 == 00000000`00000001
...[생략]...

[r11 + 8], [rsp + 10h] 표기로 각각 첫 번째/두 번째 인자를 전달하고 있기 때문에 [rsp + 00h]가 첫 번째 파라미터라고 인지하고 있으면 해석에 혼란을 줄 수 있습니다.

최종적으로, NtDelayExecution의 경우 인자에 대한 백업이 되었다면 kv 명령어로 얻은 Child-SP 값에 따라 다음의 패턴으로 인자 값을 확인할 수 있습니다.

[000000ca`9bbad048 + 08h] : 호출 함수에 전달할 파라미터 1를 저장
[000000ca`9bbad048 + 10h] : 호출 함수에 전달할 파라미터 2을 저장
[000000ca`9bbad048 + 18h] : 호출 함수에 전달할 파라미터 3를 저장
[000000ca`9bbad048 + 20h] : 호출 함수에 전달할 파라미터 4을 저장

물론, 위의 인자 4개는 kb 명령을 통해 windbg에서 쉽게 확인할 수 있어서 실제로 의미있는 경우는 5개 이상의 파라미터 값을 확인할 때와 로컬 변수의 시작점 정도를 파악할 때나 유용합니다.




검색해 보니까, ^^ 콜스택 및 인자 확인에 대한 글들이 많이 있군요. ^^ 다들 너무 좋은 글이니 시간 내서 꼭 한번은 읽어보실 것을 권장합니다.

x64 디버깅 강좌 (1) - x64 Stack 개요
; http://kuaaan.tistory.com/449

수동으로 64비트 콜스택 추적하기
; http://greemate.tistory.com/entry/수동으로-64비트-콜스택-추적하기

64비트 콜스택에서 함수 파라미터 찾기
; http://greemate.tistory.com/entry/64-bit에서-NtQueryAttributesFile-파라미터-찾기

수동으로 64비트 콜스택 추적하기 (2)
; http://greemate.tistory.com/entry/수동으로-64비트-콜스택-추적하기-2




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

[연관 글]






[최초 등록일: ]
[최종 수정일: 12/2/2022]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2015-10-22 02시01분
정성태
2017-01-22 01시08분
정성태
2017-01-22 01시10분
정성태
2017-01-27 10시08분
정성태
2017-01-27 10시13분
위의 4개 외부 자료 링크는 MHTML 형식의 파일로 첨부했으니 혹시 링크가 깨지면 그걸로 보시면 됩니다.
정성태
2017-07-13 01시45분
또 콜 스택 인자 추적 사례를 다음의 글에서 확인할 수 있습니다.

windbg 분석 - webengine4.dll의 MgdExplicitFlush에서 발생한 System.AccessViolationException의 crash 문제
; http://www.sysnet.pe.kr/2/0/11250
정성태
2017-10-31 03시16분
Child-SP에 대해서는 다음의 글도 참고하세요.

windbg - x64 SOS 확장의 !clrstack 명령어가 출력하는 Child SP 값의 의미
; http://www.sysnet.pe.kr/2/0/11349
정성태
2021-01-02 07시47분
CodeMachine Kernel Debugger Extension
 - !cmkd.stack -p // The !stack command displays registers based parameters passed to x64 functions.
; http://www.codemachine.com/cmkd.html
; https://stackoverflow.com/questions/21888076/how-to-find-the-context-record-for-user-mode-exception-on-x64
정성태

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13604정성태4/22/2024196오류 유형: 901. Visual Studio - Unable to set the next statement. Set next statement cannot be used in '[Exception]' call stack frames.
13603정성태4/21/2024258닷넷: 2245. C# - IronPython을 이용한 파이썬 소스코드 연동파일 다운로드1
13602정성태4/20/2024606닷넷: 2244. C# - PCM 오디오 데이터를 연속(Streaming) 재생 (Windows Multimedia)파일 다운로드1
13601정성태4/19/2024740닷넷: 2243. C# - PCM 사운드 재생(NAudio)파일 다운로드1
13600정성태4/18/2024746닷넷: 2242. C# - 관리 스레드와 비관리 스레드
13599정성태4/17/2024800닷넷: 2241. C# - WAV 파일의 PCM 사운드 재생(Windows Multimedia)파일 다운로드1
13598정성태4/16/2024818닷넷: 2240. C# - WAV 파일 포맷 + LIST 헤더파일 다운로드2
13597정성태4/15/2024797닷넷: 2239. C# - WAV 파일의 PCM 데이터 생성 및 출력파일 다운로드1
13596정성태4/14/20241035닷넷: 2238. C# - WAV 기본 파일 포맷파일 다운로드1
13595정성태4/13/20241047닷넷: 2237. C# - Audio 장치 열기 (Windows Multimedia, NAudio)파일 다운로드1
13594정성태4/12/20241064닷넷: 2236. C# - Audio 장치 열람 (Windows Multimedia, NAudio)파일 다운로드1
13593정성태4/8/20241074닷넷: 2235. MSBuild - AccelerateBuildsInVisualStudio 옵션
13592정성태4/2/20241215C/C++: 165. CLion으로 만든 Rust Win32 DLL을 C#과 연동
13591정성태4/2/20241187닷넷: 2234. C# - WPF 응용 프로그램에 Blazor App 통합파일 다운로드1
13590정성태3/31/20241078Linux: 70. Python - uwsgi 응용 프로그램이 k8s 환경에서 OOM 발생하는 문제
13589정성태3/29/20241150닷넷: 2233. C# - 프로세스 CPU 사용량을 나타내는 성능 카운터와 Win32 API파일 다운로드1
13588정성태3/28/20241240닷넷: 2232. C# - Unity + 닷넷 App(WinForms/WPF) 간의 Named Pipe 통신 [2]파일 다운로드1
13587정성태3/27/20241165오류 유형: 900. Windows Update 오류 - 8024402C, 80070643
13586정성태3/27/20241317Windows: 263. Windows - 복구 파티션(Recovery Partition) 용량을 늘리는 방법
13585정성태3/26/20241104Windows: 262. PerformanceCounter의 InstanceName에 pid를 추가한 "Process V2"
13584정성태3/26/20241058개발 환경 구성: 708. Unity3D - C# Windows Forms / WPF Application에 통합하는 방법파일 다운로드1
13583정성태3/25/20241175Windows: 261. CPU Utilization이 100% 넘는 경우를 성능 카운터로 확인하는 방법
13582정성태3/19/20241433Windows: 260. CPU 사용률을 나타내는 2가지 수치 - 사용량(Usage)과 활용률(Utilization)파일 다운로드1
13581정성태3/18/20241606개발 환경 구성: 707. 빌드한 Unity3D 프로그램을 C++ Windows Application에 통합하는 방법
13580정성태3/15/20241165닷넷: 2231. C# - ReceiveTimeout, SendTimeout이 적용되지 않는 Socket await 비동기 호출파일 다운로드1
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...