[Reversing] 5. x86 Assembly

Wonder_Land🛕·2022년 8월 25일
0

[Reversing]

목록 보기
5/6
post-thumbnail

[Reference] : 위 글은 다음 내용을 제가 공부한 후, 인용∙참고∙정리하여 만들어진 게시글입니다.


  1. 서론
  2. 어셈블리와 x86-64
  3. x86-64 어셈블리 명령어
  4. Q&A
  5. 마치며

1. 서론

컴퓨터는 여러 복잡한 논리적 인과관계 속에서, 여러 개체가 상호작용는데,
그 속에서 '기계어(Machine Code)'라는 언어를 사용합니다.

리버스 엔지니어의 일은,
그 복잡하고 거대한 세계의 동작을 이해하는 것입니다.

이를 위해, 리버스 엔지니어의 기본 소양 중 하나는 컴퓨터의 언어를 이해하는 것입니다.

하지만 우리 모두가 알다시피, 기계어는 인간이 이해하기에는 너무나도 힘듭니다.
0과 1로만 구성되어 있어, 단위 위주로 문장을 구성하는 우리와는 괴리가 너무나도 크죠.

그래서 컴퓨터 과학자 중 한 명인 David Wheeler는 EDSAC을 개발하면서 '어셈블리 언어(Assembly Language)'와 '어셈블러(Assembler)'를 고안했습니다.

나아가 사람들은 소프트웨어를 역분석하는, 즉 기계어를 어셈블리 언어로 번역하는 '역어셈블러(Disassembler)'를 개발했습니다.
기계어로 구성된 소프트웨어를 역어셈블러에 넣으면,
어셈블리 코드로 번역되는 것이죠.
이로 인해 분석가들은 소프트웨어 분석을 위해 기계어 코드를 볼 필요가 없어졌죠.

제가 공부하고 참조한 2개의 강좌를 통해, 어셈블리어에 대해 알아보겠습니다.


2. 어셈블리와 x86-64

1) 어셈블리 언어

  • 어셈블리 언어(Assembly Language)
    : 컴퓨터의 기계어와 치환되는 언어

기계어가 여러 종류라면, 어셈블리어도 여러 종류가 되어야 합니다.

명령어 집합 구조(Instruction Set Architecture, ISA)는 IA-32, x86-64, ARM, MIPS 등 종류가 굉장히 다양하죠.

따라서, 이들의 종류만큼 많은 어셈블리어가 존재합니다.


2) x64 어셈블리 언어

(1) 기본 구조

일반적으로 언어는 주어, 목적어, 서술어 등으로 이루어진 문법 구조를 가지고 있습니다.
그래서 우리는 문장에 문법적 의미를 부여하고, 그 의미를 이해할 수 있는 것이죠.

어셈블리어도 마찬가지 입니다.

어셈블리어는 동사에 해당하는 '명령어(Operation Code)',
목적어에 해당하는 '피연산자(Operand)'로 구성됩니다.

(2) 명령어(Operation Code)

인텔의 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) 피연산자(Operand)

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

  1. 상수(Immediate Value)
  2. 레지스터(Register)
  3. 메모리(Memory)

만약 피연산자로 메모리가 오게되면 [ ]으로 둘러싸인 것으로 표현됩니다.
또한 앞에 크기 지정자(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바이트만큼 참조

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

1) 데이터의 이동(Data Transfer)

  • 데이터의 이동(Data Transfer) 명령어
    : 어떤 값을 레지스터나 메모리에 옮기도록 지시

(1) mov 명령어 : mov dst, src

  • 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가 가리키는 주소에 대입

(2) lea 명령어 : lea dst, src

  • lea dst, src
    : src의 유효 주소(Effective Address, EA)를 dst에 저장

lea rsi, [rbx+8*rcx] : rbx+8*rcxrsi에 대입


2) 산술 연산(Arithmetic)

  • 산술 연산(Arithmetic) 명령어
    : 덧셈, 뺄셈, 곱셈, 나눗셈 연산을 지시

(1) add 명령어 : add dst, src

  • add dst, src
    : dst에 src의 값을 더함

add eax, 3 : eax += 3

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

(2) sub 명령어 : sub dst, src

  • sub dst, src
    : dst에 src의 값을

sub eax, 3 : eax -= 3

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

(3) inc 명령어 : inc op

  • inc op
    : op의 값을 1 증가

inc eax : eax += 1


3) 논리 연산(Logical)

  • 논리 연산(Logical) 명령어
    : and, or, xor, neg 등의 비트 연산을 지시
    : 연산이 비트 단위로 이루어짐

(1) and 명령어 : and dst, src

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

(2) or 명령어 : or dst, src

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

(3) xor 명령어 : xor dst, src

  • xor dst, src
    : dst와 src의 비트가 서로 다르면 1, 아니면 0

(4) not 명령어 : not op

  • not op
    : op의 비트 전부 반전

4) 비교 연산(Comparision)

  • 비교 연산(Comparision) 명령어
    : 두 피연산자의 값을 비교하고, 플래그를 설정

(1) cmp 명령어 : cmp op1, op2

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

cmp두 피연산자를 빼서 대소를 비교합니다.

참고로 연산의 결과는 op1에 저장하지 않습니다.

(2) test 명령어 : test op1, op2

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

test는 두 피연산자에 AND 비트연산을 취합니다.

참고로 연산의 결과는 op1에 저장하지 않습니다.


5) 분기 연산(Branch)

  • 분기 연산(Branch) 명령어
    : rip를 이동시켜 실행 흐름을 바꿈

참고로 분기문은 제가 공부하는 곳에서 나온 내용보다 훨씬 더 많은 내용이 존재합니다.

하지만 몇 개만 살펴보면 나머지는 직관적으로 의미를 파악할 수 있을 것입니다.

(1) jmp 명령어 : jmp addr

  • jmp addr
    : addr로 rip를 이동

(2) je 명령어 : je addr

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

(3) jg 명령어 : jg addr

  • jg addr
    : 직전에 비교한 두 피연산자 중 전자가 더 크면 점프(Jump if Greater)

6) 스택(Stack)

  • 스택(Stack) 명령어
    : 스택(Stack)을 조작

(1) push 명령어 : push val

  • push val
    : val을 스택 최상단에 쌓음
  • [연산]
    rsp -=8
    [rsp] = val

(2) pop 명령어 : pop val

  • pop val
    : 스택 최상단의 값을 꺼내서 reg에 대입
  • [연산]
    reg = [rsp]
    rsp += 8

7) 프로시저(Procedure)

  • 프로시저(Procedure) 명령어
    : 특정 기능을 수행하는 코드 조각

프로시저를 사용하면 반복되는 연산을 프로시저 호출로 대체할 수 있습니다.
또한 기능별로 코드 조각에 이름을 붙일 수 있습니다.

따라서, 전체 코드의 크기를 줄일 수 있고, 가독성을 높일 수 있습니다.

프로시저를 부르는 행위를 '호출(Call)'이라고 하며,
프로시저에서 돌아오는 행위를 '반환(Return)'이라고 합니다.

프로시저를 호출하고 수행이 완료되면, 원래의 실행 흐름으로 되돌아와야하기 때문에,
call 다음의 명령어 주소(Return Address, 반환 주소)를 스택에 저장하고, 프로시저를 rip로 이동시킵니다.

(1) call 명령어 : call addr

  • call addr
    : addr에 위치한 프로시저 호출
  • [연산]
    push return_address : 스택에 반환 주소 저장
    jmp addr : addr로 이동

(2) leave 명령어 : leave

  • leave
    : 스택프레임 정리
  • [연산]
    mov rsp, rbp
    pop rbp

(3) ret 명령어 : ret

  • ret
    : return address로 반환
  • [연산]
    pop rip

4. Q&A

-


5. 마치며

오늘은 어셈블리어에 대해 알아보았습니다.

사실 어셈블리어는 이렇게 보면 어느정도 알겠지만,
실제로 적용해보면 너무나 어려워서....

전 특히나 스택프레임을 보면 정말 눈과 머리가 핑핑 돌아요..

사실 어렵다기보다는 헷갈리는게 맞는거겠죠...😢

그래도 천천히 계속해서 꾸준히 하다보면 언젠간 익숙해지는 날이오겠죠...?😒

열심히 해보겠습니다....😐

[Reference] : 위 글은 다음 내용을 제가 공부한 후, 인용∙참고∙정리하여 만들어진 게시글입니다.

profile
아무것도 모르는 컴공 학생의 Wonder_Land

0개의 댓글