add, sub, inc, decand, or, xor, notcmp, testjmp, je, jgmov dst, src: src의 값을 dst에 대입lea dst, src: src의 유효 주소를 dst에 대입add dst, src: src의 값을 dst에 더함sub dst, src: src의 값을 dst에서 뺌inc op: op의 값을 1 더함dec op: op의 값을 1 뺌and dst, src: dst와 src가 모두 1이면 1, 아니면 0or dst, src: dst와 src 중 한 쪽이라도 1이면 1, 아니면 0xor dst, src: dst와 src가 다르면 1, 같으면 0not op: op의 비트를 모두 반전cmp op1, op2: op1에서 op2를 빼고 플래그를 설정test op1, op2: op1과 op2에 AND 연산을 하고, 플래그를 설정jmp addr: addr로 rip 이동je addr: 직전 비교에서 두 피연산자의 값이 같을 경우 addr로 rip 이동jg addr: 직전 비교에서 두 피연산자 중 전자의 값이 더 클 경우 addr로 rip 이동컴퓨터 세계에서 통용되는 기계어(Machine Code) 라는 언어가 있습니다. 그리고 해커가 하는 일은 그 세계의 허점을 공격하여 시스템을 장악하는 것입니다.
컴퓨터 속 세계를 잘 이해할수록 시스템에 침투하기 쉽습니다. 컴퓨터의 언어로 작성된 소프트웨어에서 취약점을 발견해야 하는 시스템 해커가 가장 기본적으로 습득해야 하는 지식은 컴퓨터 언어에 관한 것입니다. 그런데 문제는 컴퓨터의 언어인 0과 1로 된 기계어가 우리의 일상 언어와 너무나 다르다는 것입니다.
다행스럽게도 기계어가 난해한 것은 기계어로 코드를 작성하던 초기 컴퓨터 개발자들도 마찬가지였습니다. 그래서 컴퓨터 과학자 중 한 명인 David Wheeler는 EDSAC을 개발하면서 어셈블리 언어(Assembly Language) 와 어셈블러(Assembler) 라는 것을 고안했습니다.
어셈블러는 일종의 통역사인데, 개발자들이 어셈블리어로 코드를 작성하면 컴퓨터가 이해할 수 있는 기계어로 코드를 치환해줬습니다.
그런데 소프트웨어를 역분석하는 사람들은 여기에 역발상을 더해, 기계어를 어셈블리 언어로 번역하는 역어셈블러(Disassembler) 를 개발했습니다. 기계어로 구성된 소프트웨어를 역어셈블러에 넣으면, 어셈블리 코드로 번역됩니다. 이로 인해 소프트웨어 분석가들은 소프트웨어를 분석하려고 기계어를 직접 읽을 필요가 없어졌습니다.
어셈블리 언어는 컴퓨터의 기계어와 치환되는 언어입니다. 이는 기계어가 여러 종류라면 어셈블리어도 여러 종류여야 함을 의미합니다. 그리고 CPU에 사용되는 명령어 집합구조(Instruction Set Architecture, ISA) 는 IA-32, x86-64, ARM, MIPS 등 종류가 굉장히 다양합니다.
따라서 이들의 종류만큼 많은 수의 어셈블리어가 존재합니다.이 언어는 많이 알면 알수록 좋습니다. 그러나 입문 단계에서는 점유율이 높은 x64 어셈블리어만을 소개하겠습니다.
x64 어셈블리 언어의 문장은 동사에 해당하는 명령어(Operation Code, Opcode)와 목적어에 해당하는 피연산자(Operand)로 구성됩니다.
mov eax, 3
eax: operand1 eax에3: operand2 3을mov: opcode 대입 명령| 명령어 | |
|---|---|
| 데이터 이동(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 [0x8048000] |
0x8048000의 데이터를 8바이트만큼 참조 |
DWORD PTR [0x8048000] |
0x8048000의 데이터를 4바이트만큼 참조 |
WORD PTR [rax] |
rax가 가르키는 주소에서 데이터를 2바이트 만큼 참조 |
WORD의 크기가 2바이트인 이유초기에 인텔은 WORD의 크기가 16비트인 IA-16 아키텍처를 개발했습니다. CPU의 WORD가 16비트였기 때문에, 어셈블리어에서도 WORD를 16비트 자료형으로 정의하는 것이 자연스러웠습니다.
이후에 개발된 IA-32, x86-64 아키텍처는 CPU의 WORD가 32비트, 64비트로 확장됐습니다. 그러므로 이 둘의 아키텍처에서는 WORD 자료형이 32비트, 64비트의 크기를 지정하는 것이 당연할 것 같습니다.
그러나 인텔은 WORD 자료형의 크기를 16비트로 유지했습니다. 왜냐하면, WORD 자료형의 크기를 변경하면 기존의 프로그램들이 새로운 아키텍처와 호환되지 않을 수 있기 때문입니다. 그래서 인텔은 기존에 사용하던 WORD의 크기를 그대로 유지하고, DWORD(Double Word, 32bit)와 QWORD(Quad Word, 64bit)자료형을 추가로 만들었습니다.
데이터 이동 명령어는 어떤 값을 레지스터나 메모리에 옮기도록 지시합니다.
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에 대입 |
산술 연산 명령어는 덧셈, 뺄셈, 곱셈, 나눗셈 연산을 지시합니다.
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 |
dec op: op의 값을 1 감소 시킴 |
|
|---|---|
dec eax |
eax -= 1 |
and & or논리 연산 명령어는 and, or, xor, neg 등의 비트 연산을 지시합니다. 이 연산은 비트 단위로 이루어 집니다. 아래의 예시들을 통해 이해를 돕겠습니다.
and dst, src: dst와 src의 비트가 모두 1이면 1, 아니면 0[Register]
eax = 0xffff0000
ebx = 0xcafebabe
[Code]
and eax, ebx
[Result]
eax = 0xcafe0000
or dst, src: dst와 src의 비트 중 하나라도 1이면 1, 아니면 0[Register]
eax = 0xffff0000
ebx = 0xcafebabe
[Code]
or eax, ebx
[Result]
eax = 0xffffbabe
xor & notxor dst, src: dst와 src의 비트가 서로 다르면 1, 같으면 0[Register]
eax = 0xffffffff
ebx = 0xcafebabe
[Code]
xor eax, ebx
[Result]
eax = 0x35014541
not op: op의 비트 전부 반전[Register]
eax = 0xffffffff
[Code]
not eax
[Result]
eax = 0x00000000
비교 명령어는 두 피연산자의 값을 비교하고, 플래그를 설정합니다.
cmp op1, op2: op1과 op2를 비교cmp는 두 피연산자를 빼서 대소를 비교합니다. 연산의 결과는 op1에 대입하지 않습니다.
예를 들어, 서로 같은 두 수를 빼면 결과가 0이 되어 ZF 플래그가 설정되는데, 이후에 CPU는 이 플래그를 보고 두 값이 같았는지 판단할 수 있습니다.
[Code]
1: mov rax, 0xA
2: mov rbx, 0xA
3: cmp rax, rbx ; ZF=1
test op1, op2: op1과 op2를 비교test는 두 피연산자에 AND 비트연산을 취합니다. 연산의 결과는 op1에 대입하지 않습니다.
예를 들어, 아래 코드에서 처럼 0이된 rax를 op1과 op2로 삼아 test를 수행하면, 결과가 0이므로 ZF플래그가 설정됩니다. 이후에 CPU는 이 플래그를 보고 rax가 0이었는지 판단할 수 있습니다.
[Code]
1: xor rax, rax
2: test rax, rax ; ZF=1
분기 명령어는 rip를 이동시켜 실행 흐름을 바꿉니다.
⚠️분기문은 여기 소개된 것 외에도 굉장히 많은 수가 존재합니다.
jmp addr: addr로 rip를 이동시킵니다.[Code]
1: xor rax, rax
2: jmp 1 ; jump to 1
je addr: 직전에 비교한 두 피연산자가 같으면 점프 (jump if equal)[Code]
1: mov rax, 0xcafebabe
2: mov rbx, 0xcafebabe
3: cmp rax, rbx ; rax == rbx
4: je 1 ; jump to 1
jg addr: 직전에 비교한 두 연산자 중 전자가 더 크면 점프 (jump if greater)[Code]
1: mov rax, 0x31337
2: mov rbx, 0x13337
3: cmp rax, rbx ; rax > rbx
4: jg 1 ; jump to 1