프로그램이 실행되는 동안 함수가 호출될 때마다 메모리 관리가 효율적으로 이루어져야 한다. 이를 위해 대부분의 프로그래밍 언어와 컴파일러는 '프롤로그(Prologue)'와 '에필로그(Epilogue)'라는 특별한 코드 시퀀스를 사용하여 스택 메모리를 체계적으로 관리한다. 이 글에서는 이러한 메커니즘을 설명합니다.
스택 프레임(Stack Frame)은 함수가 호출될 때 스택에 생성되는 메모리 영역으로, 해당 함수만의 고유한 작업 공간이다. 스택 영역은 함수의 호출과 함께 할당되며, 함수의 호출이 완료되면 소멸한다.
스택 프레임에는 다음과 같은 정보가 포함된다.
스택은 후입선출(LIFO: Last-In-First-Out) 구조로 동작한다. 가장 나중에 저장된 데이터가 가장 먼저 인출되는 방식이다. 함수가 호출될 때마다 새로운 스택 프레임이 스택의 상단에 쌓이고, 함수 실행이 완료되면 그 스택 프레임이 제거된다.
예를 들어, main() 함수에서 func1()을 호출하고, func1()에서 func2()를 호출하는 경우를 생각해본다.
1. 먼저 main() 함수의 스택 프레임이 생성된다.
2. func1() 호출 시 func1()의 스택 프레임이 그 위에 쌓인다.
3. func2() 호출 시 func2()의 스택 프레임이 가장 위에 쌓인다.
4. func2() 종료 시 해당 스택 프레임이 제거된다.
5. func1() 종료 시 해당 스택 프레임이 제거된다.
6. main() 종료 시 해당 스택 프레임이 제거되고 프로그램이 종료된다.
스택 프레임을 관리하는 데 핵심적인 역할을 하는 레지스터가 있다.
현재 스택 프레임의 기준점이 되는 레지스터이다. 함수가 실행되는 동안 고정된 값을 유지하여 지역 변수와 매개변수에 일관된 접근을 가능하게 한다.
스택의 가장 꼭대기를 가리키는 레지스터이다. 스택이 변할 때마다 값이 변화하며, push와 pop 연산이 이 위치에서 발생한다.
CPU가 다음에 실행할 명령어의 주소가 저장된 레지스터이다. 함수 반환 시 이 레지스터에 적절한 반환 주소가 로드되어 실행 흐름이 원래 위치로 돌아간다.
프롤로그(Prologue)는 함수가 호출되어 실행을 시작할 때 수행되는 과정으로, 새로운 스택 프레임을 설정한다.
push ebp ; 이전 함수의 베이스 포인터를 스택에 저장
mov ebp, esp ; 현재 스택 포인터 값을 베이스 포인터에 복사
Push ebp
Mov ebp, esp
지역 변수 공간 할당
sub esp, X
명령어로 필요한 만큼 스택 공간을 할당한다.에필로그(Epilogue)는 함수 실행이 완료되어 호출했던 함수로 돌아가기 전에 수행되는 과정으로, 스택을 원래 상태로 복원한다.
leave ; 스택 프레임 정리 (mov esp, ebp와 pop ebp의 조합)
ret ; 함수 반환 (pop eip와 jmp eip의 조합)
leave 명령어는 내부적으로 다음과 같은 두 명령어로 구성된다.
ret 명령어는 내부적으로 다음과 같은 두 명령어로 구성된다.
PE(Portable Executable) 파일은 Windows 운영체제에서 사용되는 실행 파일 형식이다. 종류는 다음과 같다.
PE 파일은 크게 PE 헤더와 PE 바디로 구성된다.
PE 헤더
PE 바디
PE 파일이 외부 DLL 함수를 사용할 때는 다음과 같은 메커니즘이 작동한다.
스택 오버플로우는 스택 메모리 영역의 경계를 넘어서 데이터가 쓰여질 때 발생하는 보안 취약점이다. 함수의 재귀 호출이 무한히 반복되거나 대량의 지역 변수가 할당될 경우 발생할 수 있다. 스택의 모든 공간을 차지한 후에도 데이터가 계속 쌓이면, 스택 영역을 넘어서 메모리에 접근하게 된다. 이는 프로그램 오동작이나 보안 취약점으로 이어질 수 있다.
함수 에필로그에서 ret 명령어 실행 시 스택에서 pop되는 리턴 주소를 조작하면 공격자가 원하는 코드를 실행할 수 있다. 버퍼 오버플로우 취약점 등을 통해 스택에 저장된 리턴 주소를 악의적인 셸 코드 주소로 변경할 수 있다. 이 때문에 스택 보호 메커니즘(스택 쿠키, ASLR 등)이 중요하다.
프로그램이 외부 라이브러리 함수를 호출할 때 사용하는 메커니즘이다.
64비트 시스템에서는 레지스터 이름이 변경된다.
또한 매개변수 전달 방식이 다르고, 스택 프레임 구조에도 약간의 차이가 있다.
Direct EIP Overwrite는 가장 기본적인 버퍼 오버플로우 공격 기법이다. 이 방식은 버퍼의 크기를 초과하는 데이터를 입력하여 스택에 저장된 반환 주소(Return Address)를 조작하는 방식으로 작동한다. 공격자는 EIP 레지스터가 가리키는 값을 자신이 원하는 주소로 덮어써서 프로그램의 실행 흐름을 변경한다.
Trampoline 기법은 쉘 코드의 주소를 직접 찾아 실행 흐름을 바꾸는 대신 중간 다리 역할을 하는 명령어를 활용하는 방식이다. 원리는 다음과 같다:
특히 쉘 코드의 주소를 직접 알기 어려운 ASLR이 적용된 환경에서 유용한 기법이다. TEB(Thread Environment Block)나 PEB(Process Environment Block)를 통해 동적으로 주소를 찾는 방식을 사용한다.
SEH(Structured Exception Handling) Overwrite는 윈도우의 예외 처리 메커니즘을 악용한 공격 기법이다. 이 기법의 작동 원리는 다음과 같다:
SEH의 구조는 다음과 같이 구성된다:
SEH Overwrite 공격이 가능한 이유는 윈도우 시스템의 SEH가 예외 처리기 등록 구조체를 스택에 위치시키기 때문이다. SafeSEH가 도입된 후에도 SafeSEH가 적용되지 않은 모듈을 찾거나 다른 우회 방법을 사용하여 공격할 수 있다
Windows 7 이상의 운영체제에서는 ASLR(Address Space Layout Randomization) 보안 기능으로 인해 kernel32.dll의 상위 2바이트 주소가 부팅할 때마다 변경된다. 이로 인해 쉘코드에서 WinAPI 함수를 하드코딩하여 사용할 수 없다. 따라서 실행 중에 동적으로 함수의 주소를 찾아내는 Universal 쉘코드 기법이 필요하다.
Universal 쉘코드의 핵심은 TEB(Thread Environment Block)와 PEB(Process Environment Block)를 활용하여 함수의 주소를 동적으로 찾는 것이다. 과정은 다음과 같다:
mov ebx, [fs:0x30] ; PEB 주소 획득
mov ebx, [ebx + 0x0c] ; PEB_LDR_DATA 주소 획득
mov ebx, [ebx + 0x14] ; InMemoryOrderModuleList 주소 획득
mov ebx, [ebx] ; ntdll.dll 엔트리 획득
mov ebx, [ebx] ; kernel32.dll 엔트리 획득
mov ebx, [ebx + 0x10] ; kernel32.dll 베이스 주소 획득
kernel32.dll의 베이스 주소를 찾은 후에는 PE 헤더의 Export Directory를 탐색하여 원하는 함수의 주소를 찾을 수 있다:
이러한 기법을 활용하면 Windows 버전에 관계없이 작동하는 쉘코드를 작성할 수 있다. 특히 WinExec, CreateProcess와 같은 유용한 API 함수를 호출하여 더 복잡한 쉘코드를 작성할 수 있다.