동아리 활동

System Hacking : x86 어셈블리 (1)

Hicecream 2023. 5. 1. 11:21

출처

https://dreamhack.io/lecture/courses/37

 

x86 Assembly🤖: Essential Part(1)

시스템 해킹을 배우기 위해 필요한 기초적인 어셈블리 언어를 배웁니다.

dreamhack.io

 

 

System Hacking

STAGE 2 - Background: Computer Science (3/3)

x86 Assembly (1) 공부 겸 메모

 

 

✍️ 개요

- 어셈블리어란?
- x86-64 어셈블리어
- 데이터 이동: mov, lea
- 산술 연산: add, sub, inc, dec
- 논리 연산: and, or, xor, not
- 비교: cmp, test
- 분기: jmp, je, jg

 

 

1. 서론

해커의 언어: 어셈블리 💬

 

컴퓨터 속에는 하나의 거대한 세계가 있다. 복잡한 논리적 인과관계가 존재하고, 여러 개체가 상호작용하며, 그 세계에서 통용되는 기계어(Machine Code)라는 언어가 있다. 그리고 해커가 하는 일은 그 거대한 세계의 허점을 공격하여 시스템을 장악하는 것이다.

 

컴퓨터 언어, 운영체제, 네트워크, 암호학 등 다양한 배울 것들이 존재하지만, 그중 시스템 해커가 가장 기본적으로 습득해야 하는 지식은 컴퓨터 언어에 관한 것이다. 왜냐하면, 시스템 해커는 컴퓨터의 언어로 작성된 소프트웨어에서 취약점을 발견해야 하기 때문이다.

 

하지만 컴퓨터의 언어인 기계어는 0과 1로만 구성되어 있어서, 우리는 이해하기 매우 어렵다. 그래서 초기 컴퓨터 과학자 중 한 명인 David Wheeler는 EDSAC을 개발하면서 어셈블리 언어(Assembly Language)어셈블러(Assembler)라는 것을 고안했다.

 

어셈블러는 일종의 통역사인데, 개발자들이 어셈블리어로 코드를 작성하면 컴퓨터가 이해할 수 있는 기계어로 코드를 치환해줬다.

 

그런데 소프트웨어를 역분석하는 사람들은 여기에 역발상을 더해, 기계어를 어셈블리 언어로 번역하는 역어셈블러(Disassembler)를 개발했다. 기계어로 구성된 소프트웨어를 역어셈블러에 넣으면, 어셈블리 코드로 번역된다. 이로 인해 소프트웨어 분석가들은 소프트웨어를 분석하려고 기계어를 읽을 필요가 없어졌다.

 

이 포스팅에서 다루는 x86-64를 비롯하여 대중적으로 많이 사용되는 아키텍처들은 인터넷에서 역어셈블러를 구하기 매우 쉽다. 따라서 어셈블리어만 이해할 수 있다면 역어셈블러를 사용하여 소프트웨어를 분석해 볼 수 있다.

 

따라서 이번 포스팅에서는 어셈블리 언어에 대해 개략적인 설명을 하고, x86-64의 명령어들을 소개할 것이다. 이를 이해하고 나면 소프트웨어를 읽을 수 있는 기본적인 언어 지식을 갖추게 될 것이다.

 

 


 

2. 어셈블리어와 x86-64

어셈블리 언어는 위에서 설명했듯이 컴퓨터의 기계어와 치환되는 언어이다. 이는 기계어가 여러 종류라면 어셈블리어도 여러 종류여야 함을 의미한다. 그리고 이전 포스팅에서 명령어 집합구조(Instruction Set Architecture, ISA)를 설명할 때 얘기했듯이 CPU에 사용되는 ISA는 IA-32, x86-64, ARM, MIPS 등 종류가 굉장히 다양하다.

 

따라서 이들의 종류만큼 많은 수의 어셈블리어가 존재한다. x64의 세계에는 x64의 어셈블리어가 있고, ARM의 세계에는 ARM의 어셈블리어가 있다. 이 언어는 많이 알면 알수록 좋지만, 해당 포스팅에선 x64아키텍처를 대상으로 하기 때문에 x64 어셈블리어만을 소개하겠다.

 

 

2-1. x64 어셈블리 언어 기본 구조

한국어가 주어, 목적어, 서술어 등으로 이루어진 문법 구조를 갖듯이 어셈블리 언어도 마찬가지다. x64 어셈블리 언어는 우리가 사용하는 언어보다는 훨씬 단순한 문법 구조를 지닌다. 이들의 문장은 동사에 해당하는 명령어(Operation Code, Opcode)와 목적어에 해당하는 피연산자(Operand)로 구성된다.

x86-64 어셈블리어의 문법 구조

 

 

2-2. 명령어

인텔의 x64에는 매우 많은 명령어가 존재하지만, 여기선 아래의 중요한 21개의 명령어만 자세히 알아보도록 하겠다.

데이터 이동(Data Transfer) movlea
산술 연산(Arithmetic) incdecaddsub
논리 연산(Logical) andorxornot
비교(Comparison) cmptest
분기(Branch) jmpjejg
스택(Stack) pushpop
프로시져(Procedure) callretleave
시스템 콜(System call) syscall

 

 

2-3. 피연산자

피연산자에는 총 3가지 종류가 올 수 있다.

  • 상수 (Immediate Value)
  • 레지스터(Register)
  • 메모리(Memory)

메모리 피연산자는 []으로 둘러싸인 것으로 표현되며, 앞에 크기 지정자(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) 자료형을 추가로 만들었다.

 

 


 

3. x86-64 어셈블리 명령어

3-1. 데이터 이동🚚

데이터 이동 명령어는 어떤 값을 레지스터나 메모리에 옮기도록 지시한다.

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에 대입

 

예제📝 데이터 이동

레지스터, 메모리 및 코드가 다음과 같을 때, 아래에서 적절한 값을 채우시오.

[Register]
rbx = 0x401A40

=================================

[Memory]
0x401a40 | 0x0000000012345678
0x401a48 | 0x0000000000C0FFEE
0x401a50 | 0x00000000DEADBEEF
0x401a58 | 0x00000000CAFEBABE
0x401a60 | 0x0000000087654321

=================================

[Code]
1: mov rax, [rbx+8]
2: lea rax, [rbx+8]

1. Code를 1까지 실행했을 때, rax에 저장된 값은 ??? 이다.

답: 0xC0FFEE

 

2. Code를 2까지 실행했을 때, rax에 들어있는 값은 ??? 이다.

답: 0x401A48

 

 

3-2. 산술 연산❌

산술 연산 명령어는 덧셈, 뺄셈, 곱셈, 나눗셈 연산을 지시한다. 곱셈과 나눗셈은 여기서 설명하지 않겠다.

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

 

예제📝덧셈과 뺄셈

레지스터, 메모리 및 코드가 다음과 같을 때, 아래에서 적절한 값을 채우시오.

[Register]
rax = 0x31337
rbx = 0x555555554000
rcx = 0x2

=================================

[Memory]
0x555555554000| 0x0000000000000000
0x555555554008| 0x0000000000000001
0x555555554010| 0x0000000000000003
0x555555554018| 0x0000000000000005
0x555555554020| 0x000000000003133A

==================================

[Code]
1: add rax, [rbx+rcx*8]
2: add rcx, 2
3: sub rax, [rbx+rcx*8]
4: inc rax

1. Code를 1까지 실행했을 때, rax에 저장된 값은 ??? 이다.

답: 0x3133A

풀이: rax의 값은 rbx+0x10(0x555555554010) 에 저장된 0x3 만큼 증가한다.

 

2. Code를 3까지 실행했을 때, rax에 저장된 값은 ??? 이다.

답: 0

풀이: rax의 값은 rbx+0x20에 저장된 0x3133A 만큼 감소한다.

 

3. Code를 4까지 실행했을 때, rax에 저장된 값은 ??? 이다.

답: 1

풀이: rax의 값은 1 증가한다.

 

 

3-3. 논리 연산🤔 - 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​

 

 

예제📝논리 연산 - and, or

레지스터, 메모리 및 코드가 다음과 같을 때, 아래에서 적절한 값을 채우시오.

[Register]
rax = 0xffffffff00000000
rbx = 0x00000000ffffffff
rcx = 0x123456789abcdef0

==================================

[Code]
1: and rax, rcx
2: and rbx, rcx
3: or rax, rbx

1. Code를 1까지 실행했을 때, rax에 저장된 값은 ??? 이다.

답: 0x1234567800000000

 

2. Code를 2까지 실행했을 때, rbx에 저장된 값은 ??? 이다.

답: 0x000000009abcdef0

 

3. Code를 3까지 실행했을 때, rax에 저장된 값은 ??? 이다.

답: 0x123456789abcdef0

 

 

3-4. 논리연산🤔 - xor & not

xor 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​

 

 

예제📝논리 연산 - xor, not

레지스터, 메모리 및 코드가 다음과 같을 때, 아래에서 적절한 값을 채우시오.

[Register]
rax = 0x35014541
rbx = 0xdeadbeef

==================================

[Code]
1: xor rax, rbx
2: xor rax, rbx
3: not eax

1. Code를 1까지 실행했을 때, rax에 저장되는 값은 ??? 이다.

답: 0xebacfbae

 

2. Code를 2까지 실행했을 때, rax에 저장되는 값은 ??? 이다.

답: 0x35014541

풀이: xor연산을 동일한 값으로 두 번 실행할 경우, 원래 값으로 돌아간다.

 

3. Code를 3까지 실행했을 때, rax에 저장되는 값은 ??? 이다.

답: 0xcafebabe

풀이: 참고로 [Code]의 3번에서 rax가 아닌 eax를 not 하여도 괜찮은 이유는 eax가 rax의 하위 32비트를 가리키는 부분이기 때문이다. 만약 not rax를 수행했다면 답은 0xffffffffcafebabe가 된다.

 

 

3-5. 비교 ⚖️

비교 명령어는 두 피연산자의 값을 비교하고, 플래그를 설정한다.

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​

 

 

3-6. 분기 🔀

분기 명령어는 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​

 

 


 

4. 요약 정리

1. 데이터 이동 연산자

  • mov dst, src: src의 값을 dst에 대입
  • lea dst, src: src의 유효 주소를 dst에 대입

 

2. 산술 연산

  • add dst, src: src의 값을 dst에 더함
  • sub dst, src: src의 값을 dst에서 뺌
  • nc op: op의 값을 1 더함
  • dec op: op의 값을 1 뺌


3. 논리 연산

  • 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의 비트를 모두 반전

 

4. 비교

  • cmp op1, op2: op1에서 op2를 빼고 플래그를 설정
  • test op1, op2: op1과 op2에 AND 연산을 하고, 플래그를 설정

 

5. 분기

  • jmp addr: addr로 rip 이동
  • je addr: 직전 비교에서 두 피연산자의 값이 같을 경우 addr로 rip 이동
  • jg addr: 직전 비교에서 두 피연산자 중 전자의 값이 더 클 경우 addr로 rip 이동