Security Tech
함수 프롤로그/에필로그 분석
작성자 - dhkstn1. 목표
- 스택을 이용한 함수를 직접 구현하고, 해당 함수 내에서 프롤로그(Prologue)와 에필로그(Epilogue)가 디컴파일 없이 어떤 방식으로 수행되는지 파악하기!
- 각 인스트럭션이 수행하는 동작과 의미를 기록하기
- RSP와 RBP 레지스터의 움직임을 중점으로, 어떤 인스트럭션이 사용되는지와 어떤 단계를 거치는지 정리하기!
2. 분석
2.1 코드 분석 및 사전 지식
코드 분석 및 변수 확인을 위해 GCC 컴파일 시 `-g` 옵션을 추가하여 디버깅 정보를 삽입함
// gcc -g -o add_func add_func.c
#include <stdio.h>
int add_func(int a, int b) {
int c = a + b;
return c;
}
int main() {
int add_result = add_func(10, 20);
printf("add = %d\n", add_result);
return 0;
}
분석 대상 파일은 64 bit 환경의 ELF 파일이며, System V 호출 규약을 따르고 있는 것을 확인함
System V의 함수 호출 규약은 다음 특징을 가짐
- 함수 호출 시 최대 6개의 인자는 레지스터를 통해 전달됨
- 전달 순서는 `RDI` → `RSI` → `RDX` → `RCX` → `R8` → `R9` 순서임
- 인자가 7개 이상일 경우, 나머지 인자는 스택을 이용하여 전달함
- 함수 호출 이후, 스택을 통해 전달된 인자 영역은 호출자(Caller)가 정리함
- 함수의 반환 값은 `RAX` 레지스터를 이용하여 전달됨
2.2 함수 프롤로그(Prologue)
함수 프롤로그는 함수가 호출된 직후 실행되는 코드 영역으로, 기존 호출자의 스택 프레임을 보존하고, 새로운 스택 프레임을 구성하여 지역 변수 저장 공간을 확보하는 작업을 수행함
분석 대상 파일에서 호출자(Caller)는 `main()` 함수이며, 피호출자(Callee)는 `add_func()` 함수가 됨
이제 GDB를 활용하여 `main()` 함수의 프롤로그 동작 및 스택 프레임 구성을 분석하고, 해당 함수 내에서 피호출자(Callee)인 `add_func()` 함수를 호출하기 위해 어떤 인스트럭션이 수행되는지 그 과정을 분석함
- `main()` 함수 진입 직후부터 함수 프롤로그 동작을 분석하기 위해 첫 번째 인스터럭션(`push rbp`) 실행 전에 중단점을 설정함
- 사용 명령어: `break * main` -> `run`
- 프로그램 실행 후, GDB에서 자동 출력되는 Context 창(Registers / Stack / Disasm 정보)을 이용하여 현재 레지스터 상태 및 스택 메모리 상태를 확인함
- `RSP`: 현재 스택 최상단 주소
- `RBP`: 호출자 스택 프레임 기준 주소
- `push rbp` 명령어실행 시, 기존 RBP 값을 스택에 저장하며, RSP 값이 8byte 감소하는 동작을 확인함
- `main()` 함수 진입 시, 초기 `RBP` 값: `1`
- 코드 실행 명령어: `ni`
- `mov rbp, rsp` 명령어를 통해 현재 `RSP` 값을 `RBP`에 복사하여 새로운 스택 프레임 기준을 설정함
- `sub rsp, 0x10` 명령어를 통해 지역 변수 저장 공간 확보를 위해 `RSP` 값을 16 byte 감소함
- 여기까지의 과정이 `main()` 함수의 프롤로그에 해당함
- `RSP` 값 변화: 기존 `0x7ffffffddb0` → `0x7fffffffdda0`
- 지역 변수 공간 확보로 인해 RBP-0x10 위치를 가리키고 있음
- 이후 `add_func(10, 20)` 함수를 호출하기 위해 호출 규약에 따라 첫 번째 인자인 `a` 값은 `RDI` 레지스터에, 두 번째 인자인 `b` 값은 `RSI` 레지스터에 전달됨
- `call add_func` 명령어 수행 시, `main()` 함수 복귀 주소(`main+27`)가 스택에 저장되어 있는 것을 확인함
- 현재 `RSP` 레지스터는 `main()` 함수의 복귀 주소가 저장된 위치(`0x7fffffffdd98`)를 가리키고 있으며, 이후 `add_func()` 함수로 진입하여 피호출자(Callee)의 프롤로그 동작이 수행됨
- 피호출자 프롤로그에서는 새로운 스택 프레임 구성을 위해 `push rbp` → `mov rbp, rsp` 명령어가 순차적으로 수행됨
- `push rbp` 명령어 실행 시, 호출자(`main()` 함수)의 `RBP` 값을 스택에 저장하며, `RSP` 값이 8 byte 감소함을 확인함
- 저장된 `RBP` 값: `0x7fffffffddb0` → 1
- `RSP` 값 변화: `0x7fffffffdd98` → `0x7fffffffdd90`
- 코드 실행 명령어: `ni`
- `mov rbp, rsp` 명령어 실행 시, 현재 `RSP` 값을 `RBP` 레지스터에 복사하여 새로운 스택 프레임 기준을 설정하는 동작을 확인함
- 여기까지가 `add_func()` 함수의 프롤로그에 해당함
- 이 동작은 함수 종료 시, `pop rbp` 명령어를 통해 이전 스택 프레임(`main()` 함수)의 기준 주소로 복귀할 수 있도록 하기 위함임
- `add_func()` 함수 내부에서는 호출 규약에 따라 첫 번째 인자인 `a` 값은 `RDI` 레지스터를 통해, 두 번째 인자인 `b` 값은 `RSI` 레지스터를 통해 전달됨
- 이후, 전달받은 인자 값 `a`와 `b`를 더하는 연산이 수행되며, 연산 결과는 지역 변수 `c`가 위치한 [RBP-0x4] 주소에 저장됨
- `RBP-0x4` 주소를 확인해 보면 `a`와 `b`의 연산 결과인 30(0x1e)이 저장되어 있는 것을 확인함
- `add_func()` 함수 내부에서 `return c` 구문이 존재하므로, 함수 반환 시 지역 변수 `c`에 저장된 값을 다시 `EAX` 레지스터로 로드하는 동작이 수행됨
2.3 함수 에필로그(Epilogue)
함수 에필로그(Epilogue)는 함수 실행이 끝난 뒤, 호출자(Caller) 함수로 돌아가기 위해 스택 프레임을 정리하는 과정임
일반적으로 함수 에필로그에서는 leave 명령어와 ret 명령어를 수행하여 호출자(Caller) 함수로 복귀하는 동작이 이루어짐
leave 명령어는 mov rsp, rbp 명령어와 pop rbp 명령어를 묶어놓은 명령어임
mov rsp, rbp 명령어를 통해 스택 포인터(RSP)를 현재 스택 프레임 기준 주소(RBP)로 복원한 후, pop rbp 명령어를 통해 호출자 함수의 RBP 값을 복구하는 방식임
이후 ret 명령어를 통해 스택에 저장된 복귀 주소를 가져와(RIP 레지스터에 저장) 호출자 함수로 프로그램 흐름이 이동하게됨
2.2 함수 프롤로그 실습에 이어, 에필로그가 어떻게 동작하는지 분석함
- `add_func()` 함수의 에필로그 구간에서 `pop rbp` 명령어와 `ret` 명령어가 사용된 것을 확인함
- `leave` 명령어 대신 `pop rbp` 명령어가 사용된 이유는, 함수 내부에서 스택 프레임은 구성되었으나 별도의 지역 변수 공간 확보(`sub rsp, xxx`)가 수행되지 않았기 때문임
- 그 결과, 스택 포인터 복원 과정 없이 `pop rbp` 명령어만 사용하여 호출자 함수의 `RBP` 값을 복구하는 구조로 동작함
- `ret` 명령어는 스택에 저장되어 있는 복귀 주소를 꺼내(`RSP`가 가리키는 위치), 이를 `RIP` 레지스터에 로드하여 호출자 함수로 복귀하는 명령어임
- `add_func()` 함수에서는 `ret` 명령어 실행 시, 스택에 저장된 복귀 주소(`main()` 함수)가 `RIP` 레지스터에 로드되어 `main()` 함수로 프로그램 흐름이 이동함
- `add_func()` 함수에서 `main()` 함수로 복귀하는 이 과정이 함수의 에필로그 동작에 해당함
- `main()` 함수로 복귀한 뒤, 함수 반환 값(0x1e)은 `mov [rbp-0x4], eax` 명령어를 통해 지역 변수 `add_result`에 저장됨
- `RIP` 값: 0x555555555182 (main+27)
- `main()` 함수에서는 `printf` 함수를 호출하여 `add_result` 변수 값을 출력함
- 호출 규약에 따라 출력 문자열 포맷(`"add = %d\\n"`)은 `RDI` 레지스터를 통해 전달되며, 출력할 값(`add_result`)은 `RSI` 레지스터를 통해 전달됨
- 이후 `call printf@plt` 명령어를 통해 `printf`가 호출되고, 화면에 연산 결과가 출력됨
- `printf` 함수 호출 이후, `main()` 함수 종료 구간에서는 에필로그 동작이 수행됨
- 먼저, `mov eax, 0` 명령어를 통해 `main()` 함수의 반환 값을 `0`으로 설정함
- 이후 `leave` 명령어를 통해 스택 프레임을 정리하고, `ret` 명령어를 통해 호출자 함수(`__libc_start_call_main`)로 복귀하게 됨
- `main()` 함수 에필로그 수행 이후, 프로그램 흐름은 호출자 함수인 `__libc_start_call_main`으로 이동함
- 이후 `call exit` 명령어를 통해 프로그램이 정상적으로 종료됨
2.4 전체 스택 구조
함수 호출 시 스택 프레임이 어떻게 구성되는지 전체 구조를 그림으로 정리하였음
아래 그림은 `main()` 함수 실행 도중 `add_func()` 함수를 호출하는 과정에서 스택 프레임이 생성되는 구조를 나타낸 것임
상위 함수인 `main()` 함수의 스택 프레임 위에, 하위 함수인 `add_func()` 함수의 스택 프레임이 추가로 쌓이는 형태로 동작함
3. 참고 자료
'Security Tech' 카테고리의 다른 글
[Mobile] QuarkEngine 툴 소개 및 사용법 (0) | 2025.05.31 |
---|---|
스택 구조 그리기 (0) | 2025.04.30 |
OWASP - MASTG UnCrackable-LEVEL 3 (1) (0) | 2025.04.27 |
V2X (Vehicle-to-Everything) 통신 (0) | 2025.02.28 |
API 취약점 진단 (0) | 2025.02.07 |
Contents