본문 바로가기
게임해킹/리버싱 핵심원리(나뭇잎책)

7장 스택 프레임

by HHack 2024. 9. 4.
반응형

7.1 스택 프레임

  • ESP : 스택에 저장된 최하위 주소값. 최근에 스택에 저장된 데이터 주소값
  • EBP : 함수에서 사용될 스택의 기준 주소값

스택 프레임이란 ESP가 아닌 EBP를 사용하여 스택 내의 로컬 변수, 파라미터, 복귀 주소에 접근하는 기법을 말합니다.

 

ESP 값은 프로그램 안에서 수시로 변경되기 때문에 어떤 기준 시점(함수 시작)의 ESP 값을 EBP로 저장하고 이를 함수 내에서 유지시켜 주면 ESP 값이 아무리 변하더라도 EBP를 기준으로 안전하게 해당 함수의 변수, 파라미터, 복귀주소에 접근할 수 있습니다.

[스택 프레임의 구조]
PUSH EBP                        ; 함수 시작(EBP를 사용하기 전에 기존의 값을 스택에 저장)
MOV EBP, ESP                    ; 현재의 ESP를 EBP에 저장

...                             ; 함수 본체
                                ; 여기서 ESP가 변경되더라도 EBP가 변경되지 않으므로
                                ; 안전하게 로컬 변수와 파라미터를 액세스 할 수 있음.
                             
MOV ESP, EBP                    ; ESP를 정리(함수 시작했을 때의 값으로 복원시킴)
POP EBP                         ; 리턴되기 전에 저장해 놓았던 원래 EBP 값으로 복원
RETN                            ; 함수 종료

스택 프레임을 이용하면 함수 호출 depth가 깊고 복잡해져도 스택을 완벽하게 관리할 수 있습니다.

※ 참고

  1. 최신 컴파일러는 최적화 옵션을 통해 간단한 함수 같은 경우에 스택 프레임을 생성하지 않습니다.
  2. 스택에 복귀 주소가 저장된다는 점이 보안 취약점으로 작용할 수 있습니다. Buffer Overflow 기법을 사용하여 복귀주소가 저장된 스택 메모리를 의도적으로 다른 값으로 변경할 수 있습니다.

7.2 실습 예제 - stackframe.exe

7.2.1 StackFrame.cpp

#include "stdio.h"

long add(long a, long b) {
    long x = a, y = b;
    return (x + y);
}

int main(int argc, char* argv[]) {
    long a = 1, b = 2;
    
    printf("%d\n", add(a, b));

    return 0;
}

x32dbg를 실행시켜 파일의 디스어셈 코드를 살펴봅니다. Goto 명령을 통해 401000 주소로 갑니다.

그림 7.1 디버거 화면

7.2.2 main() 함수 시작 & 스택 프레임 생성

main() 함수부터 살펴보겠습니다. main() 함수(401020)에 BP(Break Point)를 설치[F2]한 후 실행[F9]합니다.

7.2 스택의 초기값

그림 7.2를 보면 현재 ESP 값은 19FF2C이고 저장된 값 401250은 main() 함수의 실행이 끝난 후 돌아갈 리턴주소 입니다.

main() 함수는 시작하자마자 스택 프레임을 생성시킵니다.

00401020	PUSH EBP			; # main()

'PUSH'는 값을 스택에 집어넣는 명령입니다. 위 명령의 해석은 "EBP의 값을 스택에 집어넣어라."로 해석됩니다. main() 함수에서 EBP가 베이스 포인터의 역할을 하게 될테니 EBP가 가지고 있던 값을 스택에 백업해두기 위한 용도로 사용됩니다.

00401021	MOV EBP,ESP

'MOV' 명령은 데이터를 옮기는 명령으로 "ESP의 값을 EBP로 옮겨라" 라고 해석됩니다. 즉, 이 명령부터 EBP는 현재 ESP와 같은 값을 가지게 되며 main() 함수가 끝날 때까지는 EBP의 값은 고정됩니다. 이 말의 의미는 스택에 저장된 함수 파라미터와 로컬 변수들은 EBP를 통해 접근하겠다. 라는 뜻입니다. 401020과 401021 주소의 두 명령어에 의해서 main() 함수에 대한 스택 프레임이 생성되었습니다.

7.3 스택에 백업된 EBP 초기 값

7.2.3 로컬 변수 세팅

스택에 main() 함수의 로컬 변수 a, b를 위한 공간을 만들고 값을 입력합니다. main() 함수에서 선언된 변수 a, b가 어떻게 스택 메모리에 생성되고 관리되는지 살펴보겠습니다.

00401023	SUB ESP,8

'SUB'는 빼기 명령어입니다. 이 명령어를 해석하면 'ESP 값에서 8을 빼라'라는 뜻입니다. ESP에서 8을 빼는 이유는 a, b는 long 타입이므로 각각 4바이트의 크기를 가집니다. 즉, 이 두 변수를 스택에 저장하기 위해서는 총 8바이트의 공간이 필요합니다. 그래서 ESP에서 8을 빼서 두 변수에게 필요한 메모리 공간을 확보한 것입니다. 이제 main() 함수 내에서 ESP값이 아무리 변해도 'a'와 'b' 변수를 위해서 확보한 스택 영역은 훼손되지 않습니다.

00401026	MOV DWORD PTR SS:[EBP-4],1			; [EBP-4] = local 'a'
0040102D	MOV DWORD PTR SS:[EBP-8],2			; [EBP-8] = local 'b'

위 명령어 형식의 DWORD PTR SS:[EBP-4]를 살펴보겠습니다.

 어셈블리 언어  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

간결하게 번역해보면 "EBP-4 주소에서 4바이트 크기의 메모리 내용"이라고 해석할 수 있습니다.

 

이제 다시 위의 두 MOV 명령어를 해석해보면 "[EBP-4]에는 1을 넣고, [EBP-8]에는 2를 넣어라." 라는 뜻입니다. 즉, [EBP-4]는 로컬 변수 a를 의미하고 [EBP-8]은 로컬변수 b를 의미하는 것입니다.

7.4 변수 a, b

7.2.4 add() 함수 파라미터 입력 및 add() 함수 호출

00401034	MOV EAX,DWORD PTR SS:[EBP-8]			; [EBP-8] = b
00401037	PUSH EAX					; Arg2 - 00000002
00401038	MOV ECX,DWORD PTR SS:[EBP-4]			; [EBP-4] = a
0040103B	PUSH ECX					; Arg1 - 00000001
0040103C	CALL 00401000					; add()

위 어셈블리 코드는 전형적인 함수 호출 과정을 보여줍니다. 40103C 주소의 CALL 401000 명령어에서 401000 함수가 바로 add() 함수입니다. 위 401034 ~ 40103B 주소의 코드에서 변수 a, b를 스택에 넣고 있습니다. 주목할 내용은 파라미터가 C언어의 입력 순서와는 반대로 스택에 저장된다는 것입니다.

7.5 add() 함수 파라미터 입력

∴ 복귀주소

CALL 명령어가 실행되어 해당 함수로 들어가기 전에 CPU는 무조건 해당 함수가 종료될 때 복귀할 주소(return address)를 스택에 저장합니다. 그림 7.1을 보면 40103C 주소에서 add() 함수를 호출하였고, 그 다음 명령어의 주소는 401041입니다. 따라서 add() 함수의 실행이 완료되면 401041 주소로 돌아와야 합니다. 이 주소(401041)가 바로 add() 함수의 복귀 주소입니다.

7.6 Add() 함수의 복귀주소

7.2.5 add() 함수 시작 & 스택 프레임 생성

add() 함수가 시작되면 자신만의 스택 프레임을 따로 생성합니다.

00401000	PUSH EBP
00401001	MOV EBP,ESP

코드는 main() 함수의 스택 프레임 생성 과정과 동일합니다. 원래의 EBP 값(main() 함수의 Base Pointer)을 스택에 저장 후 현재의 ESP(Stack Pointer)를 EBP에 입력합니다. 이제 add() 함수의 스택 프레임이 생성되었습니다. add() 함수 내에서 EBP 값은 고정됩니다.

7.7 add() 함수의 스택 프레임 생성

7.2.6 add() 함수의 로컬 변수(x, y) 세팅

add() 함수의 로컬 변수 x, y에 각각 파라미터 a, b를 대입합니다. 함수 내에서 파라미터와 로컬 변수가 어떤 식으로 표시되는지 알아보겠습니다.

00401003	SUB ESP,8

로컬 변수 x, y에 대한 스택 메모리 영역(8 바이트)을 확보합니다.

00401006	MOV EAX,DWORD PTR SS:[EBP+8]			; [EBP+8] = param a
00401009	MOV DWORD PTR SS:[EBP-8],EAX			; [EBP-8] = local x
0040100C	MOV ECX,DWORD PTR SS:[EBP+C]			; [EBP+C] = param b
0040100F	MOV DWORD PTR SS:[EBP-4],ECX			; [EBP-4] = local y

add() 함수에서 새롭게 스택 프레임이 생성되면서 EBP 값이 변하였습니다. [EBP+8], [EBP+C]가 각각 파라미터 a, b를 가리킵니다. 그리고 [EBP-8], [EBP-4]는 각각 add() 함수의 로컬 변수 x, y를 의미합니다.

7.8 add() 함수의 로컬 변수 x, y

7.2.7 ADD 연산

00401012	MOV EAX,DWORD PTR SS:[EBP-8]			; [EBP-8] = local x

변수 x의 값([EBP-8] = 1)을 EAX에 넣습니다.

00401015	ADD EAX,DWORD PTR SS:[EBP-4]			; [EBP-4] = local y

'ADD' 명령어는 덧셈 연산 명령입니다. EAX에 변수 y의 값([EBP-4] = 2)을 더합니다. EAX의 값은 3이 되었습니다.

위와 같이 함수가 리턴하기 직전에 EAX에 어떤 값을 입력하면 그대로 리턴값이 됩니다.

7.2.8 add() 함수의 스택 프레임 해제 & 함수 종료(리턴)

이제 add() 함수가 리턴되어야 합니다. 그 전에 add() 함수의 스택 프레임을 해제해야 합니다.

00401018	MOV ESP,EBP

현재의 EBP 값을 ESP에 대입합니다. 이 명령어는 앞에서 실행된 401001 주소의 MOV EBP, ESP 명령어에 대응하는 것입니다. 즉, add() 함수 시작할 때의 ESP 값을 EBP에 넣어 두었다가 함수가 종료될 때 ESP를 원래대로 복원시키는 목적으로 사용됩니다.

0040101A	POP EBP

add() 함수가 시작되면서 스택에 백업함 EBP 값을 복원합니다. 이 명령어는 앞에서 실행된 401000 주소의 PUSH EBP 명령에 대응하는 것입니다. 이 값은 main() 함수의 EBP 값입니다. 이제 add() 함수의 스택 프레임은 해제되었습니다.

7.9 add() 함수의 스택 프레임 해제

위의 그림을 보면 ESP = 19FF28이고 그 주소값은 19FF70입니다. 이 값은 앞에서 CALL 401000명령에서 CPU가 스택에 입력한 복귀주소 입니다.

0040101B	RET

위와 같이 RET 명령어가 실행되면 스택에 저장된 복귀 주소로 리턴합니다. 스택의 모습은 그림 7.10과 같습니다.

7.10 add() 함수 리턴

프로그램은 이런식으로 스택을 관리하기 때문에 함수 호출이 계속 중첩된다고 하더라도 스택이 깨지지 않고 잘 유지되는 것입니다. 하지만 스택에 로컬 변수, 함수 파라미터, 리턴 주소 등을 한 번에 보관하기 때문에 문자열 함수의 취약점 등을 이용한 Stack Buffer Overflow 기법에 속수무책으로 당하기도 합니다.

7.2.9 add() 함수 파라미터 제거(스택 정리)

이제 main() 함수 코드로 돌아왔습니다.

00401041	ADD ESP,8

'ADD' 명령으로 ESP에 8을 더하고 있습니다. 그 이유는 add() 함수에게 넘겨준 파라미터 a와 b가 add() 함수가 종료됨에 따라 더이상 필요 없기 때문에 스택을 정리하는 것입니다.

 

이제 스택은 아래의 그림과 같이 됩니다.

7.11 add() 함수 파라미터 정리

※ Calling Convention

cdecl : 함수를 호출한 쪽(Caller)에서 파라미터를 정리하는 방식

stdcall : 호출당한 쪽(Callee)에서 파라미터를 정리하는 방식

7.2.10 printf() 함수 호출

00401044	PUSH EAX					; add() 함수의 리턴 값
00401045	PUSH 0040B384					; "%d\n"
0040104A	CALL 00401067					; printf()
0040104F	ADD ESP,8

401044 주소의 EAX 레지스터에는 add() 함수에서 저장된 리턴 값(3)이 들어있습니다. 그리고 40104A 주소의 CALL 401067 명령어에서 401067 함수는 Visual Studio에서 생성한 C표준 라이브러리 printf() 함수입니다. 위 경우 printf() 함수의 파라미터 개수는 2개이며 크기 역시 8바이트입니다. 따라서 40104F 주소에 ADD ESP,8 명령으로 스택에서 함수 파라미터를 정리하고 있습니다. printf() 함수 호출 후 스택이 정리되었기 때문에 스택은 그림 7.11과 동일합니다.

7.2.11 리턴 값 세팅

main() 함수의 리턴 값을 세팅합니다.(return 0;)

00401052	XOR EAX,EAX

MOV EAX,0 명령어보다 실행 속도가 빨라서 위와 같이 레지스터를 초기화시킬 때 많이 사용됩니다.

7.2.12 스택 프레임 해제 & main() 함수 종료

드디어 메인 함수가 종료됩니다. add() 함수와 마찬가지로 리턴하기 전에 스택 프레임을 해제합니다.

00401054	MOV ESP,EBP
00401056	POP EBP

위 두 명령어로 인해 main() 함수의 스택 프레임은 해제되었습니다. 그리고 main() 함수의 로컬 변수인 a,b 역시 더이상 유효하지 않게 되었습니다. 여기까지 실행 한 후 스택의 모습은 아래와 같습니다.

7.12 main() 함수 스택 프레임 해제

그림 7.12는 main() 함수 시작할 때의 모습과 완벽히 동일합니다.

00401057	RET

이제 메인 함수가 종료(리턴) 되면서 리턴 주소(401250)로 점프합니다. 그 주소는 Visual Studio의 Stub Code 영역입니다.

7.3 x32dbg 옵션 변경

7.13 디스어셈블러 설정

설정 -> 환경설정 -> 디스어셈블러를 눌러 위와 같이 체크합니다. 그럼 위의 그림 7.13과 같이 세그먼트 표시와 메모리 크기 표시가 사라집니다.

 

x32dbg에서 ollydbg처럼 LOCAL과 ARG를 구분해주는 설정을 찾지 못했습니다. 혹시 아시는 분은 댓글로 알려주시면 감사하겠습니다.

ollydbg
x32dbg

 

반응형