← SEARCHABLE ARCHIVE
Assembly · 2026-08-15

x86-64 디버깅과 역어셈블 실전

크래시 주소에서 함수·스택·ABI·메모리 계약을 복원하는 순서

INTERACTIVE READING MODE →

x86-64 디버깅과 역어셈블 실전

본 블로그 작성 글들은 강의를 목표로 제작되었습니다. 역어셈블은 binary의 관찰 가능한 동작을 해석하는 도구이지, 소스 의도나 권한을 증명하는 도구가 아닙니다. 본인이 분석할 권한이 있는 프로그램과 크래시를 대상으로 사용하세요.

최적화 빌드의 크래시는 소스 줄 하나로 설명되지 않는 경우가 많다. 변수는 사라지고, 함수는 inline되며, instruction pointer는 mov 한 줄 안을 가리킨다. 이때 필요한 것은 opcode를 추측하는 능력보다 크래시 시점의 register, stack, memory와 ABI 계약을 증거 순서대로 묶는 절차다.

가장 먼저 보존할 증거

재현 전에 다음을 남긴다.

  • 정확한 실행 파일·DLL·공유 라이브러리와 build ID 또는 해시
  • PDB/DWARF 같은 해당 build의 symbol
  • exception code 또는 signal
  • faulting instruction address와 module offset
  • 모든 general/vector register와 flags
  • call stack과 stack memory 일부
  • fault address와 접근 종류(read/write/execute)
  • 입력, 설정, thread, OS와 CPU 정보

“최신 binary와 비슷한 source”로 symbolicate하면 offset이 다른 함수를 가리킬 수 있다. 크래시와 정확히 일치하는 artifact가 우선이다.

크래시 주소를 module-relative로 바꾼다

ASLR 때문에 실행 때 module base가 달라진다.

module_offset = faulting_address - loaded_module_base

symbol server나 map file, PDB/DWARF는 이 offset을 함수와 소스 위치로 연결한다. 로그에 절대 주소만 남기면 다음 실행의 주소와 직접 비교하기 어렵다.

faulting instruction부터 읽는다

mov eax, dword ptr [rcx + rdx*4]

read access violation이라면 다음을 계산한다.

effective_address = RCX + RDX * 4
access_width = 4 bytes

질문 순서는 다음과 같다.

  1. rcx는 null, 해제된 pointer, 잘못된 object 중 무엇인가?
  2. rdx는 index인가 byte offset인가?
  3. 계산 결과가 fault address와 일치하는가?
  4. 접근 폭 4바이트 전체가 유효 범위 안인가?
  5. 이전 instruction이 rcx나 rdx를 어디서 만들었는가?

fault가 난 instruction은 문제가 드러난 위치이지 원인이 생긴 위치와 같지 않을 수 있다.

register provenance를 거슬러 올라간다

mov rcx, qword ptr [rbx + 20h]
test rcx, rcx
je error
mov edx, dword ptr [rsi + 8]
mov eax, [rcx + rdx*4]

rcx는 [rbx+20h], rdx는 [rsi+8]에서 왔다. test가 있었는데도 마지막 접근에서 null이라면 분기 경로를 다시 확인하고, 다른 thread가 field를 바꿀 수 있는지, rbx object 자체가 유효한지, register dump가 정확한 시점인지 본다.

동시성 race는 단일 thread의 instruction trace만으로 증명되지 않을 수 있다. lifetime과 synchronization 계약을 별도로 조사한다.

call stack을 맹신하지 않는다

stack trace는 symbol과 unwind metadata로 복원된다. 다음 상황에서 끊기거나 거짓 frame이 나타날 수 있다.

  • stack buffer overflow로 return address나 saved register가 손상됨
  • handwritten ASM의 unwind 정보가 실제 prologue와 불일치
  • frame pointer가 없고 unwind 정보도 불완전
  • 잘못된 symbol build 사용
  • tail call과 inlining을 일반 call로 오해
  • stack pivot나 context switch 같은 특수 runtime 동작

각 frame의 return address가 실행 가능한 module 범위에 있고 call site 근처로 이어지는지 확인한다.

함수 진입 상태는 ABI로 복원한다

Windows x64 함수 진입에서 정수 인자는 보통 rcx, rdx, r8, r9, System V AMD64에서는 rdi, rsi, rdx, rcx, r8, r9다. 하지만 크래시가 함수 중간이면 이미 다른 용도로 재사용되었을 수 있다.

따라서 현재 register를 소스 인자로 곧바로 이름 붙이지 않는다. prologue와 data flow를 거슬러 caller의 call site까지 확인한다.

prologue와 epilogue를 찾아 stack 상태를 감사한다

push rbx
sub rsp, 40h
mov rbx, rcx

함수 진입 rsp를 S라고 두면 현재 rsp와 saved rbx, return address 위치를 계산할 수 있다. 모든 exit에서 add rsp,40h, pop rbx, ret가 의미상 대응하는지 확인한다.

Windows에서는 .pdata/.xdata와 unwind directive가 실제 코드와 맞는지 dumpbin /UNWINDINFO 또는 debugger로 확인한다. 정상 반환만 테스트해서는 exception 경로를 증명하지 못한다.

최적화된 소스 줄은 범위다

compiler는 다음을 수행한다.

  • inline과 tail call
  • constant propagation과 dead-code elimination
  • loop unrolling/vectorization
  • 공통 subexpression 결합
  • instruction scheduling
  • 여러 소스 줄을 한 instruction range에 매핑

debugger가 한 소스 줄을 가리켜도 그 줄 하나가 원인이라고 단정하지 않는다. mixed source/disassembly view에서 instruction range와 live variable location을 본다. “optimized out”은 debugger 실패가 아니라 값이 더 이상 독립 저장소에 없다는 뜻일 수 있다.

비교와 분기에서 입력 범위를 복원한다

cmp rdx, r8
jae fail
mov eax, [rcx + rdx*4]

정상 경로에는 unsigned rdx < r8가 성립한다. 이 사실을 path constraint로 기록한다. 만약 crash dump에서 rdx >= r8인데 load에 도달했다면 현재 instruction pointer와 flags, 분기 직후의 register 변경, code bytes가 예상 binary와 일치하는지 확인한다.

signed jl과 unsigned jb를 혼동하면 가능한 입력 범위를 잘못 복원한다.

간접 호출을 해석한다

call qword ptr [rax + 18h]

가능한 의미는 function pointer table, C++ vtable, import thunk, callback 구조체다. 분석 순서는:

  1. rax가 어디서 왔는가?
  2. [rax+18h]가 읽을 수 있는가?
  3. 읽힌 target이 실행 가능한 영역인가?
  4. target의 calling convention과 준비된 인자가 맞는가?
  5. object lifetime과 Control Flow Guard/CET 같은 완화가 정상인가?

주소가 executable이라는 사실만으로 올바른 함수 signature임을 증명하지는 않는다.

memory dump는 폭과 endian으로 본다

mov eax,[rcx]에서 4바이트를 읽었다면 memory 창의 8바이트 정수 표시만 보지 말고 원시 바이트도 본다. little endian, field offset, alignment와 object boundary를 함께 확인한다.

use-after-free는 해제된 영역이 아직 읽을 수 있어 즉시 fault하지 않을 수 있다. allocator fill pattern, guard page, AddressSanitizer 같은 도구가 원인 위치를 더 일찍 잡는 데 도움을 준다.

정적 분석과 동적 관찰을 구분한다

  • disassembler: code bytes를 instruction으로 해석
  • debugger: 특정 실행의 register와 memory를 관찰
  • decompiler: control/data flow에서 고수준 표현을 추정
  • profiler: 실행 시간 또는 event를 sampling
  • sanitizer: instrumentation으로 특정 정의 위반 탐지

decompiler가 만든 if와 변수 이름은 가설이다. 실제 branch, memory width, call target, ABI를 disassembly에서 교차 확인한다.

Debug와 Release를 비교하는 방법

작은 재현 함수에 대해 두 빌드를 나란히 둔다.

항목DebugRelease
frame pointer자주 보임생략 가능
local memorystack에 자주 materializeregister 또는 제거
함수 경계비교적 유지inline/tail call 가능
loop소스와 유사변형·unroll·SIMD 가능
보안 검사설정 의존설정 의존

Debug에서 재현되지 않는다고 UB나 race가 사라진 것은 아니다. layout과 timing이 달라져 숨었을 수 있다.

binary patch보다 source fix를 우선한다

크래시 instruction을 NOP으로 바꾸거나 branch를 뒤집으면 증상은 사라질 수 있지만 ABI, flags, register lifetime, code signature, relocation, unwind, 보안 완화를 깨뜨릴 수 있다.

권한과 배포 절차가 있는 긴급 hot patch 시스템이 아니라면 원인을 source와 계약에서 수정하고 전체 build/test를 거친다. 역어셈블은 fix 위치를 찾는 증거로 사용한다.

크래시 분석 보고서 템플릿

증상과 영향:
정확한 build/symbol:
exception/signal:
module+offset / faulting instruction:
access type, width, effective address:
관련 register provenance:
ABI와 stack 감사:
재현 입력과 빈도:
가장 강한 원인 가설:
반증할 증거:
source 수준 수정:
회귀·경계·sanitizer 테스트:

실습

다음 버그를 각각 Debug와 Release로 재현한다.

  1. 배열 index가 count와 같은 off-by-one
  2. 반환 뒤 사용하는 dangling pointer
  3. Windows ASM에서 rbx 복원 누락
  4. 함수 호출 전 stack alignment 오류

각 사례에서 faulting address, effective address 계산, register provenance, stack 변화, source fix를 기록한다. 단순히 “access violation”이라고 끝내지 않는다.

정리

x86-64 크래시 분석은 faulting instruction에서 시작해 effective address, register provenance, path constraint, ABI, stack과 object lifetime으로 범위를 넓히는 과정이다. symbol과 binary가 정확히 일치해야 하며, stack trace와 decompiler 출력도 검증 대상이다.

instruction이 문제를 드러낸 장소와 원인이 발생한 장소를 구분하고, 최종 수정은 source 계약과 회귀 테스트로 닫아야 재현 조건이 조금 바뀌어도 같은 버그가 돌아오지 않는다.

공식 참고 자료