맨땅에 코딩

취약점 분석 - 어셈블리 언어 본문

화이트햇 스쿨 2기/이론교육

취약점 분석 - 어셈블리 언어

나는 푸딩 2025. 2. 13. 19:01

*화이트햇 스쿨 2기에서 이수한 이론교육 내용을 바탕으로 작성되었습니다.

 

1. 어셈블리 개요

 

CPU 아키텍쳐

- CPU 내부 동작 방식과 구성 요소를 정의하는 설계 개념

- CPU 아키텍처에 따라 명령어, 레지스터 구조 등이 다름

- x86, arm, mips, Power PC 등이 있음

 

x86

- 32bit 아키텍쳐

- 64bit 버전으로 x64(amd64, x86_64)가 있음

- Windows, Linux, Mac OS 등에서 사용됨

 

arm

- 32bit 아키텍쳐

- 64bit 버전으로 arm64(AArch64)가 있음

- 모바일 기기 및 임베디드 시스템에 사용됨

 

어셈블리어

- 어셈블리어: 기계어와 일대일 대응이 되는 저급 언어

- 기계어: 0과 1로 이루어진 컴퓨터가 이해할 수 있는 언어

- 저급언어: 컴퓨터가 이해하기 쉬운 언어로, 어셈블리어, 기계어가 있음

- 고급언어: 사람이 이해하기 쉬운 언어로, C언어, 자바, 파이썬 등이 있음

 

어셈블리어를 공부하는 이유

- 디컴파일러 버그: 함수나 switch문 등을 인식 못하는 경우, 함수가 너무 큰 경우, 디컴파일된 코드만 보면 변수에 0 또는 1만 삽입될 수 있지만 어셈블리로 보면 -1도 올 수 있는 경우 등

- 가젯 탐색: 시스템 해킹 시 코드 조각들을 이어 붙여서 공격하는데, 이때 코드 조각은 어셈블리어로 이루어져 있음

- 셸 코드 작성: 셸 코드는 소프트웨어 공격 시에 사용되는 작은 코드로, 보통 어셈블리어로 작성된 후 기계어로 변환하여 사용됨

 

2. 레지스터

 

범용 레지스터 - x86

- 주소나 값을 저장하는 공간

- EAX, EBX, ECX, EDX ESI, EDI, EBP,ESP

- 특수한 용도로 쓰이는 레지스터 이외에 자유롭게 사용하면 됨

  - EAX: 연산을 할 때 자유롭게 사용되고, 함수의 리턴 값을 저장함

  - EBP: 현재 스택 프레임의 베이스 주소를 저장함

  - ESP: 스택의 최상단의 주소를 저장함

 

범용 레지스터 - x64

- 주소나 값을 저장하는 공간

- RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, R8~R15

- 특수한 용도로 쓰이는 레지스터 이외에 자유롭게 사용하면 됨

  - RAX: 연산을 할 때 자유롭게 사용되고, 함수의 리턴 값을 저장함

  - RBP: 현재 스택 프레임의 베이스 주소를 저장함

  - RSP: 스택의 최상단의 주소를 저장함

- 8바이트 데이터를 RAX에 저장하는 예시

 

명령 포인터

- 다음에 실행할 명령의 주소를 저장하는 레지스터

- x86에서는 EIP, x64에서는 RIP라고 부름

- 시스템 해킹의 주요 목표는 명령 포인터를 조작하는 것

 

3. 데이터 이동

 

어셈블리 문법 구조

- 어셈블리 언어는 명령어(Opcode)와 피연산자(Operand)로 구성됨

 

피연산자

- 피연산자 종류: 상수, 레지스터, 메모리

 

데이터 이동 - mov

- mov

  - mov는 operand2를 operand1으로 이동

  - oeprand1에는 레지스터나 메모리 모두 올 수 있음

  - operand2에는 상수, 레지스터, 메모리 모두 올 수 있음

  - 두 피연산자의 크기가 반드시 일치해야함

  - 두 피연산자 동시에 메모리가 올 수 없음

- 잘못된 mov 예시

- 올바른 mov 예시

 

데이터 이동 - lea

- lea

  - lea는 operand2의 주소를 operand1로 이동함

  - operand1에는 레지스터만 올 수 있음

  - operand2에는 메모리만 올 수 있음

  - lea는 주소를 연산해서 가져올 수 있음

 

4. 산술연산

 

음수 표현

- 2의 보수를 통해 음수 값을 저장하고 처리함

- 최상위 비트는 부호 비트이며, 최상위 비트가 1일 때 음수임

- 2의 보수는 모든 비트를 반전시키고 1을 더하면 됨

 

산술연산 - add, sub

- add

  - operand1과 operand2를 더해서 operand1에 저장함

- sub

  - operand1에서 operand2를 빼서 operand1에 저장함

 

산술연산 - add 예제

- 다음 어셈블리 명령이 실행 된 이후 eax 값은?

정답: 0 

add의 연산 결과가 레지스터의 범위를 넘어가면, 초과한 값은 버려짐

eax는 4바이트지만, add의 첫 번째 피연산자가 ax이기 때문에 0x10000에서 1이 버려짐

 

산술연산 - sub 예제

- 다음 어셈블리 명령이 실행 된 이후 eax의 값은?

정답: 0xfffe

sub 연산은 내부적으로 두번째 피연산자를 2의 보수로 변환하고 더함

- 3을 2의 보수로 변환 (0xfffd)

- 1 - 3: 1 + 0xfffd = 0xfffe

 

산술연산 - mul

- mul

  - 부호 없는 정수 (Unsigned)의 곱셈을 수행함

 

산술연산 - imul

  - 부호 있는 정수 (Signed)의 곱셈을 수행함

  - operand1에는 레지스터만 올 수 있음

  - operand2에는 레지스터 또는 메모리만 올 수 있음

  - operand3에는 상수만 올 수 있음

  - mul과 같은 방식으로 동작함

  - operand1과 operand2를 곱해서 operand1에 저장함

  - operand2와 operand3(상수)을 곱해서 operand1에 저장함

  - imul에서 피연산자를 2개 이상 사용할 경우 레지스터를 넘어가는 값은 버려짐

 

산술연산 - div

- 부호 없는 정수(Unsigned)의 나눗셈을 수행함

 

산술연산 - idiv

  - 부호 있는 정수(Signed)의 나눗셈을 수행함

 

증감연산 - inc, dec

- inc

  - operand1에 1을 더함

- dec

  - operand1에 1을 뺌

 

5. 비트연산

  - operand1과 operand2를 and 연산한 후 operand1에 저장함

 

비트연산 - or

- or

  - operand1과 operand2를 or 연산한 후 operand1에 저장함

 

비트연산 - xor

- xor

  - operand1과 operand2를 xor 연산한 후 operand1에 저장함

 

비트연산 - not

- not

  - operand1의 비트를 반전하고 operand1에 저장함 (1의 보수)

 

비트연산 - neg

- neg

  - operand1을 부호를 반전하고 operand1에 저장함 (2의 보수)

 

비트연산 - shl, shr

- shl, shr

  - operand1의 비트를 왼쪽이나 오른쪽으로 operand2 만큼 이동시킴

  - 범위를 벗어난 비트는 버려지고 새로 생기는 공간은 0으로 채움

  - operand2에는 상수나 cl 레지스터가 올 수 있음

 

비트연산 - rol, ror

- rol, ror

  - operand1의 비트를 왼쪽이나 오른쪽으로 operand2 만큼 이동시킴

  - 범위를 벗어난 비트는 버려지지 않고 회전되어 새로 생기는 공간으로 이동함

  - operand2에는 상수 cl 레지스터가 올 수 있음

 

비트연산 - 예제

- 1비트 이동 예제

 

 

6. 제어문

 

제어문 - cmp

- cmp

  - 두 연산자를 빼서 비교하며 결과는 저장하지 않음

  - 비교 결과에 따라 다양한 플래그가 설정됨

 

제어문 - test

- test

  - 두 피연산자를 AND 연산하여 비교하며 결과는 저장하지 않음

  - 비교 결과에 따라 다양한 플래그가 설정됨

  - 매우 높은 확률로 operand1과 operand2는 같은 레지스터가 위치함

  - 레지스터의 값이 0이라면 ZF가 세팅됨(레지스터의 값이 0인지 확인하기 위하여 test 명령을 사용함)

 

EFLAGS 레지스터

- 다양한 산술 연산 결과의 상태를 알려줌

- 상태 플래그의 종류는 다양하지만 대표적인 것들만 알아도 됨

  - CF (Carry): 부호 없는 수의 연산 결과의 비트가 넘을 경우 설정 (자리올림, 자리빌림)
  - ZF (Zero): 연산 결과가 0일 경우에 설정

  - SF (Sign): 연산의 결과가 음수일 경우 설정
  - OF (Overflow): 연산의 결과로 인해 부호 비트가 반전될 경우 설정 (오버플로우, 언더플로우)

 

상태 플래그 설정 예제

- 1바이트 공간에서 0xff + 1을 계산하면 자리올림이 발생하고 al에 0이 저장됨

- 즉 CF, ZF가 설정됨

  - 0x7f(127)가 0x80(-128)이 되어서 오버플로우가 발생함

  - 즉 SF, OF가 설정됨

- 0x80(-128)이 0x7f(127)가 되어서 언더플로우가 발생함

- 즉 OF가 설정됨

 

점프 명령 - jmp

- 아무 조건 없이 무조건 operand1로 점프함

  - 어셈블리 프로그래밍을 할 때는 operand1에 라벨을 넣은

  - 리버스 엔지니어링을 할 때는 operand1에 주소가 들어있음

- 어셈블리에서 점프 명령어는 무수히 많지만 모두 알 필요는 없음

  - jmp, ja, jae, jb, jbe, jcxz, jecxz, je, jg, jge, jl, jle, jna, jnb, jnbe, jnc, jne 등

 

조건 점프

 

점프 명령 응용

- jnz, jne와 같이 중간에 n(not)을 삽입하면 부정할 수 있음
  - jnz, jne, jnl, jnb, jng, jns, jns
- jle, jbe와 같이 명령어의 끝에 e(equal)를 삽입하면 "같다"라는 의미를 추가할 수 있음
  - jle, jbe, jge, jae
- 위 두가지 규칙을 동시에도 적용할 수 있으나 거의 사용하지는 않는다.
  - jnle(jg), jnbe(ja), jnge(jl), jnae(jb)

스택과 함수

스택

- 후입선출(LIFO, Last-In-First-Out) 특성을 가지는 자료구조임
- push 연산으로 데이터를 스택에 넣고 pop 연산으로 데이터를 꺼낼 수 있음
- 스택은 높은 주소에서 낮은 주소로 거꾸로 자람

 

스택 - push

스택의 최상단에 operand1을 삽입함
- 스택 포인터가 감소함 (32비트일땐 4, 64비트일땐 8)
- operand1에는 레지스터, 메모리, 상수 모두 올 수 있음

 

스택 - pop

- 스택의 최상단 값을 operand1에 로드함
- 스택 포인터가 증가함 (32비트일땐 4, 64비트일땐 8)
- operand1에는 레지스터와 메모리만 올 수 있음

 

함수 - call

- 함수를 호출 (operand1로 이동)
  - 어셈블리 프로그래밍을 할 때는 operand1에 라벨을 넣음

  - 리버스 엔지니어링을 할 때는 operand1에 주소나 함수 명이 들어있음
  - 인자가 있는 경우 함수 호출 규약에 따라 인자를 레지스터 또는 스택으로 전달함
  - operand1로 이동하기 전에 복귀 주소를 스택에 넣고 이동

 

- 복귀 주소

  - 함수 호출이 끝난 이후 다시 돌아와야 하기 때문에 스택에 저장하는 주소

 

함수 - leave, ret

- leave: 스택 프레임을 정리함

- ret: return address로 복귀함

 

함수의 프롤로그

- 함수의 프롤로그

 

- 함수 호출

 

- SFP(Saved Frame Pointer) 저장

 

- rbp 이동

 

- 스택 공간 확보

 

함수의 에필로그

- 함수의 에필로그

 

- 스택 프레임 정리(mov rsp, rbp)

 

- 스택 프레임 정리 (pop rbp)

 

- return address 복귀(pop rip)

 

8. 기타 명령어

 

데이터 이동 - xchg

- xchg

  - 두 피연산자의 값을 교환함

  - 두 피연산자의 레지스터나 메모리만 올 수 있음

 

데이터 이동 - movzx

- movzx

  - operand2를 operand1로 이동하고 나머지 비트를 0으로 채운다.
  - operand2가 양수일 때 사용한다.
  - operand1이 operand2보다 커야 한다.
  - operand2에는 1바이트, 2바이트 크기의 레지스터 또는 메모리만 올 수 있다.
    - movzx rax, ebx와 같은 명령은 성립하지 않는다.

 

- movzx 예제

  - 다음 어셈블리 명령이 실행 된 이후 rax의 값은?

  - 정답: 0x2222
  - movzx를 사용하면 두 operand의 크기가 달라도 값을 이동할 수 있음
  - 2바이트 데이터를 이동한 후 나머지 비트는 0으로 채움

 

- movzx 예제

  - 다음 어셈블리 명령이 실행 된 이후 rax의 값은?

  - 정답: 0x22222222
  - x86-64에서 4바이트 mov는 movzx와 같이 동작하여 상위 4바이트를 0으로 채움
  - 1바이트, 2바이트 mov는 상위 바이트를 0으로 채우지 않음

 

데이터 이동 - movsx

- movsx

  - operand2를 operand1로 이동하고 나머지 비트를 1로 채움
  - operand2가 음수일 때 사용함
  - operand1이 operand2보다 커야 함
  - movsxd 명령을 사용하면 4바이트 크기의 데이터도 operand1로 이동할 수 있음

 

- movsx 예제

  - 다음 어셈블리 명령이 실행 된 이후 rax의 값은?

  - 정답: 0xfffffffffffffedc
  - 0xfedc가 음수를 유지하기 위해선 확장될 때 나머지 비트가 모두 1이 되어야 함

 

nop

- nop

  - 아무런 역할을 하지 않는 명령어
  - 주로 코드 패치를 할 때나 셸 코드를 사용할 때 씀

9. 시스템 콜

 

시스템 콜

- 커널이 제공하는 서비스에 접근하기 위한 인터페이스

- 프로세스, 파일, 장치, 정보, 통신, 보안 등의 기능은 커널에서 처리해서 결과를 반환해줌

- 각 시스템 콜에는 번호가 할당됨

- 자주 접하는 시스템 콜: open, read, write, mmap, mprotect, execve

 

시스템 콜 - x86

- int

 - x86에서 시스템 콜을 호출하는 명령임
  - 시스템 콜을 호출하기 이전에 시스템 콜 번호와 인자들을 세팅해야 함
  - x86에서는 eax에 시스템 콜 번호를 넣고 인자는 ebx, ecx, edx, esi, edi, ebp에 넣음

 

시스템 콜 – x64

- syscall

  - x64에서 시스템 콜을 호출하는 명령임
  - 시스템 콜을 호출하기 이전에 시스템 콜 번호와 인자들을 세팅해야 함
  - x64에서는 rax에 시스템 콜 번호를 넣고 인자는 rdi, rsi, rdx, r10, r8, r9에 넣음