[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] : 위 글은 다음 내용을 제가 공부한 후, 인용∙참고∙정리하여 만들어진 게시글입니다.