2021. 5. 18. 22:45ㆍLayer7/Layer7_Reverse Engineering
이 글은 어셈블리에 관한 글이다. 어셈블리가 무엇인지 정확히 모른다면 실행파일이 만들어지는 과정 글을 참고하기 바란다.
어셈블리
어셈블리어는 기계어와 1대 1 대응되는 컴퓨터 친화적(저수준) 언어이다. 사람이 작성하는 C언어와 CPU가 처리하는 기계어 사이의 중간 단계라 할 수 있다. 오로지 0과 1만으로 구성된 기계어 그대로를 사람이 읽고 이해하기 쉽게 문자로 바꾸어 표현하는 언어이다.
각 CPU 제조사마다 ISA(명령어 구조)가 다를 수 있기 때문에 당연히 기계어도 제조사마다 다를 것이고, 이에 따라 자연스럽게 어셈블리도 달라질 수밖에 없다.
ISA는 대표적으로 CISC와 RISC로 구분할 수 있고, CISC에는 x64, x86, RISC에는 ARM, MIPS 등이 있다. 이 글에서는 CISC에 속하는 x64에 대해서 알아볼 것이다.
x64
x64는 Intel과 AMD(CPU 시장의 대다수를 차지하는 두 회사)에서 개발한 64bit CPU에서 사용하는 ISA, 즉 명령어 처리 구조이다.
x64는 x86에서 파생된 ISA이므로 x64는 x86의 상위 버전이라 할 수도 있고, 당연히 x64는 x86을 완벽 지원한다.
x86-64, AMD64, Intel64는 같은 x64 ISA를 자신의 회사에서 조금씩 수정하고 사용하고 있는 아키텍처이다.
x64 레지스터
레지스터
레지스터는 CPU 내에서 사용하는 저용량 초고속 저장 장치이다. 레지스터는 매우 빠른 반면 속도에 용량이 반비례하여 저장할 수 있는 데이터의 양에 한계가 있으므로 CPU를 구동하는 데 꼭 필요한 데이터만 저장한다.
CPU가 처리하는 프로그램은 결국 기계어의 묶음이고, 기계어 하나하나를 실행하는 것이 목적이다. 이때 구동하는 데 꼭 필요한 데이터로는 현재 실행 중인 명령어의 주소 등이 있다.
레지스터의 구조
x64 아키텍처에서는 레지스터가 64bit, 즉 8바이트이다. 이 8바이트 단위를 다른 말로 QWORD(quad word)라고도 한다. 네 개의 WORD라는 뜻으로, 한 WORD의 크기는 2바이트이다. (WORD는...이다)
x86에서 사용하는 레지스터의 크기는 DWORD(한 WORD의 두 배, double word), 즉 32bit (4바이트, x64 레지스터의 반)이다.
x64 레지스터의 특징은 레지스터의 이름에 r이 붙었다는 것이다. (예: rax, rdx, rsi, rbp, r8, r10 등)
x86 레지스터는 e로 시작한다. (예: eax, edx, esi, esp 등)
x64가 x86을 호환한다고 했는데, 이는 x64 레지스터가 x86 레지스터의 두 배이기 때문이다. 따라서, x64 레지스터를 반으로 나누면 x86 크기의 공간 두 개를 얻을 수 있다.
DWORD(4바이트)를 다시 반으로 쪼개면 2바이트 크기의 WORD를 얻는다. (ax, bx, dx 등)
이 WORD의 상위 8bit, 즉 상위 1바이트에는 ah, bh처럼 h(high)가 붙고, 하위 1바이트에는 al, bl처럼 l(low)가 붙는다.
각각의 레지스터들이 정확히 어디에 사용되는지를 알아보자.
범용 레지스터
범용 레지스터는 용도가 제한되어 있지 않는 레지스터이다. 제한된 레지스터로는 뒤에서 알아볼 RBP, RSP, RIP 등이 있다. 이들은 각기의 사용 목적이 존재하고 그 목적으로만 사용이 가능하다.
범용 레지스터는 사용 용도가 엄격하게 정해져 있지 않지만, 대다수의 사람들이 사용하는 목적이 존재한다. 따르지 않는다고 해서 문제가 생기지 않지만, 가장 일반적으로 사용하는 것이라면 따르는 것이 좋겠죠?
RAX (Accumulator) - 산술 /논리 연산의 결괏값, 또한 함수가 종료될 때 함수의 반환(return) 값을 저장하는 레지스터이다.
RBX (Base) - 메모리 상의 주소를 저장하는 레지스터
RCX (Counter) - 반복문에서 카운터(i)로 사용되는 레지스터이다.
RDX (Data) - RAX와 같이 연산에 사용되는 여분의 레지스터이다.
R8-R15 - 다용도로 사용되는 여분의 레지스터이다.
x64에서는 RAX, RBX, RCX 등이 x86에서는 EAX, EBX, ECX로, 크기는 다르나 용도는 똑같은 레지스터가 있다. R8부터 R15까지는 x64에서 새로 등장한 레지스터들이다.
인덱스 레지스터
인덱스 레지스터는 주소 관련 레지스터이다. 데이터를 옮기는 과정에서 한 상수나 변수를 다른 레지스터에 값을 넣게 되는데, 이때 인덱스 레지스터가 사용된다.
mov rax, 0x10
위와 같은 명령은 rax에 0x10이라는 값을 넣으라는 뜻이다. 이때 복사할 데이터는 0x10, 값이 저장되는 곳은 rax이다. 즉, source 주소는 0x10이 저장되어 있는 메모리 주소이고, destination 주소는 rax가 있는 메모리 주소이다.
RSI (Source Index) - 데이터의 source 주소를 저장하는 레지스터이다. (복사할 데이터의 주소)
RDI (Destination Index) - 데이터의 destination 주소를 저장하는 레지스터이다. (복사되어 값이 바뀐 데이터의 주소)
포인터 레지스터
포인터 레지스터는 포인터의 개념과 유사하다. 포인터에 관한 내용이 새롭거나 어려우면 이전에 포인터에 관해서 올린 글을 참고하길 바란다.
포인터는 메모리의 특정 주소 값을 저장하여 포인터 연산을 통해 그 주소 값에 접근할 수 있고 그 주소에 있는 변수의 값을 변화시킬 수 있도록 하는 것이다. 포인터 레지스터는 C언어의 포인터와 비슷하게 특정 주소를 저장하여 가리키고 있다.
어셈블리에서 포인터 레지스터를 사용하는 이유는 뒤에서 알아볼 효율적인 메모리 관리를 위해서 만들어진 스택 프레임의 경계를 표시하기 위해서이다.
RBP (Base Pointer) - 현재 사용되는 스택 프레임의 가장 큰 주소 값을, 즉 프레임이 시작되는 주소를 저장한다.
RSP (Stack Pointer) - 스택의 가장 낮은 주소 값을, 즉 스택 프레임의 맨 위를 가리키고 있어 어디까지가 프레임인지를 구분하는 포인터 레지스터이다. 마지막으로 데이터가 추가된 위치를 가리키고 있다.
RIP (Instruction Pointer) - 다음에 실행될 명령어의 메모리 주소를 저장하는 컴퓨터의 동작에서 필수적인 레지스터이다.
함수의 매개변수로 사용되는 레지스터
int func(int a, int b, int c, int d, int e, int f, int g, int h)라는 함수를 호출한다고 가정해 보자.
이때 각 매개변수에 대응되는 레지스터가 어셈블리에서 존재한다. 이러한 규칙을 호출 규약 (Calling Convention)이라고 부른다.
1번째 매개변수: RDI
2번째 매개변수: RSI
3번째 매개변수: RCX
4번째 매개변수: RDX
5번째 매개변수: R8
6번째 매개변수: R9
7번째 이상: 스택 영역
플래그 레지스터
플래그 레지스터는 말 그대로 어떠한 상태를 나타내는 것이다. 어떠한 상태를 나타내는 것이 참과 거짓만이 존재하기 때문에 2진수 한 자리인 1bit이면 충분하다. (거짓이면 0, 참이면 1)
이러한 플래그 레지스터들이 매우 많지만, 가장 자주 사용되는 레지스터 몇 가지만 알아보자.
플래그 레지스터는 보통 연산에서 발생한 결과를 저장한다.
CF (Carry Flag) - unsigned 수끼리의 연산에서 자리올림 현상이 발생할 경우 1이 된다.
ZF (Zero Flag) - 연산 결과가 0이 될 때 1이 되는 레지스터이다.
SF (Sign Flag) - 연산 결과가 음수일 때 1, 양수일 때 0이 되는 레지스터이다.
OF (Overflow Flag) - signed 수끼리의 연산에서 자료형이 최대로 저장할 수 있는 수의 범위를 넘어선 경우에 오버플로우가 발생하는데, 이때 1이 된다.
이러한 플래그 레지스터들의 값이 조건문에서 많이 이용된다. je, jne 등과 같은 조건문이 전에 실행했던 연산의 결과에 따른 플래그 레지스터의 상태를 보기 때문이다.
x64 메모리 관리
메모리의 구조
메모리의 영역을 크게 4가지로 분류할 수 있다.
Text 영역은 가장 낮은 주소에 있는 영역이다. 실행할 소스 코드가 기계어 형태로 저장된 메모리 영역이다.
다음으로 Data 영역이 존재하는데, Data에서는 전역 변수와 정적 변수(함수 내에서만 사용되도록 제한되지 않아 프로그램이 동작하는 동안 프로그램 내에 어디서나 접근 가능한 변수)가 저장된다.
Heap 영역에서는 malloc, calloc과 같이 동적 할당된 변수들이 모인 공간이다. 동적 할당이란, 정적으로 사용할 변수들의 크기를 지정하지 않고 필요할 때 운영체제에서 메모리 공간을 빌리고 사용 후 다시 반납하는 과정이다. Heap 영역이 커질 때에는 Stack과 반대 방향으로 커진다.
우리는 가장 복잡하고 가장 취약점이 많이 발생하는 마지막 영역인 Stack에 관해서 알아볼 것이다. Stack은 말 그대로 변수들이 쌓이고 쌓이는 메모리 영역이다. 스택은 가장 높은 메모리 주소부터 시작하여 데이터가 쌓이면 쌓을수록 데이터의 주소는 점점 낮아진다. Heap 영역은 데이터가 많을수록 주소가 커지지만, Stack은 데이터가 쌓일수록 주소가 작아진다.
Stack 영역에서는 우리가 가장 일반적으로 사용하는 지역변수들이 저장된다. 지역변수란, 한 함수 내에서만 사용과 접근이 가능한 변수이며, 함수가 종료될 시 메모리 관리가 역할인 운영체제가 할당했던 지역 변수의 공간이 다시 운영체제에게 반환되어 더 이상 사용할 수 없게 된다. 따라서 변수 공간이 재활용되는 특징이 있다.
따라서 Stack에서 함수가 실행될 때마다 새로운 변수들이 추가되고 사용될 텐데, 함수가 아주 많은 (몇 10만, 몇 100만 개에 이를 수도 있는) 프로그램의 경우에는 어떻게 해야 공간이 낭비되지 않고 효율적으로 메모리를 관리할 수 있을까? 이러한 복잡한 스택 메모리 관리 방법이 스택 프레임이다.
스택 프레임
스택 프레임은 실행되는 프로그램의 지역변수들이 효율적으로 저장될 수 있게 해주는 메모리 관리 방법이다.
함수가 실행될 때마다 프레임이라는 공간이 생긴다. 이 공간에 변수들이 저장되는 것이다. 많은 스택 프레임이 모여 스택을 구성한다.
스택은 선입 후출 방식이다. 즉, 가장 먼저 생성되는 프레임이 가장 늦게 나오고, 맨 마지막으로 만들어진 프레임, 즉 맨 위에 있는 프레임이 가장 먼저 빠져나온다.
주의해야 할 것은, 함수를 호출할 때 스택 프레임의 내부가 아닌 아예 새로운 프레임을 생성한다는 것이다. 새로운 프레임이 생성될 때 항상 이전 프레임의 바로 위에 만들어진다. 즉, main()에서 func()를 호출하면 main이 차지하는 스택 영역 위에 func가 사용할 스택 영역이 만들어질 것이다.
함수가 실행되면 항상 새로운 프레임이 만들어진다. 이때 포인터 레지스터인 RBP와 RSP가 사용된다.
RBP는 프레임의 가장 밑을 가리키고 있는데, 새로운 프레임을 생성하려면 새로운 프레임의 가장 밑을 가리켜야 한다. 다시 말해, RBP는 항상 현재 실행 중인 스택 프레임의 가장 밑을 가리켜야 하기 때문에 새로운 프레임을 만들면 일단 RBP는 실행 중이었던 프레임 바로 위로 올라와야 새로운 프레임이 생성될 맨 밑에 있는 주소를 가리키게 된다.
그러면 실행 중이었던 프레임의 바로 위 주소를 어떻게 알아낼까? 그 주소를 바로 RSP가 저장하고 있다. RSP는 항상 현재 프레임의 가장 윗부분을 가리키고 있는다.
이때 mov rbp, rsp를 하면 언뜻 보기에는 rbp에 rsp의 값을 집어넣는 것 같지만, 실제로 이는 현재 프레임의 맨 밑에 있었던 RBP에 RSP가 가리키는 주소를 복사하여 RBP를 현재 프레임의 맨 위에 올려준 것이다. (2번 그림)
RBP와 RSP의 값이 같아진 지금, RBP는 새로 만들어질 프레임의 가장 밑을 가리키고 있으므로 RBP의 역할은 끝났다. 다만 RSP는 프레임의 가장 위를 가리켜야 하므로 RSP의 값에서 특정 수만큼 빼주어야 한다. 빼주는 이유는 메모리는 밑으로 갈수록 주소가 커지기 때문에, RSP에서 수를 빼면 더 낮은 주소를 가리키게 되어 RSP가 위쪽으로 이동한 효과를 얻을 수 있다. (3번 그림) 얼마나 빼줄 것인지는 데이터의 양과 크기에 따라 다르다. 이 부분은 컴파일러가 최적화를 통해 해결해 준다.
이때, RBP가 상대적으로 밑에 있고, RSP는 상대적으로 위에 있다. 이 둘이 프레임의 양 끝 (각각 맨 아래와 맨 위)를 가리키고 있으므로 프레임의 경계를 표시해 준다. 사실 메모리 내에서는 프레임의 위치가 지정되어 있지도 않고 사진처럼 영역들이 깔끔하게 나누어져 있지도 않는다. 그래서 사용하는 것이 포인터이다.
RBP의 값은 함수 호출부터 종료될 때까지 고정된 값을 가지며, RSP는 프레임에 얼마나 많은 양의 데이터가 들어오는지에 따라 계속 위치가 변하기 때문에 값도 자주 변경된다. 따라서 특정 데이터에 접근하려면 RBP를 기준으로 주소를 명시하는 것이 좋다.
종합적으로, 함수가 실행될 때 함수가 실행되고 프레임이 만들어지는 것이다. RBP가 RSP와의 위치가 같아지고, RSP에서 값을 빼어 RBP와 RSP 사이에 공간을 만드는 함수 실행을 위한 준비 과정이 Function Prologue이다. 반대로 함수가 종료될 때 메모리를 정리하기 위하여 RSP를 RBP와 같게 하여 RSP가 낮은 주소로 내려오게 하고, RBP는 원래 프레임의 맨 밑으로 들어가는 과정을 Function Epilogue라고 한다.
스택의 구조
x86의 경우에는 스택 프레임의 가장 아래 부분에 매개변수가 들어간다. 처음으로 값이 들어가므로 가장 아래에 위치하게 된다. 매개변수란, 호출 함수(새로운 함수를 호출하는 함수)에서 피호출 함수(호출되는 함수)로 넘어오는 값인 인수 또는 인자를 저장하는 변수이다. main()에서 두 인자를 더하는 함수인 sum()을 호출하여 인수를 sum(2,3)과 같이 주면 sum 쪽에서 sum(int a, int b)와 같이 인수를 저장하는 변수가 매개변수가 되는 것이다. 이때 2와 3이 sum 함수의 스택 프레임의 맨 아래에 들어가게 된다.
그러나 x64 아키텍처에서는 인자 값이 레지스터에 저장된다. 인자의 수가 6개를 넘을 때부터 스택에 저장되고, 대부분의 경우에는 레지스터에만 저장된다.
그다음으로 Return Address가 들어간다. 흔히 RET라고 하며, 이는 함수가 종료될 때 전에 실행했던 함수로 돌아갈 수 있게 하기 위해서이다. main()에서 실행한 func()를 예로 들면, func 함수의 스택 프레임에서 맨 밑에 main함수의 리턴 주소가 있어 func가 종료될 때 main으로 돌아갈 수 있게 해 준다.
버퍼 오버플로우와 같은 해킹 공격에서는 이러한 스택의 취약점을 공격하는 것이다. 메모리의 버퍼 영역에 과도하게 많은 데이터를 삽입하여 버퍼 영역은 물론, 주로 Return Address를 덮어씌워 바꾸게 하는 공격이다. 이때 Return Address를 멀웨어와 같은 악성 프로그램으로 바꿈으로써 함수가 종료될 시에 원래 돌아가야 하는 함수가 아닌 해커가 지정한 메모리 위치로 가서 실행을 이어서 하기 때문에 매우 위험한 기술이다.
리턴 주소 다음으로 세 번째로 들어가는 것이 이전 함수의 스택 프레임 포인터의 주소이다. 다른 말로 SFP라고도 한다. RBP는 새로운 함수가 실행될 때마다 위치가 바뀐다. 그래서 Function Prologue로 인해 RBP가 현재 스택에 올라오긴 했으나, RBP는 현재 실행 중인 함수가 종료되면 전에 실행했던 프레임의 맨 아래로 다시 돌아가야 한다. 이때 RBP가 전 프레임에서 위치한 주소 값을 SFP에 저장한다. 함수가 종료되고 나서 Function Epilogue가 실행될 때 RSP는 RBP의 위치로 내려오고, RBP는 SFP에 저장되었던 주소 값, 즉 밑에 있는 프레임에서의 RBP 값으로 최종적으로 돌아가게 되는 것이다.
세 번째로 들어가는 것은 지역변수이다. 리턴 주소와 이전 함수의 스택 프레임 포인터의 주소가 모두 들어가면 드디어 함수 내에서 사용하는 변수들이 들어가게 된다. int a, char b와 같은 친구들이다.
x64에서는 매개변수가 일반적으로 스택에 저장되지 않는다. 단, x86이거나 인자의 갯수가 6개보다 많을 때 스택에 저장된다.
x64 어셈블리 명령어
흐름 제어 명령어
call - 함수를 호출한다 (call은 피연산자로 함수의 주소를 받는다.)
ret - 함수를 종료한다 (return 0과는 다른 개념이다. 이는 종료만 할 뿐, 함수의 리턴 값은 RAX에 저장된다.)
jmp - 조건이 성립하면 특정 메모리 주소로 이동한다 (다음 명령어를 가리키는 레지스터 RIP의 값을 변경)
- je (jump if equal) - A == B 이면 점프한다.
- jne (jump if not equal) - A != B 이면 점프한다.
- ja (jump if above) - A > B
- jae (jump if above or equal) - A >= B
- jb (jump if below) - A < B
- jbe (jump if below or equal) - A <= B
CALL과 RET
call은 실행한 함수가 종료될 시 원래 함수로 돌아올 수 있도록 먼저 Return Address (RET)를 PUSH 해 주며, 그다음 지정된 주소로 JMP 한다.
함수의 스택 프레임이 모두 정리된 상태이므로 RSP가 가리키는 것은 Return Address이다. 따라서 ret은 Return Address를 POP 한 뒤 RIP 레지스터에 넣은 다음 JMP를 수행한다.
데이터 이동 명령어
push - 스택에 새로운 데이터를 추가한다. RSP의 위치에 데이터를 추가하고 RSP가 한 칸 올라간다.
pop - 스택에서 맨 위에 있는 데이터를 빼낸다. RSP의 위치에서 값을 빼고 RSP가 한 칸 내려간다.
mov A, B - A에 B의 값을 복사한다.
lea A, B - A에 B의 메모리에 위치한 주소를 복사한다.
PUSH와 POP
PUSH를 할 때 스택의 맨 위에 값 하나 넣는 것처럼 보이지만, 실제로 더 복잡한 과정이 이루어진다.
맨 위에 있는 데이터를 가리키는 RSP에서 먼저 특정 값을 뺀다 (보통 들어갈 데이터의 크기만큼 뺀다). RSP에서 값을 뺌으로써 가리키는 주소가 더 낮아질 것이고, 메모리에서는 위로 갈수록 주소가 낮으므로 RSP는 데이터가 들어갈 공간을 확보하는 효과가 나타난다. 그다음 데이터를 복사하기만 하면 된다.
push rdi //sub rsp,8
//mov [rsp], rdi
POP을 할 때는 반대로 데이터 크기만큼 RSP에 더하여 RSP는 낮은 주소로 내려온다. 실제로 POP을 할 때 들어있는 값이 메모리에서 지워지지는 않지만, RBP와 RSP 사이의 영역에서 벗어나 운영체제는 할당하지 않은 메모리 영역이라고 생각한다. 따라서 다음 데이터가 들어오면 덮어 씌우게 될 것이다.
pop rdi //mov rdi, [rsp]
//add rsp,8
산술 명령어
가감
add A, B - A에 B를 더하여 결과를 A에 저장한다.
sub A, B - A에서 B를 빼어 결과를 A에 저장한다.
inc A - A의 값을 1만큼 증가시킨다.
dec A - A의 값을 1만큼 감소시킨다.
승제
mul A, B - A와 B를 곱하여 결과를 A에 저장한다. (부호 무시)
imul A, B - A와 B를 곱하여 결과를 A에 저장한다. (부호 유지)
div A, B - A를 B로 나누어 결과를 A에 저장한다. (부호 무시) (나눗셈할 때 피제수는 AX, 몫은 AL, 나머지는 AH에 저장)
idiv A, B - A를 B로 나누어 결과를 A에 저장한다. (부호 유지)
그 외의 산술 명령어
cmp A, B - A-B 연산을 하여 결과에 따라 플래그 레지스터의 상태를 변경한다. A와 B가 같이 결과가 0이 되면 ZF가 1이 되며, A보다 B가 더 커 결과가 음수가 되면 SF가 1이 된다.
test A, B - A&B (이진수 값 A와 B에 대하여 논리곱) 연산을 하여 0일 때 ZF 레지스터의 값을 0으로 변경하고, 그렇지 않은 경우에는 아무것도 하지 않는다.
정리
1. 어셈블리어
기계어와 1대 1 대응되는 저수준 언어. 사람이 작성하는 C언어와 CPU가 처리하는 기계어 사이의 중간 단계.
2. x64 아키텍처
x64는 Intel과 AMD에서 개발한 64bit CPU에서 사용하는 ISA이다. x64는 x86을 완벽 지원한다.
3. 레지스터
레지스터는 CPU 내에서 사용하는 저용량 초고속 저장 장치이다. CPU를 구동하는 데 꼭 필요한 데이터만 저장한다.
4. 레지스터의 구조
- RAX, RBX, RCX QWORD 64bit (x64에서 사용)
- EAX, EBX, ECX DWORD 32bit (x86에서 사용)
- AX, BX, CX WORD 16bit
- AH, BH, CH *X의 상위 8bit
- AL, BL, CL *X의 하위 8bit
5. x64에서 사용되는 레지스터
- 범용 레지스터 (RAX, RBX, RCX, RDX, R8-R15)
- 인덱스 레지스터 (RSI, RDI)
- 포인터 레지스터 (RBP, RSP, RIP)
- 매개변수 레지스터 (RDI-RSI-RCX-RDX-R8-R9-STACK)
- 플래그 레지스터 (CF, ZF, SF, OF)
6. 메모리의 구조
- Text (소스 코드)
- Data (전역 변수, 지역 변수)
- Heap (동적 할당)
- Stack (지역 변수)
7. 스택 프레임
- Function Prologue
- Function Epilogue
8. 스택의 구조
- RET
- SFP (RBP)
- 지역 변수
9. x64 어셈블리 명령어
- 흐름 제어 (CALL, RET, JMP)
- 데이터 이동 (PUSH, POP, MOV, LEA)
- 산술 연산 (ADD, SUB, INC, DEC, MUL, DIV, COMP, TEST)
메인 사진 출처: Unsplash
© 남찬우, 2021
'Layer7 > Layer7_Reverse Engineering' 카테고리의 다른 글
리버싱 실습 (0) | 2021.05.27 |
---|---|
어셈블리 동적 분석 실습 (0) | 2021.05.24 |
Pwndbg 명령어 (0) | 2021.05.16 |
실행파일이 만들어지는 과정 (0) | 2021.05.13 |
포인터에 대하여 (0) | 2021.05.10 |