Anatomy of State of the Art Debugger

Nikhil gupta
15 min readSep 14, 2024

--

Debuggers are specialized tools used by developers, security researchers, and reverse engineers to inspect the internal behavior of programs. They allow the user to pause (or “break”) the execution of a program, examine its current state (like memory, registers, and stack contents), step through code line by line, set breakpoints, modify memory or registers, and understand the dynamic behavior of the code.

Popular examples of debuggers include:

  • OllyDbg: A 32-bit debugger for Windows binaries, known for its intuitive, GUI-based interface that allows users to analyze binaries at the assembly level, making it a favorite among malware analysts and reverse engineers.
  • WinDbg: A powerful debugger from Microsoft that supports both user-mode and kernel-mode debugging, providing extensive capabilities to debug Windows applications, drivers, and the operating system itself.
  • GDB (GNU Debugger): A versatile debugger for Linux and Unix-like systems that can handle different types of binaries (e.g., ELF files) and supports remote debugging over networks.
  • IDA Pro (Interactive DisAssembler Professional): A combined debugger and disassembler, IDA Pro is known for its static and dynamic analysis capabilities. It helps reverse engineers understand the functionality of binary programs.

How Do Debuggers Differ from Disassemblers?

Disassemblers and debuggers serve distinct purposes but are often used together.

  • Disassemblers: These tools take binary code (machine code) and convert it into human-readable assembly language. Disassemblers are typically used for static analysis, meaning they examine the program without executing it. For example, IDA Pro and Radare2 can disassemble code, allowing users to study the flow and control structures in a program statically.
  • Debuggers: While they can also disassemble code (as part of their analysis), debuggers go further by providing dynamic analysis capabilities. They can control the execution of a program (start, stop, pause), set breakpoints, step through instructions, modify registers or memory, and observe how the program’s state changes over time.

Debuggers provide an additional layer of analysis by allowing the user to:

  • Observe real-time execution: Unlike disassemblers that only show potential code paths, debuggers allow users to see what paths are actually taken during execution.
  • Examine dynamic behavior: Debuggers let users inspect dynamic behavior such as memory allocation, API calls, and network interactions.
  • Alter state: Debuggers allow for altering the program state (like changing variable values or modifying memory) to test different code paths or bypass security checks.

Why Are Debuggers Required?

Debuggers are essential for several reasons:

  • Software Development: Debuggers are critical in the software development lifecycle. They help developers find and fix bugs by allowing them to inspect the internal state of an application, understand crashes, and verify code behavior.
  • Reverse Engineering: Security researchers and analysts use debuggers to understand how an application works, particularly if source code is unavailable. This is crucial for analyzing malware, understanding proprietary software, or finding vulnerabilities.
  • Security Analysis: Debuggers allow analysts to examine and interact with code in real-time, providing a way to understand how exploits or malicious payloads function, aiding in developing mitigations or patches.
  • Education and Research: Debuggers serve as educational tools, helping programmers and security professionals understand low-level operations, such as assembly instructions, system calls, and memory management.

How Do Debuggers Work: Technical Intricacies

Debuggers are sophisticated tools with several technical intricacies. Here is a deeper look into how they work:

Setting Breakpoints

Breakpoints are crucial for debugging. When a breakpoint is set, the debugger modifies the binary code at the desired location (such as a specific instruction or memory address) to pause execution when it is reached.

  • Software Breakpoints: These are set by replacing the target instruction with an interrupt (like INT 3 on x86 architecture). When the CPU executes this interrupt, control is transferred to the debugger.
  • Hardware Breakpoints: These rely on the CPU’s debugging registers to monitor specific memory addresses or instructions. Unlike software breakpoints, they do not modify the program’s code.

Disassembling Code

Debuggers disassemble code to show its assembly language representation:

  • Decoding Instructions: The debugger reads the program’s machine code (binary data) and decodes it into assembly instructions, understanding the CPU’s architecture and instruction set.
  • Instruction Flow: Debuggers track control flow, showing jumps, calls, and returns to help the user understand the execution paths within the binary.

Stepping Through Code

“Stepping” involves executing code line by line or instruction by instruction:

  • Step Into: Executes the next instruction and, if it is a function call, moves into the function.
  • Step Over: Executes the next instruction but skips over function calls, treating them as a single instruction.
  • Step Out: Runs the rest of the current function and breaks on return.

Intercepting Native API Calls and Syscalls

Syscalls (system calls) are requests made by a program to the operating system’s kernel. Debuggers intercept these calls to provide a deeper analysis:

  • API Call Hooking: The debugger hooks or intercepts API calls to observe which system functions are invoked by the application. This is done by modifying the program’s Import Address Table (IAT) or by placing breakpoints on specific API functions.
  • Syscall Monitoring: Debuggers track syscalls by placing breakpoints at the entry point of the syscall handler in the kernel. They can capture and display parameters passed to the syscalls, the return values, and the effects on registers.

Memory and Register Inspection

Debuggers provide insights into the program’s memory and register states:

  • Memory Inspection: The debugger can read and write to the process’s memory space, allowing the user to see data, stack frames, and other information.
  • Register Inspection: The debugger can view and modify CPU registers, such as the instruction pointer (EIP/RIP), stack pointer (ESP/RSP), general-purpose registers (AX, BX, CX, DX), etc.

Debugger Internals: OS Integration

Debuggers work closely with the operating system:

  • Process Control: Debuggers use OS-specific APIs to control the process being debugged. For example, Windows debuggers use functions like DebugActiveProcess, WaitForDebugEvent, and ContinueDebugEvent to manage debugging sessions.
  • Exception Handling: When a breakpoint is hit or an error occurs, the debugger handles the exceptions generated by the OS, analyzes them, and provides relevant information to the user.

Event Handling and Hooking

Debuggers rely on event-driven models:

  • Event Loop: Debuggers operate in an event loop, listening for events like breakpoints, memory access violations, or system calls.
  • Hooks: Debuggers hook into various low-level events (like exceptions or hardware interrupts) to intercept and analyze program execution.

Now, Let’s dive deeper into each point regarding working of debuggers as we discussed above, covering their underlying mechanics, how they interact with the system, and the detailed technicalities involved.

1. Memory Inspection in Debuggers

Memory inspection is a fundamental feature of debuggers, enabling the examination and manipulation of a program’s memory space while it is being executed. This allows a debugger to view, modify, and analyze variables, stack frames, heap allocations, and other memory regions.

How Memory Inspection Is Done:

Virtual Memory Mapping:

  • Modern operating systems provide each process with a virtual address space that isolates the process’s memory from other processes. The debugger uses system calls or APIs (like VirtualQueryEx in Windows or /proc/[pid]/maps in Linux) to obtain a map of the process's memory regions.
  • The memory regions are classified into several categories such as stack, heap, code, and data sections. Each region has specific properties, like whether it is readable, writable, or executable.

Reading Memory:

  • Windows: In Windows, a debugger reads the memory of a process using the ReadProcessMemory function. This function takes the handle to the process, a base address, a buffer to receive the data, the number of bytes to read, and a variable to store the number of bytes read.
  • Linux: In Linux, memory can be read using ptrace(PTRACE_PEEKDATA, pid, addr, 0). The PTRACE_PEEKDATA command allows reading the data from a specific address in the target process’s memory.

Writing to Memory:

  • Debuggers often need to modify a process’s memory, such as when setting breakpoints (by inserting an interrupt instruction) or patching code.
  • Windows: The WriteProcessMemory function allows writing data to a specific memory address in a target process.
  • Linux: Writing to memory can be done with ptrace(PTRACE_POKEDATA, pid, addr, data). The PTRACE_POKEDATA command writes the specified data to a particular memory address.

Finding Specific Data:

  • Debuggers can search for specific patterns in memory, such as strings, function pointers, or shellcode. This can be done by reading memory in chunks and performing a byte-by-byte comparison to match the desired pattern.

Tracking Memory Allocations:

  • Debuggers can intercept and monitor memory allocation functions (malloc, calloc, realloc, free in C/C++, or HeapAlloc in Windows API). By setting breakpoints on these functions, a debugger can track when memory is allocated, reallocated, or freed, and can log or analyze these actions.

2. Register Inspection in Debuggers

Register inspection allows debuggers to view and modify the values stored in CPU registers. Registers are fast storage locations within the CPU that hold data, addresses, or instructions.

How Register Inspection Is Done:

Reading Register Values:

  • Debuggers read the values of registers using OS-specific APIs or system calls.
  • Windows: The GetThreadContext API retrieves the current context (including register values) of a specific thread. The CONTEXT structure contains fields for all relevant registers (e.g., EAX, EBX, ECX, etc., for x86 architectures, or RAX, RBX, RCX, etc., for x86_64).
  • Linux: ptrace(PTRACE_GETREGS, pid, 0, &regs) can be used to retrieve all the registers of the target process. The registers are returned in a struct user_regs_struct for x86 or struct user_pt_regs for ARM.

Writing to Registers:

  • Debuggers can modify register values to control the execution flow or alter program behavior.
  • Windows: The SetThreadContext API sets the context of a specific thread, allowing a debugger to modify register values.
  • Linux: The ptrace(PTRACE_SETREGS, pid, 0, &regs) command allows the debugger to modify the register values by providing an updated structure.

Understanding Register Categories:

  • General-Purpose Registers: Used to hold operands, pointers, and intermediate results (e.g., EAX, EBX in x86, RAX, RBX in x86_64).
  • Instruction Pointer (IP): Holds the address of the next instruction to be executed (EIP in x86, RIP in x86_64).
  • Stack Pointer (SP) and Base Pointer (BP): Manage the stack frames for function calls (ESP and EBP in x86, RSP and RBP in x86_64).
  • Segment Registers: Used for segmented memory management (CS, DS, ES, FS, GS, SS).
  • Flags Register: Stores status flags that represent the outcome of operations (e.g., Zero Flag, Sign Flag, Overflow Flag).

Stepping Through Code Using Registers:

  • Debuggers often modify the instruction pointer (IP) to control program flow. For example, stepping over a function involves reading the current IP, calculating the next IP (usually by skipping the function call), and setting the IP to the new value.

3. Instruction Decoding in Debuggers

Instruction decoding is the process of translating binary machine code into human-readable assembly language instructions. It involves interpreting the raw bytes of machine code according to the CPU’s instruction set architecture (ISA).

How Instruction Decoding Is Done:

Understanding the CPU Architecture:

  • The debugger must understand the CPU’s architecture (like x86, x86_64, ARM, MIPS, etc.) and its instruction set. Each architecture has a unique encoding format for its instructions, which defines how binary opcodes and operands are structured.
  • For example, x86 instructions can range from 1 to 15 bytes in length, whereas ARM instructions are typically 4 bytes.

Fetching Machine Code:

  • The debugger reads raw bytes from the target’s memory where the code resides. This is done using the same memory inspection techniques described earlier (ReadProcessMemory in Windows, ptrace in Linux).

Decoding Instructions:

  • Decoding involves interpreting the opcodes and operands to produce the corresponding assembly instructions. Libraries like Capstone and libopcodes (part of the GNU binutils) provide robust decoding functionalities.
  • The decoder reads the first few bytes of the instruction to determine its length, then identifies the opcode (which represents the type of instruction, like MOV, ADD, JMP), and finally extracts any operands (like registers, memory addresses, or constants).

Instruction Length Determination:

  • On variable-length architectures like x86, determining the length of an instruction requires examining prefix bytes (like operand-size override 0x66 or address-size override 0x67), the main opcode, mod-reg-r/m bytes, and potential displacement or immediate values.

Handling Different Encodings:

  • The debugger must handle multiple encoding formats, such as VEX, EVEX for AVX instructions in x86_64, or Thumb mode instructions for ARM processors. Each encoding has different rules for parsing the bytes.

4. Disassembly in Debuggers

Disassembly is the process of converting binary machine code into a human-readable assembly language format.

How Disassembly Is Done:

Static Disassembly:

  • Static disassembly is done without executing the code. The debugger reads the program’s binary data and attempts to interpret it as machine code. Tools like IDA Pro perform static disassembly.
  • Recursive Traversal: The disassembler starts at a known entry point (like the program entry in the PE or ELF header) and recursively follows all code paths, interpreting bytes as instructions.
  • Linear Sweep: Alternatively, it can perform a linear sweep, interpreting each subsequent byte sequence as an instruction, which can lead to inaccuracies if data is mistaken for code.

Dynamic Disassembly:

  • Dynamic disassembly is performed at runtime. The debugger executes the program and disassembles instructions as they are executed, which is more accurate since it only disassembles executed code paths.
  • Breakpoints: The debugger sets breakpoints to stop at specific points, allowing for the disassembly of the code that leads to those points.

Symbol Resolution:

  • Debuggers use symbol information (from debugging symbols like PDB files in Windows or DWARF in Linux) to map addresses to function names, variable names, and source code lines. This enhances the readability of disassembly.

Code Annotations:

  • Advanced disassemblers like IDA Pro provide annotations that show cross-references, jump targets, and function boundaries, which help in understanding the control flow of the program.

5. Breakpoints and Stepping Mechanics

Breakpoints

Software Breakpoints:

  • Mechanism: When a software breakpoint is set, the debugger replaces the first byte of the target instruction with an INT 3 (0xCC) interrupt on x86 architectures, which triggers a software interrupt handled by the OS. Upon hitting the breakpoint, the OS suspends the process and notifies the debugger.
  • Handling Breakpoint Hits: The debugger catches the interrupt and performs necessary actions like displaying the current context, dumping memory or register states, or stepping through the code. Before resuming, the debugger restores the original instruction byte and adjusts the instruction pointer.

Hardware Breakpoints:

  • Mechanism: Hardware breakpoints leverage CPU debug registers (like DR0-DR3 on x86). The CPU is configured to monitor specific addresses or memory ranges for read/write/execute access. When such an event occurs, the CPU generates an exception.
  • Usage: Hardware breakpoints are ideal for scenarios where you cannot modify the code (e.g., read-only memory) or need to monitor memory access patterns.

Stepping

Instruction Stepping:

  • Single Stepping: The debugger uses single-stepping mode (trap flag in x86 or PTRACE_SINGLESTEP in Linux). The CPU executes a single instruction and then generates a debug exception.
  • Step Over: The debugger calculates the length of the next instruction and sets a temporary breakpoint at the instruction following the current one. It then continues execution until it hits the temporary breakpoint.
  • Step Into: The debugger steps into function calls, allowing the user to inspect every line of code within the called function.

Advanced Control Flow Management:

  • Step Out: The debugger runs the rest of the current function until it encounters a return instruction, which takes it back to the caller.
  • Run to Cursor: The debugger sets a temporary breakpoint at a specified location (e.g., a line of code or address) and continues execution until it reaches that point.

6. Intercepting Native API Calls and Syscalls

API Call Hooking

Function Redirection:

  • Debuggers can modify the Import Address Table (IAT) or Export Address Table (EAT) to redirect API calls. For instance, when a program calls MessageBoxA, the debugger can replace its address with a custom function, effectively intercepting the call.

Hooking Techniques:

  • Inline Hooking: The debugger replaces the first few bytes of a target function with a jump instruction to a custom handler function. After the custom code executes, control is returned to the original function.
  • IAT/EAT Hooking: By modifying the IAT or EAT, the debugger can reroute calls to any API or function to a handler function.

Detours Library:

  • Microsoft Detours is an example of a hooking library that allows for interception and redirection of Win32 API calls. It uses inline hooking by overwriting the first few bytes of a function with a jump to custom code.

Syscall Interception

Understanding Syscalls:

  • Syscalls (system calls) are the interface between user-mode applications and the kernel. For example, read, write, open, mmap are common Linux syscalls.
  • Each syscall has a unique number (the syscall number), and its parameters are passed through specific registers (like RAX for syscall number, RDI, RSI, etc., for arguments in x86_64).

Intercepting Syscalls:

  • Linux: The debugger can intercept syscalls by placing a breakpoint on the syscall entry point in the program (int 0x80 on x86 or syscall instruction on x86_64). It can monitor or modify syscall arguments and results.
  • Windows: Syscalls are typically wrapped by API functions. Debuggers often intercept these APIs instead of direct syscalls, as Windows has more complex mechanisms for syscall dispatching (using ntdll.dll).

Modifying Syscall Behavior:

  • Once intercepted, the debugger can modify the arguments or results of the syscall. This is useful for simulating different environments or bypassing security checks.

7. Process Control and Exception Handling

Process Control

Creating a Debugging Session:

  • Attaching to a Process: The debugger uses OS-specific APIs to attach to an existing process. In Windows, DebugActiveProcess is used to attach to a running process, and WaitForDebugEvent is used to listen for debugging events.
  • Spawning a Debuggee: Debuggers can also spawn a process to be debugged using APIs like CreateProcess with the DEBUG_PROCESS flag in Windows or ptrace(PTRACE_TRACEME) in Linux.

Managing Debug Events:

  • The debugger operates in an event loop, processing various debug events such as breakpoints, single-step completions, exceptions, and process exits. This loop continues until the debugging session is terminated.

Exception Handling

Exception Handling:

  • When an exception occurs (e.g., access violation, illegal instruction), the OS suspends the process and notifies the debugger. The debugger inspects the cause of the exception, presents the relevant information (like the faulty instruction, register states, or memory access details), and allows the user to decide how to proceed.
  • Windows: The EXCEPTION_DEBUG_EVENT provides details about the exception. The debugger uses ContinueDebugEvent to resume execution or to terminate the process.
  • Linux: Signals (like SIGSEGV for segmentation fault) are sent to the debugger, which decides how to handle them.

Custom Exception Filters:

  • Debuggers can implement custom exception filters to automate responses to specific exceptions, like ignoring benign ones or pausing execution for critical ones.

Code Example: Building a Simple Debugger in Python

To illustrate how debuggers work, let’s write a simple Python script. This script includes:

  1. Attaching and Detaching: Attach to a process, read its memory and registers, and detach cleanly.
  2. Setting and Removing Breakpoints: Set breakpoints at specific addresses and remove them.
  3. Handling Exceptions: Capture and handle exceptions like segmentation faults.
  4. Reading and Writing Memory: Read from and write to the target process’s memory.
  5. Reading Multiple Registers: Read and display several registers at once.

This example assumes that you are working with x86_64 architecture and Linux. It will also use the ctypes library for interacting with system calls.

import ctypes
import sys
import os

# Constants for ptrace
PTRACE_TRACEME = 0
PTRACE_PEEKUSER = 3
PTRACE_POKEDATA = 4
PTRACE_SINGLESTEP = 9
PTRACE_CONT = 7
PTRACE_ATTACH = 16
PTRACE_DETACH = 17

# Constants for registers (x86_64 architecture)
RIP = 16 # Instruction pointer
RSP = 24 # Stack pointer
RAX = 0 # General-purpose register
RBX = 1 # General-purpose register
RCX = 2 # General-purpose register
RDX = 3 # General-purpose register

# Define ctypes for syscall usage
libc = ctypes.CDLL("libc.so.6")

def ptrace(request, pid, addr=0, data=0):
"""Wrapper for the ptrace system call"""
return libc.ptrace(request, pid, ctypes.c_void_p(addr), ctypes.c_void_p(data))

def read_register(pid, reg):
"""Read a specific register value from the process"""
return ptrace(PTRACE_PEEKUSER, pid, 8 * reg)

def write_memory(pid, addr, data):
"""Write data to a specific address in the process memory"""
return ptrace(PTRACE_POKEDATA, pid, addr, data)

def read_memory(pid, addr):
"""Read data from a specific address in the process memory"""
return ptrace(PTRACE_PEEKUSER, pid, addr)

def set_breakpoint(pid, addr):
"""Set a breakpoint at a specific address"""
# Read the original byte at the address
original_byte = read_memory(pid, addr)
if original_byte == -1:
raise RuntimeError("Failed to read memory for setting breakpoint")

# Insert a breakpoint instruction (INT 3) at the address
breakpoint_byte = 0xCC
write_memory(pid, addr, breakpoint_byte)

return original_byte

def remove_breakpoint(pid, addr, original_byte):
"""Remove a breakpoint and restore the original byte"""
write_memory(pid, addr, original_byte)

def handle_exception(pid, status):
"""Handle exceptions and display relevant information"""
if os.WIFSIGNALED(status):
signal = os.WTERMSIG(status)
print(f"Process received signal: {signal}")

def print_registers(pid):
"""Print the values of several registers"""
registers = {
'RIP': RIP,
'RSP': RSP,
'RAX': RAX,
'RBX': RBX,
'RCX': RCX,
'RDX': RDX
}
for name, reg in registers.items():
value = read_register(pid, reg)
print(f"{name}: 0x{value:016x}")

def main():
if len(sys.argv) < 2:
print("Usage: python3 advanced_debugger.py <pid>")
return

pid = int(sys.argv[1])
breakpoint_addr = int(sys.argv[2], 16) if len(sys.argv) > 2 else None

# Attach to the process
if ptrace(PTRACE_ATTACH, pid) != 0:
print(f"Failed to attach to process {pid}")
sys.exit(1)

print(f"Attached to process {pid}")

# Wait for the process to stop
os.waitpid(pid, 0)

# Set breakpoint if address is provided
if breakpoint_addr:
print(f"Setting breakpoint at 0x{breakpoint_addr:x}")
original_byte = set_breakpoint(pid, breakpoint_addr)

try:
while True:
# Print register values
print_registers(pid)

# Continue the process
ptrace(PTRACE_CONT, pid)
status = os.waitpid(pid, 0)[1]
handle_exception(pid, status)

if os.WIFEXITED(status):
print(f"Process exited with status {os.WEXITSTATUS(status)}")
break
elif os.WIFSIGNALED(status):
print(f"Process killed by signal {os.WTERMSIG(status)}")
break
elif os.WIFSTOPPED(status):
print(f"Process stopped by signal {os.WSTOPSIG(status)}")
print(f"Current RIP: 0x{read_register(pid, RIP):x}")

# Step to the next instruction
ptrace(PTRACE_SINGLESTEP, pid)
os.waitpid(pid, 0)

except KeyboardInterrupt:
print("Detaching from process...")

# Remove breakpoint if set
if breakpoint_addr:
print(f"Removing breakpoint from 0x{breakpoint_addr:x}")
remove_breakpoint(pid, breakpoint_addr, original_byte)

# Detach from the process
ptrace(PTRACE_DETACH, pid)
print("Detached.")

if __name__ == "__main__":
main()

Explanation

Attaching and Detaching:

  • The script attaches to the specified process using PTRACE_ATTACH and detaches using PTRACE_DETACH.

Setting and Removing Breakpoints:

  • The set_breakpoint function inserts a 0xCC byte (INT 3) at the specified address and stores the original byte.
  • The remove_breakpoint function restores the original byte at the breakpoint location.

Handling Exceptions:

  • The handle_exception function interprets and prints the signal or exit status of the process.

Reading and Writing Memory:

  • Functions read_memory and write_memory allow for reading from and writing to the process's memory.

Reading Multiple Registers:

  • The print_registers function prints values of several registers, which helps in understanding the process state.

#Debuggers #WindowsInternals #OperatingSystems

--

--

Nikhil gupta
Nikhil gupta

Written by Nikhil gupta

Incident Response, Threat Hunting, and Reverse Engineering professional, writing things to learn them better. https://www.linkedin.com/in/nikhilnow/

No responses yet