스택 프레임에 대하여.

김성진·2022년 6월 28일
1

기초부터 다시 정리를 해보자 한다.


📒 What is Stack Frame?

스택 프레임에 대하여 알아보자.
우선 함수 하나가 호출된다면 그 스택 내에는

  • 함수의 매개변수
  • 호출이 끝나고 돌아갈 주소 (RET)
  • 호출한 함수의 ebp (SFP)
  • 함수에 선언된 지역변수

이렇게 저장된다.

📖 add.c (with 32bit)

//gcc -m32 -mpreferred-stack-boundary=2 -fno-stack-protector -z execstack -no-pie -fno-pic -o stack stack.c
#include <stdio.h>

int add(int a, int b){
	int c = a + b;
	return a+b;
}

int main(void){
	int var1 = 1;
	int var2 = 2;
	add(var1, var2);
	return 0;
}

32비트로 보호기법을 해제하고 컴파일 해보자.

📖 debug add_main

main 함수에서 add 함수를 호출할 때를 잘 살펴보자. 우선 main+17까지 확인을 해보자. main+17까지 실행이 된다면 스택은 이런 형태를 띌 것이다. 실제로 그런지 디버깅 해보자. 실제로 스택 영역을 확인해 본다면, 일치한다는 것이 보인다. 이제 함수를 호출시켜보자.
main+24 코드부터는 함수 호출을 진행하는 코드이다.

   0x080491ad <+24>:	push   DWORD PTR [ebp-0x8]
   0x080491b0 <+27>:	push   DWORD PTR [ebp-0x4]
   0x080491b3 <+30>:	call   0x8049176 <add>

[ebp-0x8] (main_var1)을 스택에 PUSH 한다.
이후 [ebp-0x4] (main_var2)도 스택에 PUSH 한다.
이후 함수를 호출하게 된다. 호출하기 직전까지의 스택을 살펴보자.
이런 스택 구조를 가지겠다. 여기서 중요한건, push된 두 개의 값에 대해서는 내가 add함수의 인자인 a, b 변수라고 지정을 했다는 것이다. 왜 그랬는 지 확인을 해보자. add를 call한다.

gdb-peda$ disas add
Dump of assembler code for function add:
   0x08049176 <+0>:	endbr32 
   0x0804917a <+4>:	push   ebp
   0x0804917b <+5>:	mov    ebp,esp
   0x0804917d <+7>:	sub    esp,0x4
   0x08049180 <+10>:	mov    edx,DWORD PTR [ebp+0x8]
   0x08049183 <+13>:	mov    eax,DWORD PTR [ebp+0xc]
   0x08049186 <+16>:	add    eax,edx
   0x08049188 <+18>:	mov    DWORD PTR [ebp-0x4],eax
   0x0804918b <+21>:	mov    edx,DWORD PTR [ebp+0x8]
   0x0804918e <+24>:	mov    eax,DWORD PTR [ebp+0xc]
   0x08049191 <+27>:	add    eax,edx
   0x08049193 <+29>:	leave  
   0x08049194 <+30>:	ret    
End of assembler dump.
gdb-peda$ disas main
Dump of assembler code for function main:
   0x08049195 <+0>:	endbr32 
   0x08049199 <+4>:	push   ebp
   0x0804919a <+5>:	mov    ebp,esp
   0x0804919c <+7>:	sub    esp,0x8
   0x0804919f <+10>:	mov    DWORD PTR [ebp-0x4],0x1
   0x080491a6 <+17>:	mov    DWORD PTR [ebp-0x8],0x2
   0x080491ad <+24>:	push   DWORD PTR [ebp-0x8]
   0x080491b0 <+27>:	push   DWORD PTR [ebp-0x4]
   0x080491b3 <+30>:	call   0x8049176 <add>
   0x080491b8 <+35>:	add    esp,0x8
   0x080491bb <+38>:	mov    eax,0x0
   0x080491c0 <+43>:	leave  
   0x080491c1 <+44>:	ret    
End of assembler dump.

이 쯤에서 한 번 다시 적는 어셈블리.

main+30에서 add를 call 하는데, call 명령은 push EIP, jmp ADDR 이다.
위와같이 main에서 call 한 다음 실행될 주소가 들어가게 된다. 저 곳을 RET라 부른다. 이제 호출된 함수에서 처음에 무슨 일을 할까 ? 이것을 함수의 프롤로그라고 부른다.

📖 Function Prologue

gdb-peda$ disas add
Dump of assembler code for function add:
   0x08049176 <+0>:	endbr32 
   0x0804917a <+4>:	push   ebp
   0x0804917b <+5>:	mov    ebp,esp
   0x0804917d <+7>:	sub    esp,0x4
   0x08049180 <+10>:	mov    edx,DWORD PTR [ebp+0x8]
   0x08049183 <+13>:	mov    eax,DWORD PTR [ebp+0xc]
   0x08049186 <+16>:	add    eax,edx
   0x08049188 <+18>:	mov    DWORD PTR [ebp-0x4],eax
   0x0804918b <+21>:	mov    edx,DWORD PTR [ebp+0x8]
   0x0804918e <+24>:	mov    eax,DWORD PTR [ebp+0xc]
   0x08049191 <+27>:	add    eax,edx
   0x08049193 <+29>:	leave  
   0x08049194 <+30>:	ret    
End of assembler dump.
push	ebp
mov		ebp, esp

위의 과정이 함수 프롤로그이다. 원래 함수에서 쓰이던 ebp(SFP)를 push하고, 현재의 esp를 ebp로 만들게 된다. ebp는 함수 내에서 변수들 위치를 설정하는 기준이 되므로 매우 중요하다.
아무리 pop을 하거나 아무리 add를 한다고 한들, 함수 내에서 esp가 ebp보다 커질 수는 없다.

📖 debuging add_add

이제 [ebp+0x8]과 [ebp+0xc]를 각각 edx, eax에 집어넣게 된다.
이후 eax = eax + edx를 진행하게 되는데, 여기서 알 수 있는 것이 [ebp+0x8]과 [ebp+0xc]가 인자로 들어가졌다는 것이다. 내가 지금까지 그린 스택의 경우 아래의 주소가 위보다 크기에 (위로 자라나는 구조) ebp+n의 주소를 보고자 한다면, 아래로 내려가야 한다.실제로 main+8은 인자 a이며 main+c는 인자 b인 것을 스택에서 확인할 수 있다. 이후 eax에는 그 합이 들어가 있으며, [ebp-0x4]에 그 값을 집어넣게 된다. 즉 스택은 최종적으로 위의 구조를 이루고 있다.

return c;

만약 add 함수가 위처럼 끝난다면 바로 함수의 에필로그를 진행하면 되지만, 나의 실수로 인해

return a+b;

를 해버렸기에 add+21부터 add+27까지 똑같은 과정을 또 반복하고 있다.
어쨌든 이제는 함수의 에필로그를 확인해 보도록 하자.

📖 Function Epilogue

   0x08049193 <+29>:	leave  
   0x08049194 <+30>:	ret 

함수의 에필로그는 leave와 ret로 이루어져 있다.

leave는 mov esp, ebp; pop ebp로 이루어져 있다.
이 과정을 거치면 무슨 일이 벌어질까 ? 먼저 mov를 해보자.
스택은 위와같이 구성된다.
즉 esp가 ebp가 가리키던 add의 SFP를 가리키게 된다. 이 add의 SFP에는 main SFP의 주소가 들어가있는데, 그 값을 pop 하여 ebp에 넣게된다. 즉 ebp는 원래 main의 SFP 주소를 가지게 된다.

이후 ret를 거쳐보자.
ret는 pop EIP; jmp EIP 이다. SFP에서 pop을 한 상태이기에, 현재 esp는 RET의 주소를 가지고 있다. 이 때 pop EIP를 하게 된다면, EIP는 RET를 가지게 되고 jmp까지 하면 main+35의 주소를 가지게 된다.

즉, main+35부터 나머지 과정을 계속 실행하게 된다.

📒 About 64 bit ?

64비트의 경우 32bit 처럼 stack을 통한 인자 전달이 아닌 레지스터를 통한 인자전달이라고 생각하면 된다.

push	ebp
mov		esp, ebp

leave
ret

위의 과정은 동일하다. 포너블 풀이 또는, 앞으로 한 번 더 정리를 하게 될 것이다.

profile
Today I Learned

0개의 댓글