스택 프레임(Stack Frame) 은 프로그램에서 선언되는 로컬 변수와 함수 호출에 사용됩니다. 스택 프레임을 이해하면 스택에 저장된 함수 파라미터와 함수 로컬 변수 등을 쉽게 파악할 수 있습니다.
스택 프레임이란 ESP(스택 포인터)가 아닌 EBP(베이스 포인터) 레지스터를 사용하여 스택 내의 로컬 변수, 파라미터, 복귀 주소에 접근하는 기법입니다.
ESP 레지스터의 값은 프로그램 안에서 수시로 변경되기 때문에 스택에 저장된 변수와 파라미터에 접근하고자 할 때 ESP 값을 기준으로 하면 프로그램을 만들기 힘들고, CPU가 정확한 위치를 참고할 때 어려움이 있습니다.
그렇기 어떤 기준 시점(함수 시작)의 ESP 값을 EBP에 저장하고 이를 함수 내에서 유지시켜 주면, ESP 값이 아무리 변하더라도 EBP를 기준(base)으로 안전하게 해당 함수의 변수와 파라미터, 복귀 주소에 접근할 수 있습니다.
이것이 EBP 레지스터의 베이스 포인터 역할입니다.
스택 프레임을 어셈블리 코드로 보면,
PUSH EBP
; 함수 시작(EBP를 사용하기 전에 기존의 값을 스택에 저장)
MOV EBP, ESP
; 현재의 ESP(스택 포인터) 값을 EBP에 저장
...
; 함수 본체. 여기서 ESP가 변경되더라도 EBP는 변경되지 않기 때문에 안전하게 로컬 변수와 파라미터를 엑세스할 수 있음
MOV ESP, EBP
; ESP를 정리(함수 시작했을 때의 값으로 복원시킴)
POP EBP
; 리턴되기 전에 저장해 놓았던 원래 EBP 값으로 복원
RETN
; 함수 종료
스택 프레임을 이용하여 함수 호출을 관리하면, 아무리 함수 호출 깊이가 깊고 복잡하다해도 스택을 완벽하게 관리할 수 있습니다.
최신 컴파일러는 최적화(Optimization) 옵션을 가지고 있어서 간단한 함수는 스택 프레임을 생성하지 않습니다.
스택에 복귀 주소가 저장된다는 점은 보안 취약점으로 작용할 수 있습니다. buffer overflow 기법을 사용하여 복귀 주소가 저장된 스택 메모리를 의도적으로 다른 값으로 변경할 수 있습니다.
#include "stdio.h"
long add(long a, long b) {
long x = a, y = b;
return (x + y);
}
int main() {
long a = 1, b = 2;
printf("%d\n", add(a, b));
return 0;
}
OllDbg로 StackFrame.exe 파일을 열고 401000 주소로 이동합니다. (Go to 명령어 사용[Ctrl+G])
코드 흐름 순서에 맞게 main() 함수부터 시작합니다. main() 함수(401020)에 BP(Break Point)를 설치[F2]한 후 실행[F9]합니다.
main() 함수 시작 시 스택의 상태입니다.
현재 ESP = 19FF2C, EBP = 19FF70 입니다. ESP에 저장된 값 401250은 main() 함수의 실행이 끝난 후 돌아갈 리턴 주소(Return Address)입니다.
main() 함수는 시작하자마자 스택 프레임을 생성합니다.
'PUSH'는 값을 스택에 집어넣는 명령입니다. 'EBP 값을 스택에 집어넣어라'
라고 해석할 수 있습니다.
main() 함수에서 EBP가 베이스 포인터의 역할을 하게 되기 때문에 EBP가 이전에 가지고 있던 값을 스택에 백업해두기 위한 용도로 사용됩니다.
나중에 main() 함수가 종료(RETN)되기 전에 이 값을 회복시켜 줍니다.
'MOV'는 데이터를 옮기는 명령입니다. 'ESP의 값을 EBP로 옮겨라'
라고 해석할 수 있습니다.
이 명령 이후부터는 EBP와 현재 ESP는 같은 값을 가지게 됩니다. 그리고 main() 함수가 끝날 때까지 EBP 값은 고정됩니다.
이 뜻은 스택에 저장된 함수 파라미터와 로컬 변수들은 EBP를 통해 접근(Access)하겠다는 것입니다.
401020과 401021 주소의 두 명령어에 의해서 main() 함수에 대한 스택 프레임이 생성되었습니다.(EBP가 세팅되었습니다.)
OllDbg의 스택 창에서 마우스 우측 메뉴 - Address - Relative to EBP
를 선택합니다. 스택 창에서 EBP의 위치를 확인할 수 있습니다.
현재 EBP 값은 19FF28로 ESP와 동일하고, 19FF28 주소에는 19FF70이라는 값이 저장되어 있습니다. 19FF70은 main() 함수가 시작할 때 EBP가 가지고 있던 초기 값입니다.
long a = 1, b = 2;
스택에 main() 함수의 로컬 변수(a, b)를 위한 공간을 만들고 값을 입력합니다. main() 함수에서 선언된 변수 a, b가 어떻게 스택 메모리에 생성되고 관리되는지 살펴봅니다.
'SUB'는 빼기 명령어입니다. 'ESP 값에서 8을 빼라'
라고 해석할 수 있습니다. 현재 ESP = 19FF28입니다.
ESP에서 8을 빼는 이유는 무엇일까요? 함수의 로컬 변수는 스택에 저장됩니다. main() 함수의 로컬 변수는 'a'와 'b'입니다. 'a'와 'b'는 long 타입이기 때문에 각각 4바이트 크기를 가집니다.
결국 이 두 변수를 스택에 저장하기 위해서는 총 8바이트가 필요한 것입니다. 그래서 ESP에서 8을 빼 두 변수에게 필요한 메모리 공간을 확보(예약)한 것입니다.
이제 main() 함수 내에서 ESP 값이 아무리 변해도 'a'와 'b' 변수를 위한 스택 영역은 훼손되지 않습니다. EBP 값은 main() 함수 내에서 고정이므로 이를 기준으로 삼아 로컬 변수에 엑세스할 수 있습니다.
C 언어의 포인터와 같은 개념으로 생각하면 됩니다.
어셈블리와 C 언어의 포인터 구문 형식
어셈블리 언어 | C 언어 | Type casting |
---|---|---|
DWORD PTR SS:[EBP-4] | *(DWORD*)(EBP-4) | DWORD (4 바이트) |
WORD PTR SS:[EBP-4] | *(WORD*)(EBP-4) | WORD (2 바이트) |
BYTE PTR SS:[EBP-4] | *(BYTE*)(EBP-4) | BYTE |
위 구문에서 SS(Stack Segment)를 표시하는 이유는 해당 메모리가 어떤 세그먼트에 소속되어 있는 지를 표시해주기 위해서 입니다.
실제로 32비트 Windows OS에서는 SS(Stack Segment), DS(Data Segment), ES(Extra data Segment)의 값은 모두 0이기 때문에 이런 식으로 세그먼트를 붙여주는 것에 큰 의미는 없습니다.
ESP와 EBP는 스택을 가리키는 레지스터들이기 때문에 SS 레지스터를 붙여준 것입니다.
위의 MOV 명령어들은 '[EBP-4]에는 1을 넣고, [EBP-8]에는 2를 넣어라'
라고 해석할 수 있습니다.
즉 [EBP-4]는 로컬 변수 a를 의미하고, [EBP-8]은 로컬 변수 b를 의미합니다. 여기까지 실행한 후의 스택의 상태입니다.
printf("%d\n", add(a, b));
위 어셈블리 코드는 전형적인 함수 호출 과정입니다. 40103C 주소의 CALL 401000 명령어에서 401000 함수가 add() 함수입니다.
add() 함수는 파라미터로 a와 b를 받습니다. 위 401034~40103B 주소의 코드에서는 변수 a와 b를 스택에 넣고 있습니다.
여기서 파라미터가 C 언어의 소스 코드 입력 순서와는 반대로 스택에 저장된다는 것을 기억해야 합니다.(파라미터의 역순 저장)
변수 b[EBP-8]가 스택에 먼저 들어가고 변수 a[EBP-4]가 나중에 들어갑니다.
40103C 주소의 CALL 명령어를 실행하여 add() 함수(401000) 안으로 들어간 이후의 스택 변화입니다.
복귀 주소
CALL 명령어가 실행되어 해당 함수로 들어가기 전에 CPU는 무조건 해당 함수가 종료될 때 복귀할 주소(return address)를 스택에 저장합니다.
코드를 보면 40103C 주소에서 add() 함수를 호출하였고, 그 다음 명령어의 주소는 401041이기 때문에 add() 함수의 실행이 완료되면 401041 주소로 돌아와야 합니다.
401041 주소가 add() 함수의 복귀 주소입니다.
long add(long a, long b) {
add() 함수가 시작되면 자신만의 스택 프레임을 따로 생성합니다.
코드는 main() 함수의 스택 프레임을 생성할 때와 완전히 동일합니다.
원래의 EBP 값(main() 함수의 Base Pointer)을 스택에 저장한 후 현재의 ESP(Stack Pointer)를 EBP에 입력합니다.
이제 add() 함수의 스택 프레임이 생성되었습니다. add() 함수 내에서 EBP 값은 고정됩니다.
여기까지 실행 후 스택의 모습입니다.
main() 함수에서 사용되는 EBP 값(19FF28)을 스택에 백업한 후 EBP가 19FF10으로 새롭게 세팅되었습니다.
long x = a, y = b;
add() 함수의 로컬 변수 x, y에 각각 파라미터 a, b를 대입합니다. 함수 내에서 파라미터와 로컬 변수가 어떤 식으로 표시되는지 확인합니다.
로컬 변수 x와 y에 대한 스택 메모리 영역(8바이트)을 확보합니다.
add() 함수에서 새롭게 스택 프레임이 생성되면서 EBP 값이 변합니다.
[EBP+8], [EBP+C]가 각각 파라미터 a와 b를 가리킵니다. [EBP-8], [EBP-4]는 각각 add() 함수의 로컬 변수 x, y를 의미합니다.
401006 ; [EBP+8] = param a
401009 ; [EBP-8] = local x
40100C ; [EBP+C] = param b
40100F ; [EBP-4] = local y
여기까지 실행한 후의 스택 변화입니다.
return (x + y);
변수 x의 값([EBP-8] = 1)을 EAX에 넣습니다.
'ADD' 명령어는 덧셈 연산 명령어입니다. EAX에 변수 y의 값([EBP-4] = 2)을 더합니다. EAX 값은 1 + 2 = 3이 되었습니다.
EAX는 '범용 레지스터'로 산술 연산에 사용되며, 또 다른 특수 용도로는 리턴 값으로 사용됩니다. 위와 같이 함수가 리턴하기 직전에 EAX에 어떤 값을 입력하면 그대로 리턴 값이 됩니다.
이 과정에서의 스택 변화는 없습니다.
return (x + y); }
add() 함수가 리턴될 차례입니다. 그 전에 add() 함수의 스택 프레임을 해제해야 합니다.
현재 EBP 값을 ESP에 대입합니다. 앞에서 실행된 401001 주소의 MOV EBP, ESP
명령어에 대응합니다.
즉, add() 함수를 시작할 때의 ESP 값(19FF10)을 EBP에 넣어 두었다가 함수가 종료될 때 ESP를 원래대로 복원시키는 목적으로 사용하는 것입니다.
위 명령에 의해서 401003 주소의 SUB ESP, 8 명령의 효과는 사라집니다. add() 함수의 로컬 변수 x, y는 더 이상 유효하지 않습니다.
add() 함수를 실행하며 스택에 백업해두었던 EBP 값을 복원합니다. 앞에서 실행된 401000 주소의 PUSH EBP
명령에 대응합니다.
복원된 EBP 값은 19FF28이며, 이 값은 main() 함수의 EBP 값입니다. 이제 add() 함수의 스택 프레임이 해제되었습니다.
스택의 변화 모습입니다.
ESP = 19FF14, 주소 값은 401041입니다. 이 값은 앞 CALL 401000 명령에서 CPU가 스택에 입력한 복귀 주소입니다.
RETN 명령어가 실행되면 스택에 저장된 복귀 주소로 리턴합니다.
add() 함수를 호출하기 전의 스택 상태로 완전히 돌아왔습니다.
이렇게 스택을 관리하기 때문에 함수 호출이 계속 중첩된다고 하더라도 스택이 깨지지 않고 유지될 수 있습니다.
하지만 스택에 로컬 변수와 함수 파라미터, 리턴 주소 등을 한 번에 보관하기 때문에 문자열 함수의 취약점을 이용한 Stack Buffer Overflow
기법에 쉽게 당할 수 있습니다.
이제 main() 함수 코드로 돌아왔습니다.
'ADD' 명령으로 ESP에 8을 더합니다. ESP에 8을 더하는 이유는 위 스택의 상태에서 19FF18과 19FF1C 주소의 내용이 add() 함수에게 넘겨준 파라미터 a, b 이기 때문입니다.
add() 함수가 완전히 종료되었기 때문에 파라미터 a, b도 필요가 없어졌습니다. 그렇기 때문에 ESP에 8을 더하여 스택을 정리할 수 있습니다. (파라미터 a, b는 long 타입으로 각각 4바이트씩 8바이트 입니다.)
add() 함수를 호출하기 전에 파라미터 a, b를 PUSH 명령으로 스택에 넣었었습니다.
현재 스택의 모습입니다.
위 방식처럼 함수를 호출한 쪽(Caller)에서 (스택에 저장된) 파라미터를 정리하는 것을 'cdecl' 방식이라고 합니다. 반대로 호출당한 쪽(Callee)에서 (스택에 저장된) 파라미터를 정리하는 것을 'stdcall' 방식이라고 합니다.
이런 함수 호출 규약을 일컬어 Calling Convention이라고 부릅니다.
printf("%d\n", add(a, b));
printf() 함수의 호출 코드입니다.
401044 주소의 EAX 레지스터에는 add() 함수에서 저장된 리턴 값(3)이 들어 있습니다. 40104A 주소의 CALL 401067 명령어에서 401067 함수는 Visual C++에서 생성한 C 표준 라이브러리 printf() 함수를 의미합니다.
위의 경우 printf() 함수의 파라미터 갯수는 2개이며 크기는 8바이트입니다. (32비트 레지스터 + 32비트 상수 = 64비트 = 8바이트)
따라서 40104F 주소에 ADD ESP, 8 명령으로 스택에서 함수 파라미터를 정리합니다. printf() 함수 호출 후 스택이 정리되었기 때문에 스택의 상태는 동일합니다.
return 0;
main() 함수의 리턴 값(0)을 세팅합니다.
'XOR' 명령어는 Exclusive OR bit 연산입니다. 같은 값끼리 XOR하면 0이 되는 특징이 있습니다.
MOV EAX, 0
명령어보다 실행 속도가 빠르기 때문에 레지스터를 초기화시킬 때 많이 사용합니다.
같은 값을 이용해서 2번 연속으로 XOR 연산을 수행하면 원본 값이 됩니다. 따라서 이 특징을 암호화/복호화에 많이 적용합니다.
return 0; }
마지막으로 메인 함수가 종료됩니다. add() 함수와 마찬가지로 리턴하기 전에 스택 프레임을 해제합니다.
main() 함수의 스택 프레임이 해제되었습니다. main() 함수의 로컬 변수인 a와 b도 유효하지 않습니다.
현재 스택의 상태입니다.
main() 함수가 시작할 때의 스택 상태와 완전히 동일합니다.
메인 함수가 종료(리턴)되면서 리턴 주소(401250)로 점프합니다. 이 주소는 Visual C++의 Stub Code 영역입니다.
이후에는 프로세스 종료 코드가 실행됩니다.
Code 창의 디스어셈블리 코드를 보여주는 옵션입니다.
OllyDbg의 Option Dialog를 실행합니다.([Alt+O])
옵션 다이얼로그에서 [Disasm] 탭을 선택 후 'Show default segments'
항목과 'Always show size of memory operands'
항목을 선택해제합니다.
세그먼트 표시와 메모리 크기 표시가 사라지는 것을 볼 수 있습니다.
[Analysis1] 탭을 선택 후 'Show ARSs and LOCALs in procedures'
항목을 체크합니다.
EBP로 표시되던 함수의 로컬 변수와 파라미터가 [LOCAL.1], [ARG.1] 형식으로 표시됩니다. 좋은 가독성을 제공하므로 디버깅에 큰 도움이 됩니다.
이 옵션은 OllyDbg에서 직접 함수의 스택 프레임을 분석하여 로컬 변수의 개수, 파라미터의 개수 등을 화면에 표시해줍니다. 틀리게 표시될 때도 있지만 직관적으로 표시해주기 때문에 디버깅에 도움이 될 때도 많습니다.
ⓒ 리버싱 핵심 원리