x86-64 어셈블리 배우기

5 hours ago 2

  • 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 어셈블리의 전반적 흐름을 안내함
  • 다음 파트에서 보다 다양한 기능 구현과 실제 코드를 다룰 예정임

Read Entire Article