Practical x86-64 Debugging and Disassembly
Recover function, stack, ABI, and memory contracts from a crash address
Practical x86-64 Debugging and Disassembly
These blog posts are written as lessons. Disassembly explains observable binary behavior; it does not prove source intent or authorization. Analyze only programs and crashes you are authorized to inspect.
An optimized crash rarely maps cleanly to one source line. Variables disappear, functions are inlined, and the instruction pointer lands inside one mov. The essential skill is a procedure that connects registers, stack, memory, and ABI contracts in evidence order.
Preserve evidence first
Collect the exact executable and loaded modules with build IDs or hashes, matching PDB/DWARF symbols, exception or signal, faulting address and module offset, general/vector registers and flags, stack trace and stack bytes, fault address and access kind, input, thread, OS, and CPU.
Symbols from a similar newer build can map the same offset to the wrong function. Exact artifacts come first.
Normalize an ASLR address
module_offset = faulting_address - loaded_module_base
Map the module-relative offset through symbols or a map file. An absolute address alone is difficult to compare across runs.
Begin at the faulting instruction
mov eax, dword ptr [rcx + rdx*4]
For a read access violation:
effective_address = RCX + RDX * 4
access_width = 4 bytes
Ask whether rcx is null, freed, or the wrong object; whether rdx is an element index or byte offset; whether the calculated value equals the fault address; whether all four bytes are in range; and where previous instructions obtained both registers. The fault is where the bug became visible, not necessarily where it originated.
Trace register provenance backward
mov rcx, qword ptr [rbx + 20h]
test rcx, rcx
je error
mov edx, dword ptr [rsi + 8]
mov eax, [rcx + rdx*4]
rcx came from an object field and rdx from another load. Verify both parent objects, the actual branch path, and possible concurrent lifetime changes. A race may not be provable from one thread's instruction snapshot; inspect synchronization and ownership separately.
Do not blindly trust the call stack
Stack traces depend on symbols and unwind metadata. They can fail because of buffer overflow, mismatched handwritten-assembly unwind data, incomplete frame information, wrong symbols, tail calls, inlining, or specialized runtime context switching. Check that each return address belongs to executable module memory and corresponds to a plausible call site.
Reconstruct entry state through the ABI
Windows x64 usually starts integer arguments in rcx, rdx, r8, r9; System V uses rdi, rsi, rdx, rcx, r8, r9. In the middle of a function those registers may have been reused. Do not label the current value with a source parameter name until data flow is traced back to the call site.
Audit prologue, epilogue, and unwind
push rbx
sub rsp, 40h
mov rbx, rcx
Let entry rsp be S, then derive saved-register and return-address positions. Check every exit for semantically matching restoration. On Windows, verify .pdata/.xdata or MASM unwind directives against the real prologue; a normal ret test does not validate exception unwinding.
Optimized source locations are ranges
Inlining, tail calls, constant propagation, dead-code elimination, unrolling, vectorization, common-subexpression elimination, and scheduling destroy a simple line-to-instruction relationship. Use mixed source/disassembly and live-location information. “Optimized out” often means a value has no independent storage, not that the debugger failed.
Recover path constraints
cmp rdx, r8
jae fail
mov eax, [rcx + rdx*4]
The normal edge proves unsigned rdx < r8. Record such facts. If a dump appears to violate them, verify instruction address, flags, intervening register changes, and that code bytes match the expected build. Confusing jl with jb reconstructs the wrong range.
Interpret indirect calls carefully
call qword ptr [rax + 18h]
This may be a function-pointer table, vtable, import thunk, or callback. Trace rax, validate the table access, ensure the loaded target is executable, verify the target calling convention and prepared arguments, and inspect object lifetime and control-flow mitigations. An executable address alone does not prove the correct function signature.
Inspect raw memory with width and endian
If mov eax,[rcx] reads four bytes, inspect those raw bytes, not only an eight-byte debugger view. Consider little endian, field offset, alignment, and object boundary. A use-after-free may remain readable; allocator fill patterns, guard pages, and AddressSanitizer can catch the cause earlier.
Separate analysis tools
- A disassembler decodes code bytes.
- A debugger observes one execution's registers and memory.
- A decompiler hypothesizes high-level control and data flow.
- A profiler samples time or events.
- A sanitizer instruments for selected contract violations.
Decompiler names and if statements are hypotheses. Cross-check branches, widths, targets, and ABI in the real disassembly.
Prefer a source fix over a binary patch
NOPing a faulting instruction or reversing a branch can disturb flags, register liveness, relocation, signatures, unwind metadata, and mitigations. Outside an authorized hot-patch system, fix the source contract and rebuild with regression tests. Use disassembly as evidence to locate the fix.
Crash-report template
symptom/impact:
exact build/symbols:
exception/signal:
module+offset and instruction:
access type, width, effective address:
register provenance:
ABI/stack audit:
reproduction input/frequency:
strongest causal hypothesis:
evidence that could falsify it:
source-level fix:
regression/boundary/sanitizer tests:
Summary
Start an x86-64 crash investigation at the faulting instruction, then expand through effective address, register provenance, path constraints, ABI, stack, and object lifetime. Exact symbols and binaries are essential, and both stack traces and decompiler output require verification. Close the investigation with a source-level contract fix and regression tests.