문서원본 : http://www.codemachine.com/article_x64deepdive.html
번역자 : kuaaan (kuaaan at naver dot com)
비고 :
1. 본 강좌의 번역 및 게시 관련하여 원저작권자의 허가를 득했습니다.
2. 출처의 기재 여부와 상관 없이 재배포를 금지합니다. 참고하길 원하시는 분께서는 URL로 링크를 거시기 바랍니다. (번역이 잘못된 부분 등이 발견되었을 때 원본을 수정하기 위해서입니다.)
3. 오역된 부분은 알려주시면 즉시 반영하겠습니다.
4. 번역된 내용에 대해 번역자는 일체 책임을 지지 않습니다.
X64 에 푹 빠져보기 (X64 Deep Dive)
이 문서에서는 컴파일러 최적화, 예외처리 파라메터 전달 및 파라메터 검색 등 x64 cpu 상에서 실행되는 코드의 핵심적인 특징들을 살펴보고 이 특징들이 서로 어떻게 연관되어 있는지를 살펴보고자 한다. 또한 상기 주제들과 관련된 디버거 명령들을 다루고 그 명령들의 실행결과를 해석하고 이해하는데 필요한 배경 지식을 제공한다. 그리고 이 문서에서는 x64 cpu가 x86 cpu와 어떻게 다른지 그리고 그 차이가 x64에서의 디버깅에 어떤 영향을 주는지를 조명한다. 최종적으로 설명했던 모든 지식을 종합해서, x64에서 디버깅할 때마다 장애물이 되곤 하는 "x64 콜스택에서 레지스터 기반 파라메터를 검색하는 방법"을 다루고자 한다. 이 문서에서는 내용을 설명하기 위해 단계적으로 접근할 것이며, 핵심을 이해시키기 위해 도표들과 어셈블리, 그리고 디버거의 출력 내용을 광범위하게 사용할 것이다. 이 문서를 읽는 독자들은 x86 CPU 상에서의 레지스터 사용, 스택 사용, 그리고 함수 레이아웃에 대해 잘 이해하고 있다고 가정하고 설명을 진행할 것이다.
컴파일러 최적화 (Compiler Optimizations)
레지스터의 변화 (Register Changes)
1: kd> r rax=fffffa60005f1b70 rbx=fffffa60017161b0 rcx=000000000000007f rdx=0000000000000008 rsi=fffffa60017161d0 rdi=0000000000000000 rip=fffff80001ab7350 rsp=fffffa60005f1a68 rbp=fffffa60005f1c30 r8=0000000080050033 r9=00000000000006f8 r10=fffff80001b1876c r11=0000000000000000 r12=000000000000007b r13=0000000000000002 r14=0000000000000006 r15=0000000000000004 iopl=0 nv up ei ng nz na pe nc cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000282 nt!KeBugCheckEx: fffff800`01ab7350 48894c2408 mov qword ptr [rsp+8],rcx ss:0018:fffffa60`005f1a70=000000000000007f
-
'비휘발성 레지스터'란 다른 함수를 호출한 후에도 (호출한 함수로부터 리턴된 후에도 : 역자 주) 저장된 값이 유지되는 레지스터를 의미한다. x64 는 예전 x86의 비휘발성 레지스터 들을 포함할 뿐 아니라 더 확장된 비휘발성 레지스터 세트(R12~R15)를 제공한다. 이 레지스터들은 레지스터 기반 함수 파라메터를 검색할 때 중요하게 사용된다.
-
함수에 파라메터를 전달하는데 Fastcall 레지스터가 사용된다. Fastcall은 x64의 디폴트 호출규약(Calling Convention)이며, 처음 네 개의 파라메터들은 RCX, RDX, R8, R9 등 네 개의 레지스터를 통해 전달된다.
-
RBP는 더이상 프레임 포인터로 사용되지 않고, RBX, RCX처럼 일반적인 목적으로 사용된다. 디버거는 더이상 콜스택을 구성하는데 RBP를 사용하지 않는다.
X86 CPU에서 FS Segment 레지스터는 Thread Environment Block (TEB)과 Processor Control Region (KPCR) 을 저장하고 있었지만 X64에서는 GS레지스터가 대신 (유저모드의 경우)TEB와 (커널모드에서는)KPCR을 저장한다. 하지만 WOW64 어플리케이션(즉, X64시스템에서 동작하는 32비트 어플리케이션)인 경우에는 FS레지스터가 여전히 32비트 버젼 TEB를 가리킨다. -
X64에서는 트랩프레임 구조체 (nt!_KTRAP_FRAME)는 더이상 유효한 비휘발성 레지스터의 값들을 담고 있지 않다. X64 함수의 프롤로그 부분에서는 그 함수 내에서 사용(Overwrite)할 비휘발성 레지스터가 있다면, 그 값들을 (리턴할 때 원복시키기 위해 : 역자 주) 스택에 저장한다. 디버거는 트랩프레임 대신 스택에서 비휘발성 레지스터들을 구해온다. X64에서 커널 디버깅을 할 때, ".trap" 명령어는 아래와 보이는 바와 같이 '트랩에서 추출한 모든 레지스터 값들이 정확하지 않을 수도 있다'는 사실을 강조하는 안내문을 출력한다. 이 규칙엔 예외도 있는데, 예를 들면 유저모드에서 커널모드로 변경되는 중에 만들어지는 트랩 프레임에는 모든 레지스터들의 값들이 정확하게 들어있다.
1: kd> kv Child-SP RetAddr : Args to Child . . . nt!KiDoubleFaultAbort+0xb8 (TrapFrame @ fffffa60`005f1bb0) . . . 1: kd> .trap fffffa60`005f1bb0 NOTE: The trap frame does not contain all registers. Some register values may be zeroed or incorrect
함수 인라인 처리 (Function in-lining)
함수를 인라인 처리했을 때의 단점은 코드가 중복되기 때문에 실행파일의 사이즈가 커진다는 점과 캐쉬가 miss되면서 페이지 폴트가 늘어난다는 점이다. 또한 디버깅할 함수에 브레이크포인트를 설정했을 때 설정된 함수가 인라인 처리되면 디버거가 인라인 처리된 함수의 심볼을 찾지 못하기 때문에, 함수 인러인 처리는 디버깅을 어렵게 하는 측면도 있다.
소스 파일 레벨의 인라인 처리는 컴파일러의 /Ob 플래그로 컨트롤될 수 있으며, __declspec(noinline) 를 이용해 함수 별로 disable시킬 수도 있다.
그림 1은 함수2와 함수3이 함수1 안에 인라인 처리된 모습을 보여준다.
Figure 1 : Function In-lining |
마지막 함수 호출 생략 (Tail Call Elimination)
호출하는 함수(caller)와 호출되는 함수(callee)는 같은 스택프레임를 공유하게 되며 호출된 함수(callee)는 호출한 함수(caller)를 호출했던 함수(caller's caller)로 바로 리턴하게 된다. 이 최적화는 특히 호출하는 함수(caller)와 호출되는 함수(callee)가 파라메터가 동일할 경우 (그 파라메터들이 해당 레지스터에 이미 들어있고 변경되지 않았다면) 바로 재활용될 수 있기 때문에 특히 효과적이다.
그림 2는 함수1에서 함수4를 호출할 때 '마지막 함수호출 생략'이 일어나 함수1에서 함수4로 바로 jump한 후, 살행울 마쳤을 때 함수1로 바로 리턴하는 모습을 보여주고 있다.
Figure 2 : Tail Call Elimination |
프래임 포인터 생략 (Frame Pointer Omission)
그리하여, X64에서 RBP 레지스터는 스택을 관리할 책임으로부터 해방되었고, 일반적인 목적의 레지스터로 사용될 수 있게 되었다. 단, 동적으로 스택에 공간을 할당하기 위해 alloca()를 사용하는 함수는 이 규칙에 예외이다. 그런 함수는 X86에서 EBP를 사용한 것처럼 RBP 레지스터를 프레임 포인터로 사용할 것이다.
아래 어셈블리 코드는 x86 함수 KERNELBASE!Sleep 을 보여준다. EBP 레지스터를 참조하는 부분들을 보면 이것이 프레임 포인터로 사용되고 있음을 알 수 있다. SleepEx()를 호출하는 동안 파라메터가 스택에 푸시된 다음 SleepEx가 call 문으로 호출되고 있다.
0:009> uf KERNELBASE!Sleep KERNELBASE!Sleep: 75ed3511 8bff mov edi,edi 75ed3513 55 push ebp 75ed3514 8bec mov ebp,esp 75ed3516 6a00 push 0 75ed3518 ff7508 push dword ptr [ebp+8] 75ed351b e8cbf6ffff call KERNELBASE!SleepEx (75ed2beb) 75ed3520 5d pop ebp 75ed3521 c20400 ret 4.
0:000> uf KERNELBASE!Sleep KERNELBASE!Sleep: 000007fe`fdd21140 xor edx,edx 000007fe`fdd21142 jmp KERNELBASE!SleepEx (000007fe`fdd21150)
스택 포인터 기반 로컬변수 접근 (Stack Pointer based local variable access)
그래서 X64에서 수행되는 모든 스택 참조(접근)은 RSP 에 기반해서 이루어진다. 이 때문에 X64 함수들은 함수 전체에 걸쳐 로컬변수나 파라메터에 접근할 때 기준이 되는 RSP 레지스터 값이 변하지 않을 것이라고 가정하고 동작한다. push와 pop 명령이 스택포인터를 변경시키기 때문에, X64 함수들은 push와 pop 을 함수의 각각 프롤로그(push)와 에필로그(pop)에서만 수행하도록 제한하고 있다. 그림3에 나터난 것처럼, 스택 포인터가 프롤로그부터 에필로그에 이르기까지 변경되지 않는다는 것은 x64의 독특한 특징이다.
Figure 3 : Static Stack Pointer |
프롤로그와 에필로그 사이에서 스택 내용에 접근할 때 RSP를 기준으로 사용하기 때문에 함수 몸체에서는 push나 pop이 없다.
0:000> uf user32!DrawTextExW user32!DrawTextExW: 00000000`779c9c64 sub rsp,48h 00000000`779c9c68 mov rax,qword ptr [rsp+78h] 00000000`779c9c6d or dword ptr [rsp+30h],0FFFFFFFFh 00000000`779c9c72 mov qword ptr [rsp+28h],rax 00000000`779c9c77 mov eax,dword ptr [rsp+70h] 00000000`779c9c7b mov dword ptr [rsp+20h],eax 00000000`779c9c7f call user32!DrawTextExWorker (00000000`779ca944) 00000000`779c9c84 add rsp,48h 00000000`779c9c88 ret
예외 처리 (Exception Handling)
이 섹션에서는 X64 함수가 예외를 처리하기 위해 사용하는 매커니즘과 자료구조를 살펴보고, 또한 이 구조체들이 디버거가 이 콜스택을 분석하는데 어떤 영향을 미치는지 알아보기로 하자. 그것은 X64 콜스택의 고유한 특징이기도 하다.
RUNTIME_FUNCTION
X64 실행파일은 X86이 사용했던 PE 포맷의 변종인 PE32+라는 파일포맷을 사용한다. X64 실행파일들은 ".pdata" 또는 "예외 디렉토리"라 불리우는 예외 처리에 필요한 정보를 담고 있는 추가적인 섹션을 가지고 있다. 이 "예외 디렉토리"는 실행파일 내의 모든 non-leaf function 들에 대한 RUNTIME_FUNCTION 구조체를 가지고 있다. non-leaf function이란 해당 함수 내에서 다른 함수를 한번 이상 호출하는 함수를 의미한다. (<== 이 문장은 원문이 잘못되었음을 저자에게 확인받았습니다. 즉, 원문이 틀리고, 본 번역문이 맞습니다. : 역자 주) 각 RUNTIME_FUNCTION 구조체들은 각 함수의 최초 명령어(instruction)과 마지막 명령어의 Offset(즉, 함수의 범위)를 저장하고 있으며, 예외가 발생했을 때 함수의 콜스택을 어떻게 되돌릴 수 있는지 알려주는 Unwind 정보 구조체의 포인터를 담고 있다.
그림 4는 모듈에 대한 RUNTIME_FUNCTION 구조체가 모듈 내에 있는 각 함수의 시작/끝 지점에 대한 Offset을 저장하고 있다는 것을 설명하고 있다.
Figure 4 : RUNTIME_FUNCTION |
아래 어셈블리 코드는 예외 처리 관련하여 X86과 X64에서 생성된 코드가 어떻게 다른지를 보여주고 있다. X86에서 C/C++과 같은 High Level 언어 코드에서 __try/__except와 같은 구조화된 예외처리 구조를 사용했을 때, 컴파일러는 런타임에 스택에 예외 프레임을 만들어내는 코드를 함수의 프롤로그와 에필로그 부분에 생성한다. 이것은 아래의 코드에서 ntdll!_SEH_prolog4 과 ntdll!_SEH_epilog4를 호출하는 부분을 보면 확인할 수 있다.
0:009> uf ntdll!__RtlUserThreadStart ntdll!__RtlUserThreadStart: 77009d4b push 14h 77009d4d push offset ntdll! ?? ::FNODOBFM::`string'+0xb5e (76ffc3d0) 77009d52 call ntdll!_SEH_prolog4 (76ffdd64) 77009d57 and dword ptr [ebp-4],0 77009d5b mov eax,dword ptr [ntdll!Kernel32ThreadInitThunkFunction (770d4224)] 77009d60 push dword ptr [ebp+0Ch] 77009d63 test eax,eax 77009d65 je ntdll!__RtlUserThreadStart+0x25 (77057075) ntdll!__RtlUserThreadStart+0x1c: 77009d6b mov edx,dword ptr [ebp+8] 77009d6e xor ecx,ecx 77009d70 call eax 77009d72 mov dword ptr [ebp-4],0FFFFFFFEh 77009d79 call ntdll!_SEH_epilog4 (76ffdda9) 77009d7e ret 8
하지만 X64 함수에서는 함수 내에서 구조화된 예외처리를 사용하고 있다는 어떠한 흔적도 보이지 않는다. 이것은 런타임에 어떠한 스택기반 예외프레임도 생성되지 않는다는 것을 의미한다.
대신, 인스트럭션 포인터 레지스터(RIP)의 값이 변화함에 따라 실행 파일 내에서 예외 처리 정보들의 위치를 가리키는데 RUNTIME_FUNCTION 구조체가 사용된다.
0:000> uf ntdll!RtlUserThreadStart Flow analysis was incomplete, some code may be missing ntdll!RtlUserThreadStart: 00000000`77c03260 sub rsp,48h 00000000`77c03264 mov r9,rcx 00000000`77c03267 mov rax,qword ptr [ntdll!Kernel32ThreadInitThunkFunction (00000000`77d08e20)] 00000000`77c0326e test rax,rax 00000000`77c03271 je ntdll!RtlUserThreadStart+0x1f (00000000`77c339c5) ntdll!RtlUserThreadStart+0x13: 00000000`77c03277 mov r8,rdx 00000000`77c0327a mov rdx,rcx 00000000`77c0327d xor ecx,ecx 00000000`77c0327f call rax 00000000`77c03281 jmp ntdll!RtlUserThreadStart+0x39 (00000000`77c03283) ntdll!RtlUserThreadStart+0x39: 00000000`77c03283 add rsp,48h 00000000`77c03287 ret ntdll!RtlUserThreadStart+0x1f: 00000000`77c339c5 mov rcx,rdx 00000000`77c339c8 call r9 00000000`77c339cb mov ecx,eax 00000000`77c339cd call ntdll!RtlExitUserThread (00000000`77bf7130) 00000000`77c339d2 nop 00000000`77c339d3 jmp ntdll!RtlUserThreadStart+0x2c (00000000`77c53923)
UNWIND_INFO 와 UNWIND_CODE
RUNTIME_FUNCTION 구조체의 BeginAddress 와 EndAddress 필드는 가상 메모리 상에서 (모듈의 시작부분 부터) 함수 코드의 시작 지점과 끝 지점의 Offset을 각각 담고 있다. 함수가 예외를 발생시키면, OS는 현재의 실행위치(인스트럭션 주소)를 포함하고 있는 RUNTIME_FUNCTION 구조체를 찾아내기 위해 PE 파일을 메모리 맵핑한 사본을 검색한다. RUNTIME_FUNCTION 구조체의 UnwindData 필드는 OS에게 런타임에 스택을 어떻게 Unwinding하는지에 대해 알려주는 UNWIND_INFO 구조체의 Offset을 담고 있다. 이 UNWIND_INFO 구조체는, 각 구조체들은 함수 프롤로그에서 수행된 단일 스택에 대한 작업들을 거꾸로 되돌릴 수 있는 가변 갯수의 UNWIND_CODE 구조체를 담고 있다.
동적으로 생성된 코드를 위해, OS는 런타임에 RUNTIME_FUNCTION 구조체를 생성하는데 사용되는 RtlAddFunctionTable() 과 RtlInstallFunctionTableCallback() 라는 함수를 지원한다. 그림 5는 RUNTIME_FUNCTION 구조체와 UNWIND_INFO 구조체과 함수 위치와의 메모리 상에서의 관계를 보여주고 있다.
Figure 5 : Unwind Information |
디버거의 ".fnent" 커맨드는 지정된 함수의 RUNTIME_FUNCTION 의 정보를 출력해준다. 아래의 예는 ntdll!RtlUserThreadStart 함수에 대한 ".fnent" 커맨드의 결과를 보여주고 있다.
0:000> .fnent ntdll!RtlUserThreadStart Debugger function entry 00000000`03be6580 for: (00000000`77c03260) ntdll!RtlUserThreadStart | (00000000`77c03290) ntdll!RtlRunOnceExecuteOnce Exact matches: ntdll!RtlUserThreadStart =BeginAddress = 00000000`00033260 EndAddress = 00000000`00033290 UnwindInfoAddress = 00000000`00128654 Unwind info at 00000000`77cf8654, 10 bytes version 1, flags 1, prolog 4, codes 1 frame reg 0, frame offs 0 handler routine: ntdll!_C_specific_handler (00000000`77be50ac), data 3 00: offs 4, unwind op 2, op info 8 UWOP_ALLOC_SMALL
위에서 보여진 BeginAddress를 RtlUserThreadStart를 포함한 모듈(ntdll.dll)의 베이스주소에 더하면, 결과값인 주소 0x0000000077c03260 는 아래와 같이 RtlUserThreadStart 함수의 시작 지점이 된다.
0:000> ?ntdll+00000000`00033260 Evaluate expression: 2009084512 = 00000000`77c03260 0:000> u ntdll+00000000`00033260 ntdll!RtlUserThreadStart: 00000000`77c03260 sub rsp,48h 00000000`77c03264 mov r9,rcx 00000000`77c03267 mov rax,qword ptr [ntdll!Kernel32ThreadInitThunkFunction (00000000`77d08e20)] 00000000`77c0326e test rax,rax 00000000`77c03271 je ntdll!RtlUserThreadStart+0x1f (00000000`77c339c5) 00000000`77c03277 mov r8,rdx 00000000`77c0327a mov rdx,rcx 00000000`77c0327d xor ecx,ecx
EndAddress 를 똑같이 적용하면, 결과 주소값은 아래 보여진 예와 같이 함수의 끝을 가리킨다.
0:000> ?ntdll+00000000`00033290 Evaluate expression: 2009084560 = 00000000`77c03290 0:000> ub 00000000`77c03290 L10 ntdll!RtlUserThreadStart+0x11: 00000000`77c03271 je ntdll!RtlUserThreadStart+0x1f (00000000`77c339c5) 00000000`77c03277 mov r8,rdx 00000000`77c0327a mov rdx,rcx 00000000`77c0327d xor ecx,ecx 00000000`77c0327f call rax 00000000`77c03281 jmp ntdll!RtlUserThreadStart+0x39 (00000000`77c03283) 00000000`77c03283 add rsp,48h 00000000`77c03287 ret 00000000`77c03288 nop 00000000`77c03289 nop 00000000`77c0328a nop 00000000`77c0328b nop 00000000`77c0328c nop 00000000`77c0328d nop 00000000`77c0328e nop 00000000`77c0328f nop
그래서 RUNTIME_FUNCTION 구조체의 BeginAddress 와 EndAddress 필드는 메모리상에서 해당 함수가 어디에 위치하고 있는 지를 설명한다. 하지만, 모듈이 링크된 후에 최적화가 진행될 경우엔 위에서 확인된 결과가 달라질 수도 있는데 이 부분은 나중에 다시 설명하기로 하자.
UNWIND_INFO 구조체와 UNWIND_CODE 구조체가 사용되는 주 목적이 예외가 발생했을 때 콜스택을 되돌리는데 필요한 정보를 제공하는 것이지만, 디버거는 해당 모듈의 심볼 없이 콜스택을 구성하는데에 이 정보를 사용하기도 한다. 각 UNWIND_CODE 구조체는 함수의 프롤로그에서 수행되는 아래 작업 중 한가지 씩을 기술하고 있다.
- SAVE_NONVOL - 비휘발성 레지스터를 스택에 저장한다. (mov에 해당 : 역자 주)
- PUSH_NONVOL - 비휘발성 레지스터를 스택에 푸시한다. (push에 해당 : 역자 주)
- ALLOC_SMALL - 최대 128 바이트까지의 공간을 스택에 할당한다.
- ALLOC_LARGE - 최대 4GB까지의 공간을 스택에 할당한다.
그러므로, 결론적으로 UNWIND_CODE 구조체들은 함수 프롤로그의 메타데이터를 표현하고 있다고 할 수 있다.
그림 6은 함수 프롤로그에서 수행되는 스택 관련 작업들과 UNWIND_CODE 구조체에서 기술하고 있는 내용과의 관계를 보여주고 있다. UNWIND_CODE 구조체들은 예외가 발생했을 때, 그것들이 생성된 반대 방향으로 스택이 되돌려질 수 있도록 그들이 표현하고 있는 명령어(instrunction)들의 역순으로 배치된다.
Figure 6 : Unwind Code |
아래 예는 x64 시스템에서 notepad.exe의 x64 버젼 (native version)의 PE파일에 있는 ".pdata" 섹션 헤더를 보여주고 있다. "virtual address" 필드는 .pdata 섹션이 실행파일 시작지점으로부터 0x13000 offset 지점에 위치하고 있음을 나타낸다.
. . . SECTION HEADER #4 .pdata name 6B4 virtual size 13000 virtual address (0000000100013000 to 00000001000136B3) 800 size of raw data F800 file pointer to raw data (0000F800 to 0000FFFF) 0 file pointer to relocation table 0 file pointer to line numbers 0 number of relocations 0 number of line numbers 40000040 flags Initialized Data Read Only . . .T:\link -dump ?headers c:\windows\system32\notepad.exe
다음 샘플은 동일한 실행파일(즉, notepad.exe)에서 UNWIND_INFO 와 UNWIND_CODE 구조체를 보여주고 있다. 각 UNWIND_CODE 는 함수 프롤로그에서 수행되고, 스택이 되돌려질 때(unwound)될 때 함께 원상복구(undone)되어야 하는 "PUSH_NONVOL" 이나 "ALLOC_SMALL"과 같은 작업을 기술하고 있다. 아래 보여진 것처럼 . 디버거의 ".fnent" 커맨드도 이 두 구조체들의 내용을 보여주기는 하지만, "link -dump -unwindinfo"는 ".fnent"와는 달리 UNWIND_CODE 구조체의 전체 내용을 해석(decode)해서 보여준다.
T:\link -dump -unwindinfo c:\windows\system32\notepad.exe . . . 00000018 00001234 0000129F 0000EF68 Unwind version: 1 Unwind flags: None Size of prologue: 0x12 Count of codes: 5 Unwind codes: 12: ALLOC_SMALL, size=0x28 0E: PUSH_NONVOL, register=rdi 0D: PUSH_NONVOL, register=rsi 0C: PUSH_NONVOL, register=rbp 0B: PUSH_NONVOL, register=rbx. . . .
상기 출력 결과의 ALLOC_SMALL 는 함수 프롤로그에서 수행되어 0x28바이트의 스택 공간을 할당하는 "sub" 명령(instruction)을 기술하고 있다. 각 PUSH_NONVOL 은 함수 프롤로그에서 비휘발성 레지스터를 저장하는 "push" 명령에 대응되는데, 그것은 에필로그에서 수행되는 "pop" 명령에 의해 복원된다. 이 명령들은 함수 offset 0x1234 지점의 어셈블리 코드 상에서 아래와 같이 보여진다.
0:000> ln notepad+1234 (00000000`ff971234) notepad!StringCchPrintfW | (00000000`ff971364) notepad!CheckSave Exact matches: notepad!StringCchPrintfW =notepad!StringCchPrintfW = 0:000> uf notepad!StringCchPrintfW notepad!StringCchPrintfW: 00000001`00001234 mov qword ptr [rsp+18h],r8 00000001`00001239 mov qword ptr [rsp+20h],r9 00000001`0000123e push rbx 00000001`0000123f push rbp 00000001`00001240 push rsi 00000001`00001241 push rdi 00000001`00001242 sub rsp,28h 00000001`00001246 xor ebp,ebp 00000001`00001248 mov rsi,rcx 00000001`0000124b mov ebx,ebp 00000001`0000124d cmp rdx,rbp 00000001`00001250 je notepad!StringCchPrintfW+0x27 (00000001`000077b5) ... notepad!StringCchPrintfW+0x5c: 00000001`00001294 mov eax,ebx 00000001`00001296 add rsp,28h 00000001`0000129a pop rdi 00000001`0000129b pop rsi 00000001`0000129c pop rbp 00000001`0000129d pop rbx 00000001`0000129e ret
성능 최적화 (Performance Optimization)
윈도우즈 운영체제의 바이너리들은 "Basic Block Tools (BBT)"라고 불리우는 프로파일 기반 최적화 (profile guided optimization) 대상이 된다. 함수 내에서 자주 실행되는 부분들은 (가급적 동일한 페이지 내에 존재할 수 있도록) 함게 모여있게 되며, 덜 자주 실행되는 부분들은 다른 위치로 이동되게 된다. 최적화 결과, 메모리에서 가장 일반적으로 실행되는 경로의 코드를 적재하기 위해 필요한 메모리의 페이지 수가 감소되며, 결국 전체 워킹 셋(Working Set)이 줄어들게 된다. 이러한 방식의 최적화를 적용하기 위해서는 바이너리들이 링크되고, 실행되고, 데이터를 수집한 후, 수집된 데이터들을 이용하여 함수의 각 부분들을 실행 빈도에 따라 재배치하여야 한다.
결과적으로 완성된 함수 내에서, 일부 함수의 코드 블럭들은 RUNTIME_FUNCTION 구조체에서 당초에 정의되었던 함수의 본체 밖에 매치되기도 한다. 코드 블럭들이 이동된 결과 함수 본체는 연속되지 않은 몇 개의 조각으로 쪼개지게 되며, 그러므로 최초에 링커에 의해 생성된 RUNTIME_FUNCTION 구조체로는 더이상 정확하게 함수의 범위를 확인할 수 없게 된다.
BBT 프로세스는 이러한 문제를 처리하기 위해서, 각각이 하나의 연속된 코드 블럭을 정의하는 여러개의 RUNTIME_FUNCTION 구조체를 최적화된 함수에 추가하게 된다. 이 RUNTIME_FUNCTION 구조체는 BeginAddress 가 항상 함수의 시작부분을 가리키는 원래의 RUNTIME_FUNCTION 구조체 체인의 끝부분에 함께 연결된다.
그림 7은 세 개의 기본 블럭들로 구성된 함수를 보여주고 있다. BBT 프로세스를 적용한 후, #2 블럭이 함수 본체의 바깥쪽에 배치되게 되면서 기존의 RUNTIME_FUNCTION 는 부정확해 졌다(invalidated). 그래서 BBT 프로세스는 두번째 RUNTIME_FUNCTION 구조체를 생성하고, 그것을 첫번째 구조체에 연결하여 전체 함수를 기술할 수 있도록 하고 있다.
Figure 7 : Performance Optimization : Basic Block Tools |
현재 공개된 디버거는 RUNTIME_FUNCTION 구조체의 체인 전체를 분석하지는 못하고 있다. 그래서 리턴 어드레스가 함수 본체 코드 블럭 밖에 위치하도록 최적화된 함수에 대해서는 디버거가 정확한 함수 이름을 보여주지 못하기도 한다.
아래의 예는 이름이 잘못 표시된 콜스택을 보여주고 있는데, (정확한 이름 대신) "ntdll! ?? ::FNODOBFM::`string'"과 같은 형태의 이름이 표시되고 있다. 디버거는 0x0c 프레임의 리턴어드레스인 0x0000000077c17623 을 "ntdll! ?? ::FNODOBFM::`string'+0x2bea0" 라고 잘못 번역하고 있음을 알 수 있다.
0:000> kn # Child-SP RetAddr Call Site 00 00000000`0029e4b8 000007fe`fdd21726 ntdll! ?? ::FNODOBFM::`string'+0x6474 01 00000000`0029e4c0 000007fe`fdd2dab6 KERNELBASE!BaseSetLastNTError+0x16 02 00000000`0029e4f0 00000000`77ad108f KERNELBASE!AccessCheck+0x64 03 00000000`0029e550 00000000`77ad0d46 kernel32!BasepIsServiceSidBlocked+0x24f 04 00000000`0029e670 00000000`779cd161 kernel32!LoadAppInitDlls+0x36 05 00000000`0029e6e0 00000000`779cd42d user32!ClientThreadSetup+0x22e 06 00000000`0029e950 00000000`77c1fdf5 user32!_ClientThreadSetup+0x9 07 00000000`0029e980 000007fe`ffe7527a ntdll!KiUserCallbackDispatcherContinue 08 00000000`0029e9d8 000007fe`ffe75139 gdi32!ZwGdiInit+0xa 09 00000000`0029e9e0 00000000`779ccd1f gdi32!GdiDllInitialize+0x11b 0a 00000000`0029eb40 00000000`77c0c3b8 user32!UserClientDllInitialize+0x465 0b 00000000`0029f270 00000000`77c18368 ntdll!LdrpRunInitializeRoutines+0x1fe 0c 00000000`0029f440 00000000`77c17623 ntdll!LdrpInitializeProcess+0x1c9b 0d 00000000`0029f940 00000000`77c0308e ntdll! ?? ::FNODOBFM::`string'+0x2bea0 0e 00000000`0029f9b0 00000000`00000000 ntdll!LdrInitializeThunk+0xe
다음의 예는 위에서 잘못된 함수 이름이 표시되었던 리턴 어드레스 0x0000000077c17623 에 대한 RUNTIME_FUNCTION, UNWIND_INFO and UNWIND_CODEs 을 보여주고 있다. 표시된 정보는 "Chained Info:"라는 이름의 섹션 명을 포함하고 있는데, 이것은 함수 코드 블럭의 일부가 함수 본체의 밖에 있음을 의미한다.
0:000> .fnent 00000000`77c17623 Debugger function entry 00000000`03b35da0 for: (00000000`77c55420) ntdll! ?? ::FNODOBFM::`string'+0x2bea0 | (00000000`77c55440) ntdll! ?? ::FNODOBFM::`string' BeginAddress = 00000000`000475d3 EndAddress = 00000000`00047650 UnwindInfoAddress = 00000000`0012eac0 Unwind info at 00000000`77cfeac0, 10 bytes version 1, flags 4, prolog 0, codes 0 frame reg 0, frame offs 0 Chained info: BeginAddress = 00000000`000330f0 EndAddress = 00000000`000331c0 UnwindInfoAddress = 00000000`0011d08c Unwind info at 00000000`77ced08c, 20 bytes version 1, flags 1, prolog 17, codes a frame reg 0, frame offs 0 handler routine: 00000000`79a2e560, data 0 00: offs f0, unwind op 0, op info 3 UWOP_PUSH_NONVOL 01: offs 3, unwind op 0, op info 0 UWOP_PUSH_NONVOL 02: offs c0, unwind op 1, op info 3 UWOP_ALLOC_LARGE FrameOffset: d08c0003 04: offs 8c, unwind op 0, op info d UWOP_PUSH_NONVOL 05: offs 11, unwind op 0, op info 0 UWOP_PUSH_NONVOL 06: offs 28, unwind op 0, op info 0 UWOP_PUSH_NONVOL 07: offs 0, unwind op 0, op info 0 UWOP_PUSH_NONVOL 08: offs 0, unwind op 0, op info 0 UWOP_PUSH_NONVOL 09: offs 0, unwind op 0, op info 0 UWOP_PUSH_NONVOL
위에서 "Chained Info" 다음에 표시된 BeginAddress 는 원래 함수의 시작 지점을 가리키고 있다. 아래 "ln" 명령의 결과를 보면, 잘못 표시되었던 함수의 원래 이름이 ntdll!LdrpInitialize임을 알 수 있다.
0:000> ln ntdll+000330f0 (00000000`77c030f0) ntdll!LdrpInitialize | (00000000`77c031c0) ntdll!LdrpAllocateTls Exact matches: ntdll!LdrpInitialize =
디버거의 "uf" 커맨드는 해당 함수의 모든 주소를 포함하는 전체 함수의 어셈블리 코드를 출력한다. 이 명령은 각 코드 블럭의 jmp/jCC 명령을 따라들어가 해당 함수의 모든 코드 블럭을 추적하기 때문에 함수의 전체 코드를 출력할 수 있다. 아래 출력된 코드는 ntdll!LdrpInitialize 함수의 완전한 전체 어셈블리 리스트를 보여주고 있다. 함수 본체는 주소 00000000`77c030f0에서 시작하고, 주소 00000000`77c031b3에서 끝난다. 하지만, 이 00000000`77bfd1a4에도 함수에 속한 코드 블럭이 존재한다. 이 코드 이동은 BBT 프로세스의 결과물이다. 디버거는 이 주소를 가장 가까운 심볼에 매핑하려고 시도한 결과, 앞선 스택 추적 결과에서 보았던 바와 같이 잘못된 심볼인 "ntdll! ?? ::FNODOBFM::`string'+0x2c01c"가 표시되었다.
0:000> uf 00000000`77c030f0 ntdll! ?? ::FNODOBFM::`string'+0x2c01c: 00000000`77bfd1a4 48c7842488000000206cfbff mov qword ptr [rsp+88h],0FFFFFFFFFFFB6C20h 00000000`77bfd1b0 443935655e1000 cmp dword ptr [ntdll!LdrpProcessInitialized (00000000`77d0301c)],r14d 00000000`77bfd1b7 0f856c5f0000 jne ntdll!LdrpInitialize+0x39 (00000000`77c03129) . . . ntdll!LdrpInitialize: 00000000`77c030f0 48895c2408 mov qword ptr [rsp+8],rbx 00000000`77c030f5 4889742410 mov qword ptr [rsp+10h],rsi 00000000`77c030fa 57 push rdi 00000000`77c030fb 4154 push r12 00000000`77c030fd 4155 push r13 00000000`77c030ff 4156 push r14 00000000`77c03101 4157 push r15 00000000`77c03103 4883ec40 sub rsp,40h 00000000`77c03107 4c8bea mov r13,rdx 00000000`77c0310a 4c8be1 mov r12,rcx . . . ntdll!LdrpInitialize+0xac: 00000000`77c0319c 488b5c2470 mov rbx,qword ptr [rsp+70h] 00000000`77c031a1 488b742478 mov rsi,qword ptr [rsp+78h] 00000000`77c031a6 4883c440 add rsp,40h 00000000`77c031aa 415f pop r15 00000000`77c031ac 415e pop r14 00000000`77c031ae 415d pop r13 00000000`77c031b0 415c pop r12 00000000`77c031b2 5f pop rdi 00000000`77c031b3 c3 ret
BBT 최적화의 적용대상인 모듈인지 여부는 아래 보여지는 바와 같이 “!lmi” 디버거명령의 출력 결과 중 "Characteristics" 필드의 "perf"라는 단어로 확인할 수 있다.
0:000> !lmi notepad Loaded Module Info: [notepad] Module: notepad Base Address: 00000000ff4f0000 Image Name: notepad.exe Machine Type: 34404 (X64) Time Stamp: 4a5bc9b3 Mon Jul 13 16:56:35 2009 Size: 35000 CheckSum: 3e749 Characteristics: 22 perf Debug Data Dirs: Type Size VA Pointer CODEVIEW 24, b74c, ad4c RSDS - GUID: {36CFD5F9-888C-4483-B522-B9DB242D8478} Age: 2, Pdb: notepad.pdb CLSID 4, b748, ad48 [Data not mapped] Image Type: MEMORY - Image read successfully from loaded memory. Symbol Type: PDB - Symbols loaded successfully from symbol server. c:\symsrv\notepad.pdb\36CFD5F9888C4483B522B9DB242D84782\notepad.pdb Load Report: public symbols , not source indexed c:\symsrv\notepad.pdb\36CFD5F9888C4483B522B9DB242D84782\notepad.pdb
파라메터 전달 (Parameter Passing)
이번 장에서는 X64 함수로 파라메터가 어떻게 전달되는지, 그리고 함수 스택프레임이 어떻게 만들어지며 디버거가 스택을 추적하기 위해 어떻게 이 정보들을 사용하는지를 얘기하고자 한다.
레지스터 기반 파라메터 전달 (Register based parameter passing)
X64에서 처음 4개의 파라메터들은 언제나 레지스터를 통해 전달되며, 나머지 파라메터들은 스택을 통해 전달된다. 이것은 디버깅하는 개발자들을 좌절시키는 주 원인인데, 왜냐하면 레지스터 값은 함수가 실행됨에 따라 바뀌게 마련이고, 따라서 원래 함수에 전달되었던 값이 무엇인지를 실행 도중에 확인하기가 어렵게 되기 때문이다. 파라메터 값을 검색하는 문제를 제외하면 X64 디버깅은 X86 디버깅과 별로 다르지 않다.
그림 8은 호출하는 함수(caller)에서 호출되는 함수(callee)로 파라메터가 전달되는 방법을 보여주는 X64 어셈블리 코드이다.
Figure 8 : Parameter Passing on X64 |
아래 콜스택에서는 kernel32!CreateFileWImplementation 에서 KERNELBASE!CreateFileW 을 호출하고 있다.
0:000> kn # Child-SP RetAddr Call Site 00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile 01 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd 02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d . . .
MSDN 문서에 따르면, CreateFileW() 는 일곱개의 파라메터를 가지며, 프로토타입은 다음와 같다.
HANDLE WINAPI CreateFile( __in LPCTSTR lpFileName, __in DWORD dwDesiredAccess, __in DWORD dwShareMode, __in_opt LPSECURITY_ATTRIBUTES lpSecurityAttributes, __in DWORD dwCreationDisposition, __in DWORD dwFlagsAndAttributes, __in_opt HANDLE hTemplateFile );
앞에서 보았던 콜스택에 따르면, KERNELBASE!CreateFileW을 포함하고 있는 프레임에 대한 리턴 어드레스는 00000000`77ac2aad 이다. 이 리턴어드레스로부터 반대방향으로 디스어셈블링(ub)해보면 kernel32!CreateFileWImplementation에서 kernel32!CreateFileW을 호출하기 직전에 수행된 명령들을 알 수 있다.
이 명령들 "mov rcx,rdi", "mov edx,ebx", "mov r8d,ebp", "mov r9,rsi" 은 kernel32!CreateFileW 호출을 준비하기 위해 처음 네개의 파라메터가 레지스터에 저장되는 것을 보여준다. 비슷하게, "mov dword ptr [rsp+20h],eax", "mov dword ptr [rsp+28h],eax" 그리고 "mov qword ptr [rsp+30h],rax"은 나머지 파라메터 (즉, 5번째에서 7번째)가 스택에 저장되는 것을 보여주고 있다.
0:000> ub 00000000`77ac2aad L10 kernel32!CreateFileWImplementation+0x35: 00000000`77ac2a65 lea rcx,[rsp+40h] 00000000`77ac2a6a mov edx,ebx 00000000`77ac2a6c call kernel32!BaseIsThisAConsoleName (00000000`77ad2ca0) 00000000`77ac2a71 test rax,rax 00000000`77ac2a74 jne kernel32!zzz_AsmCodeRange_End+0x54fc (00000000`77ae7bd0) 00000000`77ac2a7a mov rax,qword ptr [rsp+90h] 00000000`77ac2a82 mov r9,rsi 00000000`77ac2a85 mov r8d,ebp 00000000`77ac2a88 mov qword ptr [rsp+30h],rax 00000000`77ac2a8d mov eax,dword ptr [rsp+88h] 00000000`77ac2a94 mov edx,ebx 00000000`77ac2a96 mov dword ptr [rsp+28h],eax 00000000`77ac2a9a mov eax,dword ptr [rsp+80h] 00000000`77ac2aa1 mov rcx,rdi 00000000`77ac2aa4 mov dword ptr [rsp+20h],eax 00000000`77ac2aa8 call kernel32!CreateFileW (00000000`77ad2c88)
Homing Space (번역이 마땅치 않아 원어 그대로 사용합니다. : 역자 주)
비록 처음 네 개의 파라메터가 레지스터를 통해 전달된다 하더라도, 여전히 스택에는 이 네개의 파라메터를 위한 공간이 할당되어 있다. 이것을 "parameter homing space"이라고 부르는데, 이것은 함수에서 파라메터에 값 대신 주소로 접근해야 하는 경우나, 함수가 /homeparams 플래그가 지정되어 컴파일 된 경우에 파라메터를 저장하는데 사용된다. 4개 이하의 파라메터를 취하는 함수라 할지라도, Homing Space는 0x20 바이트 또는 네 개의 64bit 슬롯 사이즈가 할당된다. 만약 Homing Space가 파라메터를 저장하는데 사용되지 않는 경우에는, 컴파일러가 그 공간을 비휘발성 레지스터를 저장하는데 사용할 수도 있다.
그림 9는 레지스터 기반 파라메터들을 위한 스택 상의 Homing Space 들과, 함수의 프롤로그에서 어떤식으로 비휘발성 레지스터들을 이 parameter homing space에 저장하는지를 보여주고 있다.
Figure 9 : Parameter Homing Space |
아래의 예에 따르면, "sub rsp, 20h" 명령은 함수 프롤로그에서 0x20 바이트를 스택에 할당하는 것을 보여주고 있으며, 이것은 네 개의 64비트 값들을 저장하기에 충분한 homing space이다. 예제의 다음 부분에서는 msvcrt!malloc()이 다수의 다른 함수들을 호출하는 non-leaf function임을 보여주고 있다.
0:000> uf msvcrt!malloc msvcrt!malloc: 000007fe`fe6612dc mov qword ptr [rsp+8],rbx 000007fe`fe6612e1 mov qword ptr [rsp+10h],rsi 000007fe`fe6612e6 push rdi 000007fe`fe6612e7 sub rsp,20h 000007fe`fe6612eb cmp qword ptr [msvcrt!crtheap (000007fe`fe6f1100)],0 000007fe`fe6612f3 mov rbx,rcx 000007fe`fe6612f6 je msvcrt!malloc+0x1c (000007fe`fe677f74) . . . 0:000> uf /c msvcrt!malloc msvcrt!malloc (000007fe`fe6612dc) msvcrt!malloc+0x6a (000007fe`fe66132c): call to ntdll!RtlAllocateHeap (00000000`77c21b70) msvcrt!malloc+0x1c (000007fe`fe677f74): call to msvcrt!core_crt_dll_init (000007fe`fe66a0ec) msvcrt!malloc+0x45 (000007fe`fe677f83): call to msvcrt!FF_MSGBANNER (000007fe`fe6ace0c) msvcrt!malloc+0x4f (000007fe`fe677f8d): call to msvcrt!NMSG_WRITE (000007fe`fe6acc10) msvcrt!malloc+0x59 (000007fe`fe677f97): call to msvcrt!_crtExitProcess (000007fe`fe6ac030) msvcrt!malloc+0x83 (000007fe`fe677fad): call to msvcrt!callnewh (000007fe`fe696ad0) msvcrt!malloc+0x8e (000007fe`fe677fbb): call to msvcrt!errno (000007fe`fe661918) . . .
아래의 WinMain의 프롤로그 부분의 어셈블리 코드를 보면 비휘발성 레지스터들이 parameter homing area로 할당된 스택 위치에 저장되고 있음을 알 수 있다.
0:000> u notepad!WinMain notepad!WinMain: 00000000`ff4f34b8 mov rax,rsp 00000000`ff4f34bb mov qword ptr [rax+8],rbx 00000000`ff4f34bf mov qword ptr [rax+10h],rbp 00000000`ff4f34c3 mov qword ptr [rax+18h],rsi 00000000`ff4f34c7 mov qword ptr [rax+20h],rdi 00000000`ff4f34cb push r12 00000000`ff4f34cd sub rsp,70h 00000000`ff4f34d1 xor r12d,r12d
Parameter Homing (이것도.. 원어 그대로 사용합니다. : 역자 주)
앞선 섹션에서 설명된 바와 같이, 모든 X64 non-leaf function들은 스택에 할당된 Parameter Homing Area를 가진다. X64 함수 호출 규약에 따라, 호출하는 함수(caller)는 언제나 처음 네개의 파라메터들을 호출되는 함수(callee)에게 전달하기 위해 레지스터를 사용한다. 컴파일러에 /homeparams 플래그를 적용하여 Parameter homing을 사용(Enable)하도록 빌드된 경우에는, 오로지 호출되는 함수(callee) 측의 코드만 영향을 받는다. 이 플래그는 Windows Driver Kit (WDK)를 사용하여 빌드되는 바이너리라면 checked/debug 빌드 모두에서 항상 적용된다. 호출되는 함수(callee)의 프롤로그는 파라메터 값들을 레지스터로 부터 읽어서 스택 상의 parameter homing area에 저장한다.
그림 10은 호출하는 함수(caller) 측에서 파라메터 값들을 레지스터에 저장하는 어셈블리 코드이다. 그것은 또한, /homeparams 플래그로 컴파일된 호출되는 함수(callee)의 프롤로그도 함께 보여주고 있는데, (/homeparams 플래그가 지정되면) 호출되는 함수(callee)에서는 파라메터 값들을 스택에 저장하게 된다. 호출되는 함수(callee)의 프롤로그는 레지스터에서 파라메터 값들을 읽어서 paramater homing area의 스택에 저장하는 모습을 보여주고 있다.
Figure 10 : Parameter Homing |
아래의 코드는 (printf의 프롤로그에서) 레지스터 값들이 printf를 호출한 함수(caller)에 의해 할당된 homing area로 저장되는 것을 보여준다.
0:000> uf msvcrt!printf msvcrt!printf: 000007fe`fe667e28 mov rax,rsp 000007fe`fe667e2b mov qword ptr [rax+8],rcx 000007fe`fe667e2f mov qword ptr [rax+10h],rdx 000007fe`fe667e33 mov qword ptr [rax+18h],r8 000007fe`fe667e37 mov qword ptr [rax+20h],r9 000007fe`fe667e3b push rbx 000007fe`fe667e3c push rsi 000007fe`fe667e3d sub rsp,38h 000007fe`fe667e41 xor eax,eax 000007fe`fe667e43 test rcx,rcx 000007fe`fe667e46 setne al 000007fe`fe667e49 test eax,eax 000007fe`fe667e4b je msvcrt!printf+0x25 (000007fe`fe67d74b) . . .
스택이 사용되는 방법 (Stack Usage)
X64 함수에서의 스택 프레임에는 다음과 같은 것들이 저장된다.
- 호출자로의 리턴 어드레스.
- 함수 프롤로그에서 스택에 Push 된 비휘발성 레지스터 값들.
- 함수에서 사용될 로컬 변수들.
- 호출된(될) 함수(callee)로 전달된(될) 스택 기반 파라메터들.
- 호출된(될) 함수(callee)로 전달된(될) 레지스터 기반 파라메터를 위한 Homing space.
리턴어드레스는 제외한 스택 상의 모든 것들은 함수의 프롤로그에서 저장된다. 로컬변수가 차지하고 있는 스택 공간, 호출된 함수(callee)로 전달된 스택 기반 파라메터, 파라메터를 위한 homing space 등은 "sub rsp, xxx" 어셈블리 한줄에 의해 할당된다. 스택 기반 파라메터를 위해 예약된 공간은 호출되는 함수(callee)에게 최대 파라메터 갯수만큼이 제공한다. 레지스터 기반 파라메터 homing space는 non-leaf functions의 경우에만 존재한다. 그렇게 많은 파라메터를 필요로하는 함수가 하나도 없는 경우라 할지라도, homing space는 네개의 파라메터를 위한 공간을 포함하고 있다.
그림 11은 X64 CPU에서의 함수 스택 프레임의 레이아웃을 보여준다. 그림 상에서 RSP 레지스터는 함수의 프롤로그 실행이 끝난 직후의 위치를 가리키고 있다.
Figure 11 : Stack Usage |
디버거의 "knf" 커맨드는 콜스택 상에서 스택의 각 프레임에서 사용되는 스택 공간의 총량을 표시해준다. 이 스택 공간 사용량은 "Memory" 컬럼에 표시된다.
0:000> knf # Memory Child-SP RetAddr Call Site 00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile 01 8 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd 02 160 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d 03 60 00000000`0029bdc0 000007fe`fe55dc08 usp10!UniStorInit+0xdd 04 a0 00000000`0029be60 000007fe`fe5534af usp10!InitUnistor+0x1d8
아래의 어셈블러 코드는 CreateFileW의 프롤로그이다. 여기서는 비휘발성 레지스터 r8d와 edx를 parameter homing area에 저장하고 있다. rbx, rbp, esi, edi 를 스택에 push하고, 로컬 변수와 호출될 함수에 전달할 파라메터를 저장하는데 사용할 0x138 바이트의 공간을 할당하고 있다.
0:000> uf KERNELBASE!CreateFileW KERNELBASE!CreateFileW: 000007fe`fdd24ac0 mov dword ptr [rsp+18h],r8d 000007fe`fdd24ac5 mov dword ptr [rsp+10h],edx 000007fe`fdd24ac9 push rbx 000007fe`fdd24aca push rbp 000007fe`fdd24acb push rsi 000007fe`fdd24acc push rdi 000007fe`fdd24acd sub rsp,138h 000007fe`fdd24ad4 mov edi,dword ptr [rsp+180h] 000007fe`fdd24adb mov rsi,r9 000007fe`fdd24ade mov rbx,rcx 000007fe`fdd24ae1 mov ebp,2 000007fe`fdd24ae6 cmp edi,3 000007fe`fdd24ae9 jne KERNELBASE!CreateFileW+0x449 (000007fe`fdd255ff)
자식 SP (Child-SP)
디버거의 "k" 명령으로 표시된 자식 SP 레지스터의 값은 스택 포인터(RSP)가 가리키는 주소를 나타내는데, 함수가 그 프레임에서 표시되는 시점에서는 프롤로그의 실행이 끝난 상태이다.
그 다음으로 스택에 push 될 아이템은 자식 함수(Callee) 호출에 따른 리턴어드레스가 될 것이다. X64 함수는 일단 프롤로그를 지나가면 RSP의 값을 수정하지 않기 대문에, 함수의 나머지 부분에서는 이 스택 포인터를 기준으로 하여 상대적인 값으로 스택을 참조한다. 스택 기반 파라메터와 로컬변수에 대한 접근이 여기에 해당된다.
Figure 12 : Relationship between Child-SP and function frames |
다음 콜스택에서, #01 프레임의 Child-SP 값은 00000000`0029bc00이다. 이것은 CreateFileW()의 프롤로그 실행이 완료된 시점에서의 RSP 레지스터 값이다.
0:000> knf # Memory Child-SP RetAddr Call Site 00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile 01 8 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd 02 160 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d 03 60 00000000`0029bdc0 000007fe`fe55dc08 usp10!UniStorInit+0xdd 04 a0 00000000`0029be60 000007fe`fe5534af usp10!InitUnistor+0x1d8 . . .
위에서 살펴본 바와 같이, 00000000`0029bc00 주소 직전 위치의 스택의 내용은 KERNELBASE!CreateFileW+0x2cd 에 대응되는 리턴 어드레스 000007fe`fdd24d76 이고, ntdll!NtCreateFile 를 call 하는 과정에서 push되었다.
0:000> dps 00000000`0029bc00-8 L1 00000000`0029bbf8 000007fe`fdd24d76 KERNELBASE!CreateFileW+0x2cd
콜스택 추적하기 (Walking the call stack)
X86 CPU에서, 디버거는 가장 최근 함수부터 가장 오래된 함수까지의 콜스택을 추적하기 위해 프레임 포인터(EBP) 체인을 따라다녔다. 디버거는 전통적으로 이것을 콜스택 상에 나타나는 모듈들의 심볼 없이도 잘 수행할 수 있었다. 하지만, 이 프레임 포인터 체인은 어떠한 환경에서는 끊어질 수도 있는데, 예를 들면, 함수에 "프레임 포인터 생략(FPO)"이 적용된 경우가 그것이다. 이 경우에는 디버거는 정확하게 콜스택을 추적하기 위해 모듈의 심볼이 필요하다.
반면에 X64 함수는 프레임포인터로서 RBP 레지스터를 사용하지 않기 때문에 디버거는 프레임포인터 체인을 따라가지 않는다. 대신, 디버거는 스택을 추적하기 위해 스택 포인터와 스택 프레임의 사이즈를 사용한다. 디버거는 RUNTIME_FUNCTION, UNWIND_INFO 그리고 UNWIND_CODE 구조체의 위치를 확인하고, 콜 스택상에 존재하는 각 함수의 스택 공간의 사용량을 계산하여 이 값들을 Child-SP에 더함으로서 그 다음 Child-SP의 값을 계산해낸다.
그림 13은 함수 스택프레임의 레이아웃을 보여주고 있다. 스택프레임의 전체 사이즈 (또는 스택 공간 사용량)은 리턴어드레스 크기(8바이트)와 비휘발성 레지스터가 차지하는 스텍 공간의 총량, 로컬변수, 호출될 함수로의 스택 기반 파라메터들과 네 개의 레지스터 기반 파라메터를 위해 할당된 homing space (0x20 바이트)를 모두 더해서 계산할 수 있다. UNWIND_CODE 구조체는 스택에 push된 비휘발성 레지스터들의 갯수와 로컬 변수 및 파라메터를 위해 할당된 스택 공간의 총 량을 나타낸다.
Figure 13 : Walking the x64 call stack |
다음의 스택 트레이스에서, 프레임 #1 (즉, CreateFileW) 함수에 의한 스택 사용량은 0x160 바이트이다. 다음 섹션은 어떻게 이 값이 계산되었는지와 디버거가 이 값을 어떻게 사용하여 프레임 #2의 Child-SP를 계산하는지를 보여준다. 프레임 #1에서 사용된 스택 공간이 프레임 #2의 "Memory" 컬럼 아레 표시된다는 점에 주의하라.
0:000> knf # Memory Child-SP RetAddr Call Site 00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile 01 8 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd 02 160 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d 03 60 00000000`0029bdc0 000007fe`fe55dc08 usp10!UniStorInit+0xdd 04 a0 00000000`0029be60 000007fe`fe5534af usp10!InitUnistor+0x1d8 . . .
다음의 출력은 UNWIND_CODE 구조체에 의해 기술되는 작업을 보여주고 있다. 스택에 푸시된 총 4개의 비휘발성 레지스터와 로컬변수 및 파라메터를 위해 할당된 0x138바이트의 스택 공간이 있다. move된(UWOP_SAVE_NONVOL) 비휘발성 레지스터들은, 스택에 push된(UWOP_PUSH_NONVOL) 경우와는 달리 스택 사용량에 관여하지 않고 있다.
0:000> .fnent kernelbase!CreateFileW Debugger function entry 00000000`03be6580 for: (000007fe`fdd24ac0) KERNELBASE!CreateFileW | (000007fe`fdd24e2c) KERNELBASE!SbSelectProcedure Exact matches: KERNELBASE!CreateFileW =BeginAddress = 00000000`00004ac0 EndAddress = 00000000`00004b18 UnwindInfoAddress = 00000000`00059a48 Unwind info at 000007fe`fdd79a48, 10 bytes version 1, flags 0, prolog 14, codes 6 frame reg 0, frame offs 0 00: offs 14, unwind op 1, op info 0 UWOP_ALLOC_LARGE FrameOffset: 138 02: offs d, unwind op 0, op info 7 UWOP_PUSH_NONVOL 03: offs c, unwind op 0, op info 6 UWOP_PUSH_NONVOL 04: offs b, unwind op 0, op info 5 UWOP_PUSH_NONVOL 05: offs a, unwind op 0, op info 3 UWOP_PUSH_NONVOL
위에 리스팅된 사이즈를 모두 더하면 스택 사용량 (0x138 + (8*4) = 0x158 bytes)을 구할수 있다.
0:000> ?138+(8*4) Evaluate expression: 344 = 00000000`00000158
리턴 어드레스 사이즈 (8바이트)를 위의 값에 더하면 총 스택프레임 사이즈인 0x160을 구할 수 있다. 이것은 디버거의 "knf" 커맨드로 앞에서 확인했던 값과 일치한다.
0:000> ?158+8 Evaluate expression: 352 = 00000000`00000160
"knf"의 결과값을 참조하여, 디버거는 프레임사이즈(0x160)를 프레임 #01의 Child-SP (즉, 00000000`0029bc00)에 더하여 프레임 #02 의 Child-SP (00000000`0029bd60)를 구한다.
0:000> ?00000000`0029bc00+160 Evaluate expression: 2735456 = 00000000`0029bd60
그래서 각 프레임 별로 스택에 할당된 공간은 PE 파일 자체에 들어있는 정보(RUNTIME_FUNCTION, UNWIND_INFO and UNWIND_CODE)를 사용해 구할 수 있다. 이 덕분에, 디버거는 (public이든 private이든) 심볼 없이도 콜스택을 추적할 수 있다. 아래의 콜스택은 Microsoft's public symbol server에서 심볼이 제공되지 않는 "vmswitch" 모듈에 대해 디버거가 스틱을 추적하고 콜스택을 정확하게 보여주고 있는데, X64 콜스택이 심볼 없이도 정확하게 추적될 수 있다는 것을 보여주는 예라고 할수 있다.
1: kd> kn # Child-SP RetAddr Call Site 00 fffffa60`005f1a68 fffff800`01ab70ee nt!KeBugCheckEx 01 fffffa60`005f1a70 fffff800`01ab5938 nt!KiBugCheckDispatch+0x6e . . . 21 fffffa60`01718840 fffffa60`0340b69e vmswitch+0x5fba 22 fffffa60`017188f0 fffffa60`0340d5cc vmswitch+0x769e 23 fffffa60`01718ae0 fffffa60`0340e615 vmswitch+0x95cc 24 fffffa60`01718d10 fffffa60`009ae31a vmswitch+0xa615 . . . 44 fffffa60`0171aed0 fffffa60`0340b69e vmswitch+0x1d286 45 fffffa60`0171af60 fffffa60`0340d4af vmswitch+0x769e 46 fffffa60`0171b150 fffffa60`034255a0 vmswitch+0x94af 47 fffffa60`0171b380 fffffa60`009ac33c vmswitch+0x215a0 . . .
파라메터 검색 (Parameter Retrieval)
이전 섹션에서 X64 스택의 내부 동작이 설명되었고, 디버거에 의해 출력된 스택 추적 결과의 세부 데이터들을 어떻게 해석해야 하는지에 대한 정보 역시 함께 설명되었다.
이번 섹션에서는 이 이론들을 적용하여 X64 함수에 전달된 레지스터 기반 파라메터들의 값을 구하는 기술을 선보일 것이다. 유감스럽게도, 이 파라메터들을 구하는 "은빛 탄환"(묘책)은 없다. 여기서 보여질 모든 기술들은 컴파일러가 만들어낸 X64 어셈블리 명령(instruction)들과 깊이 관련되어 있다. 만약 파라메터들이 도달할 수 없는 메모리에 저장되어 있다면, 이 값을 쉽게 구할수 있는 방법은 없다. 콜스택에 나타나는 모듈이나 함수들의 private 심볼을 구하는 것 역시 크게 도움이 되지 않는다. Private 심볼은 함수가 받는 파라메터의 수와 타입을 알려주지만, 그냥 그것 뿐이다. 그것은 그 파라메터 값이 무엇인지를 알려주지는 않는다.
기술 요약 (Summary of Techniques)
이번 장에서는 X64함수들이 /homeparams 플래그 없이 컴파일되었다고 가정한다. /homeparams 플래그가 지정되어 컴파일되었을 때는, 호출된 함수(callee)가 stack 에 저장한다는 것이 보장되기 때문에, 레지스터 기반 파라메터를 구하는 것은 별 문제가 없다. 또한 다섯번째 혹은 그 이후의 파라메터들은 언제나 스택을 통해 전달되기 때문에 /homeparams 적용 여부와 무관하며, 이러한 파라메터들 값을 구하는 것은 어떠한 경우에도 논란 거리가 되지 않는다.
함수 프롤로그가 진행되는 동안에는 처음 네개의 파라메터들을 각각 RCX, RDX, R8 and R9 레지스터에서 구할 수 있으므로, 라이브 디버깅 시에는 함수의 시작부분에 브레이크 포인트를 설정하는 것이 레지스터 기반 파라메터를 구하는 가장 쉬운 방법이다.
하지만, 함수 본체가 실행되면서 파라메터 레지스터들의 값은 변경되고, 최초의 파라메터들은 덮어 씌워지게 된다. 그리하여, 이 레지스터 기반 파라메터들의 값을 (함수 내의 어디서든) 밝혀내기 위해 우리가 알아내야 할 것은... "이 파라메터 값이 어디에서 왔는가? (read from)", "이 값들이 어디로 갔는가? (written to)" 이다. 이 질문들에 대한 답은 디버거에서 다음과 같은 일련의 작업들을 통해 얻어질 수 있다.
- 파라메터들이 최초에 메모리로부터 읽혀져서 (파라메터 전달에 사용되는) 레지스터에 저장되었는지를 확인한다. 만약 그렇다면 파라메터 값을 확인하기 위해서는 (파라메터의 출처인) 해당 메모리 위치를 확인하면 된다.
- 파라메터들이 최초에 비휘발성 레지스터로부터 (파라메터 전달에 사용되는 레지스터에) 적재되었고, 그 비휘발성 레지스터가 호출된 함수(Callee)에 의해 (메모리에) 저장되었는지를 확인한다. 만약 그렇다면, 저장된 비휘발성 레지스터 값들을 확인하면 파라메터 값을 얻을 수 있다.
- 파라메터 값이 (파라메터로 전달된) 레지스터에서 메모리로 저장되었는지를 확인한다. 만약 그렇다면, 레지스터가 저장된 메모리 위치를 검사하면 파라메터 값을 구할 수 있다.
- 파라메터 값들이 비휘발성 레지스터에 저장되었고, 그 레지스터 값이 호출된 함수(Callee)에 의해 (메모리로) 저장되었는지를 확인한다. 만약 그렇다면, 저장된 비휘발성 레지스터 값을 검사하여 파라메터 값을 구할 수 있다.
다음의 몇 단원에서 위의 기술들 각각에 예제를 곁들여서 자세하게 설명할 것이다. 각각의 기술들은 파라메터 전달과 관련된 호출하는 함수(Caller)와 호출되는 함수(Callee)에 대한 디스어셈블링이 요구된다. 그림 14는 함수 f2에 전달된 파라메터를 알아내는 것이 목적이라면 파라메터가 어디서 왔는지(Source)를 확인하기 위해 프레임 2 가 디스어셈블되어야 하며, 파라메터가 어디로 갔는지 (Destination)를 확인하기 위해 프레임 0 또한 디스어셈블 되어야 한다.
Figure 14 : Finding Register Based Parameters |
파라메터가 어디서 왔는지 확인하기 (Identifying Parameter Sources)
이 방법은 파라메터 레지스터에 저장된 값이 어디서 왔는지를 확인하는 것과 관련이 있다. 레지스터에 저장된 값이 상수이거나, 글로벌 데이터 구조체이거나, 스택상의 주소이거나, 스택에 저장된 값 등등인 경우...에 유효한 방법이다.
그림 15에서 설명한 것처럼, 호출한 함수(X64caller)를 디스어셈블해 보면 파라메터로 전달되기 위해 RCX, RDX, R8, R9 에 저장된 값이 (값이 변경되지만 않았다면) 디버거에서 확인될 수 있는 Source로부터 저장되었는지를 알 수 있다.
Figure 15 : Identifying parameter sources |
다음의 예제는, 이 방법을 이용해 아래의 콜스택에서 보여지는 NtCreateFile()에 전달된 세번째 파라메터 값을 찾아내는 과정을 보여준다.
0:000> kn # Child-SP RetAddr Call Site 00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile 01 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd 02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d . . .
아래 NtCreateFile()의 프로토타입에서 보여진 것처럼, 세번째 파라메터의 타입은 POBJECT_ATTRIBUTES 이다.
NTSTATUS NtCreateFile( __out PHANDLE FileHandle, __in ACCESS_MASK DesiredAccess, __in POBJECT_ATTRIBUTES ObjectAttributes, __out PIO_STATUS_BLOCK IoStatusBlock, . . . );
프레임 #0의 리턴 어드레스를 이용해 호출 함수(Caller)를 디스어셈블링 해보면 아래와 같은 명령들을 볼 수 있다. R8 (즉, 세번째 파라메터로 배정된 레지스터)에 저장된 값은 rsp+0xc8 이다. 위에서 "kn" 커맨드의 결과로 확인한 것처럼 호출한 함수(KERNELBASE!CreateFileW) 가 실행되었던 시점에서 RSP 레지스터의 값은 00000000`0029bc00 였다.
0:000> ub 000007fe`fdd24d76 KERNELBASE!CreateFileW+0x29d: 000007fe`fdd24d46 and ebx,7FA7h 000007fe`fdd24d4c lea r9,[rsp+88h] 000007fe`fdd24d54 lea r8,[rsp+0C8h] 000007fe`fdd24d5c lea rcx,[rsp+78h] 000007fe`fdd24d61 mov edx,ebp 000007fe`fdd24d63 mov dword ptr [rsp+28h],ebx 000007fe`fdd24d67 mov qword ptr [rsp+20h],0 000007fe`fdd24d70 call qword ptr [KERNELBASE!_imp_NtCreateFile]
위의 정보들을 이용해 R8 레지스터에 저장되었던 값을 수동으로 재구성해보면 OBJECT_ATTRIBUTE 구조체로 형변환 해서 확인해볼 수 있는 값을 구해낼 수 있다.
0:000> dt ntdll!_OBJECT_ATTRIBUTES 00000000`0029bc00+c8 +0x000 Length : 0x30 +0x008 RootDirectory : (null) +0x010 ObjectName : 0x00000000`0029bcb0 _UNICODE_STRING "\??\C:\Windows\Fonts\staticcache.dat" +0x018 Attributes : 0x40 +0x020 SecurityDescriptor : (null) +0x028 SecurityQualityOfService : 0x00000000`0029bc68
파라메터 값의 Source가 비휘발성 레지스터인 경우 (Non-Volatile Registers as parameter sources)
이 기술은 파라메터 레지스터에 저장된 값이 비휘발성 레지스터로부터 Read되었고, 그 비휘발성 레지스터가 스택에 저장된 경우에 알아내는 방법이다.
그림 16은 호출 함수(X64caller)의 디스어셈블리와 호출된 함수(X64Callee)를 보여주고 있다. 호출한 함수가 호출된 함수(callee)를 호출하기 직전(좌측)의 어셈 코드들은 파라메터 레지스터들(RDI, R12, RBX, R9)에 저장된 값의 Source가 비휘발성 레지스터 (RDI, R12, RBX, R9)임을 보여준다. 호출된 함수(우측) 프롤로그 부분의 어셈코드를 보면 이 비휘발성 레지스터들이 스택에 저장되고 있음을 알 수 있다. 이 저장된 값들은 검색될 수 있고, 결국 파라메터 레지스터에 저장되었던 값들을 간접적으로 알아낼 수 있게 된다.
Figure 16 : Non-Volatile Registers as parameter sources |
아래 예제는 이 기술을 사용하여 아래 콜스택 상에서 CreateFileW()에 전달된 첫번째 파라메터를 구하는 과정을 보여준다.
0:000> kn # Child-SP RetAddr Call Site 00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile 01 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd 02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d . . .
아래 보여진 대로, CreateFile의 프로토타입에 따르면, 첫번째 파라메터의 타입은 LPCTSTR이다
HANDLE WINAPI CreateFile( __in LPCTSTR lpFileName, __in DWORD dwDesiredAccess, __in DWORD dwShareMode, __in_opt LPSECURITY_ATTRIBUTES lpSecurityAttributes, . . . );
프레임 1의 리턴 어드레스를 이용해 호출한 함수(caller) 측을 디스어셈블링 해보면 아래와 같은 명령들을 확인할 수 있다. RCX 레지스터(첫번째 파라메터 전달에 사용되는 레지스터)에 저장된 값은 비휘발성 레지스터인 RDI 레지스터에서 읽어지고 있다. 다음 단계는 호출된 함수(callee)인 CreateFileW()에서 EDI를 저장하는지를 확인하는 것이다.
0:000> ub 00000000`77ac2aad L B kernel32!CreateFileWImplementation+0x4a: 00000000`77ac2a7a mov rax,qword ptr [rsp+90h] 00000000`77ac2a82 mov r9,rsi 00000000`77ac2a85 mov r8d,ebp 00000000`77ac2a88 mov qword ptr [rsp+30h],rax 00000000`77ac2a8d mov eax,dword ptr [rsp+88h] 00000000`77ac2a94 mov edx,ebx 00000000`77ac2a96 mov dword ptr [rsp+28h],eax 00000000`77ac2a9a mov eax,dword ptr [rsp+80h] 00000000`77ac2aa1 mov rcx,rdi 00000000`77ac2aa4 mov dword ptr [rsp+20h],eax 00000000`77ac2aa8 call kernel32!CreateFileW (00000000`77ad2c88)
호출된 함수(callee) 측을 디스어셈블링해서 확인한 함수 프롤로그 부분의 코드는 아래와 같다. RDI레지스터는 스택에 "push rdi" 명령을 통해 저장되고 있다. 이 값은 RCX에 로딩된 값과 동일하다. 다음 단계는 저장된 EDI 레지스터의 값을 확인하는 것이다.
0:000> u KERNELBASE!CreateFileW KERNELBASE!CreateFileW: 000007fe`fdd24ac0 mov dword ptr [rsp+18h],r8d 000007fe`fdd24ac5 mov dword ptr [rsp+10h],edx 000007fe`fdd24ac9 push rbx 000007fe`fdd24aca push rbp 000007fe`fdd24acb push rsi 000007fe`fdd24acc push rdi 000007fe`fdd24acd sub rsp,138h 000007fe`fdd24ad4 mov edi,dword ptr [rsp+180h]
디버거의 ".frame /r" 명령은 특정 함수가 실행되었던 시점에서 비휘발성 레지스터의 값들을 보여준다. 이 명령은 앞에서 논의된 대로 호출된 함수(callee)의 프롤로그에서 비휘발성 레지스터 값들을 어디에 저장하는지를 검색하여 그 값들을 찾아낸다. 아래의 커맨드는 CreateFileWImplementation()이 CreateFileW()를 호출한 시점에서 EDI 레지스터의 값이 000000000029beb0 라는 것을 보여준다. 이 값을 사용하면 CreateFile()에 전달된 파일명 파라메터를 표시할 수 있다.
0:000> .frame /r 2 02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d rax=0000000000000005 rbx=0000000080000000 rcx=000000000029bc78 rdx=0000000080100080 rsi=0000000000000000 rdi=000000000029beb0 rip=0000000077ac2aad rsp=000000000029bd60 rbp=0000000000000005 r8=000000000029bcc8 r9=000000000029bc88 r10=0057005c003a0043 r11=00000000003ab0d8 r12=0000000000000000 r13=ffffffffb6011c12 r14=0000000000000000 r15=0000000000000000 0:000> du /c 100 000000000029beb0 00000000`0029beb0 "C:\Windows\Fonts\staticcache.dat"
파라메터 값이 어디로 갔는지를 확인하기 (Identifying parameter destinations)
이 기술은 파라메터 레지스터의 값들이 함수 안에서 메모리에 저장된 경우에 알아내는 방법이다. 함수가 /homeparams 플래그가 지정되어 컴파일되었다면, 함수의 프롤로그는 항상 파라메터 레지스터의 내용을 스택의 parameter homing area에 저장할 것이다. 하지만, /homeparams 로 컴파일되지 않은 함수의 경우, 파라메터 레지스터의 내용들은 함수 수행 중에 메모리 어디든 저장될 수 있다.
그림 17은 파라메터 레지스터 RCX, RDX, R8 and R9가 스택에 저장되는 부분의 어셈 코드를 보여주고 있다. 이 파라메터들은 현재 프레임의 스택포인터 값을 사용하여 파라메터 레지스터가 저장된 메모리 위치의 값을 출력하면 확인할 수 있다.
Figure 17 : Identifying parameter destinations |
아래의 예제는 이 방법을 사용해서 아래 콜스택에서 DispatchClientMessage() 함수의 세번째와 네번째 파라메터를 찾아내는 것을 보여준다.
0:000> kn # Child-SP RetAddr Call Site . . . 26 00000000`0029dc70 00000000`779ca01b user32!UserCallWinProcCheckWow+0x1ad 27 00000000`0029dd30 00000000`779c2b0c user32!DispatchClientMessage+0xc3 28 00000000`0029dd90 00000000`77c1fdf5 user32!_fnINOUTNCCALCSIZE+0x3c 29 00000000`0029ddf0 00000000`779c255a ntdll!KiUserCallbackDispatcherContinue . . .
세번째와 네번째 파라메터를 전달하는데 각각 R8, R9 레지스터가 사용된다. DispatchClientMessage() 를 디스어셈블링 하여 R8, R9로부터 메모리에 값이 저장되는 부분을 찾아보면 mov qword ptr [rsp+28h], r9" 와 "mov qword ptr [rsp+20h], r8" 라는 명령문이 확인되는데, 이것은 세번째와 네번째 파라메터가 스택에 저장됨을 의미한다. 이 명령들은 함수 프롤로그 부분이라기 보다는 큰 함수 본체의 일부이다. R8, R9 레지스터 값이 스택에 저장되기 전에 변경되었는지를 체크하는 것이 중요하다. 비록 DispatchClientMessage()에서는 이런 일이 발생하지 않았지만, 이 방법을 사용할 때는 항상 파라메터 레지스터가 변경되지 않았는지를 검증하는 것이 중요하다.
0:000> uf user32!DispatchClientMessage user32!DispatchClientMessage: 00000000`779c9fbc sub rsp,58h 00000000`779c9fc0 mov rax,qword ptr gs:[30h] 00000000`779c9fc9 mov r10,qword ptr [rax+840h] 00000000`779c9fd0 mov r11,qword ptr [rax+850h] 00000000`779c9fd7 xor eax,eax 00000000`779c9fd9 mov qword ptr [rsp+40h],rax 00000000`779c9fde cmp edx,113h 00000000`779c9fe4 je user32!DispatchClientMessage+0x2a (00000000`779d7fe3) user32!DispatchClientMessage+0x92: 00000000`779c9fea lea rax,[rcx+28h] 00000000`779c9fee mov dword ptr [rsp+38h],1 00000000`779c9ff6 mov qword ptr [rsp+30h],rax 00000000`779c9ffb mov qword ptr [rsp+28h],r9 00000000`779ca000 mov qword ptr [rsp+20h],r8 00000000`779ca005 mov r9d,edx 00000000`779ca008 mov r8,r10 00000000`779ca00b mov rdx,qword ptr [rsp+80h] 00000000`779ca013 mov rcx,r11 00000000`779ca016 call user32!UserCallWinProcCheckWow (00000000`779cc2a4) . . .
"kn" 명령의 결과물에서 프레임 #27의 스택 포인터 (RSP) 값 (00000000`0029dd30)을 구하여 R8 레지스터가 저장된 옵셧을 더하면 00000000`00000000 을 확인할 수 있는데, 이것이 DispatchClientMessage() 의 세번째 파라메터이다.
0:000> dp 00000000`0029dd30+20 L1 00000000`0029dd50 00000000`00000000
이와 비슷하게 R9 레지스터가 저장된 지점의 옵셋을 더하면 DispatchClientMessage()에 전달된 네번째 파라메터인 00000000`0029de70을 확인할 수 있다.
0:000> dp 00000000`0029dd30+28 L1 00000000`0029dd58 00000000`0029de70
파라메터의 Destination이 비휘발성 레지스터인 경우 (Non-Volatile Registers as Parameter Destinations)
이 기술은 파라메터 레지스터가 문제의 함수에 의해 비휘발성 레지스터에 저장되었고, 그 호출된 함수(callee)에 의해 파라메터의 값이 스택에 저장된 경우에 이것을 찾아내는 방법이다.
그림 18은 호출한 함수(X64Caller)의 어셈블리 코드와 호출된 함수 (X64Callee)의 어셈 코드를 보여준다. 목적은 호출한 함수(X64Caller)에 전달된 레지스터 기밧 파라메터의 값을 찾아내는 것이다. 호출한 함수 (X64Caller) 의 본체 (좌측)는 파라메터 레지스터들(RCX, RDX, R8 and R9)을 비휘발성 레지스터 (RDI, RSI, RBX, RBP)에 저장하는 코드를 포함하고 있다. 호출된 함수(X64Callee)의 프롤로그는 (우측) 비휘발성 레지스터들을 스택에 저장하는 코드를 포함하고 있어서 이 값들을 찾아낼 수 있고, 결국 간접적으로 파라메터 레지스터 값들을 찾아낼 수 있다.
Figure 18 : Non-Volatile Registers as Parameter Destinations |
다음의 예제에서는 이 기술을 사용하여 아래 콜스택 중 CreateFileWImplementation()의 레지스터 기반 파라메터 네 개의 값을 모두 찾아내고 있다.
0:000> kn # Child-SP RetAddr Call Site 00 00000000`0029bbf8 000007fe`fdd24d76 ntdll!NtCreateFile 01 00000000`0029bc00 00000000`77ac2aad KERNELBASE!CreateFileW+0x2cd 02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d 03 00000000`0029bdc0 000007fe`fe55dc08 usp10!UniStorInit+0xdd
CreateFileWImplementation() 함수 전체의 어셈 코드를 살펴보면, 함수 프롤로그 바로 다음에서 파라메터 레지스터들을 비휘발성 레지스터에 저장되는 명령을 보여주고 있다. ("mov ebx,edx", "mov rdi,rcx", mov rsi,r9" and "mov ebp,r8d") 이 명령들을 다음번 함수인 CreateFileW() 가 호출될 때까지 이 비휘발성 레지스터의 값들이 변경되지 않았다는 것을 확인하는 것이 중요하다. 여기에서 명시적으로 확인되지는 않았지만, 이에 대한 검증은 CreateFileW() 호출로 이어지는 CreateFileWImplementation() 의 모든 코드 경로를 검사하는 방법으로 수행되어 왔다. 다음 단계는 CreateFileW()의 프롤로그를 디스어셈블하여 이 레지스터 기반 파라메터들을 저장하고 있는 비휘발성 레지스터들이 스택에 저장되었는지를 확인하는 것이다.
0:000> uf kernel32!CreateFileWImplementation kernel32!CreateFileWImplementation: 00000000`77ac2a30 mov qword ptr [rsp+8],rbx 00000000`77ac2a35 mov qword ptr [rsp+10h],rbp 00000000`77ac2a3a mov qword ptr [rsp+18h],rsi 00000000`77ac2a3f push rdi 00000000`77ac2a40 sub rsp,50h 00000000`77ac2a44 mov ebx,edx 00000000`77ac2a46 mov rdi,rcx 00000000`77ac2a49 mov rdx,rcx 00000000`77ac2a4c lea rcx,[rsp+40h] 00000000`77ac2a51 mov rsi,r9 00000000`77ac2a54 mov ebp,r8d 00000000`77ac2a57 call qword ptr [kernel32!_imp_RtlInitUnicodeStringEx (00000000`77b4cb90)] 00000000`77ac2a5d test eax,eax 00000000`77ac2a5f js kernel32!zzz_AsmCodeRange_End+0x54ec (00000000`77ae7bc0) . . .
아래의 출력 결과는 CreateFileW() 가 비휘발성 레지스터 (rbx, rbp, rsi and edi) 들을 스택에 저장하고 있음을 보여주는데, 이 저장된 값들은 디버거의 ".frame /r" 커맨드를 통해 확인할 수 있다.
0:000> u KERNELBASE!CreateFileW KERNELBASE!CreateFileW: 000007fe`fdd24ac0 mov dword ptr [rsp+18h],r8d 000007fe`fdd24ac5 mov dword ptr [rsp+10h],edx 000007fe`fdd24ac9 push rbx 000007fe`fdd24aca push rbp 000007fe`fdd24acb push rsi 000007fe`fdd24acc push rdi 000007fe`fdd24acd sub rsp,138h 000007fe`fdd24ad4 mov edi,dword ptr [rsp+180h]
CreateFileWImplementation()를 포함하고 있는 프레임 2에 대해 ".frame /r"를 명령을 실행하면 그 프레임이 실행중이었던 시점에서의 비휘발성 레지스터의 값들을 출력해볼 수 있다.
0:000> .frame /r 02 02 00000000`0029bd60 000007fe`fe5b9ebd kernel32!CreateFileWImplementation+0x7d rax=0000000000000005 rbx=0000000080000000 rcx=000000000029bc78 rdx=0000000080100080 rsi=0000000000000000 rdi=000000000029beb0 rip=0000000077ac2aad rsp=000000000029bd60 rbp=0000000000000005 r8=000000000029bcc8 r9=000000000029bc88 r10=0057005c003a0043 r11=00000000003ab0d8 r12=0000000000000000 r13=ffffffffb6011c12 r14=0000000000000000 r15=0000000000000000 iopl=0 nv up ei pl zr na po nc cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000244 kernel32!CreateFileWImplementation+0x7d: 00000000`77ac2aad mov rbx,qword ptr [rsp+60h] ss:00000000`0029bdc0={usp10!UspFreeForUniStore (000007fe`fe55d8a0)}
앞에서 살펴본 "mov" 명령을 기반으로 하여 비휘발성 레지스터들을 파라메터 레지스터들과 연결해 보면 다음과 같은 결과를 얻을 수 있다.
- P1 = RCX = RDI = 000000000029beb0
- P2 = EDX = EBX = 0000000080000000
- P3 = R8D = EBP = 0000000000000005
- P4 = R9 = RSI = 0000000000000000
X64 콜스택으로 부터 파라메터를 검색하기 위해 이 장에서 논의된 네 단계를 적용한다는 것은 상당히 시간이 걸리고 귀찮은 일일 수 있다. CodeMachine은 이 전체 과정을 자동화할 수 있는 Debugger Extension 커맨드 !cmkd.stack -p 를 제공한다. 이 커맨드는 해당 스레드의 X64 콜스택 상에 나타난 모든 함수들에 대해 파라메터 검색/출력을 시도한다. 이 커맨드를 사용하여 임의의 스레드에 대한 파라메터를 검색하려면 "~s" 커맨드를 사용해 해당 스레드로 전환하면 된다. 커널모드에서는 마찬가지로 ".thread" 커맨드를 사용하면 된다.
이 문서에서는 지금까지 X64 CPU 에서 생성된 코드가 X86과 매우 달라지게 하는 컴파일러 최적화를 다루었다. 또한 예외 처리 매커니즘과 실행파일 포맷 및 데이터구조가 이러한 특징을 수행하기 위해 어떻게 변경되었는지를 살펴보았다. 그러고 나서, X64 스택 프레임이 런타임에 어떻게 만들어지는지, X64 함수에 전달된 레지스터 기반 파라메터를 어떻게 검색할수 있는지, 그리고 이 어려운 장애물을 어떻게 극복할 수 있는지를 살펴보았다.
'번역' 카테고리의 다른 글
[번역] C++ for Kernel Mode Drivers: Pros and Cons (0) | 2018.04.03 |
---|