https://dreamhack.io/lecture/courses/20
Introduction
디스어셈블 과정을 거쳐 나온 어셈블리 코드를 이해하기 위해 본격적으로 어셈블리 코드에 대해 공부합니다.
앞서 배운 것 처럼 어셈블리 코드는 기계 코드와 1:1 대응되므로, 기계 코드가 실제로 동작할 CPU에 따라 기계 코드 역시 달라지게 됩니다. 다시 말해서 CPU에 따라 어셈블리 코드도 다릅니다.
여기서는 가장 널리 쓰이는 Intel 구조의 64bit 버전 명령어 집합(64-bit version IA32 Intruction Set, 이하 x64 )에서 쓰이는 x64 명령어 집합(x86-64 Instruction Set)에 대해 알아보겠습니다.
-> 어셈블리 코드중 가장 널리 쓰이는 Intel 구조의 x64를 알아보자
그 전에,
Instruction Cycle
어셈블리 코드는 기계 코드와 대응되므로, 기계 코드가 동작할 CPU가 어떤 역할을 하고 어떻게 동작하는지를 알아보는것이 어셈블리 코드를 이해하는데에 도움이 됩니다. 어셈블리 코드를 이해하기에 앞서 CPU가 하는 기본적인 동작 과정에 대해 간단히 알아보겠습니다.
CPU는 아주 복잡해보이지만, 기본적으로는 다음 실행할 명령어를 읽어오고(Fetch) → 읽어온 명령어를 해석한 다음(Decode) → 해석한 결과를 실행하는(Execute) 과정을 반복하는 장치입니다. 이렇게 한 개의 명령어, 다시 말해 기계 코드가 실행되는 한 번의 과정을 Instruction Cycle이라고 합니다. 이러한 일련의 과정이 매우 고도화되어 있지만, 추상적인 관점에서 보자면 CPU를 구성하는 요소들은 이 역할을 효율적으로 수행하기 위해 필요한 것들이라고 볼 수 있습니다.
Instruction(명령어) Cycle: Fetch -> Decode -> Execute
레지스터(Register)와 명령어(Instruction)
CPU는 Instruction Cycle을 수행하기 위해 기계 코드에 해당하는 각종 명령어를 해석하기 위한 구성 요소 외에도 읽어온 명령어가 저장된 공간을 임시로 기억해 둘 구성 요소나, 명령어를 실행한 결과를 저장해 둘 구성 요소가 필요합니다. 이렇게 CPU의 동작에 필수적인 저장 공간의 역할을 하는 CPU의 구성 요소를 레지스터(Register) 라고 합니다.
한편, CPU가 실행할 명령어(Instruction) 들은 수행하는 동작에 따라 조금씩 형태가 다릅니다. 다양한 명령어의 종류와 각각의 문법에 대해 이어서 자세히 알아보겠습니다.
3. 레지스터
레지스터와 그 종류
레지스터는 CPU가 사용하는 저장 공간입니다. 대개의 경우 각각의 레지스터들은 특별히 쓰임새가 정해져 있지 않습니다.
그러나 쓰임새가 정해진 것은 아니지만 관행적으로 용도를 정해놓고 쓰는 레지스터도 있고, 엄격히 정해진 용도로만 쓰이는 레지스터가 있기도 합니다. 하나씩 차례로 알아봅시다.
범용 레지스터
범용 레지스터(General-Purpose Registers, GPR) 는 말 그대로 용도를 특별히 정해두지 않고 다양하게 쓸 수 있는 레지스터입니다. 우리가 수학 계산을 할 때 연습장을 두고 쓰듯이, CPU도 범용 레지스터를 연습장처럼 씁니다. x64의 범용 레지스터는 총 16개로, 오른쪽에 빨간 글씨로 표시된 레지스터들이 여기에 해당됩니다. 범용 레지스터는 원칙적으로 용도가 정해져 있진 않지만, 관행적으로 그 쓰임새가 정해져 있는 경우도 있습니다.
rax는 함수가 실행된 후 리턴값을 저장 하기 위해 쓰입니다. 즉, 어떤 함수의 실행이 종료되고 나면 해당 함수의 결과값이 반환될 때 이 rax 레지스터에 담겨 반환됩니다. 그러나 rax가 리턴값을 위해서만 쓰이는 것은 아닙니다. 함수가 반환되기 전까지 범용 레지스터로 자유롭게 사용되다가, 종료 후 리턴값을 반환하기 위한 레지스터로는 rax만이 사용됩니다.
x64의 범용 레지스터들 중에서는 함수가 실행될 때 필요한 인자들을 저장하는 용도로 사용하는 레지스터들도 있습니다. 이를 함수 호출 규약(Calling Convention) 이라고 부르며, 운영체제의 종류나 함수의 종류에 따라 조금씩 다릅니다. 이 강의에서는 Windows 64bit 환경에 대해 중점적으로 다루고 있으므로, Windows 64bit에서 사용하는 함수 호출 규약에 대해 알아보겠습니다.
rcx, rdx, r8, r9는 Windows 64bit에서 함수를 호출할 때 필요한 인자들을 순서대로 저장합니다. 즉 첫번째 인자는 rcx에, 두번째 인자는 rdx에… 하는 방식으로 인자를 레지스터에 담아 함수를 호출합니다.
rax와 마찬가지로, 함수 호출 규약에서 쓰이는 레지스터들 역시 함수를 호출할 때 인자를 전달하는 용도로 이 레지스터들이 정해집니다. 함수가 호출된 이후에는 이들 역시 범용 레지스터로 자유롭게 사용할 수 있습니다.
rsp는 조금 특별합니다. 16개의 범용 레지스터들 중 하나로 분류되지만, 다른 범용 레지스터들과 달리 용도가 정해져 있습니다. rsp는 스택 포인터(Stack Pointer)로, 스택의 가장 위쪽 주소를 가리킵니다. 스택은 함수가 사용할 지역 변수들(local variables)을 저장하기 위해 준비해놓는 공간입니다.
: rsp (register stack pointer)
명령어 포인터
명령어 포인터(Instruction Pointer)는 범용 레지스터들과는 달리 그 용도가 엄격히 정해져 있는 레지스터입니다.
명령어 포인터인 rip는 다음에 실행될 명령어가 위치한 주소를 가리키고 있습니다. 즉 프로그램의 실행 흐름과 관련된 중요한 레지스터이므로, 범용으로 사용되지 않는 레지스터입니다.
rip : register instruction pointer (찍음)
Data Size
CPU가 사용하는 값의 크기 단위를 WORD라고 합니다. 오래 전 16bit CPU가 처음 등장했을 때 당시 CPU가 사용하는 값의 단위였던 16bit를 WORD라고 하면서, WORD 단위를 처리할 수 있는 범용 레지스터의 이름이 ax, cx, dx, bx로 붙은 것이 현재 우리가 사용하는 레지스터 이름의 기원이 되었습니다. 시간이 흘러 32bit 단위로 레지스터의 크기가 커지면서(extended) 레지스터의 이름도 eax, ecx, edx, ebx 가 되었습니다. 이후 64bit CPU에서 사용하는 레지스터 이름은 우리가 앞서 레지스터에 대해 공부한 바와 같습니다.
우리가 사용하는 64bit CPU인 x64의 레지스터들이 담을 수 있는 값의 크기는 64bit (8byte, QWORD)입니다. 그러나 꼭 8byte 단위로만 값을 저장해야 하는 것은 아닙니다.
그림과 같이 rcx 레지스터를 예로 들어 설명하면, rcx 레지스터에 저장된 값 중 하위 32bit (4byte, DWORD)만 연산에 사용할 수도 있고, 혹은 하위 16bit (2byte, WORD)나 하위 8bit (1byte, BYTE)만 사용하는 것도 가능합니다. 이렇게 레지스터의 하위 비트만 접근하려면 어셈블리 코드에서 접근할 레지스터 이름으로 ecx, cx를 사용하면 됩니다.
마찬가지로 r8~ r15 까지 64비트에서 새로 추가된 범용 레지스터들도 하위 일부 비트만 접근하여 사용하기 위해 다른 레지스터 이름을 사용할 수 있습니다. r8의 경우 r8d, r8w, r8b를 통해 각각 하위 32비트, 16비트, 8비트에 접근할 수 있습니다. 이렇게 d, w, b 와 같은 접미사(suffix)를 붙이는 방식은 r8~ r15 레지스터들이 동일하게 사용합니다.
8바이트 말고 2바이트 1바이트처럼 작은 단위로 사용 가능
1바이트 = 8비트 = BYTE
2바이트=16비트 = WORD : ax,cx,dx,bx
4바이트=32비트 = DWORD : eax,ecx,edx,ebx
8바이트=64비트 = QWORD : rcx
FLAGS
마지막으로 알아볼 레지스터는 상태 레지스터인 FLAGS 입니다. '깃발'을 의미하는 단어 뜻 그대로, 현재 상태나 조건을 0과 1로 나타내는 레지스터입니다. 앞서 본 레지스터들과 달리, FLAGS 레지스터를 구성하는 64개의 비트들 각각이 서로 다른 의미를 지닙니다. 다시 말해 0번째 비트, 1번째 비트, 2번째 비트... 등 각각의 비트가 서로 다른 상태를 나타낸다고 볼 수 있습니다.
몇 번째 비트가 어떤 플래그인지를 상세히 알 필요는 없지만(디버거에 어떤 플래그인지 표시되기 때문입니다.), 몇 가지 중요한 플래그들에 대해서는 짚고 넘어가겠습니다.
Flag Abbreviation | 설명 |
CF (Carry Flag) |
더하거나 빼는 등의 산술 연산 혹은 bit shift/rotate(레지스터에 저장된 값의 비트를 한 자리씩 옮기는 것) 등의 연산이 일어났을 때, 자리 올림(carry)이 생기는 경우 CF의 값이 1이 됩니다. CF의 특징은 연산에 사용된 값들에 부호가 없다는(unsigned) 점입니다. |
ZF (Zero Flag) |
연산의 결과가 0일 때 ZF는 1이 됩니다. 아주 간단한 플래그이지만, 매우 널리 쓰입니다. 예를 들어 두 값의 크기를 비교할 때 CPU는 한 값에서 다른 한 값을 빼는 방식으로 비교하는데, 두 값이 같다면 연산의 결과가 0이 되어 ZF가 1이 되므로 ZF의 값을 확인하면 비교한 두 대상의 값이 같은지를 확인할 수 있게 됩니다. |
SF (Sign Flag) |
CF가 부호 없는(unsigned) 값의 연산에서 쓰인다면, SF는 부호가 있는 (signed) 값의 연산에서 쓰여서 결과가 음수인지 혹은 양수인지를 가리킵니다. 수행한 결과가 양수일 때, 즉 최상위 비트가 0이면 SF=0, 반대로 결과가 음수가 되어 최상위 비트가 1이면 SF=1이 됩니다. |
OF (Overflow Flag) |
OF는 부호가 있는 (signed) 값의 연산에서 CF의 역할을 합니다. 즉 부호가 없는 값을 연산할 때 자리 올림이 생길 경우 CF를 통해 표시했지만, 연산에 사용된 값들에 부호가 있을 경우에는 CF 대신 OF를 사용합니다. ‘overflow’ 라는 이름에서 알 수 있듯이 부호 있는 값들을 대상으로 산술 연산을 했을 때 자리 올림이 생겼다는 것은 표시할 수 있는 값의 범위를 넘어갔다(overflowed)는 것을 의미합니다. |
자리올림: CF(부호X, 캐리있을때), OF(부호O, 오버플로우있을때)
ZF : 값이 같으면(0이면) 1
SF: 결과값이 양수면 0, 음수면 1
4. Instruction Format
Instruction Format
지금까지 레지스터에 대해 상세히 알아 보았으니, 본격적으로 명령어에 대해 알아 보겠습니다.
하지만 이에 앞서 x64 어셈블리 코드에서 많이 볼 수 있는, 명령어를 구성하는 두 개의 큰 요소인 명령 코드(Opcode) 와 피연산자(Operand) 에 대해 먼저 공부해봅시다.
명령어 = 명령 코드 + 피연산자
Opcode (Operation Code)
명령 코드(Opcode, Operation Code) 는 명령어에서 실제로 어떤 동작을 할지를 나타내는 부분입니다. 자료를 옮기거나, 산술 연산을 하거나, 자료를 제어하는 등 다양한 종류의 명령 코드가 있습니다.
명령 코드(Opcode), 기계 코드(Machine Code), 어셈블리 코드(Assembly Code) 등 비슷한 용어가 계속해서 등장하니 혼란을 줄이기 위해 각각에 대해 간단히 용어를 정리하고 다음 단계로 넘어갑시다.
기계 코드(Machine Code) 또는 명령 코드(Opcode)
컴파일러가 만드는 결과물인 바이너리를 구성하고 있으며, CPU가 실제로 수행할 작업을 나타내는 숫자입니다. 디버거를 사용해 프로그램을 살펴보면, 우측에 보이는 결과와 같이 왼쪽의 숫자들처럼 생긴 명령 코드를 확인할 수 있습니다. 이 명령 코드는 CPU의 종류별로 다른 값일 수 있으며, 명령 코드에 따라 피연산자(Operand)가 필요하기도 합니다. 뒤에서 자세히 다뤄보겠습니다.
어셈블리 코드 (Assembly Code)
- 숫자로 이뤄져 있는 명령 코드는 사람이 구분하고 이해하기 쉽지 않습니다. 따라서 이것이 어떤 의미를 갖는지 알아보기 쉽도록 문자로 작성된(Mnemonic) 코드입니다. 명령 코드를 알아보기 쉽도록 문자로 치환한 것이므로, 앞서 말한 것처럼 명령 코드와 1:1로 대응됩니다. 뿐만 아니라 명령 코드가 연산할때 사용할 피연산자도 알아보기 쉽습니다. 명령 코드와 피연산자를 묶어 하나의 명령어(Instruction)가 됩니다.
- 어셈블리 코드는 CPU의 동작을 그대로 옮겨놓은 것에 가깝기 때문에 매우 직관적이고 단순한 반면, 실제 소스코드와 달리 고차원적인 전체 흐름을 파악하기는 어렵습니다.
Operand
Operand(피연산자)
명령 코드가 연산할 대상을 피연산자(Operand)라고 합니다. 명령 코드를 함수라고 생각하면, 피연산자는 함수에 들어가는 인자라고 생각하면 조금 더 이해하기 쉽습니다. 명령 코드에 따라 조금씩 다를 수 있지만, Intel 방식의 어셈블리를 읽을 때에는 명령 코드에 따라 연산한 결과를 왼쪽 피연산자에 저장된다고 이해하는 것이 일반적입니다.
명령 코드가 작업을 수행할 대상인 피연산자는 어떤 상수일 수도 있고, 레지스터에 들어 있는 값일 수도 있으며, 어떤 주소에 들어있는 값일 수도 있습니다. 즉 피연산자를 어떻게 지정해 줄 것인지는 그 값을 사용할 방식에 따라 다양합니다. 이어서 자세히 알아봅시다.
Operand Types
주어진 명령 코드의 피연산자로는 상수, 레지스터, 혹은 레지스터가 가리키고 있는 메모리의 어떤 주소 가 올 수 있습니다. 마지막 세 번째 경우는 C에서 프로그래밍을 할 때 자주 사용하는 포인터 개념과도 매우 밀접하게 연관되어 있습니다. 다음 예를 통해 자세히 공부해 보겠습니다.
상수값(Immediate)
가장 간단한 케이스로, 피연산자로 사용되는 값이 상수인 경우입니다.
아래의 예시를 보면 mov 명령어의 피연산자 중 하나로 0xbeef가 사용되었습니다.
레지스터
레지스터도 피연산자로 사용될 수 있습니다. 이 경우에는 레지스터에 들어있는 값이 피연산자로 사용됩니다.
아래의 예시를 보면, mov 명령어의 결과로 rbx에 들어있는 값이 rcx에 들어가게 됩니다.
Addressing Modes
리버싱을 할 때 매우 자주 볼 수 있는 사례로, 레지스터에 있는 값이 피연산자가 되는 것이 아니라 레지스터에 저장된 메모리 주소를 참조한 값이 피연산자가 되는 경우입니다. 다시 말해 레지스터에 들어있는 값은 메모리 주소로, 실제로는 해당 메모리 주소를 참조한 값이 피연산자로 사용됩니다. 이것은 앞서 말한 것과 같이 C언어의 포인터 개념과 유사합니다.
- [reg]
오른쪽의 첫번째 예시를 보면, mov 명령어의 결과로 rax 에 들어있는 값을 rcx 레지스터가 참조하는 주소의 메모리에 저장하게 됩니다.
오른쪽의 두번째 예시에서 사용된 byte ptr은 Pointer Directive라고 하며, 앞서 공부한 Data Size가 실제 어셈블리 코드에서 사용된 케이스입니다. 즉 rax 레지스터가 저장하고 있는 값 중 하위 8bit, 곧 1byte만 rcx 가 참조하는 주소에 저장하게 됩니다.
레지스터의 값이 메모리 주소로 바로 쓰이기도 하는 반면, 레지스터에 들어있는 값에서 특정 오프셋(offset)만큼 떨어진 주소값을 참조하기도 합니다. 이렇게 읽으면 어렵게 들리지만 사실 C언어에서의 배열이나 구조체를 생각해보면 이해하기 쉽습니다.
- [reg+d]
레지스터에 들어있는 값을 주소의 기준으로 하여 d 만큼 떨어진 오프셋을 실제로 참조한 다음 피연산자로 씁니다.
오른쪽의 세번째 예시를 보면, rax 레지스터에 들어있는 값을 저장할 때 rbp 의 값을 참조한 메모리 주소에 바로 넣는 것이 아니라, 그 메모리 주소로부터 -0x1C 떨어진 곳을 계산하여 넣습니다. 여기서도 등장하는 Pointer Directive를 고려하면, DWORD에 해당하는 사이즈인 하위 4byte만 넣는 것을 알 수 있습니다. - [reg1+reg2]
한 레지스터에 들어있는 값과 다른 레지스터에 들어있는 값을 더한 결과를 참조할 메모리 주소로 사용하는 경우입니다. - [reg1+reg2*i+d]
가장 복잡해 보이지만, 그만큼 많이 쓰이기도 합니다. 구조체가 사용된 경우 등에서 자주 보이는 방식입니다.
오른쪽 예시 중 네번째를 보면 rdi 레지스터에 담긴 주소를 기준으로, rcx 레지스터의 값을 단위로 하여 4단위 떨어진 곳에 다시 offset 3만큼 더한 주소를 실제로 참조하고 있습니다. 여기에 1byte 사이즈의 값인 0xff가 저장됩니다.
reg2에 해당하는 레지스터에 담긴 값은 대개 자료형이나 구조체의 크기인 경우가 많습니다.
Outro
지금까지 명령어를 이루는 명령 코드와 피연산자에 대해서 공부했습니다. 이제 본격적으로 어셈블리 코드를 이해하기 위해 각종 명령 코드에 대해 종류별로 알아보겠습니다.
5. Instructions(명령 코드)
Instructions
Instructions(1/5) - Data Movement
값을 레지스터나 메모리 주소에 옮기는 명령어들을 먼저 알아봅시다.
mov
앞의 예제에서 많이 본 mov는 src에 들어있는 값을 dst로 옮깁니다.
lea
lea는 Load Effective Address로, dst에 주소를 저장합니다.
1.
2.
Instructions(2/5) - Arithmetic Operations
이번에 설명할 명령어들은 산술 연산과 관련된 것들입니다. 따라서 FLAGS 레지스터의 CF, OF, ZF 등과 관련이 있습니다.
Unary Instructions
increase,decrease,neg(2의 보수), not(0<->1 비트 반전) : 피연산자 하나
Binary Instructions
add,sub,imul(imultiply),논리연산 and,or,xor
Shift Instructions
shl,shr(shift left, shift right) - 0으로 채워짐
sal,sar(shift a left, shift a right) - 최상위비트 채워짐
k만큼 시프트
Instructions(3/5) - Conditional Operations
여기서 알아볼 명령어들은 분기문이나 조건문과 같이 코드의 실행 흐름을 제어하는 것과 밀접한 연관이 있습니다. 특히 분기에서 어떻게 코드의 실행 흐름을 정할지는 앞서 공부한 FLAGS 레지스터의 각종 플래그와 밀접한 관련이 있기 때문에, 각각의 명령어가 어떤 플래그의 영향을 받는지를 아는 것이 중요합니다.
* 플래그
자리올림: CF(부호X, 캐리있을때), OF(부호O, 오버플로우있을때)
ZF : 값이 같으면(0이면) 1
SF: 결과값이 양수면 0, 음수면 1
test
test dst,src는 and와 마찬가지로 AND 논리연산을 하지만, 결과값을 피연산자에 저장하지 않는다는 특징을 지닙니다. 다시 말해서 and dst,src의 결과는 dst에 저장되지만, test 명령어에서는 그렇지 않습니다.
대신 test의 연산 결과는 FLAGS 레지스터에 영향을 미칩니다. 두 피연산자에 대해 AND 연산을 한 경우가 음수이면(최상위비트가 1이면) SF가 1이 되고, AND 연산의 결과가 0이면 ZF를 1로 만듭니다. 이러한 특징은 레지스터에 들어있는 값이 음수인지, 혹은 0인지를 확인하는 데에 유용하게 쓰입니다.
-> 결과값이 저장되는 것이 아닌 플래그에 영향을 미침
cmp
cmp dst,src는 sub과 마찬가지로 dst에서 src를 빼지만, 그 결과값이 피연산자인 dst에 저장되지 않고 FLAGS 레지스터의 ZF와 CF 플래그에만 영향을 미친다는 점에서 test와 유사합니다.
따라서 dst=src일 때에는 ZF=1, CF=0이 되고, dst<src일 때에는 ZF=0, CF=1, 반대로 dst>src일 때에는 ZF=0, CF=0이 됩니다.
jmp, jcc
jmp 및 jcc는 피연산자가 가리키는 곳으로 점프한다는 점은 같지만, 무조건 점프하는 것과(jmp) 조건에 따라 점프의 수행 여부가 달라진다는(jcc) 점에서 중요한 차이가 있습니다.
특히 jcc가 사용하는 조건은 FLAGS 레지스터의 플래그와 밀접한 관련이 있습니다. 이 경우 점프 명령어를 수행하기 전에 어떤 산술 연산을 하거나, test, cmp 등의 연산을 수행한 결과로 바뀐 플래그를 바탕으로 점프의 수행 여부를 결정합니다.
jcc는 명령어의 이름이 아니라, 조건부 jmp를 묶어서 이르는 이름입니다(Jump if condition is met). 오른쪽에 정리한 바와 같이 특정한 결과에 따라 점프의 수행 여부가 정해집니다.
jg greater 클때 jge greater equal 크거나 같을때
jl less jle less equal
ja jb : 부호없을때
js : negative(음수) jns : not negative(양수)
예시 중 첫번째는 cmp 명령어의 결과를 바탕으로 jle 명령어를 수행하는 내용입니다. rbp 레지스터가 가리키는 주소에서 -0x2c만큼 떨어진 곳에 들어있는 값과 0x47을 비교하여, 이 값이 0x47보다 작거나 같으면(less or equal) 0x400a31로 점프합니다.
두번째 예제는 test 명령어를 수행한 결과로 je의 수행 여부가 정해지는 내용입니다. 만약 rax가 0이면 test 명령어를 수행한 뒤 ZF=1이 되므로, je 명령어에 의해 0x4006c5로 점프하게 됩니다.
주어진 조건을 만족하지 않을 경우에는 jcc를 수행하지 않고 다음으로 넘어갑니다.
Instruction(4/5) - Stack Operations
이어서 알아볼 명령어는 스택과 관련된 명령어들입니다. 프로그램이 동작하는 동안 함수 안에서 지역 변수(Local Variables)를 사용할 때가 많습니다. 지역 변수는 함수가 종료되고 나면 더 이상 참조되거나 사용되지 않기 때문에, 함수 안에서는 마치 연습장과 같은 역할을 합니다. 지역 변수들은 스택(Stack)에 저장됩니다.
지역 변수를 사용하기 위한 '연습장’인 스택은 레지스터가 아닌 메모리에 준비됩니다. 새로운 함수가 시작될 때 스택이 준비되고(Function Prologue), 함수가 종료될 때 스택이 정리됩니다(Function Epilogue). 이 과정은 레지스터들 중 스택의 가장 윗부분을 가리키는 rsp 레지스터와 밀접한 관련이 있습니다.
rsp revisit
레지스터들에 대해 공부할 때 잠깐 다뤘던 rsp 레지스터를 먼저 살펴봅시다. rsp는 스택 포인터(Stack Pointer)로, 스택의 가장 위쪽 주소를 가리킵니다.
스택에 새로운 데이터를 담을수록 스택은 점점 길어집니다. 이 때, 스택Stack이라는 이름에서 알 수 있는 것처럼 마지막으로 담은 데이터 위에 새로운 데이터를 쌓아가는 방식으로 길어지게 됩니다. 따라서 스택의 가장 위쪽은 마지막으로 데이터가 담긴 메모리 주소입니다. 다시 말해 rsp는 스택의 가장 위쪽을 가리키므로, 마지막으로 데이터가 추가된 위치를 저장하는 레지스터입니다.
스택이 자라는 방향은 아키텍처에 따라 다릅니다. 아키텍처에 따라 새로운 데이터가 추가될 때 더 높은 메모리 주소에 쌓이는 경우도 있고, 그 반대의 경우도 있습니다. 우리가 강좌에서 다루는 Intel x86-64 아키텍처의 경우는 그 반대의 경우에 해당됩니다.
즉 Intel x86-64 아키텍처에서 스택은 낮은 주소(=더 작은 숫자)를 향해 자라기 때문에 스택이 자랄수록 rsp에 저장된 메모리 주소는 점점 낮아집니다.
인텔에서 스택은 더 낮은(적은 숫자순으로) 주소가 자란다.
Function Prologue/Epilogue
함수가 시작될 때(Function Prologue) 에는 rsp 레지스터에 들어있는 주소에서 충분한 값을 빼줍니다. 이렇게 하면 rsp가 가리키는 곳을 낮은 주소로 당겨오는 효과가 있기 때문에, 함수 안에서 지역 변수를 사용하기 위한 공간을 확보하는 효과가 생깁니다. 이 때 rsp를 얼마나 내릴 것인지, 즉 스택을 어느 정도의 크기로 확보할 것인지는 컴파일러가 최적화를 통해 결정합니다.
반대로 함수가 끝날 때(Function Epilogue) 에는 프롤로그에서 빼준 값 만큼 다시 rsp에 더해줍니다. 이렇게 스택 포인터를 복원하면 함수에서 사용했던 스택을 정리하는 효과를 볼 수 있습니다.
이 점을 기억하면서 Stack Operation인 push와 pop에 대해 알아봅시다.
push, pop
push와 pop 명령어는 스택에 새로운 데이터를 추가하거나 뺄 때 사용합니다.
스택에 새로운 데이터를 넣는 명령어는 push입니다. 새로운 데이터가 들어가면 rsp 레지스터도 새로운 데이터가 들어간 주소를 가리켜야 하므로, 오른쪽 예시에서와 같이 push는 1) rsp가 가리키는 주소에서 들어갈 데이터의 사이즈만큼 빼서 데이터가 들어갈 크기를 확보한 뒤 2)데이터를 복사하는 과정과 동일한 효과를 냅니다.
pop은 push와 반대로 스택의 최상단에 있는 데이터를 빼내는 명령어이므로, 그 반대 순서로 진행하는 것과 동일한 효과를 냅니다.
Instruction(5/5) - Procedure Call Instructions
마지막으로 알아볼 명령어는 함수를 호출하는 명령어와 함수를 종료하는 명령어입니다.
call
함수를 실행할 때에는 call 명령어가 쓰입니다. call은 피연산자로 실행할 함수의 주소를 받습니다.
한편, call로 호출한 함수가 종료되고 나면 다음 명령어를 실행할 장소로 돌아와야 합니다. 즉 call을 사용한 이후에 실행되어야 하는 명령어의 주소가 어디인지 기억해둬야 함수가 종료된 다음에도 프로그램의 실행을 이어갈 수 있습니다. 이렇게 함수의 종료 이후에 돌아와야 하는 주소, 즉 리턴할 때 참조해야 할 주소를 Return Address라고 부릅니다. call의 수행은 Return Address를 스택에 push해 둔 다음, 호출할 함수의 주소로 jmp하는 것과 동일한 원리로 실행됩니다.
ret
호출된 함수가 마지막으로 사용하는 명령어는 ret입니다. 이 명령어는 함수를 종료한 뒤 Return Address로 돌아가는 역할을 합니다. 따라서 스택에 들어있는 Return Address를 pop하여 명령어 포인터인 rip레지스터에 넣은 다음, 그 주소로 jmp하는 것과 동일한 효과를 냅니다.
ret 명령어를 사용하기 전까지는 함수에서 스택을 모두 정리한 상태입니다. 따라서 Function Epilogue까지 마무리되어 있으므로 rsp는 함수가 시작하기 직전에 스택에 넣은 값을 가리키고 있습니다. 이 값은 call할때 스택에 넣었던 Return Address이므로, pop을 하면 스택에서 Return Address를 가져오게 됩니다.
함수 실행->다시 돌아갈 주소 저장 -> 함수 실행 끝나면 원위치로 돌아옴
Outro
이렇게 해서 x64 어셈블리에 대해 자세히 공부하는 것을 마쳤습니다. 낯설고 어려운 내용처럼 보이지만, 몇 번의 리버싱 연습을 하고 나면 금새 눈에 익숙해지게 됩니다. x64에서 사용하는 명령어는 매우 방대하지만, 대부분의 경우 여기서 공부한 명령어들이 사용되거나 혹은 이 명령어들의 변형된 형태가 사용됩니다.
더 많은 명령어가 궁금하거나 더 자세히 알고 싶다면 Intel에서 제공하는 매뉴얼의 2권을 참조하시면 됩니다.
https://software.intel.com/content/www/us/en/develop/articles/intel-sdm.html
'보안공부 > 배운내용정리' 카테고리의 다른 글
[dreamhack -Reverse Engineering] 1.리버스 엔지니어링이란 (0) | 2021.09.03 |
---|---|
dreamhack06.NoSql (0) | 2021.07.16 |
dreamhack05. Sql injection (0) | 2021.07.08 |
칼리리눅스 설치 (0) | 2021.05.23 |
dreamhack 04.Client-side Advanced (0) | 2021.05.21 |