어셈블리 언어 정리

Haruster·2022년 10월 5일
0
post-thumbnail

> 어셈블리 언어

  • 어셈블리 언어 : 어셈블리 언어는 컴퓨터의 기계어와 치환되는 언어로서 다시 말하자면, 기계어가 여러 종류라고 한다면, 어셈블리어도 여러 종류여야 함을 의미하며, CPU에 사용되는 ISA(명령어 집합 구조)에 따라서 IA-32, X86-64, ARM, MIPS 등 ISA의 종류만큼 많은 수의 어셈블리어가 존재한다.

  • (ARM에는 ARM만의 어셈블리어가 존재하며, X64에는 X64만의 어셈블리어가 존재한다.)

> 어셈블리 언어의 기본 구조

  • 어셈블리 언어는 우리가 사용하는 한국어나 영어와 같은 언어보다는 훨씬 더 단순한 문법 구조를 지니며, 어셈블리의 문장은 동사에 해당하는 명령어 (Operation Code, Opcode)와 목적어에 해당하는 피연산자(Operand)로 구성된다.
문법

Opcode	Operand1, Operand2
Ex)

mov	eax, 3	:	eax에 3이라는 값을 대입한다.

> 어셈블리의 명령어

  • 데이터 이동 : mov, lea

  • 산술 연산 (Arithmetic) : inc, dec, add, sub

  • 논리 연산 (Logical) : and, or, xor, not

  • 비교 (Comparison) : cmp, test

  • 스택 (Stack) : push, pop

  • 프로시저 (Procedure) : call, ret, leave

  • 시스템 콜 (System Call) : syscall

*어셈블리의 피연산자

  • 어셈블리의 피연산자에는 총 3가지 종류가 올 수 있으며, 아래와 같다.
  1. 상수 (Immediate Value)

  2. 레지스터 (Register)

  3. 메모리 (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) 자료형을 추가로 만들었다.

*데이터 이동

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

> mov 명령어

Ex)

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 명령어

Ex2)

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]


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

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

*산술 연산

> add 명령어

add dst, src  :  dst에 src의 값을 더한다.

add eax, 3  :  eax에 3을 더한다.

add ax, WORD PTR[rdi]  :  ax += *(WORD *)rdi

> sub 명령어

sub eax, 3  :  eax에서 3을 뺸다.

sub ax, WORD PTR[rdi] : ax += *(WORD *)rdi

> inc 명령어

inc op : op의 값을 1 증가시킨다.

inc eax : eax += 1

> dec 명령어

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, [rax+rcx*8]

2: add rcx, 2

3: sub rax, [rax+rcx*8]

4: inc rax


	Code를 1까지 실행했을 때, rax에 저장된 값은 0x3133A이다. (rax의 값은 rbx+0x10(0x555555554010)에 저장된 0x3만큼 증가한다.)

	Code를 3까지 실행했을 때, rax에 저장된 값은 0이다. (rax의 값은 rbx+0x20에 저장된 0x3133A만큼 감소한다.)

	Code를 4까지 실행했을 때, rax에 저장된 값은 1이다. (rax의 값은 1 증가한다.)

*논리 연산 (and & or)

  • 논리 연산 명령어는 and, or, xor, not, neg 등의 비트 연산을 지시하며, 해당 연산들은 비트 단위로 이루어진다.

> and 연산

and dst, src  :  dst와 src의 비트가 모두 1이면, 1, 아니면 0


[Register]

eax = 0xffff0000
ebx = 0xcafebabe


[code]

and eax, ebx


[Result]

eax = 0xcafe0000

> or 연산

or dst, src  :  dst와 src의 비트 중 하나라도 1이면, 1, 아니면 0


[Register]

eax = 0xffff0000
ebx = 0xcafebabe


[code]

or eax, ebx


[Result]

eax = 0xffffbabe
  • 논리 연산 and, or 예제
// 논리 연산 and, or 예제

[Register]

rax = 0xffffffff00000000

rbx = 0x00000000ffffffff

rcx = 0x123456789abcdef0



[Code]

1: and rax, rcx

2: and rbx, rcx

3: or rax, rbx


	Code를 1까지 실행했을 때, rax에 저장된 값은 0x1234567800000000 이다.
	Code를 2까지 실행했을 때, rbx에 저장된 값은 0x0000000009abcdef0 이다.
	Code를 3까지 실행했을 때, rax에 저장된 값은 0x123456789abcdef0 이다.

*논리연산(xor & not)

> xor 연산

xor dst, src  :  src의 비트가 서로 다르면, 1, 같으면 0


[Register]

eax = 0xffffffff
ebx = 0xcafebabe


[code]

xor eax, ebx


[Result]

eax = 0x35014541

> not 연산

not op  :  op의 비트를 전부 반전시킨다.


[Register]

eax = 0xffffffff


[code]

not eax


[Result]

0x00000000
  • 논리 연산 xor, not 예제
// 논리 연산 xor, not 예제

[Register]

rax = 0x35014541

rbx = 0xdeadbeef


[Code]

1: xor rax, rbx

2: xor rax, rbx

3: not eax


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

	Code를 2까지 실행했을 때, rax에 저장되는 값은 0x35014541 이다. (xor 연산을 동일한 값으로 두 번 실행할 경우, 원래 값으로 돌아간다.)

	Code를 3까지 실행했을 때, rax에 저장되는 값은 0xcafebabe 이다. (Code의 3번에서 rax가 아닌 eax를 not하여도 괜찮은 이유는 eax가 rax의 하위 32비트를 가리키는 부분이기 때문이며, 만약 not rax를 수행했다면, 값은 0xffffffffcafebabe가 된다.)

*비교 (cmp, test)

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

> cmp 명령어

cmp : cmp는 두 피연산자를 뺴서 대소를 비교하며, 연산의 결과는 변수에 대입되지 않는다.
-> 예를 들어, 서로 같은 두 수를 빼면, 결과가 0이 되어 ZF가 설정되는데, 이후에 CPU는 해당 플래그를 보고 두 값이 같았는지를 판단할 수 있다.

cmp op1, op2  : op1과 op2를 비교한다.


[code]

1: mov rax, 0xA
2: mov rbx, 0xA
3: cmp rax, rbx		; ZF = 1 (ZeroFlag가 설정된다. (ZF = 1))

> test 명령어

test : test는 두 피연산자에 AND 비트연산을 취하며, 연산의 결과는 변수에 대입하지 않는다.
-> 예를 들어, xor연산을 통해서 0이된 rax를 변수 1과 변수 2로 삼아 test를 수정하면, 결과가 0이므로 ZF가 설정되며, 이후에 CPU는 해당 플래그를 보고 rax가 0이였는지를 판단할 수 있다.


test op1, op2  :  op1과 op2를 비교

[code]

1: xor rax, rax
2: test rax, rax	; ZF = 1 (ZeroFlag가 설정된다. (ZF = 1))

*분기

  • 분기 명령어는 rip를 이동시켜 실행 흐름을 바꾼다.
  • 분기문은 굉장히 많은 수가 존재하지만, 몇 개만 살펴보면, 대략적으로 이름을 통해서 직관적으로 의미를 파악할 수 있다.

> jmp 명령어

jmp addr  :  addr로 rip를 이동시킨다.

[Code]

1: xor rax, rax

2: jmp 1	; jump to 1

> je 명령어

je addr : 직전에 비교한 두 피연산자가 같으면 점프한다. (Jump to Equal)

[Code]

1: mov rax, 0xcafebabe

2: mov rbx, 0xcafebabe

3: cmp rax, rbx		; rax == r

4: je 1		; jump to 1

> jg 명령어

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

스택 (Stack)

  • x64 아키텍처에서는 아래의 명령어들로 스택을 조작할 수 있습니다.

> push 명령어

push val : rsp를 8만큼 빼고, 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 명령어

pop reg : 스택 최상단의 값을 꺼내서 reg에 삽입하고, rsp를 8만큼 더합니다.

> 연산 

reg = [rsp]
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
0x7fffffff4408	|	0x0

프로시저 (Procedure)

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

  • 프로시저를 부르는 행위를 호출(Call)이라고 부르며, 프로시저에서 돌아오는 것을 반환(Return)이라고 부르고, 프로시저를 호출할 때는 프로시저를 실행하고 나서 원래의 실행 흐름으로 돌아와야 하므로, call 다음의 명령어 주소(return address, 반환 주소)를 스택에 저장하고 프로시저로 rip를 이동시킵니다.

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

> call 명령어

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



> 결과

[Register]

rip = 0x401000

rsp = 0x7fffffffc3f8


[Stack]

0x7fffffffc3f8		|	0x40005		<=	rsp
0x7fffffffc400		|	0x0


[Code]

0x400000	|	call 0x401000
0x400005	|	mov esi, eax

...

0x401000	|	push rbp <= rip

> leave 명령어

leave : 스택프레임 정리

> 연산 

mov rsp, rbp

pop rbp


> 예제

[Register]

rsp = 0x7fffffffc400

rbp = 0x7fffffffc480


[Stack]

0x7fffffffc400	|	0x0		<=	rsp

...

0x7fffffffc480	|	0x7fffffffc500	<=	rbp

0x7fffffffc488	|	0x31337


[Code]

leave


> 결과

rsp = 0x7fffffffc488

rbp = 0x7fffffffc500


[Stack]

0x7fffffffc400	|	0x0

...

0x7fffffffc480	|	0x7fffffffc500

0x7fffffffc488	|	0x31337	<=	rsp

....

0x7fffffffc500	|	0x7fffffffc500	|	0x7fffffffc550	<=	rbp

* 스택프레임이란?

  • 스택은 함수별로 자신의 지역변수 또는 연산과정에서 부차적으로 생겨나는 임시 값들을 저장하는 영역으로, 만약 해당 스택 영역을 아무런 구분 없이 사용하게 된다면, 서로 다른 두 함수가 같은 메모리 영역을 사용할 수 있게 됩니다.

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

  • 따라서 함수별로 서로가 사용하는 스택의 영역을 명확히 구분하기 위해서 스택프레임이 사용됩니다. (우분투 18.04에서는 함수를 호출할 때, 자신의 스택프레임을 만들고, 반환할 때 이를 정리합니다.)

> ret 명령어

ret : return address로 반환 (호출자의 실행 흐름으로 돌아갑니다.)


> 연산

pop rip

[Register]

rip = 0x401000
rsp = 0x7fffffffc3f8


[Stack]

0x7fffffffc3f8	|	0x400005	<=	rsp


[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

시스템 콜

  • 현대의 운영체제는 컴퓨터 자원의 효츌적인 사용 및 사용자에게 편리한 경험을 제공하기 위해서 내부적으로 매우 복잡한 동작을 하며, 운영체제는 연결된 모든 하드웨어 및 소프트웨어에 접근할 수 있으며,이들을 제어할 수도 있고, 해킹으로부터 권한들을 보호하기 위해서 커널 모드와 유저 모드로 권한을 나눕니다.

> 커널 모드

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

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

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

> 유저 모드

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

  • 브라우저를 이용하여 웹 사이트를 보거나, 개발자 도구를 사용하여 프로그래밍을 하는 것 등은 모두 유저 모드에서 이루어지며, 리눅스에서 루트 권한으로 사용자를 추가하고, 패치지를 내려 받는 행위도 마찬가지입니다.

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

> 시스템 콜 (system call, syscall)

  • 시스템 콜(System Calll, Syscall)은 유저 모드에서 커널 모드의 시스템 소프트웨어에게 어떠한 동작을 요청하기 위해서 사용되며, 소프트웨어 대부분은 커널의 도움이 필요합니다.

  • 예를 들어, 사용자가 cat flag를 실행하면, cat은 flag라는 파일을 읽어서 사용자의 화면에 출력해줘야 하지만, flag는 파일 시스템에 존재하므로, 이를 읽으려면 파일 시스템에 접근할 수 있어야 합니다.

  • 유저 모드에서는 이를 직접할 수 없으므로 커널이 도움을 주어야 하는데, 여기서, 도움이 필요하다는 요청을 시스템 콜(System Call)이라고 하며, 유저 모드의 소프트웨어가 필요한 도움을 요청하면, 커널이 요청한 동작을 수행하여 유저에게 결과를 반환합니다.

  • x64 아키텍처에서는 시스템 콜을 위한 syscall 명령어가 있습니다.

* 시스템 콜

  • 시스템 콜은 함수이며, 필요한 기능과 인자에 대한 정보를 레지스터로 전달하면, 커널이 이를 읽어서 요청을 처리합니다.

  • 리눅스에서는 x64아키텍처에서 rax로 무슨 요청인지 나타내고 인자 순서에 따라 필요한 인자를 전달합니다.

> syscall

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 테이블

  • 아래는 시스템 콜 테이블의 일부이며, 시스템 콜 테이블은 총 갯수가 300개에 달하고, 검색하면 쉽게 찾을 수 있으므로 외울 필요는 없습니다.

profile
다양한 스택을 공부하는 정보보안 전문가 지망생입니다. (Pwnable, Reversing, Webhacking, ...)

0개의 댓글