카테고리 없음

[드림핵/학습] 리버스 엔지니어링 - x86 어셈블리

공지혜 2026. 3. 24. 13:38

명령어 집합 구조(Instruction Set Architecture, ISA)마다 각기 다른 어셈블리어를 가지고 있음.

강의는 x86-64 아키텍처 기준으로 진행.

명령어 기본 구조: 명령어+피연산자

피연산자는 0개~3개일 수 있음.

 

앞으로 학습할 중요한 21개의 명령어들

 

피연산자의 종류

  • 상수
  • 레지스터
  • 메모리 - [] 안에 쓰여서 표현됨. 괄호 앞에 크기 지정자 TYPE PTR이 올 수 있음. 종류는 BYTE PTR(1바이트), WORD PTR(2바이트), DWORD PTR(4바이트), QWORD PTR(8바이트).

크기 지정자 사용하는 이유

 

 

피연산자 표기법

 

산술 연산

  • add <destination>, <source>: dst=dst+src (피연산자: 레지스터, 상수, 메모리)
  • sub <destination>, <source>: dst=dst-src (피연산자: 레지스터, 상수, 메모리)
  • mul A: 적절한 레지스터 = rax 레지스터 값 * A (피연산자: 레지스터, 메모리 주소)

mul 명령어는 부호 없는 곱셈을 한 후, 알맞은 레지스터에 (필요한 경우 쪼개서) 결과를 저장한다.

8비트(AL 값) x 8비트 = 16비트 -> AX에 저장

16비트(AX 값) x 16비트 = 32비트 -> 상위 16비트는 DX, 하위 16비트는 AX에 저장

32비트(EAX 값) x 32비트 = 64비트 -> 상위 32비트는 EDX, 하위 32비트는 EAX에 저장

64비트(RAX 값) x 64비트 = 128비트 -> 상위 64비트는 RDX, 하위 64비트는 RAX에 저장

  • imul A: mul과 똑같이 연산 후 저장, 부호 있는 정수에 대해 곱셈
  • imul <dst>, <src>: dst=dst*src
  • imul <dst>, <src>, imme: A=B*imme

이때 연산 결과가 destination 크기를 초과하여 데이터 손실이 발생하면, 캐리 플래그(CF)와 오버플로우 플래그(OF)를 1로 설정하여 이를 알림. 

 

  • div/idiv 제수(나누는 수)

피제수의 크기에 따라 AX, DX:AX, EDX:EAX, RDX:RAX에 저장 -> 나눗셈 연산 -> 몫과 나머지를 크기에 따라 AL:AH, AX:DX, RAX:RDX에 저장

  • idiv는 부호 있는 정수 나눗셈
  • inc A: 레지스터/메모리에 저장된 값을 1씩 증가시킴
  • dec A: 레지스터/메모리에 저장된 값을 1씩 감소시킴

 

논리연산

  • AND A, B: 두 개의 비트가 모두 1일 때만 결과가 1 (* 특정 비트를 선택적으로 유지하는 비트 마스킹에 사용)
  • OR A, B: 하나 이상의 비트가 1이면 결과가 1 (* 특정 비트를 설정하는 데 사용)
  • XOR A, B: 두 개의 비트가 다르면 결과가 1 (* 비트 토클 및 암호화에 사용)
  • NOT A: 비트 반전

 

데이터 이동 연산

  • mov <destination>, <source>: source(레지스터/메모리/상수)값을 destination(레지스터/메모리)에 대입
  • lea <destination>, <source>: source(메모리 피연산자)의 메모리 주소를 계산해서 destination 레지스터에 유효 주소 저장

mov와 lea의 차이점

비교 연산

  • cmp <destination>, <source> -> 두 값의 차를 계산하여 여러 플래그를 다르게 설정
    ZF(Zero Flag): source-destination=0이면 1
    SF(Sign Flag): source-destination>0이면 1
    OF(Overflow): 오버플로우 발생 시 1
  • test <destination>, <source> -> AND 연산 후 결과는 버리고 플래그 레지스터만 갱신, 특정 비트/레지스터가 0인지 확인하기 위해 사용
    ZF=1이면 두 피연산자 중 적어도 하나가 0임을 알 수 있음

 

분기문

  • jmp <label>: 무조건 지정된 주소로 점프, 특정 주소로 rip을 바꿈
  • je/jz <label>: Jump if equal/Jump if zero, ZF=1이면 점프, 직전에 비교한 두 피연산자가 같으면 점프 (==)
  • jne/jnz <label>: Jump if Not Euqal/Jump if Not zero, ZF=0이면 점프, 직전에 피교한 두 피연산자가 다르면 점프 (!=)
  • jg <label>: Jump if Greater, (ZF==0) && (SF==OF)면 점프, 직전에 비교한 두 피연산자 중 전자가 더 크면 점프 (>)
  • jge <label>: Jump if Greater or Equal, SF==OF면 점프, 직전에 비교한 두 피연산자 중 전자가 더 크거나 같으면 점프 (>=)
  • jl <label>: Jump if Less, SF!=OF면 점프, 직전에 비교한 두 피연산자 중 전자가 더 작으면 점프 (<)
  • jle <label>: Jump if Less or Equal, (ZF==1) || (SF!=OF)면 점프, 직전에 비교한 두 피연산자 중 전자가 더 작거나 같으면 점프 (<=)

 

반복문

비교 연산과 분기문을 조합하여 만듦
1. 카운터 기반 반복문 (C의 for문)

    mov rcx, 10  ; 반복 횟수 설정
loop_start:
    ; 반복할 코드
    loop loop_start  ; RCX를 감소시키며 0이 아닐 때까지 반복

2. 조건 기반 반복문 (C의 while문)

check_condition:
    cmp rax, 50   ; 특정 조건 확인
    jge loop_exit ; 조건이 만족되면 종료
    ; 반복할 코드
    jmp check_condition ; 다시 비교 연산으로 이동
loop_exit:

3. 후조건 검사 반복문 (C의 do-while문)

do_loop:
    ; 반복할 코드 실행
    cmp rax, 10   ; 조건 검사
    jl do_loop    ; 조건이 만족하면 다시 실행반복문에서 주의할 점

 

함수 (=서브루틴=프로시저)

어셈블리어에서 프로그램이 처리해야 할 명령어들을 한 덩어리로 모아 높은 코드 블록. Label로 특정 구역을 표시하고 call 명령어 등을 활용해 함수를 사용할 수 있음.

함수를 호출하는 함수 caller, 호출된 함수 callee.

Caller는 call 명령어로 callee를 호출, callee는 ret 명령어로 다시 caller에서 실행 중이던 코드로 돌아감.

 

스택 관련 명령어

스택 자료 구조: LIFO (Last in, First out, 후입선출)

x86 아키텍처에서 스택은 높은 메모리 주소에서 낮은 메모리 주소로 자람. 즉, 데이터 추가하면 메모리 주소 감소, 데이터 제거하면 메모리 주소 증가. 

 

  • rsp: 스택 포인터 레지스터, 항상 스택의 top을 가리킴 -> push하면 rsp 감소, pop하면 rsp 증가
  • push val: 값 val를 스택에 저장함, 내부적으로는 rsp-=8, [rsp]=A 연산 수행됨 (rsp 한 칸 내리고, 그 위치에 값 저장)
  • pop reg: 스택의 top 값을 꺼내서 reg 레지스터에 대입, 내부적으로는 reg=[rsp], rsp+=8 연산 수행됨 (rsp 값=top 값을 다른 곳에 저장한 뒤 rsp 한 칸 올려서 스택 줄이기)

 

함수 호출 및 반환 관련 명령어

호출: 함수를 부르는 행위

반환: 함수에서 돌아오는 것

  • call addr: 주소 값 addr에 위치한 함수 호출
    - push return_address ; call 다음 명령어를 스택에 저장, 함수 끝나고 반환할 때 돌아올 주소
    - jmp addr ; 해당 함수의 시작 주소인 addr로 rip(instruction 포인터 레지스터) 옮김
  • leave: 함수가 반환되기 전, 스택 프레임 정리
    - mov rsp, rbp: rbp값을 rsp(top의 주소)에 저장, 그러면 rsp가 rbp까지 한 번에 끌어올려지면서 함수 실행 중 사용된 모든 스택이 정리됨, 그러면 rbp, rsp가 스택에서 같은 위치를 가리키고 있음
    - pop rbp: top값을 rbp에 저장, 이때 이 값은 이전 함수의 rbp 주소, rsp는 한 칸 올라감
  • ret: rip 레지스터 값을 return address로 변경
    - pop rip: top에 있던 return address 값이 rip에 저장되어 rip(intruction 포인터 레지스터)가 return address를 가리키게 됨

 

어셈블리어에서의 함수 선언

  1. 함수 시작 위치를 나타낼 라벨(label) 정의
  2. 스택 프레임이 필요한 경우, 함수 프롤로그를 통해 스택 프레임 구성
  3. 함수 내부에서 실제 동작 구현
  4. 함수 마지막에 함수 에필로그를 통해 스택 프레임 해제, ret 명령어로 종료

x86

add:
    push ebp ; 이전 함수의 ebp 스택에 저장 (함수 프롤로그) 
    mov ebp, esp ; 현재 esp가 가리키는 곳을 ebp가 가리키게 해서(둘 다 포인터 레지스터니까) 새로운 스택 프레임 시작점 만듦 (함수 프롤로그) 
	mov eax, [ebp + 8] ; ebp+8에 첫 번째 인자 X 있음, eax=X 
	add eax, [ebp + 12] ; ebp+12에 두 번째 인자 Y 있음, eax=X+Y
    leave ; (함수 에필로그)
    ret ; (함수 에필로그)

- cdecl 함수 호출 규약을 통해 함수를 선언

- cdecl 호출 규약에서는 함수 인자를 스택으로 전달

 

x64

add: push rbp ; 이전 함수의 rbp 스택에 저장 (함수 프롤로그) 
mov rbp, rsp ; 현재 rsp가 가리키는 곳을 rbp가 가리키게 해서(둘 다 포인터 레지스터니까) 새로운 스택 프레임 시작점 만듦 (함수 프롤로그) 
mov rax, rdi ; rdi에 저장된 첫 번째 인자 X를 rax에 저장, rax=X 
add rax, rsi ; rsi에 저장된 두 번째 인자 Y, rax=X+Y 
pop rbp ; (함수 에필로그) 
ret ; (함수 에필로그)

- SYSV 함수 호출 규약을 통해 함수를 선언

- 인자를 주로 레지스터로 주고받음

 

함수 호출 과정

x86

- 32비트 환경에서 cdecl 함수 호출 규약을 사용할 경우, 인자를 스택에 push한 후(컴파일러가 call 명령어 전에 알아서 넣어 줌) call 명령어로 함수 호출

- 함수가 끝난 뒤 caller가 스택 정리해 줌

x64

- 64비트 리눅스에서, System V ADM64 ABI 함수 호출 규약을 주로 사용

- 대부분의 정수 인자가 레지스터를 통해 전달됨

- 첫 번째 인자는 rdi, 두 번재 인자는 rsi에 들어감

 

Opcode: 시스템 콜

  • 커널 모드: 운영 체제가 전체 시스템을 제어하기 위해 시스템 소프트웨어에 부여하는 권한, 모든 메모리/하드웨어 직접 접근 가능
  • 사용자 모드: 운영체제가 사용자에게 부여하는 권한, 메모리 접근 제한, 하드웨어 직접 접근 불가
  • 시스템 콜: 유저 모드가 어떤 동작을 하고 싶을 때, 커널 모드의 시스템 소프트웨어에게 보내는 요청

 

시스템 콜 사용

x86

- int 0x80 명령어를 사용하여 시스템 콜 호출

- eax 레지스터에 호출하고자 하는 시스템 콜의 번호를 저장

- 나머지 인자들은 ebx, ecx, edx, esi, edi, ebp 등에 순서대로 저장

- 이후 시스템 콜 반환값은 eax 레지스터에 저장

 

x64

- syscall 명령어를 사용하여 시스템 콜 호출

- rax 레지스터에 호출하고자 하는 시스템 콜의 번호를 저장

- 나머지 인자들은 rdi, rsi, rdx, r10, r8, r9, 스택 순서대로 저장

- 이후 시스템 콜 반환값은 rax 레지스터에 저장