For a debugger to be of any practical use, it needs the ability to pause, inspect, and resume a target process. This post will cover these topics by discussing what breakpoints are, how they are implemented by most debuggers on the x86 and x64 Windows platforms, and how to perform instruction level stepping.
Breakpoints
A breakpoint can simply be defined as a place in a executing code that causes an intentional halt of execution. In the context of debuggers, this is useful because it allows the debugger to inspect the process at that moment in time when nothing is going on — when the process is effectively “broken”. Typical functionality that is seen in debuggers when the target is in a broken state is the ability to view and modify registers/memory, print a disassembly listing of the area surrounding the breakpoint, step into or step over assembly instructions or lines of source code, and other related diagnostic features. Breakpoints themselves can come in a few different varieties.
Hardware Interrupt Breakpoints
Hardware interrupt breakpoints are probably the most common and simple breakpoints to understand and to implement in a debugger. These are specific to the x86 and x64 architectures and are implemented by using a hardware-supported instruction specifically made to generate a software interrupt that is meant for debuggers. The opcode for this instruction is 0xCC and it matches to an INT 3 instruction. The way that most debuggers implement this is to replace the opcode at the desired address — that is, the breakpoint address — with the 0xCC opcode. When the code is called, the interrupt (EXCEPTION_BREAKPOINT) will be raised, which the debugger will handle and give the user the option to perform the debugging functionality mentioned above. When the user is finished inspecting the program state at that address and wishes to continue, the debugger will replace the original instruction back, make sure that the executing address (EIP or RIP registers, depending on the architecture) point to that original address, and continue execution.
However, an interesting problem comes up. When the original instruction is replaced back, the breakpoint will be lost. This may be alright if the breakpoint is meant to be hit only once, but that is very rarely the case. There needs to be a way to re-enable that breakpoint immediately afterwards. This is done by setting the EFlags (or RFlags for x64) register to set the processor into single-step mode. Fortunately, that is pretty easily accomplished by enabling the 8th bit, i.e. performing an OR with 0x100. When the EXCEPTION_BREAKPOINT exception is handled and execution resumes, there will be another exception, EXCEPTION_SINGLE_STEP, thrown on the next instruction executed. At this point, the debugger can re-enable the breakpoint on the previous instruction and resume execution.
On the Windows platform, it is easy to see all of this at work by inspecting the DebugBreak function. This function is meant to trigger a local (from within the same process) breakpoint. Below is the disassembly of the function, which shows it simply being what is described above. Those wondering about the mov edi, edi instruction can read more about hot-patchable images, specifically Raymond Chen’s post about the explanation.
_DebugBreak@0: 752C3C5D 8B FF mov edi,edi 752C3C5F CC int 3 752C3C60 C3 ret
Hardware Debug Registers
The second breakpoint implementation technique is also specific to the x86 and x64 architectures and takes advantage of specific debug registers provided by the instruction set. These are the debug registers DR0, DR1, DR2, DR3, and DR7. The first four registers are used to hold the addresses that a hardware breakpoint will break on. Using these means that for the entire program, there can be at most four hardware breakpoints active. The DR7 register is used to control enablement of these registers, with bits 0, 2, 4, 6 corresponding to on/off for DR0, … , DR3. Additionally, bits 16-17, 20-21, 24-25, and 28-29 function as a bitmask for DR0, … , DR3 for when these breakpoints will trigger, with 00 being on execution, 01 on read, and 11 on write.
Setting these breakpoints on the Windows platform is a bit tricky. They must be set on the processes main thread. This involves getting the main thread, opening a handle to it with at least THREAD_GET_CONTEXT and THREAD_SET_CONTEXT privileges, and getting/setting the threads context with GetThreadContext/SetThreadContext with the newly added debug registers to reflect the changes. Take note that no executable code in memory is modified to set these breakpoints. It is not like the previous case where an opcode had to be replaced. These are breakpoints that set and unset by changing the contents of hardware registers. What will happen when these are set is that the process will raise an EXCEPTION_SINGLE_STEP exception upon hitting the instruction at the address, which the debugger will then process in a fashion nearly identical to the way it would in the previous section. Due to the small number limitation, these won’t be presented in the sample breakpoint code in this post, but may eventually be written about in the future for the sake of completeness. I have written about their usage for API hooking in a previous post (excuse the dead links in the beginning). The implementation for a debugger is very close to how it is presented there.
Software Breakpoints
This last class of breakpoints is performed entirely in software and is tied strongly to how the operating system functions. Another name for them is memory breakpoints. They combine some of the best features of interrupt breakpoints, namely the ability to have as many as you’d like, with the best features of hardware breakpoints, which is that nothing in the executing code needs to be overwritten. However, there is a major drawback: they add a significant slowdown to the execution of the code due to their implementation.
Instead of being implemented at the address level, these are implemented at the page level. How these work is that the address where a breakpoint will be set will have its page permissions changed to that of a guard page using VirtualProtectEx. When any instruction on the page will be accessed, there will be an EXCEPTION_GUARD_PAGE exception thrown. The debugger will handle this exception and check if the offending address is that of the breakpoint address. If so, the debugger can perform the usual handling/user prompt as with any other breakpoint. If not then the debugger must perform some extra steps.
According to the documentation, the guard page protection will be removed from the page after it is raised. This means that once the exception is handled and execution continues, any access afterwards will not generate an EXCEPTION_GUARD_PAGE exception. So in the case that the instruction accessed is not the desired breakpoint address, the breakpoint will be lost. To remedy this, the technique similar to the one presented in the hardware interrupt breakpoint section will be used. The processor will enter in to single-step mode and continue execution. On the next instruction, there will be an EXCEPTION_SINGLE_STEP exception raised. This will be handled by the debugger and the guard page property will be re-enabled on the page. This implementation also will not be covered in this post, but may be covered in the future. I have written about it before, also in the context of API hooking, here.
Implementations
As mentioned above, enabling and disabling hardware interrupt breakpoints is simply a matter of overwriting opcodes with 0xCC (INT 3). In Windows, this is accomplished in a pretty straightforward manner with the usage of ReadProcessMemory and WriteProcessMemory.
const bool InterruptBreakpoint::EnableBreakpoint() { SIZE_T ulBytes = 0; bool bSuccess = BOOLIFY(ReadProcessMemory(m_hProcess, (LPCVOID)m_dwAddress, &m_originalByte, sizeof(unsigned char), &ulBytes)); if (bSuccess && ulBytes == sizeof(unsigned char)) { bSuccess = BOOLIFY(WriteProcessMemory(m_hProcess, (LPVOID)m_dwAddress, &m_breakpointOpcode, sizeof(unsigned char), &ulBytes)); return bSuccess && (ulBytes == sizeof(unsigned char)); } else { fprintf(stderr, "Could not read from address %p. Error = %X\n", m_dwAddress, GetLastError()); } return false; } |
The original byte at the target address is read and stored and then overwritten with 0xCC. Nothing too shocking or out of the ordinary. Disabling the breakpoint is simply done by performing the opposite and writing back the original byte.
const bool InterruptBreakpoint::DisableBreakpoint() { SIZE_T ulBytes = 0; const bool bSuccess = BOOLIFY(WriteProcessMemory(m_hProcess, (LPVOID)m_dwAddress, &m_originalByte, sizeof(unsigned char), &ulBytes)); if (bSuccess && ulBytes == sizeof(unsigned char)) { return true; } fprintf(stderr, "Could not write back original opcode to address %p. Error = %X\n", m_dwAddress, GetLastError()); return false; } |
Now with the ability to enable/disable breakpoints, it is time to look at the handlers. As mentioned, upon accessing the instruction where the breakpoint resides, an EXCEPTION_BREAKPOINT exception will be raised. There are a few steps here that need to be done:
- Check if the breakpoint is in the breakpoint list. This is done because when the debugger first attaches, an EXCEPTION_BREAKPOINT will be raised from the debuggers attaching thread (see previous post). We don’t care about this exception, so just skip it and continue execution
- If it is in the breakpoint list, and therefore a breakpoint that the user explicitly set, it needs to be disabled. As mentionted before, this is to allow the original instruction to be executed.
- Open a handle to the thread and get the thread context. Change the execution pointer (EIP or RIP) to point to the breakpoint address and also enable single-step mode. Set the thread context to this newly modified context
- Save this breakpoint. This is to re-enable it during the EXCEPTION_SINGLE_STEP exception that will be raised immediately after continuing execution.
- Prompt the user to continue or perform single-steps. Wait for their response and continue execution afterwards depending on the choice.
Put into code, it looks like the following:
Register(DebugExceptions::eBreakpoint, [&](const DEBUG_EVENT &dbgEvent) { auto &exceptionRecord = dbgEvent.u.Exception.ExceptionRecord; const DWORD_PTR dwExceptionAddress = (DWORD_PTR)exceptionRecord.ExceptionAddress; fprintf(stderr, "Received breakpoint at address %p.\n", dwExceptionAddress); Breakpoint *pBreakpoint = m_pDebugger->FindBreakpoint(dwExceptionAddress); if (pBreakpoint != nullptr) { if (pBreakpoint->Disable()) { CONTEXT ctx = { 0 }; ctx.ContextFlags = CONTEXT_ALL; HANDLE hThread = OpenThread(THREAD_GET_CONTEXT | THREAD_SET_CONTEXT, FALSE, dbgEvent.dwThreadId); if (hThread != NULL) { (void)GetThreadContext(hThread, &ctx); #ifdef _M_IX86 ctx.Eip = (DWORD_PTR)dwExceptionAddress; #elif defined _M_AMD64 ctx.Rip = (DWORD_PTR)dwExceptionAddress; #else #error "Unsupported architecture" #endif ctx.EFlags |= 0x100; m_pDebugger->m_pLastBreakpoint = pBreakpoint; m_pDebugger->m_dwExecutingThreadId = dbgEvent.dwThreadId; (void)SetThreadContext(hThread, &ctx); fprintf(stderr, "Press c to continue or s to begin stepping.\n"); (void)m_pDebugger->WaitForContinue(); } else { fprintf(stderr, "Could not open handle to thread %p. Error = %X\n", dbgEvent.dwThreadId, GetLastError()); } } else { fprintf(stderr, "Could not remove breakpoint at address %p.", dwExceptionAddress); } } SetContinueStatus(DBG_CONTINUE); }); |
The handler for EXCEPTION_SINGLE_STEP exceptions serves two purposes: it is there to re-enable breakpoints that were just hit, and it is also there to allow the user to continue single-stepping execution of the program. As a result of this, there needs to be a flag declared that is set when the user wishes to enter single-step mode by themselves (instead of it being set through hitting a breakpoint). If the user is in single-step mode then show them a prompt and wait for a response to continue stepping or to continue execution entirely. Again, put into code, it looks like the following:
Register(DebugExceptions::eSingleStep, [&](const DEBUG_EVENT &dbgEvent) { auto &exceptionRecord = dbgEvent.u.Exception.ExceptionRecord; const DWORD_PTR dwExceptionAddress = (DWORD_PTR)exceptionRecord.ExceptionAddress; fprintf(stderr, "Received single step at address %p\n", dwExceptionAddress); if (m_pDebugger->m_bIsStepping) { fprintf(stderr, "Press s to continue stepping.\n"); m_pDebugger->m_dwExecutingThreadId = dbgEvent.dwThreadId; (void)m_pDebugger->WaitForContinue(); } if (!m_pDebugger->m_pLastBreakpoint->IsEnabled()) { (void)m_pDebugger->m_pLastBreakpoint->Enable(); } SetContinueStatus(DBG_CONTINUE); }); |
Debugger in action
With everything in place, the debugger is ready to be tested out. The easiest way is to write a sample program that will be attached to:
#include <stdio.h> #include <Windows.h> void TestFunction() { printf("Hello, World!\n"); } int main(int argc, char *argv[]) { printf("Test function address: %p\n", TestFunction); while (true) { getchar(); TestFunction(); } return 0; } |
On my machine, TestFunction resided at 0x13B1000. After attaching and setting a breakpoint on this address, the debugger was able to successfully step execution of the program or continue entirely.
a Target address: 0x13B1000 Received breakpoint at address 13B1000. Press c to continue or s to begin stepping. s Received single step at address 13B1001 Press s to continue stepping. s Received single step at address 13B1003 Press s to continue stepping. s Received single step at address 13B1009 Press s to continue stepping. s Received single step at address 13B100A Press s to continue stepping. c
The stepped addresses successfully matched up with the disassembly.
013B1000 55 push ebp 013B1001 8B EC mov ebp,esp 013B1003 81 EC C0 00 00 00 sub esp,0C0h 013B1009 53 push ebx 013B100A 56 push esi
This was also tested on an x64 version (with a x64 build of the debugger) with equal success.
Article Roadmap
Future posts will be related on topics closely following the items below:
- Basics
- Adding/Removing Breakpoints, Single-stepping
- Call Stack, Registers, Contexts
- Symbols
- Miscellaneous Features
The full source code relating to this can be found here. C++11 features were used, so MSVC 2012/2013 is most likely required.