-
x86-64 어셈블리 입문을 위한 시리즈의 첫 번째 글 소개임
-
현대 64비트 시스템 기준으로 도구 설치 및 기본 구조 설명 제공임
-
Flat Assembler (FASM) 와 WinDbg를 주요 개발 및 디버깅 툴로 사용 안내임
- PE 포맷, DLL 임포트, 윈도우즈 호출 규약 등 실무에서 필요한 핵심 지식 요약 포함임
- 단순 종료 프로그램 작성 및 디버깅 절차 실습 경험 중심 설명임
소개 및 의의
- x86 어셈블리를 처음 접할 때 대학에서는 구식 환경(16비트, DOS, 세그먼트 메모리)에 기반한 방식으로 강의 수강 경험임
- 현대에는 64비트 프로세서가 주류인 만큼, 본 시리즈를 통해 실제로 쓰이는 x86-64 환경만을 다루고 구형 요소는 모두 배제함
- 본 튜토리얼은 Windows 운영체제 환경에서 동작하는 64비트 프로그램 개발에 집중함
- 라이브러리를 사용하지 않고, OS에 직접적으로 접근하는 최소한의 코드부터 시작함
- 이 글은 어셈블리를 처음 배우려는 개발자를 대상으로 하며, 기본적인 C/C++ 지식이 있다고 가정함
개발 도구 준비
어셈블러(Assembler)
- CPU는 인간이 이해하기 힘든 머신 코드만 해석 가능하며, 이를 사람이 읽을 수 있는 코드로 바꾼 것이 어셈블리 언어임
- 어셈블리 언어를 머신 코드로 변환해주는 프로그램이 어셈블러임
- x86-64 어셈블리어는 표준이 정해져 있지 않고, 어셈블러마다 문법과 동작 방식이 차별화됨
- 본 시리즈에서는 Flat Assembler(FASM) 를 사용하며, 작고 사용이 간편하고 강력한 매크로 시스템과 에디터를 제공함
디버거(Debugger)
- 작성한 어셈블리 코드를 분석 및 실행 흐름 관찰을 위해 디버거를 필수 도구로 활용함
-
WinDbg를 추천하며, 레지스터, 메모리, 어셈블리 코드 등을 독립적으로 확인 및 조작 가능함
- Windows 10 SDK에서 컴포넌트만 선택하여 설치 가능함
- 디버거를 통해 프로그램 내부 상태와 메모리 구조, 레지스터 변화를 직접 관찰할 수 있음
어셈블리 프로그래밍의 관점
CPU 구조와 명령어 집합
- CPU는 특정 명령어 집합에 따라 제한된 동작만 수행 가능함
-
명령어란 CPU가 수행할 수 있는 기본 단위 작업임
- 각 명령어는 매개변수와 함께 매우 단순(값 저장, 산술 연산 등)하게 동작하는 구조임
- 저수준 프로그래밍 및 디버깅에는 이러한 구조가 모든 고수준 개념의 기반임을 이해하는 것이 핵심임
레지스터(Registers)
-
레지스터는 CPU 내부에 내장된 매우 빠른 전용 메모리 영역임
- x86-64에는 일반 목적 레지스터가 16개 있으며 모두 64비트 크기임
- 각 레지스터는 바이트, 워드, 더블워드 단위로 부분 접근이 가능함
레지스터
하위 바이트
하위 워드
하위 더블워드
rax |
al |
ax |
eax |
rbx |
bl |
bx |
ebx |
rcx |
cl |
cx |
ecx |
rdx |
dl |
dx |
edx |
rsp |
spl |
sp |
esp |
rsi |
sil |
si |
esi |
rdi |
dil |
di |
edi |
rbp |
bpl |
bp |
ebp |
r8~r15 |
r8b~r15b |
r8w~r15w |
r8d~r15d |
-
rsp는 스택 포인터, rsi/rdi는 문자열 처리 인덱스로 동작하는 등 일부 레지스터에는 특수 목적이 할당됨
-
rip는 명령어 포인터, rflags는 연산 결과 상태 플래그를 담는 특별 레지스터임
메모리와 주소
- 메모리는 0번 인덱스부터 연속된 바이트 배열처럼 동작함
- 과거 x86 구조에서는 세그먼트-오프셋 방식이 필수였으나 x86-64에서는 모든 메모리를 플랫(Flat) 주소 공간으로 다룸
- 실제로는 운영체제와 하드웨어가 각 프로세스 별로 가상 주소 공간을 물리 메모리에 동적으로 매핑하여 제공함
- 즉, 동일한 가상 주소라 해도 서로 다른 프로세스에서는 다른 물리 메모리에 대응함
- 명령어와 데이터가 동일 메모리에 존재(폰 노이만 구조)하며, 이는 아두이노에 쓰이는 AVR처럼 데이터를 따로 저장하는 하버드 아키텍처와 구별됨
첫 번째 어셈블리 프로그램 작성
- FASM을 설치한 후 아래의 간단한 프로그램 코드 작성 및 빌드 실습
format PE64 NX GUI 6.0
entry start
section '.text' code readable executable
start:
int3
ret
코드 설명
-
format PE64 NX GUI 6.0 : FASM이 생성할 실행 파일 포맷을 지정, 여기서는 PE(Portable Executable) 64비트 GUI임
-
entry start : 프로그램이 진입할 엔트리 포인트를 정의, 해당 레이블(start) 위치에서 실행 시작함
-
section '.text' code readable executable : PE의 코드 구간임을 지정, 실행 가능한 영역임
-
start: : 앞서 지정한 진입점에 이름 붙임
-
int3 : 디버거용 브레이크포인트로 프로그램을 일시정지시켜 상태 점검 목적에 사용
-
ret : 스택의 주소를 꺼내 그 위치로 제어를 전환하는 명령, 이 프로그램에서 바로 종료 응답
디버깅 실습
-
WinDbg에서 위 프로그램의 실행파일(.exe)을 열고, 디스어셈블리·레지스터 등 다양한 창을 준비함
-
F5를 눌러 프로그램이 브레이크포인트에 도달하게 하고, F8을 누를 때마다 한 명령씩 실행(단계별 진행)함
-
레지스터(rip 등)의 변화를 실시간으로 관찰 가능
-
ret 실행 이후에는 운영체제로 제어가 전달되고, 이후 RtlExitUserThread를 호출하면서 스레드 및 프로세스 종료가 이어짐
-
주의 : ret 명령만으로 종료 시 스레드 외 추가 백그라운드 실행 여부에 따라 프로세스가 남아 있을 수 있으므로, 정상적인 종료 시에는 반드시 ExitProcess를 호출하는 것이 바람직함
PE 포맷과 DLL 임포트
DLL 함수 임포트 구조 개요
-
ExitProcess와 같은 WinAPI 함수들은 KERNEL32.DLL에 있음
- 이런 외부 함수를 사용하려면 실행 파일의 임포트 테이블(.idata 섹션)을 구성해야 함
- idata 섹션의 Import Directory Table(IDT) 에는 DLL명, 함수명, IAT/ILT 등의 주소(RVA) 정보가 담김
- IAT(Import Address Table)는 실제 함수 주소로 OS 로더에 의해 런타임에 덮어써짐
- Hint/Name Table은 각 함수의 이름과 힌트 정보로 이루어짐
FASM에서 .idata 섹션 정의 예시
section '.idata' import readable writeable
idt:
dd rva kernel32_iat
dd 0
dd 0
dd rva kernel32_name
dd rva kernel32_iat
dd 5 dup(0)
name_table:
_ExitProcess_Name dw 0
db "ExitProcess", 0, 0
kernel32_name: db "KERNEL32.DLL", 0
kernel32_iat:
ExitProcess dq rva _ExitProcess_Name
dq 0
- db/dw/dd/dq : 바이트/워드/더블워드/쿼드워드(8바이트) 단위로 값 삽입
- rva : 심볼의 가상 주소(Relative Virtual Address) 계산
- IAT와 Name Table을 수작업으로 구성해 DLL 함수 참조 가능
64비트 Windows 호출 규약(MS x64 Calling Convention)
- 함수 호출 시에 인자 전달 및 스택 사용 방법을 정하는 표준 규약
- 64비트 Windows에서는 Microsoft x64 Calling Convention을 사용
- 주요 특징 :
- 스택 포인터는 항상 16바이트 정렬 되어야 함
- 첫 4개 정수/포인터 인자는 rcx, rdx, r8, r9 레지스터 사용
- 첫 4개 부동소수점 인자는 xmm0~xmm3에 넣음
- 추가 인자는 스택 사용
- 인자 개수와 무관하게 32바이트 shadow space를 스택에 확보해야 함
- 스택 정리는 호출자가 담당
ExitProcess 호출 예시
format PE64 NX GUI 6.0
entry start
section '.text' code readable executable
start:
int3
sub rsp, 8 * 5
xor rcx, rcx
call [ExitProcess]
section '.idata' import readable writeable
idt:
dd rva kernel32_iat
dd 0
dd 0
dd rva kernel32_name
dd rva kernel32_iat
dd 5 dup(0)
name_table:
_ExitProcess_Name dw 0
db "ExitProcess", 0, 0
kernel32_name db "KERNEL32.DLL", 0
kernel32_iat:
ExitProcess dq rva _ExitProcess_Name
dq 0
신규 코드 분석
-
sub rsp, 8 * 5 : 스택 포인터 조정(40바이트 확보), 16바이트 정렬 및 shadow space 확보 한 번에 처리
-
xor rcx, rcx : 첫 번째 인자인 rcx 레지스터에 0을 할당(EXIT 코드로 활용)
-
call [ExitProcess] : import table에 실제로 기록된 ExitProcess의 함수 주소로 점프
-
WinDbg에서 단계별 실행 시, 스택 포인터(rsp) 및 rcx 레지스터의 변화, 그리고 프로세스 종료 흐름을 직접 확인 가능함
마무리
- 본 글은 기초 도구 세팅부터 PE 포맷, DLL 임포트, x64 호출 규약, 첫 프로그램 작성 및 디버깅까지 실습 중심으로 x86-64 어셈블리의 전반적 흐름을 안내함
- 다음 파트에서 보다 다양한 기능 구현과 실제 코드를 다룰 예정임