[Reference] : 위 글은 다음 내용을 제가 공부한 후, 인용∙참고∙정리하여 만들어진 게시글입니다.
- 서론
- 어셈블리와 x86-64
- x86-64 어셈블리 명령어
- Q&A
- 마치며
컴퓨터는 여러 복잡한 논리적 인과관계 속에서, 여러 개체가 상호작용는데,
그 속에서 '기계어(Machine Code)'라는 언어를 사용합니다.
리버스 엔지니어의 일은,
그 복잡하고 거대한 세계의 동작을 이해하는 것입니다.
이를 위해, 리버스 엔지니어의 기본 소양 중 하나는 컴퓨터의 언어를 이해하는 것입니다.
하지만 우리 모두가 알다시피, 기계어는 인간이 이해하기에는 너무나도 힘듭니다.
0과 1로만 구성되어 있어, 단위 위주로 문장을 구성하는 우리와는 괴리가 너무나도 크죠.
그래서 컴퓨터 과학자 중 한 명인 David Wheeler는 EDSAC을 개발하면서 '어셈블리 언어(Assembly Language)'와 '어셈블러(Assembler)'를 고안했습니다.
나아가 사람들은 소프트웨어를 역분석하는, 즉 기계어를 어셈블리 언어로 번역하는 '역어셈블러(Disassembler)'를 개발했습니다.
기계어로 구성된 소프트웨어를 역어셈블러에 넣으면,
어셈블리 코드로 번역되는 것이죠.
이로 인해 분석가들은 소프트웨어 분석을 위해 기계어 코드를 볼 필요가 없어졌죠.
제가 공부하고 참조한 2개의 강좌를 통해, 어셈블리어에 대해 알아보겠습니다.
- 어셈블리 언어(Assembly Language)
: 컴퓨터의 기계어와 치환되는 언어
기계어가 여러 종류라면, 어셈블리어도 여러 종류가 되어야 합니다.
명령어 집합 구조(Instruction Set Architecture, ISA)는 IA-32, x86-64, ARM, MIPS 등 종류가 굉장히 다양하죠.
따라서, 이들의 종류만큼 많은 어셈블리어가 존재합니다.
일반적으로 언어는 주어, 목적어, 서술어 등으로 이루어진 문법 구조를 가지고 있습니다.
그래서 우리는 문장에 문법적 의미를 부여하고, 그 의미를 이해할 수 있는 것이죠.
어셈블리어도 마찬가지 입니다.
어셈블리어는 동사에 해당하는 '명령어(Operation Code)',
목적어에 해당하는 '피연산자(Operand)'로 구성됩니다.
인텔의 x64에는 매우 많은 명령어가 존재합니다.
우선 자주 사용되는 명령어만 살펴보겠습니다.
| 명령 코드 | |
|---|---|
| 데이터 이동 (Data Transfer) | mov, lea |
| 산술 연산 (Arithmetic) | inc, dec, add, sub |
| 논리 연산 (Logical) | and, or, xor, not |
| 비교 (Comparison) | cmp, test |
| 분기 (Branch) | jmp, je, jg |
| 스택 (Stack) | push, pop |
| 프로시저 (Procedure) | call, ret, leave |
| 시스템 콜 (system Call) | syscall |
피연산자로는 총 3가지 종류가 올 수 있습니다.
만약 피연산자로 메모리가 오게되면 [ ]으로 둘러싸인 것으로 표현됩니다.
또한 앞에 크기 지정자(Size Directive) TYPE PTR이 추가될 수 있습니다.
여기서 타입에는 BYTE, WORD, DWORD, QWORD가 올 수 있으며, 각각 1바이트, 2바이트, 4바이트, 8바이트의 크기를 지정합니다.
| 메모리 피연산자 | 의미 |
|---|---|
| QWORD PTR [0x1234] | 0x1234의 데이터를 8바이트만큼 참조 |
| DWORD PTR [0x1234] | 0x1234의 데이터를 4바이트만큼 참조 |
| WORD PTR [rax] | rax가 가리키는 주소에서 데이터를 2바이트만큼 참조 |
- 데이터의 이동(Data Transfer) 명령어
: 어떤 값을 레지스터나 메모리에 옮기도록 지시
- mov dst, src
: src에 있는 값을 dst에 대입
mov rdi, rsi : rsi에 있는 값을 rdi에 대입
mov QWORD PTR[rdi], rsi : rsi의 값을 rdi가 가리키는 주소에 대입
mov QWORD PTR[rdi+8*rcx], rsi : rsi의 값을 rdi+8*rcx가 가리키는 주소에 대입
- lea dst, src
: src의 유효 주소(Effective Address, EA)를 dst에 저장
lea rsi, [rbx+8*rcx] : rbx+8*rcx를 rsi에 대입
- 산술 연산(Arithmetic) 명령어
: 덧셈, 뺄셈, 곱셈, 나눗셈 연산을 지시
- add dst, src
: dst에 src의 값을 더함
add eax, 3 : eax += 3
add ax, WORD PTR[rdi] : ax += *(WORD *)rdi
- sub dst, src
: dst에 src의 값을 뺌
sub eax, 3 : eax -= 3
sub ax, WORD PTR[rdi] : ax -= *(WORD *)rdi
- inc op
: op의 값을 1 증가
inc eax : eax += 1
- 논리 연산(Logical) 명령어
: and, or, xor, neg 등의 비트 연산을 지시
: 연산이 비트 단위로 이루어짐
- and dst, src
: dst와 src의 비트가 모두 1이면 1, 아니면 0
- or dst, src
: dst와 src의 비트 중 하나라도 1이면 1, 아니면 0
- xor dst, src
: dst와 src의 비트가 서로 다르면 1, 아니면 0
- not op
: op의 비트 전부 반전
- 비교 연산(Comparision) 명령어
: 두 피연산자의 값을 비교하고, 플래그를 설정
- cmp op1, op2
: op1과 op2를 비교
cmp는 두 피연산자를 빼서 대소를 비교합니다.
참고로 연산의 결과는 op1에 저장하지 않습니다.
- test op1, op2
: op1과 op2를 비교
test는 두 피연산자에 AND 비트연산을 취합니다.
참고로 연산의 결과는 op1에 저장하지 않습니다.
- 분기 연산(Branch) 명령어
:rip를 이동시켜 실행 흐름을 바꿈
참고로 분기문은 제가 공부하는 곳에서 나온 내용보다 훨씬 더 많은 내용이 존재합니다.
하지만 몇 개만 살펴보면 나머지는 직관적으로 의미를 파악할 수 있을 것입니다.
- jmp addr
: addr로 rip를 이동
- je addr
: 직전에 비교한 두 피연산자가 같으면 점프(Jump if Equal)
- jg addr
: 직전에 비교한 두 피연산자 중 전자가 더 크면 점프(Jump if Greater)
- 스택(Stack) 명령어
: 스택(Stack)을 조작
- push val
: val을 스택 최상단에 쌓음- [연산]
rsp -=8
[rsp] = val
- pop val
: 스택 최상단의 값을 꺼내서 reg에 대입- [연산]
reg = [rsp]
rsp += 8
- 프로시저(Procedure) 명령어
: 특정 기능을 수행하는 코드 조각
프로시저를 사용하면 반복되는 연산을 프로시저 호출로 대체할 수 있습니다.
또한 기능별로 코드 조각에 이름을 붙일 수 있습니다.
따라서, 전체 코드의 크기를 줄일 수 있고, 가독성을 높일 수 있습니다.
프로시저를 부르는 행위를 '호출(Call)'이라고 하며,
프로시저에서 돌아오는 행위를 '반환(Return)'이라고 합니다.
프로시저를 호출하고 수행이 완료되면, 원래의 실행 흐름으로 되돌아와야하기 때문에,
call 다음의 명령어 주소(Return Address, 반환 주소)를 스택에 저장하고, 프로시저를 rip로 이동시킵니다.
- call addr
: addr에 위치한 프로시저 호출- [연산]
push return_address : 스택에 반환 주소 저장
jmp addr : addr로 이동
- leave
: 스택프레임 정리- [연산]
mov rsp, rbp
pop rbp
- ret
: return address로 반환- [연산]
pop rip
-
(저번에 리버싱에서 본 강의와 똑같아,
리버싱에서 제가 작성했던 글을 복사했습니다!)
[Reference] : 위 글은 다음 내용을 제가 공부한 후, 인용∙참고∙정리하여 만들어진 게시글입니다.