[리버스 엔지니어링 스터디]


함수 호출시 할당되는 메모리 블록

스택 프레임(Stack Frame)




스택에 저장되는 함수의 호출 정보를 스택 프레임(Stack Frame)라고 하며, 이러한 스택 프레임에는 함수로 전달되는 인수와, 함수 실행 모두 마치면 돌아올 복귀 주소와 지역 변수 등의 정보가 들어갑니다. 빠르고 손쉽게 지역 변수 혹은 인수 등에 접근하기 위해 EBP 레지스터를 통하여 스택 프레임을 참조할 수 있습니다. 더욱 파고들기 위해서, 함수 호출 시 스택 프레임이 어떠한 형태로 생성이 되고 소멸은 또 어떻게 되는지 한번 확인해보도록 하겠습니다. 먼저, C언어로 작성된 아래 예제의 코드를 빌드한 후 올리 디버거를 통하여 살펴보도록 합시다.

#include <stdio.h>

int sum(int a, int b)
{
	int x = a, y = b;

	return x + y;
}

int main(int argc, char* argv[])
{
	int a = 9, b = 4;

	printf("%d\n", sum(a, b));

	return 0;
}

디버거를 통해 메인 함수 부분을 어셈블리 코드로 본다면 아래와 같습니다. 함수 호출시 스택 프레임이 생성되는 부분과 소멸하는 부분만을 집중적으로 다루도록 하겠습니다.

0040101C       PUSH EBP
0040101D       MOV EBP,ESP
0040101F       SUB ESP,8
00401022       MOV DWORD PTR SS:[EBP-4],9
00401029       MOV DWORD PTR SS:[EBP-8],4
00401030       MOV EAX,DWORD PTR SS:[EBP-8]        ;  kernel32.76F4338A
00401033       PUSH EAX                            ; /Arg2 = 76F43378
00401034       MOV ECX,DWORD PTR SS:[EBP-4]        ; |
00401037       PUSH ECX                            ; |Arg1 = 00000000
00401038       CALL 00401000                       ; \EXAMPLE.00401000
0040103D       ADD ESP,8
00401040       PUSH EAX                            ;  kernel32.BaseThreadInitThunk
00401041       PUSH 00406030                       ;  ASCII "%d\n"
00401046       CALL 00401054
0040104B       ADD ESP,8
0040104E       XOR EAX,EAX                         ;  kernel32.BaseThreadInitThunk
00401050       MOV ESP,EBP
00401052       POP EBP                             ;  kernel32.76F4338A
00401053       RETN

위의 어셈블리 코드를 자세히 살펴보시면 메인 함수의 시작과 함께 스택 프레임을 생성하고 있습니다. 여기서 스택 프레임이 어떻게 생겼는지 구조만 살짝 봐보도록 합시다.

PUSH EBP
MOV EBP,ESP
...
MOV ESP,EBP
POP EBP
RETN

위 구조를 순서대로 살펴보도록 합시다. 먼저, EBP의 값을 스택에 올려 저장합니다. 그리고 ESP의 값을 EBP에 저장합니다. 이렇게 되면 함수 내부에서 ESP가 계속 변해도, 스택 프레임이 소멸되지 않는 이상 EBP는 변경되지 않으므로 안전하게 인수와 지역 변수에 접근할 수 있습니다. 함수를 끝내기 직전에는 ESP를 원래의 값으로 돌려놓고, EBP도 스택에 올려두었던 기존의 EBP 값으로 돌려놓은 뒤에 RETN를 만나 함수를 종료합니다. 다시 메인 함수로 돌아가서 40101F 부분부터 쭉 보도록 합시다.

0040101F       SUB ESP,8
00401022       MOV DWORD PTR SS:[EBP-4],9
00401029       MOV DWORD PTR SS:[EBP-8],4

위 어셈블리 코드를 천천히 보도록 합시다. 40101F에선 ESP에서 8을 감소시키고 있습니다. 이는 저장될 데이터의 크기만큼 감소시키는 것인데, 코드에서 정수형 변수 두개가 선언되니 총 8바이트가 필요하므로 스택에 저장시키기 위해서 8바이트만큼 공간을 확보하는 것입니다. 그 후, 401022~401029에서는 4바이트(DWORD) 크기에 해당하는 메모리 영역으로 각각 9와 4를 저장하고 있습니다. 여기서 SS:[EBP-4]는 지역 변수 a, SS:[EBP-8]는 지역 변수 b라는 사실을 알 수 있습니다. 이렇게 EBP를 기준으로 하여 오프셋을 더하고 빼는 작업을 통해 손쉽게 지역 변수에 접근할 수 있다는 사실을 알 수 있습니다. (참고로 BYTE는 1바이트, WORD는 2바이트, DWORD는 4바이트, QWORD는 8바이트를 의미합니다.)

00401030       MOV EAX,DWORD PTR SS:[EBP-8]        ;  kernel32.76F4338A
00401033       PUSH EAX                            ; /Arg2 = 76F43378
00401034       MOV ECX,DWORD PTR SS:[EBP-4]        ; |
00401037       PUSH ECX                            ; |Arg1 = 00000000
00401038       CALL 00401000                       ; \EXAMPLE.00401000

이번에는 401030에서 4바이트 SS:[EBP-8]의 값을 EAX에 저장하고, 그 저장된 값을 스택에 올립니다. 그다음 똑같이 SS:[EBP-4]의 값을 ECX에 저장하고, 저장된 값을 스택에 올리고 난 뒤에 함수 401000를 호출하게 됩니다. 이렇게 레지스터에 값을 저장하고 스택에 올리는 이유는, 메모리에서 메모리로 데이터가 직접적으로 전달될 수 없기 때문에 그렇습니다. 여기서 401000은 우리가 정의한 함수 sum임을 짐작할 수 있습니다. 이 함수 내부로 진입하게 되면, CPU가 함수 종료 후 돌아오게 될 주소를 스택에 올리게 됩니다. 이때의 스택을 잠시 확인해보도록 하겠습니다.

0018FF34   0040103D  RETURN to EXAMPLE.0040103D from EXAMPLE.00401000
0018FF38   00000009
0018FF3C   00000004

함수 sum이 RETN을 만나 끝날때, 스택에 올라간 복귀 주소인 40103D를 보고 돌아올 수 있는 것입니다. 18FF38, 18FF3C는 우리가 방금 스택에 올린 변수 a, b의 값입니다. 다시 돌아와서 함수 sum(401000)의 어셈블리 코드를 보도록 합시다.

00401000       PUSH EBP
00401001       MOV EBP,ESP
00401003       SUB ESP,8
00401006       MOV EAX,DWORD PTR SS:[EBP+8]
00401009       MOV DWORD PTR SS:[EBP-4],EAX
0040100C       MOV ECX,DWORD PTR SS:[EBP+C]
0040100F       MOV DWORD PTR SS:[EBP-8],ECX
00401012       MOV EAX,DWORD PTR SS:[EBP-4]
00401015       ADD EAX,DWORD PTR SS:[EBP-8]
00401018       MOV ESP,EBP
0040101A       POP EBP                              ;  EXAMPLE.0040103D
0040101B       RETN

위 어셈블리 코드를 간단하게 살펴보면 함수의 시작과 함께 역시 스택 프레임이 생성되며 지역 변수의 공간 확보가 이루어지고 있습니다. 새롭게 스택 프레임이 생성되면서 EBP의 값이 변경되고, 여기서의 SS:[EBP-4], SS:[EBP-8]은 각각 지역 변수 x와 y의 값이 들어가며 SS:[EBP+8], SS:[EBP+C]에는 매개 변수 a와 b의 값이 들어가게 됩니다. 그 후에 SS:[EBP-4]의 값을 EAX에 저장하고, SS:[EBP-8]의 값과 EAX의 값을 더해 EAX에 저장한 뒤에 스택 프레임을 소멸시키기 위해 ESP와 EBP의 값을 기존의 값으로 돌려 놓습니다. 그리고 RETN을 만남과 동시에 스택에 올려뒀던 복귀 주소로 돌아갑니다.

0040103D       ADD ESP,8
...
00401050       MOV ESP,EBP
00401052       POP EBP                             ;  kernel32.76F4338A
00401053       RETN

(생략시켜 놓은 부분은 설명했던 부분과 내용이 어느정도 비슷한 부분이므로 생략시킨 것입니다.)

40103D에서는 갑자기 ESP에서 8을 더하는 것을 보실 수가 있는데 이는 sum 함수에게 넘겨준 매개변수 a와 b가 더이상 필요하지 않으므로 8을 더해 스택을 정리하여 주는 것입니다. 여기서 왜 8인가 하면, 매개변수 a, b는 모두 정수형 변수이고 이는 각각 4바이트씩 총 8바이트의 크기를 차지하기 때문입니다. 그 다음, 401050~401053에서는 메인 함수의 스택 프레임을 소멸시키기 위해 ESP와 EBP의 값을 원래 값으로 돌려놓고 RETN를 만나 스택에 올려둔 복귀 주소인 401139로 이동합니다. (401139 부터는 Visual C++ 스텁(Stub, STARTUP) 코드 영역 이므로 더이상 보실 필요가 없습니다.)