0. 들어가기에 앞서...

  1) 이 글은 x86의 스택과 x64의 스택 구조를 비교하여 어떻게 달라졌는지를 설명하는 글입니다. x86 스택의 구조를 이해하지 못하신 분은 x86 스택을 먼저 공부하시기 바랍니다. 


  2) x64 스택 관련 내용은 다음과 같이 세 개의 시리즈로 연재될 예정입니다.

(1) x64 스택 분석 개요

(2) x64 스택 수동으로 재구성하기

(3) x64 스택에서 아규먼트 찾아내기


--------------------------------------------------------------------------------


x64의 스택은 x86과는 여러모로 달라졌습니다. 

더 어려워진 면도 있긴 하지만... 동작 원리와 구조만 정확히 이해한다면 x64 디버깅도 할 만 합니다. ^^;;;

이 글에서는 x64 스택이 x86과 비교하여 어떻게 달라졌는지를 간단하게 비교해보고자 합니다.


1. 레지스터 사용법의 변화

  1) 레지스터 이름 및 크기의 변화

   일단... 레지스터의 사이즈가 달라졌구요... 레지스터의 이름이 달라졌습니다.

EAX (4바이트) ==> RAX (8바이트)

EBX (4바이트) ==> RBX (8바이트)

EBP (4바이트) ==> RBP (8바이트)

... 나머지 레지스터들도 Prefix 'E'를 'R'로 바꾸면 됩니다. 


   그렇다고 x64에서 EAX같은 레지스터가 사용되지 않는건 아닙니다. EAX라는건 RAX 레지스터의 하위 4바이트를 의미하는 이름이거든요. 아래의 그림을 참고하시기 바랍니다.



  2) x64 에서 새로 추가된 레지스터

  x86 에 없었던 레지스터 몇개가 추가되었습니다. R8, R9, R10 ... R15 등이죠. (R12~R15는 Non-Volatile Register)

  또, RCX, RDX, R8, R9 네 개의 레지스터는 함수를 호출할때 1번째~4번째 아규먼트를 전달하는데 사용됩니다. 

  x64 레지스터의 사용법에 대해서는 여기를 참고하시기 바랍니다.


  ※ Non-Volatile Register란 자식 함수를 호출하고 리턴된 후에 값이 호출 전과 동일하게 유지되는 레지스터를 의미합니다. 따라서 모든 함수에서는 Non-Volatile Register를 사용하기 전에 스택에 백업받고 리턴하기 전에 복원해주는 작업을 프롤로그/에필로그에서 수행합니다. 


  3) 스택 프레임 포인터(EBP)의 용도 변화

 x86에서는 스택 베이스포인터(ebp)와 스택 포인터(esp)를 이용해 각 프레임 별로 사용중인 스택 영역을 확인할 수 있었습니다. 

 하지만 x64에서는 RBP 레지스터가 더이상 스택 프레임 포인터로 사용되지 않고 일반적인 목적으로 사용됩니다. 

 당연히 x64 함수에서는 더이상 다음과 같은 함수 프롤로그도 볼수 없겠군요. ^^a

push    ebp

mov     ebp,esp

.. 



2. 함수 호출규약의 변화

  1) x86 : 스택 기반 아규먼트 전달

   x86은 _cdecl이나 _stdcall 같은 호출 규약을 주로 사용했죠. 그래서 x86에서는 대부분의 함수 호출시 아규먼트가 스택을 통해 전달되었습니다. 이부분은 자료가 많으니 자세히 설명하기 보다는 어셈코드를 간단히 살펴보겠습니다.


일단 간단한 샘플코드를 만들어서 빌드한 다음 생성된 어셈을 살펴봅니다.



먼저 Caller 측의 어셈을 보면... 함수를 호출할 때 아래와 같이 오른쪽 아규먼트 ==> 왼쪽 아규먼트 순서로 스택에 push하는 것을 볼수 있습니다.

CallTest!wmain+0x23:

push    7    6

push    5

push    4

push    3

push    2

push    1

call    CallTest!CallTest (01151020)    // CallTest (1,2,3,4,5,6,7);


Callee 에서는 ebp+8, ebp+0xc...  와 같은 식으로 "ebp+xx" 식으로 아규먼트를 사용합니다.

CallTest!CallTest:

push    ebp

mov     ebp,esp

sub     esp,0Ch

mov     eax,dword ptr [ebp+0Ch]

push    eax

mov     ecx,dword ptr [ebp+8]

push    ecx

push    offset CallTest!_first_127char+0x80 (011599a0)  

call    CallTest!wprintf (01151104)

// wprintf(L"nParam1 : %d, nParam2 : %d\n", nParam1, nParam2);


  2) x64 : 레지스터 기반 아규먼트 전달

    x64에서는 fastcall 호출 규약이 사용됩니다. 

      1) 처음 1번째 ~ 4번째 아규먼트는 각각 RCX, RDX, R8, R9 네개의 레지스터에 담겨서 전달 (부동소수점의 경우 XMM0~)

      2) 5번째 이후의 아규먼트는 x86과 동일하게 스택에 저장되어 전달


x86의 샘플코드를 x64로 빌드해보면 다음과 같이 어셈이 달라집니다.

CallTest!wmain+0x30:

mov     dword ptr [rsp+30h],7 // 5~7번째 아규먼트는 스택에 저장!!

mov     dword ptr [rsp+28h],6

mov     dword ptr [rsp+20h],5

mov     r9d,4    // 1~4번째 아규먼트는 레지스터로 전달!!

mov     r8d,3

mov     edx,2

mov     ecx,1

call    CallTest!CallTest (00000001`3fed1030)  // CallTest (1,2,3,4,5,6,7);

일단 보시면... 5~7번째 아규먼트가 스택에 저장되고, 1~4번째 아규먼트는 RCX(ECX), RDX(EDX), R8, R9에 저장된 후 call되는 것을 확인할 수 있습니다. (좀 특이한 점은... x86과 달리 5~7번째 아규먼트가 스택에 push되지 않고 스택의 특정 주소로 mov 된다는 점인데 이것은 x64의 스택이 일단 함수의 프롤로그가 끝난 이후로는 늘어나거나 줄어들지 않는다는 특성 때문입니다.)


이렇게 전달된 아규먼트는 Callee 측에서 다음과 같이 레지스터를 이용해 꺼태올 수 있습니다.

CallTest!CallTest:

mov     dword ptr [rsp+20h],r9d

mov     dword ptr [rsp+18h],r8d

mov     dword ptr [rsp+10h],edx

mov     dword ptr [rsp+8],ecx

sub     rsp,68h

mov     r8d,dword ptr [rsp+78h]

mov     edx,dword ptr [rsp+70h]

lea     rcx,[CallTest!_first_127char+0x80 (00000001`3fa4ac50)]

call    CallTest!wprintf (00000001`3fa411b0)



3. 스택 구성의 차이

  1) x64에서는 함수 실행 중 스택 사이즈가 변경되지 않음

   x86에서는 일단 함수의 프롤로그 (push ebp, mov ebp esp...) 가 끝나고 나면 함수가 리턴할 때까지 ebp는 바뀌지 않지만 esp는 수시로 바뀝니다. (예를 들면, 자식함수 호출과정에서 파라메터 push할때...) 하지만 x64에서는 일단 함수의 프롤로그가 끝나고 나면 함수가 리턴될 때까지 RSP가 바뀌지 않습니다. 이 얘기는 뭐냐면... 함수의 프롤로그에서 해당 함수에서 필요한 모든 스택 공간이 한꺼번에 확보된다는 것을 의미합니다.

     예를 들어 어떤 함수가 다음과 같이 자식 함수를 두번 호출한다고 가정하면... 자식 함수를 두번 호출하는데 그 자식 함수들의 아규먼트가 5개, 7개이니 wmain의 프롤로그에서는 한번에 7개분의 스택 공간을 한꺼번에 확보해 버린다는 거죠. ^^


  2) x64 에서는 스택포인터를 기준으로 아규먼트 / 로컬 변수를 참조함

    x86에서는 스택 베이스포인터(ebp)를 기준으로 아규먼트 / 로컬변수에 접근합니다.

ebp + xx : 아규먼트 (ebp+8h = 첫번째, ebp+Ch = 두번째...)

ebp - xx : 로컬변수

    반면에 x64 에서는 스택 포인터(rsp)를 기준으로 아규먼트 / 로컬변수에 접근합니다.

rsp + xx (xx > 현재 스택사이즈) : 아규먼트 

rsp + xx (xx < 현재 스택사이즈) : 로컬변수 

     실제로 다음과 같은 간단한 함수가 있다고 할때

   x86의 어셈은 다음처럼 되지만...

push    ebp

mov     ebp,esp

sub     esp,8

mov     dword ptr [ebp-4],1Eh         // nLocal ===> [ebp-4]

mov     eax,dword ptr [ebp+8]           // nParam1 ==> [ebp+8]

add     eax,dword ptr [ebp+0Ch]        // nParam2 ==> [ebp+Ch]

add     eax,dword ptr [ebp-4]

mov     dword ptr [ebp-8],eax

mov     eax,dword ptr [ebp-8]        // nSum ==> [ebp-8]

mov     esp,ebp

pop     ebp

ret


   x64에서는 다음과 같은 어셈이 생성됩니다.

mov     dword ptr [rsp+10h],edx

mov     dword ptr [rsp+8],ecx

sub     rsp,18h

mov     dword ptr [rsp],1Eh       // nLocal ==> [rsp]

mov     eax,dword ptr [rsp+28h]  // nParam1 ==> [rsp+28h]

mov     ecx,dword ptr [rsp+20h]  // nParam2 ==> [rsp+20h]

add     ecx,eax

mov     eax,ecx

add     eax,dword ptr [rsp]

mov     dword ptr [rsp+4],eax     // nSum ==> [rsp+4]

mov     eax,dword ptr [rsp+4]

add     rsp,18h

ret


  3) Parameter Homing Space

     (1) x64에서 처음 네개의 파라메터는 레지스터를 통해 전달되지만, Caller 함수에서는 x86에서처럼 이 네개의 파라메터를 저장할 공간을 스택에 확보해놓는데 이 공간을 Parameter Homing Space라고 합니다.

   위에서 언급한 바와 같이 x64의 각 함수에서 사용할 모든 스택 공간은 프롤로그에서 일괄 확보되므로 Parameter Homing Space 역시 프롤로그에서 확보됩니다. 


     (2) Callee에서는 레지스터 RCX, RDX, R8, R9를 통해 아규먼트를 전달받지만 필요에 따라 레지스터에 들어있는 아규먼트 값을 스택에 백업받는데, 이때 Caller에서 확보해놓은 Parameter Homing Space를 사용합니다. 즉, Parameter Homing Space는 Caller가 Callee를 위해 미리 준비해주는 메모리 공간이라고 볼수 있죠..

Parameter Homing Space가 필요한 이유는... 아마도... 이런게 아닐까 싶네요.

          . 아규먼트 관련 레지스터를 다른 목적으로 사용하기 위해 백업받아야 하는 경우

          . Callee 내에서 또다른 자식함수를 호출하기 위해 RCX, RDX, R8, R9를 사용해야 할 경우... Callee에서 리턴한 후에도 계속 아규먼트를 사용하기 위해 스택상의 어딘가에 백업받아야 함

          . 어셈의 인스트럭션에 따라 아규먼트에 레지스터가 아닌 주소 값으로 접근해야 하는 경우가 있기 때문

  만약 어떤 함수가 다른 자식함수를 5번 호출한다면... 백업할 공간을 각 자식함수에서 준비한다면 메모리 할당을 5번 해야되는 반면에 부모함수가 준비해주면 1번만 하면 되기 때문에... 이런 게 있는게 아닐까 하는 개인적인 상상을 해봅니다. (스택 메모리 할당은 그냥 레지스터에서 값을 빼는 단순한 작업이기 때문에 무슨 효과가 있을까 싶긴 하네요. ㅎㅎ)


Parameter Homing Space가 사용되는 예는 다음과 같습니다. 

0:000> u KERNELBASE!CreateFileW

KERNELBASE!CreateFileW:

000007fe`fd964cb0 4489442418      mov     dword ptr [rsp+18h],r8d

000007fe`fd964cb5 89542410        mov     dword ptr [rsp+10h],edx

000007fe`fd964cb9 53              push    rbx

000007fe`fd964cba 55              push    rbp

000007fe`fd964cbb 56              push    rsi

000007fe`fd964cbc 57              push    rdi

000007fe`fd964cbd 4881ec38010000  sub     rsp,138h

000007fe`fd964cc4 8bbc2480010000  mov     edi,dword ptr [rsp+180h]


     3) 내부적으로 전혀 다른 함수를 호출하지 않는 함수(Leaf Function)의 경우 Parameter Homning space를 할당하지 않습니다. 하지만 자식함수를 하나라도 호출하는 함수라면(Non-Leaf Function) 자식함수 중 가장 많은 아규먼트가 4개 이하더라도 기본적으로 4개의 파라메터에 해당하는 Homing Space(8 * 4 = 32바이트)가 할당됩니다.

     4) Parameter Homing Space는 Callee에서 다른 목적으로 사용될 수도 있습니다. 예를 들면 Callee가 RCX, RDX...가 아닌 다른 비휘발성 레지스터를 백업받는 데 그 공간을 사용할 수도 있는거죠.


  4) x64 스택의 구조

     (1) 결론적으로 x64 스택의 구조는 다음과 같이 됩니다.



(위 그림에서 "부모함수의 esp"는 "부모함수의 rsp", "자식함수의 esp"는 "자식함수의 rsp"의 오타입니다.)

Posted by kuaaan

댓글을 달아 주세요



사랑합니다. 편안히 잠드소서

티스토리 툴바