As software developers (or reverse engineers), debuggers are an invaluable tool. They allow for runtime analysis and introspection/understanding of the program in order to find out how it works — or oftentimes doesn’t. This series of posts will go in to how they work and how to begin developing a primitive debugger targeting the Windows platform running on the x86 or x64 architectures.
Attaching/Detaching
In order to debug a process, a debugger must be able to attach to it. This means that there should be a way for the debugger to interact with the process in such a way that the debugger will have access to the processes address space, the ability to halt and continue execution, modify registers, and so on. Likewise, a debugger should be able to safety detach from a process and let it continue running when a debugging session is finished. On the Windows platform this is achieved by calling the DebugActiveProcess function and specifying the process identifier of the target. Alternatively, it can also be accomplished by calling CreateProcess with the DEBUG_PROCESS or DEBUG_ONLY_THIS_PROCESS creation flags. This latter method will create a new process and attach to it rather than attaching to one already running on the system. Once attached, the debugger can specify a policy on whether to kill the process on detach with DebugSetProcessKillOnExit. Lastly, the process for detaching is a straightforward call to DebugActiveProcessStop. Putting all of these together produces code similar to the following:
const bool Debugger::Start() { m_bIsActive = BOOLIFY(DebugActiveProcess(m_dwProcessId)); if (m_bIsActive) { const bool bIsSuccess = BOOLIFY(DebugSetProcessKillOnExit(m_bKillOnExit)); if (!bIsSuccess) { fprintf(stderr, "Could not set process kill on exit policy. Error = %X\n", GetLastError()); } return DebuggerLoop(); } else { fprintf(stderr, "Could not debug process %X. Error = %X\n", m_dwProcessId, GetLastError()); } return false; } const bool Debugger::Stop() { m_bIsActive = BOOLIFY(DebugActiveProcessStop(m_dwProcessId)); if (!m_bIsActive) { fprintf(stderr, "Could not stop debugging process %X. Error = %X\n", m_dwProcessId, GetLastError()); } return m_bIsActive; } |
where m_dwProcessId and m_bKillOnExit are parameters provided through the Debugger constructor (see sample code). The BOOLIFY macro is also just a simple
#define BOOLIFY(x) !!(x) |
definition to encourage type safety. At this point we have a simple debugger that can attach to and detach from a target process, but cannot handle any debug events. This is where the debugging loop comes in.
The Debugging Loop
The core component of any debugger is the debugging loop. This is the part responsible for waiting for a debug event, processing said event, and then waiting for the next event, hence the loop. On the Windows platform, this is pretty straightforward. It’s actually straightforward enough that Microsoft wrote up a short MSDN page on what needs to be done at this step. The steps involved are that (in a loop), the debugger calls WaitForDebugEvent which waits for a debug event from the process. These debug events are enumerated here and are commonly events related to process and thread creation/destruction, loading or unloading of DLLs, any exceptions raised, or debug strings output specifically for a debugger to see. Once this function returns, it will populate a DEBUG_EVENT structure with the information related to the particular debug event. At this point, it is the debuggers job to handle the event. After handling the event, the handlers must provide a continue status to ContinueDebugEvent, which is a code telling the thread that raised the event how to carry on execution after the event was handled. For most events, i.e. CREATE_PROCESS_DEBUG_EVENT, LOAD_DLL_DEBUG_EVENT, etc, you want to continue execution since these events do not reflect anything wrong with program behavior, but are events to notify the debugger of changing process state. This is done by choosing DBG_CONTINUE as the continue status. For the exceptional cases, such as exceptions which lead to undefined program behavior such as access violations, illegal instruction execution, divides by zero, etc, the process is nearing a point of no return. The debuggers job at this point is to gather and display information relating to the crash and in most cases terminate the process. This termination can happen inside the handler itself for these exceptions, or the debugger can choose to continue the debug event with the DBG_EXCEPTION_NOT_HANDLED continue status, meaning that the debugger is relinquishing responsibility of handling this exception properly. In almost all cases, this will lead to the program terminating on its own immediately afterwards. However, there are sometimes corner cases, particularly in malware, where the process will install its own runtime exception handler as an obfuscation technique and produce its own runtime exceptions to be handled within this handler to carry out some functionality. Continuing the exception in this case would not result in a crash since the process is able to handle its own exception after the debugger forwards it along the exception handler chain. Putting cases like those aside for now, a typical debugging loop may look like the following:
const bool Debugger::DebuggerLoop() { DEBUG_EVENT dbgEvent = { 0 }; DWORD dwContinueStatus = 0; bool bSuccess = false; while (m_bIsActive) { bSuccess = BOOLIFY(WaitForDebugEvent(&dbgEvent, INFINITE)); if (!bSuccess) { fprintf(stderr, "WaitForDebugEvent returned failure. Error = %X\n", GetLastError()); return false; } m_pEventHandler->Notify((DebugEvents)dbgEvent.dwDebugEventCode, dbgEvent); dwContinueStatus = m_pEventHandler->ContinueStatus(); bSuccess = BOOLIFY(ContinueDebugEvent(dbgEvent.dwProcessId, dbgEvent.dwThreadId, dwContinueStatus)); if (!bSuccess) { fprintf(stderr, "ContinueDebugEvent returned failure. Error = %X\n", GetLastError()); return false; } } return true; } |
with m_pEventHandler being responsible for registering handlers and setting a continue status to be passed along to ContinueDebugEvent. The example code registers handlers for events/exceptions and outputs information relevant to each event/exception. The style below is followed for all events and exceptions:
Register(DebugEvents::eCreateThread, [&](const DEBUG_EVENT &dbgEvent) { auto &info = dbgEvent.u.CreateThread; fprintf(stderr, "CREATE_THREAD_DEBUG_EVENT received.\n" "Handle: %X\n" "TLS base: %X\n" "Start address: %X\n", info.hThread, info.lpThreadLocalBase, info.lpStartAddress); SetContinueStatus(DBG_CONTINUE); }); Register(DebugEvents::eCreateProcess, [&](const DEBUG_EVENT &dbgEvent) { auto &info = dbgEvent.u.CreateProcessInfo; fprintf(stderr, "CREATE_PROCESS_DEBUG_EVENT received.\n" "Handle (image file): %X\n" "Handle (process): %X\n" "Handle (main thread): %X\n" "Image base address: %X\n" "Debug info file offset: %X\n" "Debug info size: %X\n" "TLS base: %X\n" "Start address: %X\n", info.hFile, info.hProcess, info.hThread, info.lpBaseOfImage, info.dwDebugInfoFileOffset, info.nDebugInfoSize, info.lpThreadLocalBase, info.lpStartAddress); m_hProcess = info.hProcess; SetContinueStatus(DBG_CONTINUE); }); |
That should be all that is really needed to get started on creating a basic debugger. This debugger features the ability attach/detach, handle debug events, and output information pertaining to these events. To test this out, we can create a simple program to generate an exception that will be caught by the debugger when it is attached.
#include <stdio.h> #include <Windows.h> int main(int argc, char *argv[]) { printf("Press enter to raise an exception.\n"); (void)getchar(); if (IsDebuggerPresent()) { OutputDebugStringA("This should be seen by the debugger.\n"); RaiseException(STATUS_ACCESS_VIOLATION, 0, 0, nullptr); } else { printf("Process was not being debugged.\n"); } return 0; } |
Here is the output of the debugger upon attaching to the process and receiving the access violation:
CREATE_PROCESS_DEBUG_EVENT received. Handle (image file): 4C Handle (process): 48 Handle (main thread): 44 Image base address: EE0000 Debug info file offset: 0 Debug info size: 0 TLS base: 7F03F000 Start address: 0 LOAD_DLL_DEBUG_EVENT received. Handle: 54 Base address: 77040000 Debug info file offset: 0 Debug info size: 0 Name: \\?\C:\Windows\SysWOW64\ntdll.dll LOAD_DLL_DEBUG_EVENT received. Handle: 5C Base address: 76A00000 Debug info file offset: 0 Debug info size: 0 Name: \\?\C:\Windows\SysWOW64\kernel32.dll LOAD_DLL_DEBUG_EVENT received. Handle: 50 Base address: 765D0000 Debug info file offset: 0 Debug info size: 0 Name: \\?\C:\Windows\SysWOW64\KernelBase.dll LOAD_DLL_DEBUG_EVENT received. Handle: 60 Base address: F7F0000 Debug info file offset: 0 Debug info size: 0 Name: \\?\C:\Windows\SysWOW64\msvcr120d.dll CREATE_THREAD_DEBUG_EVENT received. Handle: 64 TLS base: 7F03C000 Start address: 770EBCFC Received exception event. First chance exception: 1 Exception code: 80000003 Exception flags: 0 Exception address: 770670BC Number parameters (associated with exception): 1 Received breakpoint EXIT_THREAD_DEBUG_EVENT received. Thread 1B20 exited with code 0. OUTPUT_DEBUG_STRING_EVENT received. Debug string: This should be seen by the debugger. Received exception event. First chance exception: 1 Exception code: C0000005 Exception flags: 0 Exception address: 765E2F71 Number parameters (associated with exception): 0 Received access violation Received exception event. First chance exception: 0 Exception code: C0000005 Exception flags: 0 Exception address: 765E2F71 Number parameters (associated with exception): 0 Received access violation EXIT_PROCESS_DEBUG_EVENT received. Process 390 exited with code C0000005.
As you can see from the output, the debugger receives a CREATE_PROCESS_DEBUG_EVENT upon attaching. This event is always the first one triggered upon a debugger attaching and lets the debugger obtain a handle to the process that lets the debugger read/write from process memory, change the processes thread contexts, etc. It also may give information about any sort of debug information relevant to the process. Following that are the events related to any loaded DLLs in the process address space. Afterwards two interesting events come up. They are a CREATE_THREAD_DEBUG_EVENT and an exception with an exception code corresponding to EXCEPTION_BREAKPOINT. These are covered in the section below. Lastly, the debugger successfully displays the debug string provided by the process, and shows the access violation also being successfully received. Since the debugger sample code does not terminate the process, you can see the exception being raised multiple times. Initially it is raised as a first chance exception, but comes back around around as a second/last chance exception since the target process was not able to handle it. The process then terminates with a status code corresponding to EXCEPTION_ACCESS_VIOLATION.
What actually happens when a debugger is attached?
One current mystery about the debugger output may be where those CREATE_THREAD_DEBUG_EVENT, breakpoint, and EXIT_THREAD_DEBUG_EVENT events came from. These events are triggered as a result of the debugger attaching to the process. When the DebugActiveProcess is called, it forwards on to the NtDebugActiveProcess syscall. This syscall is responsible for setting up the process to be in a debugable state, which at the very least involves changing the BeingDebugged flag of the Process Environment Block for the target process — this is how the IsDebuggerPresent function works in the target process to check if it is being debugged. Afterwards, a thread will be created in the target process. This thread will have a start address that corresponds to an 0xCC (int 3) instruction, better known as a breakpoint on x86 and x64 architectures. This is why the debugger displays as having received a breakpoint. When execution is continued, this thread exits the process begins executing normally again.
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.