Post

Stack Frame

Pwnable 문제를 잘 풀려면 기본적으로 Stack Frame 이해가 필수적이다. 학습은 했지만 정리는 하지 않아, 이번 기회에 제대로 정리해보려고 한다.

Stack Frame

Stack Frame함수 자신만의 공간 이다. 구체적으로 스택은 함수별로 자신의 지역변수 또는 연산과정에서 부차적으로 생겨나는 임시 값들을 저장하는 영역이다. 만약 같은 스택 영역에서 A함수가 B함수를 호출하면, B에서 A의 지역변수를 모두 오염시킬 수 있다. 따라서 함수별로 서로가 사용하는 스택의 영역을 명확히 구분하기 위해 Stack Frame을 사용한다.

Stack Frame 분석

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// frame.c
// gcc -m32 -g -no-pie -mpreferred-stack-boundary=2 -o frame frame.c
#include <stdio.h>

void func2()
{
    int i = 7;
    int j = 8;
    int k = 9;
    
    printf("func2 has called\n");
}

void func1()
{
    int i = 4;
    int j = 5;
    int k = 6;
    
    printf("func1 has called\n");

    func2();
}

void main()
{
    int i = 1;
    int j = 2;
    int k = 3;
    
    printf("main has called\n");

    func1();
}

위 코드의 실행결과는 다음과 같다.

1
2
3
main has called
func1 has called
func2 has called

main()func1()func2() 이렇게 실행되었다. 이제 디버깅으로 분석해보자.

gdb ./frame 으로 디버거를 실행한 다음, disass main 명령어로 main 함수를 분석하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pwndbg> disass main
Dump of assembler code for function main:
   0x080491f5 <+0>:     push   ebp
   0x080491f6 <+1>:     mov    ebp,esp
   0x080491f8 <+3>:     push   ebx
   0x080491f9 <+4>:     sub    esp,0xc
   0x080491fc <+7>:     call   0x804923b <__x86.get_pc_thunk.ax>
   0x08049201 <+12>:    add    eax,0x2dff
   0x08049206 <+17>:    mov    DWORD PTR [ebp-0x10],0x1
   0x0804920d <+24>:    mov    DWORD PTR [ebp-0xc],0x2
   0x08049214 <+31>:    mov    DWORD PTR [ebp-0x8],0x3
   0x0804921b <+38>:    lea    edx,[eax-0x1fd6]
   0x08049221 <+44>:    push   edx
   0x08049222 <+45>:    mov    ebx,eax
   0x08049224 <+47>:    call   0x8049050 <puts@plt>
   0x08049229 <+52>:    add    esp,0x4
   0x0804922c <+55>:    call   0x80491b3 <func1>
   0x08049231 <+60>:    mov    eax,0x0
   0x08049236 <+65>:    mov    ebx,DWORD PTR [ebp-0x4]
   0x08049239 <+68>:    leave  
   0x0804923a <+69>:    ret    
End of assembler dump.

스택은 기존 주소보다 낮은 주소로 확장되는 특성이 있다. 이는 ‘아래로 자란다’라는 표현을 사용하는데 ‘ebp에서 시작해서 esp에서 끝난다’라고 생각해도 된다.

ebpBase Pointer를 의미하며, 스택 프레임의 시작 공간을 가리킨다.

espStack Pointer를 의미하며, 현재 스택의 최상단을 가리킨다. 스택은 높은 주소에서 낮은 주소로 데이터가 쌓이기 때문에 esp는 데이터의 추가 및 제거에 따라 값이 감소하거나 증가한다. 즉 push는 데이터가 쌓이기 때문에 esp가 데이터 크기만큼 감소하고, pop은 스택에서 데이터를 제거하기 때문에 esp가 데이터 크기만큼 증가한다. 이로 인해 스택의 크기는 동적으로 변하며, esp가 스택의 크기 변화를 추적하게 된다.

func

위 코드를 보면 main()func1()func2() 이렇게 실행되는 것을 알 수 있는데, 스택을 보면 위 사진처럼 쌓인다.

Function Prologue

함수 프롤로그(Function Prologue)는 함수가 호출될 때 새로운 스택 프레임을 설정하여 함수 실행에 필요한 환경을 준비하는 과정이다. 위 디스어셈블 코드에서 함수 프롤로그 부분은 다음과 같다.

1
2
   0x080491f5 <+0>:     push   ebp
   0x080491f6 <+1>:     mov    ebp,esp

push ebp는 이전 함수의 ebp 주소이다. 즉 되돌아갈 함수의 ebp주소를 스택에 저장한다.

mov ebp, esp는 현재 함수의 Stack Base를 설정한다.

그리고 변수가 들어온다면 esp값이 바뀌어 스택의 크기가 늘어난다.

따라서 위 과정들이 함수가 호출될 때 해당 함수의 스택 프레임을 설정하여 함수 실행에 필요한 환경을 준비하는 과정이다.

Function Epilogue

함수 에필로그(Function Epilogue)는 함수가 종료될 때 실행되어 스택 프레임을 해제하고 함수 호출 전에 저장된 레지스터와 포인터들을 복원하는 과정이다. 위 디스어셈블 코드에서 함수 에필로그 부분은 다음과 같다.

1
2
   0x08049239 <+68>:    leave  
   0x0804923a <+69>:    ret   

함수 에필로그의 명령을 보면 leaveret으로 구성되어 있다.

leave

1
2
mov esp, ebp
pop ebp

mov esp, ebp는 함수에서 지역 변수를 위해 할당했던 스택 공간을 해제하는 역할을 한다.함수 실행 중에 감소시켰던 espebp의 값으로 복원하여, 함수 시작 시점의 스택 상태로 되돌린다.

pop ebp은 함수 시작 시 스택에 저장했던 이전 함수의 베이스 포인터(ebp) 값을 복원하는 과정이다. 이를 통해 이전 스택 프레임의 기준으로 돌아간다. 여기서 중요한 점은 pop ebp의 동작 과정이다. pop ebp는 현재의 esp가 있는 곳에서 4byte를 복사하여 ebp에 저장한다. 여기서 현재의 esp는 되돌아갈 함수의 ebp주소 값이고, 이는 SFP(Stack Frame Pointer)라고 한다. 그리고 pop과정을 했기 때문에 esp의 값은 4바이트가 증가된다.

ret

1
2
pop eip
jmp eip

pop eip를 수행하게 된다면 esp가 있는 곳에서 4byte를 복사하는데, 이는 호출된 call 명령 다음에 실행할 부분의 주소이다.

jmp eip 이전 동작에서 eip는 호출된 call 명령 다음에 실행할 부분의 주소기 때문에 해당 주소로 이동한다.

leaveret을 통해 함수가 종료되고, 스택 프레임이 해제된다.

Backtrace

Backtrace는 프로그램의 실행 중 특정 시점에서 호출된 함수들의 호출 순서를 역순으로 나열한 것이다. frame의 호출 순서를 보면 main()func1()func2() 이렇게 실행된 것을 알 수 있다.

bt

그렇다면 왜 main+60, func1+60 이렇게 나타났을까?

위 물음에 대한 대답은 call 명령어의 특성 때문이다. call을 할 때는 다음에 실행할 명령의 주소를 스택에 push하고 이동한다.

1
2
   0x0804922c <+55>:    call   0x80491b3 <func1>
   0x08049231 <+60>:    nop

그래서 위 코드를 보면 main+55func1call하는 부분이고 main+60call명령어의 다음 실행할 명령어의 주소이다.

따라서 Backtrace에서 main+60, func1+60이렇게 나오는 이유는 call 명령어의 특성 때문이다.

Ref

[1] 드림핵 시스템 해킹 강의 Background: Computer Science

[2] 스택 프레임