C언어의 스택 프레임에 이어서 리버싱을 통한 스택 프레임을
공부해보려고 한다. 어셈블리 단에서는 여러가지 포인터 레지스터를 사용하는데,
우선 포인터 레지스터에 관련해서도 같이 공부해보려고 한다.
✔️ 레지스터?
어셈블리, 리버싱, 스택 프레임 등 여러가지 자료에 관련해서 공부를 하다보면
포인터 레지스터, 범용 레지스터, 세그먼트 레지스터 등 레지스터에 관련한
여러가지 내용과 자료들이 나오게 된다.
레지스터 자체의 의미로는 CPU가 요청을 처리할 때 필요한 데이터를
일시적으로 저장하는 기억장치라고 보면 되는데, 해당 레지스터를
다양한 종류와 역할로 나누어 구분지을수 있다.
보통 우리가 사용하는 운영체제의 아키텍처는 x86 아키텍쳐로 되어있는데,
해당 내용은 다음 주소를 참고하였다.
x86 아키텍쳐는 8개의 범용 레지스터와 6개의 세그먼트 레지스터,
그리고 1개의 플래그 레지스터와 명령어 포인터를 갖는다고 한다.
✔️ 범용 레지스터
① AX (Accumulator register) : 산술 연산에 사용된다.
② CX (Counter register) : 시프트와 회전 연산 그리고 루프에 사용된다.
③ DX (Data register) : 산술 연산과 I/O 명령에 사용된다.
④ BX (Base register) : 데이터의 주소를 가리키는 포인터로 사용된다.
⑤ SP (Stack Pointer register) : 스택의 최상단을 가리키는 포인터로 사용된다.
⑥ BP (Stack Base Pointer register) : 스택의 베이스를 가리키는 포인터로 사용된다.
⑦ SI (Source Index register register) : 스트림 명령에서 소스를 가리키는 포인터로 사용된다.
⑧ DI (Destination Index register) : 스트림 명령에서 도착점을 가리키는 포인터로 사용된다.
위에 나온 순서는 스택의 삽입 명령에서 사용되는 순서이고,
모든 레지스터는 16비트와 32비트에서 모두 접근이 가능하다고 한다.
16비트는 두글자로, 32비트는 두글자에 E(extended)를 붙여 표시한다.
또 다른 64비트에서는 E가 아닌 R을 사용한다고 한다.
1번부터 4번까지, AX부터 BX까지는 16비트 레지스터를 두 개의 8비트 레지스터로
접근할 수 있는데, 낮다는 표시는 L을 높다는 표시는 H를 사용하여
AH와 AL로 나타낼 수 있고 두 레지스터를 합치면 AX로 표시하게 되는 것이다.
✔️ 세그먼트 레지스터
① SS (Stack Segment) : 스택을 가리킨다.
② CS (Code Segment) : 코드를 가리킨다.
③ DS (Data Segment) : 데이터를 가리킨다.
④ ES (Extra Segment) : 추가적인 데이터를 가리킨다.
⑤ etc : 많은 추가적인 데이터를 가리키는 FS (F Segment)와
더 많은 추가적인 데이터를 가리키는 GS (G Segment)가 존재한다.
✔️ 플래그 레지스터
플래그 레지스터(EFLAGS)는 프로세서의 작동 결과와 상태를 저장하기 위해서
부울 값들로 이루어 사용하는 32비트 레지스터이다.
그리고 이러한 레지스터에 사용되는 비트는 다음과 같으며
비트의 이름이 0과 1이면 예약된 비트들로 수정될 수 없다고 한다.
0) CF (Carry Flag) : 마지막 연산이 레지스터 크기에 추가되거나 빼기로 세팅된다.
레지스터를 포함하며 큰 값을 처리하는 추가 혹은 빼기 작업을 수행할 때 이 값이 확인된다.
2) PF (Parity Flag) : 최하위 바이트의 비트 수가 2의 배수면 세팅한다.
4) AF (Adjust Flag) : BCD(Binary Code Decimal) 넘버의 연산을 전달한다.
6) ZF (Zero Flag) : 연산의 결과가 0일 경우 세팅한다.
7) SF (Sign Flag) : 연산의 결과가 음수일 경우 세팅한다.
8) TF (Trap Flag) : 스텝 바이 스텝으로 디버깅을 세팅한다.
9) IF (Interruption Flag) : 인터럽트가 가능한 경우 세팅한다.
10) DF (DirectionFlag) : 세팅된 경우 문자열 연산은 포인터를 감소하여 메모리를 뒤로 읽는다.
11) OF (Overflow Flag) : 서명된 연산으로 레지스터에 포함하는 값이 너무 클 경우 세팅한다.
12~13) IOP : I/O 권한 레벨 필드로 2비트에 해당하고, 현재 프로세스의 I/O 권한 레벨을 의미한다.
14) NT (Nested Task Flag) : 인터럽트의 체이닝을 제어하고,
현재 프로세스가 다음 프로세스에 연결되면 세팅한다.
16) RF (Resume Flag) : 디버그 예외에 대한 응답이다.
17) VM : Virtual-8086 모드로 8086 호환성 모드를 세팅한다.
18) AC (Alignment Check) : 메모리 참조 정렬에 대한 확인을 하면 세팅한다.
19) VIF (Virtual Interrupt Flag) : IF의 가상 이미지이다.
20) VIP (Virtual Interrupt Pending Flag) : 인터럽트가 보류 중일때 세팅한다.
21) ID (Identification Flag) : 세팅할 수 있으면 CPUID 명령을 서포트한다.
✔️ 명령어 포인터
분기가 수행되지 않으면 EIP 레지스터가 Next로 넘어갈 명령어의 주소를
가지고 있게 되고, EIP는 CALL 명령어를 사용하여 스택을 통해서만 읽을 수 있다.
여기서 나오는 EIP란 Extended Instruction Pointer의 약자로
확장된 명령 지시자라는 이름을 가지고 있다. 현재 실행하고 있는 명령어가
종료되면 EIP 레지스터에 있는 명령어가 실행된다고 한다.
✔️ 산술 및 논리 명령어
이러한 레지스터를 가리키면서 연산을 수행하고, 함수를 호출하고
분기문을 만나서 분기하는 등에 관련한 명령어들을 확인할 수 있다.
(참고 : https://www.cs.virginia.edu/~evans/cs216/guides/x86.html)
① ADD : 정수를 더하는 명령어로 두 개의 피연산자를 더해 첫 번째에 저장한다.
② SUB : 정수를 빼는 명령어로 첫 번째 피연산자에서 두 번째를 뺀 값을 첫 번째에 저장한다.
③ INC(DEC) : 증감 명령어로 INC는 1씩 증가를, DEC은 1씩 감소를 수행한다.
④ IMUL : 정수를 곱셈하는 명령어로 두 피연산자를 곱하고 첫 번째에 저장한다.
⑤ IDIV : 정수를 나누는 명령어로 EDX:EAX의 내용을 지정된 피연산자 값으로 나누어
나눗셈의 몫은 EAX에 나머지는 EDX에 저장한다.
⑥ AND, OR, XOR : 비트 논리 연산으로 지정된 논리 연산을 수행하여 첫 번째에 저장한다.
⑧ NOT : 반대로 뒤집는, 피연산자의 모든 비트 값을 뒤집는다.
⑨ NEG : 부정으로 피연산자 내용의 2의 보수 부정을 수행한다.
⑩ SHL, SHR : 왼쪽과 오른쪽으로 비트를 이동하고 빈 비트 위치는 0으로 채운다.
⑪ JMP : 피연산자가 가리키는 메모리 위치로 프로그램 제어 흐름을 전송한다. (분기한다)
⑫ Jcondition (JE, JNE, JZ, JG, JGE, JL, JLE)
- JE는 같을 때 점프한다. (Equal, ==)
- JNE는 같지 않을 때 점프한다. (Not Equal, !=)
- JZ는 마지막 결과가 0일 때 점프한다. (Zero)
- JG는 클 때 점프한다. (Greater, >)
- JGE는 크거나 같을 때 점프한다. (Greate or Equal, >=)
- JL은 작을 때 점프한다. (Less, <)
- JLE는 작거나 같을 때 점프한다. (Less or Equal, <=)
⑬ CMP : 비교 명령어로 두 피연산자의 값을 비교하여 다음 로직에
JMP 등과 같은 분기문 등을 활용한 조건 코드를 작성한다.
⑭ CALL, RET : 서브루틴을 호출, 반환하는 명령어로 피연산자가 나타내는 코드의 위치로
무조건적인 점프를 수행한다. 점프와는 다르게 서브루틴이 완료될 때 돌아갈 위치를 저장하고,
RET를 수행하여 다시 돌아가게 된다.
✔️ 앞선 명령어와 레지스터를 통한 스택 프레임 이해
PUSH EBP
MOV EBP, ESP
...
...
MOV EAX, 0
LEAVE
RETN
해당 코드처럼 생긴 형태의 스택 프레임의 구조를 이해할 수 있다.
스택 프레임은 스택 포인터인 ESP가 아닌 베이스 포인터 EBP 레지스터를 활용해서
스택의 지역변수와 파라미터, 반환 주소값 등에 접근하게 되고 함수가 시작되기 전에
EBP를 저장하여 여러가지 값들에 접근하게 된다.
그리고 나서 여러가지 연산 이후에 RETN을 통해 복귀 주소로 돌아가게 된다.
#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;
}
해당 소스코드 위의 접은글에서 볼 수 있는데, a와 b라는 변수를 받아서
덧셈하는 함수 add를 호출하고 해당 내용을 출력하게 된다.
위 과정을 스택 프레임에서의 구조를 확인하기 위해 올리디버거로 분석해보자.
해당 프로그램을 올리디버거에 올린 첫화면이다.
F8번을 누르면서 진행해보다보면 0040151C 주소를 CALL로 호출하는 부분에서
위의 코드에서 더해준 1+2의 값인 3이 값으로 던져지게 되는데,
해당 부분을 바로 시작하자마자 찾아볼 수 있다. F9를 눌러 브레이크 포인트로 가보자.
앞서 공부했던 내용대로 함수 호출을 하기 전인 EBP 주소를 저장하게 된다.
그리고 ESP 값을 EBP에 저장하여 함수 내 변수는 여러가지 연산에 접근하게 된다.
F8을 눌러 진행하게 되면 처음 EAX 값에 1이 들어있고,
MOV ESP+4, EAX 부분에서 EAX 값에 2가 들어오는 것을 확인할 수 있다.
그리고나서 MOV ESP, EAX 부분에서 EAX에 다시 1을 넘겨받고,
00401549 에서 CALL 00401500 부분을 호출하고 바로 넘겨 받는 값을
EAX에 3으로 저장해준다.
그리고나서 printf 함수를 통해 값이 출력되는 것을 볼 수 있다.
그렇다면 1과 2를 EAX에 넣고 호출한 00401500 부분은 어떻게 생겼을까?
해당 부분에 브레이크 포인트를 걸고 살펴보면 다음과 같다.
함수가 호출되면 스택 프레임이 생성되고 EBP를 저장한 다음 수행하는
여러가지 연산들을 마치면 RETN을 통해 복귀 주소로 돌아가면서
스택 프레임을 해제하는 모습을 볼 수 있다.
시작되는 지점에 62FEA8이라는 EBP를 PUSH 하는 모습을 볼 수 있는데,
연산을 수행하고 나서 EAX에 3값이 들어있는 것을 볼 수 있고
RETN 복귀 주소로 바로 위에서 PUSH한 62FEA8 값에 해당하는 EBP를 받는 것을 볼 수 있다.
그리고나서 바로 CALL을 빠져나와서 다음 부분부터 진행하게 된다.
이렇게 모든 구조를 봤을 때, 40151C 부터는 main 함수가 될 것이고
EAX에 a = 1과 b = 2라는 변수가 담겨서 add 함수를 정상적으로 호출한 것이 된다.
add 함수는 401500으로 CALL 명령어를 통해 호출하게 되고,
생성된 스택 프레임 내에서 x와 y 변수로 선언해준 곳에 각각 값을 할당하게 되고,
할당된 값은 return (x+y) 코드에 해당하는 ADD EAX, EDX 부분을 통해서
EAX에 1과 2를 더한 값인 3이 저장되고 스택 프레임이 해제되는 것이다.
그리고 나서 RETN을 통해 저장해두었던 EBP의 복귀 주소로 복귀하고 나서
printf 함수 구문을 수행하게 되고 return 0을 통해 프로그램이 정상 종료되게 된다.
이렇게 리버싱을 통해 살펴본 스택 프레임을 기억하면서
스택 오버플로우 실습에 관하여 진행해보는 것도 좋을 것 같다.
화이팅 💪
'보안 > 리버싱' 카테고리의 다른 글
[리버싱] 어셈블리의 재귀 (0) | 2022.10.17 |
---|---|
[리버싱] 어셈블리의 구구단 (0) | 2022.10.17 |
[리버싱] 어셈블리 기초 (2) (0) | 2022.10.14 |
[리버싱] 어셈블리 기초 (1) (0) | 2022.10.14 |
리버싱 기초 공부. 01 (0) | 2021.09.29 |