콘텐츠로 이동

리버싱 핵심원리 1부 7장 정리

Note

본 게시글의 내용은 리버싱 핵심원리를 보며 복습 겸 정리하였습니다.

책의 내용과 일부 상이할 수 있고, 이해를 돕기 위해 강좌 형식의 말투와 적절한(?) 예시를 추가하였습니다.

본 게시글에서 사용되는 소스 코드와 파일, 프로그램은 리버싱 핵심원리에서 제공하는 파일과 언급되는 것들을 기반으로 하며, 일부 상이할 수 있습니다.

스택 프레임

스택 프레임StackFrame이란 esp 레지스터가 아닌 ebp 레지스터를 사용하여 스택 내의 지역 변수, 매개변수, 복귀(리턴) 주소에 접근하는 기법을 말합니다. IA-32 레지스터의 기본 설명에서 esp 레지스터가 스택 포인터 역할을 하고, ebp 레지스터가 베이스 포인터 역할을 한다고 설명했습니다.

esp 레지스터의 값은 프로그램 안에서 수시로 변경되기 때문에 스택에 저장된 변수와 매개변수에 접근하고자 할 때 CPU가 정확한 위치를 특정하기 힘듭니다. 따라서 esp 레지스터의 값을 ebp 레지스터에 저장하고 이를 함수 내에서 유지해주면 esp 레지스터의 값이 변하더라도 ebp 레지스터를 기준으로 변수와 매개변수에 접근할 수 있습니다.

스택 프레임 구조

StackFrame
1
2
3
4
5
6
7
8
push ebp        ; 함수 시작: EBP 레지스터 사용 전 기존의 값을 스택에 저장
mov ebp, esp    ; ESP 레지스터의 값을 EBP 레지스터에

;...

mov esp, ebp    ; EBP 레지스터의 값을 ESP 레지스터에
pop ebp         ; EBP 레지스터의 값 복원
ret             ; 함수 종료

스택 프레임의 어셈블리 코드를 보면 대략 위와 같은 구조를 갖습니다.

스택 프레임을 사용하는 이유

독립적인 공간 보장

함수가 호출되면 새로운 스택 프레임이 생성되고 해당 함수의 지역 변수와 매개변수, 복귀 주소를 저장할 수 있습니다. 이를 통해 함수는 전달된 매개변수와 내부에서 선언된 지역 변수에 접근하여 사용할 수 있습니다. 스택 프레임이 없는 경우 동일한 메모리 공간을 사용하게 되거나 덮어씌워지는 등 예상치 못한 오류가 발생할 수 있습니다.

최적화 옵션

일부 컴파일러는 최적화 옵션에 따라 함수의 스택 프레임을 생성하지 않을 수도 있습니다.

재귀 함수의 경우 새 스택 프레임을 계속 생성

재귀 함수는 자신이 호출될 때마다 새로운 스택 프레임을 생성하기 때문에 깊이가 깊어질 수록 스택 오버플로우StackOverflow가 발생할 수 있습니다. 일부 컴파일러의 옵션에 따라 스택 프레임 생성 대신 반복문으로 변환되거나 꼬리 호출 최적화되기도 합니다.

실습

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;
}

매개변수로 넘겨 받은 ab의 값을 더해서 출력하는 굉장히 간단한 예제의 프로그램이자 스택 프레임 구조를 이해할 수 있는 좋은 프로그램이기도 합니다.

IMAGE1-1
이미지 1-1

x64dbg로 StackFrame.exe 파일을 열고 0x401000 주소로 이동해주세요.(1)

  1. Ctrl+G 로 이동할 수 있습니다.

아직 어셈블리어에 익숙하지 않아서 코드를 분석하고 이해하는데 힘들겠지만, 천천히 한줄씩 자세히 알아가보며 배우도록 합시다.

메인 함수와 스택 프레임

1
2
3
4
// ...
int main(int argc, char* argv[]) {
  // ...
}

코드 흐름 순서에 따라 메인 함수부터 살펴봅시다.

IMAGE1-2
이미지 1-2

메인 함수의 주소 0x401020에 브레이크 포인트(BP, BreakPoint)를 설치한 후 F9 를 눌러 실행해주세요.

IMAGE1-3
이미지 1-3

레지스터 창을 확인하시면 ebp 레지스터의 값은 19FF70이고, esp 레지스터의 값은 19FF2C입니다.(1)

  1. 실행 환경에 따라 주소와 값이 다르게 보일 수 있습니다.
IMAGE1-4
이미지 1-4

스택 창에서 esp 레지스터에(19FF2C) 저장된 값 401250은 메인 함수의 실행이 끝난 후 돌아갈 복귀 주소(Return Address)입니다.

push ebp      ; 0x401020

메인 함수는 호출되자마자 스택 프레임을 생성하고 있습니다.

push 명령은 스택에 값을 삽입하는 명령으로, ebp 레지스터의 값을 스택에 삽입(추가)하라는 뜻입니다. 스택에 삽입한 이유는 ebp 레지스터의 값을 백업하기 위해서입니다. 이후 메인 함수 종료 시 ebp 레지스터의 값을 pop ebp 명령으로 다시 복구합니다.

mov ebp, esp

mov 명령은 데이터를 옮기는(할당하는) 명령으로, esp 레지스터의 값을 ebp 레지스터에 옮기라는 뜻입니다. ebp 레지스터는 esp 레지스터의 값을 가지게 되고, 메인 함수 내에서 ebp 레지스터를 통해 지역 변수와 매개변수에 접근하겠다는 뜻이 됩니다. 위 두 명령에 의해서 스택 프레임이 생성되었습니다.

mov ebp, esp 명령까지 실행한 후 레지스터 창에서 espebp 레지스터의 값을 확인해보세요. 서로 동일한 값을(19FF28) 갖고 있고, 레지스터가 갖고 있는 값을 스택 창에서 확인해보면 19FF70이라는 값이 저장되어 있습니다. 이 값은 메인 함수가 호출될 때(시작될 때) 갖고 있던 ebp 레지스터의 초기값입니다.

지역 변수

long a = 1, b = 2;

메인 함수에서 선언된 지역 변수 ab가 스택 메모리에 어떻게 생성되고 관리되는 지 잘 살펴봅시다.

sub esp, 8      ; 0x401023

sub 명령은 값을 빼라는 명령입니다. 위 코드를 해석하면 esp 레지스터의 값을 8만큼 빼라는 뜻이 됩니다.

현재 esp 레지스터의 값은 19FF28입니다. 여기서 esp 레지스터의 값을 8만큼 빼는 이유는 뭐 때문일까요?

우리는 메인 함수에서 long 타입의 변수 ab 변수를 두 개 선언하였습니다. long 타입은 4 Bytes의 크기를 갖고 있죠. 이 두 변수를 스택에 저장하기 위해 4 + 4 = 8 Bytes가 필요하니 8만큼 빼는 것입니다.

ebp가 아닌 esp에서 빼는 이유는 뭐죠?

ebp는 함수의 지역 변수와 매개변수 접근에 사용해야 하고, 지역 변수의 공간 확보는 esp 레지스터를 통해 이루어지기 때문입니다.

esp 레지스터는 스택의 현재 위치(최상위)를 가리키고 있습니다. 이를 통해 함수에서 필요한 메모리 공간을 할당하여 사용할 수 있고, 사용이 끝났을 때(함수의 종료) esp를 다시 원래의 위치로 복구해야 합니다.

ebp 레지스터는 함수 시작 시(스택프레임 생성 후)에만 값이 설정되고 이후에는 변경되지 않습니다. 즉, 변경되지 않기 때문에 이를 기준점으로 스택 메모리에 접근하여 함수 내 지역 변수와 매개변수에 접근하여 사용할 수 있는 것입니다.

; 0x401026 ~ 0x40102D
mov dword ptr ss:[ebp-4], 1       ; [ebp-4]: long a
mov dword ptr ss:[ebp-8], 2       ; [ebp-8]: long b

dword ptr ss:[ebp]가 무슨 뜻인지 잘 모르실겁니다. 생각보다 기괴하게 생겨서 이해하기 어렵게 생겼죠.

mov는 앞에서 이미 설명을 했으니 생략합니다.

dword ptr에서 dword는 데이터의 유형을 나타내는데, Double Word의 약자이고 4 Bytes의 크기를 갖습니다. 뒤에 수식된 ptr은 포인터Pointer의 약자로, C 언어의 포인터 개념이 아닌 데이터의 접근 크기와 유형을 명확히하기 위해 사용합니다. 즉, dword ptr4 Bytes 크기의 데이터를 다룬다라는 의미입니다.

ss:는 스택 세그먼트StackSegment를 의미합니다. 메모리 접근 시 어떤 세그먼트를 사용할 지 지정할 수 있는데, ss:를 명시함으로써 스택 세그먼트를 사용하겠다고 알립니다.

[ebp-4][ebp-8]은 메모리 주소를 나타냅니다. 대괄호 기호([])로 묶여 있으면 메모리 주소에 접근한다고 생각하시면 됩니다. [ebp-4][ebp-8]ebp 레지스터의 값을 기준으로 4Bytes와 8Bytes 아래에 있는 위치를 가리킵니다. 우리가 sub esp, 8로 할당한 지역 변수 ab에 접근하는 것이죠.

12는 우리가 저장하려는(할당하려는) 값입니다. 각각 [ebp-4][ebp-8] 위치에 값이 저장되죠.

ptr이 없으면 오류

ptr은 앞에 수식된 데이터의 크기만큼 메모리에서 가져오거나 저장할 때 사용하겠다고 알리는 것이기 때문에 메모리 접근이 필요할 때 반드시 명시해야 합니다.

mov eax, ebx는 레지스터의 크기가 명확하기 때문에 ptr을 생략할 수 있습니다. mov eax, [ebp-4]도 마찬가지입니다.

다만, mov [ebp-4], 1와 같이 1이라는 데이터가 어느 정도의 크기를 갖는 지 모호하기 때문에 mov dword ptr [ebp-4], 1와 같이 데이터의 유형을 명시적으로 표시해줘야 합니다. 메모리 주소 자체로 어느정도의 크기를 갖는 지 알 수 없죠.

ss:는 생략 가능

ss:는 스택 세그먼트를 명시적으로 표시한 것입니다.

대부분의 경우 기본 세그먼트가 이미 설정되어 있기 때문에 ss:를 생략할 수 있으며, mov dword ptr [ebp-4], 1과 같이 작성하여 사용하는 것이 가능합니다. 어차피 ebpesp는 스택을 가리키는 레지스터라 굳이 ss:를 수식하지 않아도 무방합니다.

위 두 명령을 실행하면 [ebp - 4](19FF24)와 [ebp - 8](19FF20)에 12라는 값이 저장되는 걸 확인할 수 있습니다.

add() 함수의 매개변수 입력과 호출

printf("%d\n", add(a, b));
1
2
3
4
5
6
; 0x401034 ~ 0x40103C
mov eax, dword ptr ss:[ebp - 8]       ; [EBP-8]: long b
push eax                              ; Arg2: 00000002
mov ecx, dword ptr ss:[ebp - 4]       ; [EBP-4]: long a
push ecx                              ; Arg1: 00000001
call 401000                           ; add()

call 4010000x401000 함수를 호출하고 있습니다. 0x401000에 있는 함수가 바로 add() 함수입니다.

add() 함수는 매개변수로 ab를 받는데, 위 어셈블리 명령을 보시면 변수 ab의 값을 스택에 삽입하고 있습니다. 신기한 점은 ab의 순서가 역순이라는 점입니다.

복귀 주소

call 명령을 실행되어 해당 함수의 내부로 진입하기 전에 CPU는 되돌아 올 복귀 주소(Return Address)를 스택에 저장합니다.

call 401000의 명령은 0x40103C 주소에 있고 그 다음에 수행할 명령은 0x401041 주소에 있습니다. 따라서 0x401000의 함수가 종료되면 0x401041 주소로 돌아와야 합니다. 이 주소가 바로 복귀 주소입니다.

call 401000의 명령을 실행한 후 스택 메모리를 확인해보면 0x401041 주소가 저장되는 걸 확인할 수 있습니다.

add() 함수의 스택 프레임

1
2
3
long add(long a, long b) {

}
push ebp
mov ebp, esp

메인 함수의 스택 프레임 생성과 동일합니다. 원래의 ebp 레지스터의 값을 스택에 저장하고, esp 레지스터의 값을 ebp 레지스터에 할당합니다.

메인 함수에서 사용되었던 19FF28이 스택에 삽입되고 ebp 레지스터에는 19FF10이 할당됩니다.

add() 함수의 지역 변수

long x = a, y = b;
1
2
3
4
5
sub esp, 8
mov eax, dword ptr ss:[ebp + 8]     ; Parameter a
mov dword ptr ss:[ebp - 8], eax     ; local x
mov ecx, dword ptr ss:[ebp + c]     ; Parameter b
mov dword ptr ss:[ebp - 4], ecx     ; local y

메인 함수에서 설명했던 것처럼 지역 변수 xy의 공간만큼 확보하고 [ebp - 8][ebp - 4]ab의 값을 할당합니다. [ebp - 8][ebp - 4]는 지역 변수 xy이고, [ebp + 8][ebp + c]ab입니다.

IMAGE1-5
이미지 1-5

스택 창에서 주소를 더블 클릭하면 왜 위와 같이 ebp에 값을 더하고 빼서 접근하는 지 이해할 수 있습니다.

add() 함수의 연산

return (x + y);
mov eax, dword ptr ss:[ebp - 8]
add eax, dword ptr ss:[ebp - 4]

지역 변수 x의 값을([ebp - 8]) eax 레지스터에 할당합니다.

add 명령은 더하기 명령으로, eax 레지스터의 값과 [ebp - 4] 메모리 주소에 있는 값(지역 변수 y)을 더해 eax 레지스터에 할당합니다. 1과 2를 더하고 저장하기 때문에 eax 레지스터에는 최종적으로 3이라는 값이 저장됩니다.

eax 레지스터는 함수의 반환값 용도로 사용되기 때문에 eax 레지스터에 값이 저장되었습니다.

add() 함수의 스택 프레임 제거와 종료

return (x + y);
1
2
3
mov esp, ebp
pop ebp
ret

ebp 레지스터의 값을 esp 레지스터에 할당하여 원래의 esp 레지스터의 값으로 복원합니다. 그리고 pop ebp를 통해 원래의 ebp 레지스터의 값도 스택에서 복구합니다.

add esp, 8 연산은 왜 없나요?

스택 프레임을 생성하고 제거하는 과정에서 sub esp, 8으로 스택 공간을 확보했으면 add esp, 8과 같은 명령으로 원래의 공간을 다시 되돌리는 게 맞습니다. 하지만 모든 경우에 이렇게 하는 건 아닙니다. 그냥 조금 더 효율적으로 하기 위해 mov esp, ebp 명령을 통해 원래의 값을 한 번에 복구하여 해제하는 것뿐입니다. 이 명령을 실행하게 되면 해당 함수의 지역 변수에 접근할 수 없게됩니다.

할당한 공간을 정확히 사용했다면 add esp, 8을 해도 되지만 굳이 추가해서 추가적인 연산을 하는 것보다 mov esp, ebp로 한 번에 처리하는 게 더 낫습니다.

ret 명령을 수행하면 스택에 저장되어 있는 0x401041 주소로 복귀합니다.

add() 함수의 매개변수 제거

add esp, 8

드디어 메인 함수로 돌아왔습니다. 0x401041 주소에 위와 같은 어셈블리 명령이 작성되어 있습니다.

왜 갑자기 esp 레지스터에 8을 더하는 걸까요?

특별한 이유는 없고 add() 함수로 전달한 ab 지역 변수를 더 이상 사용할 일이 없으니 add() 함수 호출 전에 sub esp, 8로 할당한 공간을 다시 제거하는 것 뿐입니다.

printf() 함수 호출

printf("%d\n", add(a, b));
1
2
3
4
push eax          ; 3
push 40B384       ; "%d\n"
call 401067       ; print()
add esp, 8

eax 레지스터에는 add() 함수의 반환값 3이 저장되어 있습니다. 401067printf() 함수로 내용이 방대하기 때문에 여기선 다루지 않겠습니다.

printf() 함수의 매개변수는 두 개이고 크기는 총 8 Bytes(32Bit 레지스터 + 32Bit 상수)입니다. 따라서 add esp, 8printf() 함수 호출 후 스택을 정리합니다.

반환값 설정

return 0;
xor eax, eax

메인 함수의 반환값 0을 설정하는 명령입니다. xor 명령은 같은 값끼리 연산하면 0이 되기 때문에 xor 명령을 작성합니다.

메인 함수의 스택 프레임 해제와 종료

1
2
3
mov esp, ebp
pop ebp
ret

add() 함수에서 설명했던 것처럼 스택 프레임을 해제하고 메인 함수가 종료되었습니다. 메인 함수가 종료되며 복귀 주소인 0x401250으로 이동합니다. 이 주소부터는 Stub Code이고, 계속 따라가다 보면 프로세스 종료 코드를 실행합니다.