어셈블리어 속 main함수의 프롤로그와 에필로그 (레지스터, 스택, 메모리 구조와 함께 알아보자)
어셈블리어 코드를 분석하기 위해 몇 가지 알아야 할 cs지식이 있다.
레지스터와 스택, 메모리구조이다. 이 3가지를 어느 정도 이해해야 어셈블리어 코드와 c언어 코드를 일대일 대응해가며 분석할 수 있다.
우선 레지스터부터 설명해보겠다.
범용 레지스터 (x32)
상수/주소 저장, 4바이트(32bit)
EAX | Accumulator, 산술논리연산을 수행하며 함수의 리턴값이 이 레지스터에 저장된다. 호출 함수의 성공 여부, 실패 여부를 쉽게 파악할 수 있다. |
EBX | Base address, 메모리 주소 저장 용도 |
ECX | Counter, 반복 카운터 (반복할 횟수 저장, 반복 작업 수행) |
EDX | Data, EAX와 같이 쓰이고 부호 확장 명령 등에 쓰인다. 큰 수의 곱셈/나눗셈 등의 연산이 이루어질 때 |
EBP | Base Pointer, 스택 프레임의 시작 지점 주소(스택의 가장 윗 부분, 처음). 현재 사용되는 스택 프레임이 소멸되지 않는 이상 변하지 않는다. 함수가 호출되었을 때 그 순간의 ESP를 저장하고 있다가, 함수가 리턴하기 직전 다시 ESP에 값을 되돌려주어 스택이 깨지지 않게 한다. (Stack Frame 기법) |
ESP | Stack Pointer, 스택 프레임의 끝 지점 주소(스택의 가장 아랫부분, 마지막). PUSH, POP 명령에 따라 ESP의 값이 4바이트씩 변한다. |
ESI | Source Index, 데이터를 조작하거나 복사 시에 소스 데이터의 주소가 저장 |
EDI | Destination Index, 복사 시의 목적지의 주소가 저장 |
cf) ECX, EDX에 중요한 값이 있다면 Win32 API(내부에서 사용) 호출 전 다른 레지스터/스택에 백업해야 한다.
레지스터는 변수다. CPU가 사용하는 변수다.
EAX, ECX 등은 용도에 상관없이 피연산자를 담는 바구니 역할을 할 때가 더 많다. 이 점 꼭 확인하기!
프로그램 상태/컨트롤 레지스터
- EFLAGS (Flag Register)
- 각 비트는 1 또는 0의 값을 가진다.
- 조건 분기 명령어에서 이들의 값을 확인하고 그에 따라 동작 수행 여부를 결정
- ZF (Zero Flag) : 연산 명령 후 결과 값이 0이 되면 1
- OF (Overflow Flag) : 부호 있는 수의 오버플로 발생, MSB 변경될 때 1
- CF (Carry Flag) : 부호 없는 수의 오버플로 발생하면 1
- EIP (Instruction Pointer) : CPU가 처리할 명령어의 주소를 나타냄EIP 값은 직접 변경할 수 없다. 다른 명령어를 통해 간접 변경해야 함 (범용과 다른 점)
- EIP에 저장된 메모리 주소의 명령어를 하나 처리하면 자동으로 그 명령어 길이만큼 EIP 증가
이전 게시물에 스택 프레임에 관한 내용을 작성했지만 한 번 더 언급해 보겠다.
이번에는 스택 프레임의 동작 방식에 초점을 맞추어 설명하겠다.
스택 프레임
ESP값은 프로그램 안에서 수시로 변경된다.
어떤 기준 시점(함수 시작)의 ESP값을 EBP에 저장하면 EBP를 기준으로 해당 함수의 변수, 파라미터, 복귀 주소에 안전하게 접근 가능!
ESP의 변화 (스택의 동작 방식)
- PUSH : ESP는 위쪽 방향으로 이동(ESP의 값이 4바이트만큼 감소)
- POP : ESP는 아래 방향으로 이동(ESP의 값이 4바이트만큼 증가)
- 스택 포인터의 초기 값은 스택 메모리의 아래쪽에 있다.
바이트 오더링
= 데이터 저장 방식
- 빅 엔디언 : 데이터를 순서대로 저장 (예: 01 23 45 67, 네트워크 프로토콜에 사용)
- 리틀 엔디언 : 데이터를 역순으로 저장, Intel x86에서 사용
스택에 파라미터를 전달하는 방법십
실제 C 소스코드에서 함수에 넘기는 파라미터의 순서(→)가 어셈블리에서는 역순이다.
이유는 스택 메모리 구조 LIFO 때문! 역순으로 넣어주면 함수가 꺼낼 때는 순방향이 된다.
이제 어셈블리어 환경에서 함수 내부에 들어가면 존재하는 공통적인 부분을 알아보겠다.
초반 시작할 때 있어서 프롤로그, 마무리 부분에 있어서 에필로그라고 한다.
함수 프롤로그 (main 함수의 새로운 기준점)
PUSH EBP | 베이스 포인터가 이전에 가지고 있던 값을 스택에 백업 |
MOV EBP, ESP | ESP의 값을 EBP로 옮겨라 (이젠 EBP == ESP). main 함수 끝날 때까지 고정 |
main함수 스택이 삭제되면 이전 함수 스택의 기준점으로 복귀하기 위해 ebp에 저장되어 있는 ‘이전 함수의 ebp값을 스택에 백업한다.
그리고 esp에 저장된 현재 스택의 메모리 주소값을 ebp에 저장하여 ebp위치를 현재 esp위치로 만든다.
지역 변수 확보: SUB ESP, 8
ESP가 인자1로 올 경우, 스택 공간을 확보한다는 뜻이다.
스택에는 지역 변수, 함수 인자값, 리턴 주소들이 저장된다.
미리 8바이트의 스택 공간을 확보하는 것 (int a와 b(지역변수)를 위해.. int는 4바이트씩)
스택 정리: ADD ESP, 8
ESP가 인자1로 올 경우, 사용했던 스택 공간을 정리한다는 뜻이다.
sub vs. add 정리
SUB [인자1], [인자2]
인자1 = 인자1 - 인자2
ESP가 인자1로 올 경우, 스택 공간을 확보한다는 뜻이다.
ADD [인자1], [인자2]
인자1 = 인자1 + 인자2
ESP가 인자1로 올 경우, 사용했던 스택 공간을 정리한다는 뜻이다.
다음은 c언어로 작성된 main함수를 어셈블리 했을 때 스택의 움직임을 그림으로 간단히 그려보았다.
int main(int argc, char* argv[])
{
int a = 6, b = 2;
printf("%d\n", add(a, b));
return 0;
}
- 함수 인자값은 스택에 오른쪽부터 저장되고, 변수는 선언 순서대로!
- PUSH 여러번 나오면 아~ 곧 함수 호출이 일어나겠군
- CALL add : add함수로 가!
LEA EAX, [EBP-56]
PUSH EAX CALL 0x8048360 <fgets>
LEA [인자1], [인자2]는 원래 [인자2]의 주소값을 [인자1]에 넣는다는 뜻이다.
특정 메모리나 레지스터의 값을 옮기기만 하는 MOV와는 달리,
LEA는 옮겨지는 값에 연산을 할 수 있다. 즉 실행 중에 계산된 값을 넣을 수 있다.
LEA EAX, [EBP+5] = ADD EBP, 5; MOV EAX, EBP
여기까지 함수 프롤로그 + 간단한 main 함수 구조 였다.
함수 에필로그
ADD ESP, 0x10
/ LEAVE /
MOV ESP, EBP : ESP를 EBP로 이동
POP EBP : 현재 ESP가 가리키는 값을 EBP로 설정함으로써, 현재 EBP가 이전 함수의 EBP로 복구된다.
→ 최종적으로 ESP는 EBP보다 4바이트 위로 올라가게 된다.
/ RET /
POP EIP : 현재 ESP가 가리키는 값을 EIP로 설정한다.
JMP EIP : 현재 EIP에 들어있는 값으로 EIP를 이동시킨다.