Writing a Debugger From Scratch - DbgRs Part 2 - Register State and Stepping
When we left off last time, we had a basic “debugger” that could launch a Windows process and monitor events that occur in that process, but it’s not yet something that you would really call a debugger. Two things that are missing are the ability to examine the state of the process and to control its execution. So that’s what we’re going to build next.
The code for this part is on GitHub as the part2 branch. If you see any mistakes or ways to improve the code, feel free to create issues in the GitHub repo or submit a PR. I’ve had a few folks contribute issues and PRs, which I really appreciate!
Parsing commands
In order to interact with a debugger, we need some sort of user interface. To keep things simple, we’ll create a console interface that will use some of the same command names as ntsd. To do that, we’ll need some basic string parsing. The commands we’re starting with are going to be very simple without any arguments or parameters, but in a future part we’ll be adding more complicated commands. To get a head start on that, we’ll use Rust Sitter, which seems like a nice simple way to write a parser.
We’ll start with four basic commands to implement: “step into”, “go”, “display registers”, and “quit”. These are some of the most basic commands that we can implement that do not require any interpretation of the state within the target process, and can be implemented purely in terms of reading and writing to the register context.
7#[rust_sitter::grammar("command")]
8pub mod grammar {
9 #[rust_sitter::language]
10 pub enum Expr {
11 StepInto(#[rust_sitter::leaf(text = "t")] ()),
12 Go(#[rust_sitter::leaf(text = "g")] ()),
13 DisplayRegisters(#[rust_sitter::leaf(text = "r")] ()),
14 Quit(#[rust_sitter::leaf(text = "q")] ()),
15 }
16}
We’ll read input from stdin and attempt to parse it. If we fail to parse the input, we’ll display the error and ask the user for input again. I’ve omitted some parts because the focus is not the error handling, but you can see the full code in command.rs in the GitHub repo.
68pub fn read_command() -> grammar::Expr {
69 let stdin = std::io::stdin();
70 loop {
71 print!("> ");
72 std::io::stdout().flush().unwrap();
73 let mut input = String::new();
74 stdin.read_line(&mut input).unwrap();
75 let input = input.trim().to_string();
76 if !input.is_empty() {
77 let cmd = grammar::parse(&input);
78 match cmd {
79 Ok(c) => return c,
80 Err(errs) => {
81 // Omitted for brevity
82 }
83 }
84 }
85 }
86}
Reading the registers
Now we have a way of reading commands from a user but we still need to retrieve the relevant information so we can display it. The commands we’re implementing this time all revolve around the register context. Each thread has its own set of registers, so the API for reading the registers is called GetThreadContext. Note that what goes in a “context” can get very complex, especially when working with things like the AVX registers or other context extensions. We’ll skip most of that for now, but if you want to read more about it you can consult the documentation on XState.
The GetThreadContext
function takes two arguments. The first is a thread handle and it has to be opened with the THREAD_GET_CONTEXT
flag. Later we’ll also want to use SetThreadContext
, which will require the THREAD_SET_CONTEXT
flag, so we’ll use both when we open the thread handle. We can use the thread ID we got from the WaitForDebugEventEx
. Thread IDs are unique across the entire system, so no process ID is needed here.
I’ve also made a little utility container called AutoClosedHandle
to make sure that CloseHandle
gets called on the handle when it gets dropped.
137 let thread = AutoClosedHandle(unsafe {
138 OpenThread(
139 THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
140 FALSE,
141 debug_event.dwThreadId,
142 )
143 });
The second argument to GetThreadContext
is the win32 CONTEXT
structure. This is a bit of an odd structure on Windows, because it’s specific to a CPU architecture, and the version you get is determined at compile time by the architecture of the debugger process, not the target process. As you might imagine, this can make cross-architecture debugging somewhat complicated. We’ll leave that problem for another day and just assume that this is an x64 debugger process debugging an x64 target process. The definition of CONTEXT
also has some alignment requirements, which are unfortunately not automatically annotated by the windows-sys crate right now. The folks on the windows-rs project are aware of the issue, but for now we can work around this by wrapping the structure inside another structure that has proper alignment.
31#[repr(align(16))]
32struct AlignedContext {
33 context: CONTEXT,
34}
Also, because the context parameter to GetThreadContext
is an in/out parameter, we need to make sure it’s properly initialized, including the ContextFlags
field which determines which flags should be queried (and which are valid when we call SetThreadContext
). With that, we can finally call GetThreadContext
.
144 let mut ctx: AlignedContext = unsafe { std::mem::zeroed() };
145 ctx.context.ContextFlags = CONTEXT_ALL;
146 let ret = unsafe { GetThreadContext(thread.handle(), &mut ctx.context) };
Handling commands
Using the register context, we can display a summary of the current state and a prompt for the user to enter commands. Since we don’t have any kind of symbol resolution yet, we’ll have to settle for displaying the current thread ID and the current instruction pointer.
152 let mut continue_execution = false;
153
154 while !continue_execution {
155 println!("[{:X}] {:#018x}", debug_event.dwThreadId, ctx.context.Rip);
Next we’ll read a command from the prompt and have handlers for each of the commands. We’ll start with Expr::DisplayRegisters
, which has a straightforward implementation:
159 let cmd = command::read_command();
160 match cmd {
161 Expr::DisplayRegisters(_) => {
162 registers::display_all(ctx.context);
163 }
5pub fn display_all(context: CONTEXT) {
6 println!("rax={:#018x} rbx={:#018x} rcx={:#018x}", context.Rax, context.Rbx, context.Rcx);
7 println!("rdx={:#018x} rsi={:#018x} rdi={:#018x}", context.Rdx, context.Rsi, context.Rdi);
8 println!("rip={:#018x} rsp={:#018x} rbp={:#018x}", context.Rip, context.Rsp, context.Rbp);
9 println!(" r8={:#018x} r9={:#018x} r10={:#018x}", context.R8, context.R9, context.R10);
10 println!("r11={:#018x} r12={:#018x} r13={:#018x}", context.R11, context.R12, context.R13);
11 println!("r14={:#018x} r15={:#018x} eflags={:#010x}", context.R14, context.R15, context.EFlags);
12}
The Expr::Go
and Expr::Quit
commands are also straightforward:
169 Expr::Go(_) => {
170 // We'll break out of the loop and call ContinueDebugEvent
171 continue_execution = true;
172 }
173 Expr::Quit(_) => {
174 // The process will be terminated since we didn't detach.
175 return;
176 }
Step command
The simplest type of step command is the “step into” command. We don’t have any source line information, so this will specifically be an instruction-level step into, which simply means that we will ask the thread to execute a single instruction. Luckily, most CPUs have a mechanism for this, and on x86 this is called the “trap flag” or TF for short. We’ll take the context that we just read from the thread, add the trap flag to the EFLAGS register, and then set the context on the current thread.
160 Expr::StepInto(_) => {
161 ctx.context.EFlags |= TRAP_FLAG;
162 let ret = unsafe { SetThreadContext(thread.handle(), &ctx.context) };
163 if ret == 0 {
164 panic!("SetThreadContext failed");
165 }
166 expect_step_exception = true;
167 continue_execution = true;
168 }
When execution is resumed on the thread, it will allow a single instruction to execute before it causes a “trap”, which is represented as an exception debug event. But there’s a small twist here, because if you try to use the step command with what we’ve written so far, it will look like the step command worked but the process will exit unexpectedly instead of letting you continue to step. This is because the trap flag has created an unhandled exception that the program was not designed to handle, which results in the process terminating. Luckily the ContinueDebugEvent takes a parameter of dwContinueStatus
where passing DBG_CONTINUE
tells the kernel to mark the exception as “handled” and causes the execution to continue normally at the location of the current thread context. We need to pass that value only when we know the exception was caused by a step command, or else we will end up suppressing real exceptions.
We could simply assume that any exception with code 0x80000004 (EXCEPTION_SINGLE_STEP) was one that was generated by the debugger, and I’m sure some debuggers do that. However, single step exceptions can happen normally in a program without a debugger attached (either intentionally or due to program error), and are occasionally used as a form of anti-debugging. If we’re not careful, we could change the program behavior unintentionally while debugging it. To keep things simple, we’ll just assume that the next exception with code EXCEPTION_SINGLE_STEP after a step operation is one that was caused by the debugger.
109 EXCEPTION_DEBUG_EVENT => {
110 let code = unsafe { debug_event.u.Exception.ExceptionRecord.ExceptionCode };
111 let first_chance = unsafe { debug_event.u.Exception.dwFirstChance };
112 let chance_string = if first_chance == 0 {
113 "second chance"
114 } else {
115 "first chance"
116 };
117
118 if expect_step_exception && code == EXCEPTION_SINGLE_STEP {
119 expect_step_exception = false;
120 continue_status = DBG_CONTINUE;
121 } else {
122 println!("Exception code {:x} ({})", code, chance_string);
123 continue_status = DBG_EXCEPTION_NOT_HANDLED;
124 }
125 }
Trying it out
Let’s run the debugger and see how it looks:
Command line was: 'cmd.exe /k "echo hello" '
CreateProcess
[24EC] 0x00007ff812d42680
> g
LoadDll
[24EC] 0x00007ff812d42680
> g
LoadDll
[24EC] 0x00007ff812d8d5c4
> g
LoadDll
[24EC] 0x00007ff812d8d5c4
> t
[24EC] 0x00007ff812d8d5c4
> t
[24EC] 0x00007ff812d04d42
> t
[24EC] 0x00007ff812d04d44
> r
rax=0x0000000000000000 rbx=0x0000000000000000 rcx=0x00007ff812d8d5c4
rdx=0x0000000000000000 rsi=0x000001ee3e0647c0 rdi=0x000001ee3e064680
rip=0x00007ff812d04d44 rsp=0x00000063ed4fe750 rbp=0x00000063ed4fe7d0
r8=0x00000063ed4fe748 r9=0x00000063ed4fe7d0 r10=0x0000000000000000
r11=0x0000000000000344 r12=0xffffffffffffffff r13=0x00000063ed2b0000
r14=0x000001ee3e064700 r15=0x0000000000800000 eflags=0x00000246
[24EC] 0x00007ff812d04d44
> t
[24EC] 0x00007ff812d04d48
> r
rax=0x0000000000000000 rbx=0x0000000000000000 rcx=0x00007ff812d8d5c4
rdx=0x0000000000000000 rsi=0x000001ee3e0647c0 rdi=0x000001ee3e064680
rip=0x00007ff812d04d48 rsp=0x00000063ed4fe750 rbp=0x00000063ed4fe7d0
r8=0x00000063ed4fe748 r9=0x00000063ed4fe7d0 r10=0x0000000000000000
r11=0x0000000000000344 r12=0xffffffffffffffff r13=0x00000063ed2b0000
r14=0x000001ee3e064700 r15=0x0000000000800000 eflags=0x00000246
[24EC] 0x00007ff812d04d48
> q
What we have now is something incredibly close to what we could really call “a debugger”. You can continue execution, you can step through code, and you can examine the registers. There are a few really big things missing that prevent this from actually being useful, and most of them have to do with the memory of the target process. This debugger can’t read or write from the targets memory, and that means it can’t do a number of other important things like resolving symbolic names or displaying disassembly. We’ll start to tackle that in the next part. Until then, let me know what you thought of this on Twitter or Mastodon!