본문 바로가기
프로그래밍/해킹

x86 어셈블리

by 만디기 2024. 7. 6.

- CPU가 직접 해독하고 실행할 수 있는 비트 단위로 쓰인 컴퓨터 언어를 기계어라고 한다.

- 어셈블리는 컴퓨터와 개발자들 사이의 통역사 역할을 하는 언어이다. 개발자들이 어셈블리어로 코드르 작성하면 컴퓨터가 이해할 수 있는 기계어로 치환해주는 역할을 한다.

- 소프트웨어를 역분석하는 사람들은 기계어를 어셈블리 언어로 번역하는 역어셈블러를 개발하였다. x86-64를 비롯하여 대중적으로 많이 사용되는 아키텍처들은 인터넷에서 역어셈블러를 구하기 매우 쉽다.

 

1) 어셈블리 언어

- 컴퓨터의 기계어와 치환되는 언어이다. 즉, 기계어가 여러 종류라면 어셈블리어도 여러 종류여야 하며, CPU에 사용되는 ISA(명령어 집합구조, Instruction Set Architecture)는 IA-32, x86-64, ARM, MIPS 등 종류가 다양하다.

 

2) x64 어셈블리 언어

- 명령어(Operation Code, Opcode)와 목적어에 해당하는 피연산자(Operand)로 구성된다.

3) 명령어

- 피연산자 : 상수/레지스터/메모리

메모리 피연산자는 []으로 둘러싸인 것으로 표현되며, 앞에 크기 지정자(Size Directive) TYPE PTR이 추가될 수 있다.

▶ 타입에는 BYTE, WORD, DWORD, QWORD가 올 수 있으며, 각각 1바이트, 2바이트, 4바이트, 8바이트의 크기를 지정한다.

▶ 자료형 WORD의 크기가 2바이트인 이유는 기존의 프로그램들과 새로운 아키텍처와의 호환성을 위해서이다.

메모리 피연산자
QWORD PTR [0x8048000] 0x8048000의 데이터를 8바이트만큼 참조
DWORD PTR [0x8048000] 0x8048000의 데이터를 4바이트만큼 참조
WORD PTR [rax] rax가 가르키는 주소에서 데이터를 2바이트 만큼 참조

 

4) x86-64 어셈블리 명령어

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

 

 

(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

 

 

(3) 논리 연산

- and & or : 논리 연산 명령어는 and, or, xor, neg 등의 비트 연산을 지시한다. 이 연산은 비트 단위로 이루어진다.

1) and dst, src: dst와 src의 비트가 모두 1이면 1, 아니면 0
[Register]
eax = 0xffff0000
ebx = 0xcafebabe

[Code]
and eax, ebx

[Result]
eax = 0xcafe0000


2) or dst, src: dst와 src의 비트 중 하나라도 1이면 1, 아니면 0
[Register]
eax = 0xffff0000
ebx = 0xcafebabe

[Code]
or eax, ebx

[Result]
eax = 0xffffbabe


3) xor dst, src: dst와 src의 비트가 서로 다르면 1, 같으면 0
[Register]
eax = 0xffffffff
ebx = 0xcafebabe

[Code]
xor eax, ebx

[Result]
eax = 0x35014541


4) not op: op의 비트 전부 반전
[Register]
eax = 0xffffffff

[Code]
not eax

[Result]
eax = 0x00000000

 

 

(4) 비교 : 두 피연산자의 값을 비교하고, 플래그를 설정한다.

- cmp op1, op2: op1과 op2를 비교

두 피연산자를 빼서 대소를 비교하며, 연산의 결과는 op1에 대입하지 않는다.

▶ 예를 들어, 서로 같은 두 수를 빼면 결과가 0이 되어 ZF플래그가 설정되는데, 이후에 CPU는 이 플래그를 보고 두 값이 같았는지 판단할 수 있다.

[Code]
1: mov rax, 0xA
2: mov rbx, 0xA
3: cmp rax, rbx ; ZF=1

 

- test op1, op2: op1과 op2를 비교

 두 피연산자에 AND 비트연산을 취하며, 연산의 결과는 op1에 대입하지 않는다.

예를 들어, 아래 코드에서 처럼 0이된 rax를 op1과 op2로 삼아 test를 수행하면, 결과가 0이므로 ZF플래그가 설정된다. 이후에 CPU는 이 플래그를 보고 rax가 0이었는지 판단할 수 있다.

[Code]
1: xor rax, rax
2: test rax, rax ; ZF=1

 

 

(5) 분기 : rip를 이동시켜 실행 흐름을 바꾼다. (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

 

 

(6) Opcode: 스택

- push val : val을 스택 최상단에 쌓음

연산
rsp -= 8
[rsp] = val


예제
[Register]
rsp = 0x7fffffffc400

[Stack]
0x7fffffffc400 | 0x0  <- rsp
0x7fffffffc408 | 0x0

[Code]
push 0x31337


결과
[Register]
rsp = 0x7fffffffc3f8

[Stack]
0x7fffffffc3f8 | 0x31337 <- rsp 
0x7fffffffc400 | 0x0
0x7fffffffc408 | 0x0

 

- pop reg : 스택 최상단의 값을 꺼내서 reg에 대입

연산
rsp += 8
reg = [rsp-8]


예제
[Register]
rax = 0
rsp = 0x7fffffffc3f8

[Stack]
0x7fffffffc3f8 | 0x31337 <- rsp 
0x7fffffffc400 | 0x0
0x7fffffffc408 | 0x0

[Code]
pop rax


결과
[Register]
rax = 0x31337
rsp = 0x7fffffffc400

[Stack]
0x7fffffffc400 | 0x0 <- rsp 
0x7fffffffc408 | 0x0

 

 

(7) Opcode: 프로시저

- 프로시저(Procedure)는 특정 기능을 수행하는 코드 조각을 말한다.

- 프로시저를 사용하면 반복되는 연산을 프로시저 호출로 대체할 수 있어서 전체 코드의 크기를 줄일 수 있으며, 기능별로 코드 조각에 이름을 붙일 수 있게 되어 코드의 가독성을 크게 높일 수 있다.

- 프로시저를 부르는 행위를 호출(Call)이라고 부르며, 프로시저에서 돌아오는 것을 반환(Return)이라고 부른다.

- 프로시저를 호출할 때는 프로시저를 실행하고 나서 원래의 실행 흐름으로 돌아와야 하므로, call 다음의 명령어 주소(Return Address, 반환 주소)를 스택에 저장하고 프로시저로 rip를 이동시킨다.

- x64어셈블리 언어에는 프로시저의 호출과 반환을 위한 call, leave, ret 명령어가 있다.

 

- call addr : addr에 위치한 프로시져 호출

연산
push return_address
jmp addr


예제
[Register]
rip = 0x400000
rsp = 0x7fffffffc400 

[Stack]
0x7fffffffc3f8 | 0x0
0x7fffffffc400 | 0x0 <- rsp

[Code]
0x400000 | call 0x401000  <- rip
0x400005 | mov esi, eax
...
0x401000 | push rbp


결과
[Register]
rip = 0x401000
rsp = 0x7fffffffc3f8

[Stack]
0x7fffffffc3f8 | 0x400005  <- rsp
0x7fffffffc400 | 0x0

[Code]
0x400000 | call 0x401000
0x400005 | mov esi, eax
...
0x401000 | push rbp  <- rip

 

- leave: 스택프레임 정리

연산
mov rsp, rbp
pop rbp


예제
[Register]
rsp = 0x7fffffffc400
rbp = 0x7fffffffc480

[Stack]
0x7fffffffc400 | 0x0 <- rsp
...
0x7fffffffc480 | 0x7fffffffc500 <- rbp
0x7fffffffc488 | 0x31337 

[Code]
leave


결과
[Register]
rsp = 0x7fffffffc488
rbp = 0x7fffffffc500

[Stack]
0x7fffffffc400 | 0x0
...
0x7fffffffc480 | 0x7fffffffc500
0x7fffffffc488 | 0x31337 <- rsp
...
0x7fffffffc500 | 0x7fffffffc550 <- rbp

 

- ret : return address로 반환

연산
pop rip


예제
[Register]
rip = 0x401008
rsp = 0x7fffffffc3f8

[Stack]
0x7fffffffc3f8 | 0x400005    <- rsp
0x7fffffffc400 | 0

[Code]
0x400000 | call 0x401000
0x400005 | mov esi, eax
...
0x401000 | mov rbp, rsp  
...
0x401007 | leave
0x401008 | ret  <- rip


결과
[Register]
rip = 0x400005
rsp = 0x7fffffffc400

[Stack]
0x7fffffffc3f8 | 0x400005
0x7fffffffc400 | 0x0    <- rsp

[Code]
0x400000 | call 0x401000
0x400005 | mov esi, eax   <- rip
...
0x401000 | mov rbp, rsp  
...
0x401007 | leave
0x401008 | ret

 

▶ 스택 프레임이란?

- 스택은 함수별로 자신의 지역변수 또는 연산과정에서 부차적으로 생겨나는 임시 값들을 저장하는 영역이다.

- 만약 이 스택 영역을 아무런 구분 없이 사용하게 된다면, 서로 다른 두 함수가 같은 메모리 영역을 사용할 수 있게 된다.

- 예를 들어 A라는 함수가 B라는 함수를 호출하는데, 이 둘이 같은 스택 영역을 사용한다면, B에서 A의 지역변수를 모두 오염시킬 수 있다. - 이 경우, B에서 반환한 뒤 A는 정상적인 연산을 수행할 수 없다.

- 따라서 함수별로 서로가 사용하는 스택의 영역을 명확히 구분하기 위해 스택 프레임이 사용된다.

- 대부분의 Application binary interface (ABI)에서는 함수는 호출될 때 자신의 스택프레임을 만들고, 반환할 때 이를 정리한다.

 

 

(8) Opcode: 시스템 콜

- 윈도우, 리눅스, 맥 등의 현대 운영체제는 컴퓨터 자원의 효율적인 사용을 위해, 그리고 사용자에게 편리한 경험을 제공하기 위해, 내부적으로 매우 복잡한 동작을 한다.

- 운영체제는 연결된 모든 하드웨어 및 소프트웨어에 접근할 수 있으며, 이들을 제어할 수도 있다.

- 해킹으로부터 이 막강한 권한을 보호하기 위해 커널 모드 유저 모드로 권한을 나눈다.

 

- 커널 모드는 운영체제가 전체 시스템을 제어하기 위해 시스템 소프트웨어에 부여하는 권한이다.

- 파일시스템, 입력/출력, 네트워크 통신, 메모리 관리 등 모든 저수준의 작업은 사용자 모르게 커널 모드에서 진행된다.

- 커널 모드에서는 시스템의 모든 부분을 제어할 수 있기 때문에, 해커가 커널 모드까지 진입하게 되면 시스템은 거의 무방비 상태가 된다.

 

- 유저 모드는 운영체제가 사용자에게 부여하는 권한이다.

- 브라우저를 이용하는 것, 유튜브를 시청하는 것, 게임을 하고 프로그래밍을 하는 것 등은 모두 유저 모드에서 이루어진다.

- 리눅스에서 루트 권한으로 사용자를 추가하고, 패키지를 내려 받는 행위 등도 마찬가지이다.

- 유저 모드에서는 해킹이 발생해도, 해커가 유저 모드의 권한까지 밖에 획득하지 못하기 때문에 해커로 부터 커널의 막강한 권한을 보호할 수 있다.

 

- 시스템 콜(system call, syscall)은 유저 모드에서 커널 모드의 시스템 소프트웨어에게 어떤 동작을 요청하기 위해 사용된다.

- 소프트웨어 대부분은 커널의 도움이 필요하다. 예를 들어, 사용자가 cat flag를 실행하면, cat은 flag라는 파일을 읽어서 사용자의 화면에 출력해 줘야 한다. 그런데 flag는 파일 시스템에 존재하므로 이를 읽으려면 파일 시스템에 접근할 수 있어야 한다. 유저 모드에서는 이를 직접 할 수 없으므로 커널이 도움을 줘야 한다. 여기서, 도움이 필요하다는 요청을 시스템 콜이라고 한다. 유저 모드의 소프트웨어가 필요한 도움을 요청하면, 커널이 요청한 동작을 수행하여 유저에게 결과를 반환한다.

 

- syscall: x64 아키텍처에서 시스템 콜을 위한 명령어이다.

syscall
요청: rax
인자 순서: rdi -> rsi -> rdx -> rcx -> r8 -> r9 -> stack


예제
[Register]
rax = 0x1   
rdi = 0x1   
rsi = 0x401000  
rdx = 0xb   

[Memory]
0x401000 | "Hello Wo"   
0x401008 | "rld"    

[Code]  
syscall 


결과
Hello World


해석
오른쪽의 syscall table을 보면, rax가 0x1일 때, 커널에 write 시스템콜을 요청한다. 
이때 rdi, rsi, rdx가 0x1, 0x401000, 0xb 이므로 커널은 write(0x1, 0x401000, 0xb)를 수행하게 된다.
write 함수의 각 인자는 출력 스트림, 출력 버퍼, 출력 길이를 나타낸다. 
여기서 0x1은 stdout이며, 이는 일반적으로 화면을 의미한다. 
0x401000에는 Hello World가 저장되어 있고, 길이는 0xb로 지정되어 있으므로, 화면에 Hello World가 출력된다.

 

- x64 syscall 테이블

syscall rax arg0(rdi) arg1(rsi) arg2(rdx)
read 0x00 unsigned int fd char *buf size_t count
write 0x01 unsigned int fd const char *buf size_t count
open 0x02 const char *filename int flags umode_t mode
close 0x03 unsigned int fd    
mprotect 0x0a unsigned long start size_t len unsigned long prot
connect 0x2a int sockfd struct sockaddr * addr int addrlen
execve 0x3b const char *filename const char *const *argv const char *const *envp

 

 

5) 실습 문제 - 1

- end로 점프하면 프로그램이 종료된다고 가정하자. 
프로그램이 종료됐을 때, 0x400000 부터 0x400019까지의 데이터를 대응되는 아스키 문자로 변환하면 어느 문자열이 나오는가?

[Register]
rcx = 0
rdx = 0
rsi = 0x400000
=======================
[Memory]
0x400000 | 0x67 0x55 0x5c 0x53 0x5f 0x5d 0x55 0x10
0x400008 | 0x44 0x5f 0x10 0x51 0x43 0x43 0x55 0x5d
0x400010 | 0x52 0x5c 0x49 0x10 0x47 0x5f 0x42 0x5c
0x400018 | 0x54 0x11 0x00 0x00 0x00 0x00 0x00 0x00
=======================
[code]
1: mov dl, BYTE PTR[rsi+rcx]
2: xor dl, 0x30
3: mov BYTE PTR[rsi+rcx], dl
4: inc rcx
5: cmp rcx, 0x19
6: jg end
7: jmp 1

 

(1) 1회차

1: mov dl, BYTE PTR[rsi+rcx] ▶ dl = 0x67

2: xor dl, 0x30 ▶ dl = 0x57 (0110 0111 xor 0011 0000 = 0101 0111)

3: mov BYTE PTR[rsi+rcx], dl ▶ BYTE PTR[0x400000] = 0x57 (지정된 주소에 byte 단위로 dl 값 대입)

4: inc rcx ▶ rcx = 0x1

5: cmp rcx, 0x19 ▶ false

6: jg end ▶ 직전 비교에서 두 피연산자 중 전자의 값이 클 경우 end로 이동 (false)

7: jmp 1 ▶ 1로 점프

 

▶▶▶

이 과정을 rcx가 0x1a가 될 때까지 계속한다. 즉, 이 과정이 끝난 후의 메모리는 다음과 같다.

여기서 0x400000부터 0x400019까지의 16진수를 아스키 코드로 변환해 보면 답이 나온다.

[Memory]
0x400000 | 0x57 0x65 0x6c 0x63 0x6f 0x6d 0x65 0x20 
0x400008 | 0x74 0x6f 0x20 0x61 0x73 0x73 0x65 0x6d 
0x400010 | 0x62 0x6c 0x79 0x20 0x77 0x6f 0x72 0x6c 
0x400018 | 0x64 0x21 0x00 0x00 0x00 0x00 0x00 0x00

 

 

6) 실습 문제 - 2

[Code]
main:
    push rbp
    mov rbp, rsp 		▶ 1. 함수 처음 부분, rbp/rsp의 위치를 동일하게 맞춘다.
                 		rbp(고정되어 있어 움직이지 않음), 
                        	rsp(스택이 움직일 때마다 움직이며 스택의 맨 윗부분을 나타냄)
    mov esi, 0xf		▶ 2. esi = 0xf
    mov rdi, 0x400500		▶ 3. rdi = 0x400500
    call 0x400497 <write_n>     ▶ 4. write_n으로 넘어감
    mov eax, 0x0		▶ 15. eax = 0x0
    				eax(함수의 반환값 저장)
    pop rbp			▶ 16. main 함수 종료, return
    ret
    
write_n:
    push rbp					
    mov rbp, rsp			▶ 5. 함수 처음 부분, rbp/rsp의 위치를 동일하게 맞춘다.
    mov QWORD PTR [rbp-0x8],rdi		▶ 6. [rbp-0x8] 위치에 rdi 값 저장
    mov DWORD PTR [rbp-0xc],esi		▶ 7. [rbp-0xc] 위치에 esi 값 저장
    xor rdx, rdx			▶ 8. rdx = 0으로 초기화
    mov edx, DWORD PTR [rbp-0xc]	▶ 9. edx = [rbp-0xc] 위치 = esi 값 저장 
    mov rsi,QWORD PTR [rbp-0x8]		▶ 10. rsi = [rbp-0x8] 위치 = rdi 값 저장
    mov rdi, 0x1			▶ 11. rdi = 0x1 저장
    mov rax, 0x1			▶ 12. rax = 0x1 저장
    syscall				▶ 13. 시스템 콜(rax = 0x1이므로 write 시스템 콜 수행)
    					커널은 write(rdi, rsi, edx) => 
                        		write(0x1(stdout), 
                        		0x400500(출력할 데이터의 주소값), 
                      			0xf(문자열의 길이))를 수행한다.
    pop rbp				▶ 14. write_n 함수 종료, return
    ret
    
==================================    
[Memory]
0x400500 | 0x3037207964343372
0x400508 | 0x003f367562336420

 

▶▶▶

따라서 0x400500부터 15(0xf)바이트만큼의 문자열이 화면에 출력된다.

Intel은 리틀 엔디안 방식을 사용하므로 다음과 같은 순서로 해석해야 한다.

72 33 34 64 79 20 37 30

20 64 33 62 75 36 3f

 

'프로그래밍 > 해킹' 카테고리의 다른 글

시스템 해킹 - 셸코드  (0) 2024.07.10
Wireshark 관련 지식  (0) 2024.07.08
x86-64 아키텍처 정리  (0) 2024.07.05
Command Injection for Windows  (1) 2024.07.05
Shell Code 앞에 붙는 접두사에 대한 고찰.  (0) 2024.07.04