Posts rdbg - A Rust library for writing custom Windows debuggers
Post
Cancel

rdbg - A Rust library for writing custom Windows debuggers

rdbg on Github

Introduction


Writing a custom debugger can be very useful for many program analysis tasks. MSDN provides a useful template which I ended up using many times including in my TriageTool crash triaging tool which I mentionned in my previous post. However I got tired of copying this boilerplate code and have since ported all my tools to Rust which is why I decided to write rdbg.

rdbg is a small Rust library with no dependencies. It provides Rust-friendly wrappers to the Windows debugging API and implements much of the boilerplate debugger code. It exposes APIs to spawn a process, read and write its memory, place breakpoints which execute a closure when reached, single step threads and provide callbacks for many debugging events.

While using one of the crates providing Rust bindings to the Windows API was a possibility, this usually results in non-idiomatic Rust code (e.g checking tags for enums instead of pattern matching) and requires extensive use of unsafe code.

In the next section I’ll present rdbg’s API. We’ll then go over two simple examples of using it, first to solve a reverse-engineering ctf challenge and then to triage crashes.

rdbg


rdbg is built around two components:

  • The Debugger struct which allows spawning a process, running it, placing breakpoints, single stepping etc.
  • The DbgCallbacks trait which allows the user to define functions which will be called on various debugging events (thread creation, dll loading, access violation, etc.)

We will now present them briefly but I recommend taking a look at the documentation (which can be generated with cargo doc --open from the root of the repository) for more details.

Debugger

The Debugger is used to spawn and run a process. Note that spawn accepts an Option<String> for stdin which will be fed to the target process’ stdin if provided.

1
2
3
4
5
6
/// Spawn a new process under the debugger.
/// e.g cmdline = ["program_path", "arg1", "arg2", "arg3"]
pub fn spawn(cmdline: &[&str], stdin: Option<String>) -> Self

/// Run the process returning its exit code
pub fn run(&mut self, cbs: &mut impl DbgCallbacks) -> u32

The Debugger can then be used to read and write the debuggee’s memory:

1
2
3
4
5
6
7
/// Attempts to read `buf.len()` bytes of memory at `addr` in the debugged
/// process. Returns the number of bytes read.
pub fn read_mem(&self, addr: *const u8, buf: &mut [u8]) -> usize

/// Attempts to write `buf.len()` bytes of memory to `addr` in the debugged
/// process. Returns the number of bytes written.
pub fn write_mem(&self, addr: *mut u8, buf: &[u8]) -> usize

The Debugger can place breakpoints and provide a closure to be executed when the breakpoint is hit. If the module hasn’t been loaded yet, the breakpoint is deferred and will be registered when the module gets loaded.

1
2
3
4
5
6
7
8
9
10
11
/// Registers a breakpoint at address <module>+off
/// Upon reaching the address, the closure `cb` will be invoked. 
/// If `permanent` is false, the breakpoint is deleted after it's hit once
/// If `permanent` is true, the breakpoint triggers every time the address
/// is reached.
pub fn register_breakpoint(&mut self, module: &str, off: usize, 
                       cb: Box<BreakpointCallback>, permanent: bool)

/// Type of the closure called when a breakpoint is hit.
/// Arguments are: (dbg: Debugger, pid: u32, tid: u32, exception: EXCEPTION_RECORD)
pub type BreakpointCallback = dyn FnMut(&mut Debugger, u32, u32, &EXCEPTION_RECORD);

Finally, we can single-step threads:

1
2
3
4
5
6
/// Enable single stepping for thread `tid`
pub fn enable_single_stepping(&mut self, tid: u32)

/// Disable single stepping for thread `tid`. Has no effect if it was not 
/// enabled
pub fn disable_single_stepping(&mut self, tid: u32)

DbgCallbacks

This trait allows the user to provide functions that will get called when a specific event occurs.

Here is the list of events currently available:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// Called when the debugged process causes an exception
fn exception_cb
/// Called on thread creation
fn create_thread_cb
/// Called on process creation
fn create_process_cb
/// Called on thread exit
fn exit_thread_cb
/// Called on process exit
fn exit_process_cb
/// Called on DLL load
fn dll_load_cb
/// Called on DLL unload
fn dll_unload_cb
/// Called when the debugged process causes an access violation
fn access_violation_cb
/// Called on every single step
fn single_step_cb
/// Called when the debugged process attempts to execute an illegal instruction
fn illegal_inst_cb
/// Called when the debugged process divides by zero
fn div_by_zero_cb
/// Called when the debugged process uses up its stack
fn stack_overflow_cb

The user should implement this trait for a struct and pass it to the Debugger’s run method.

1
2
3
4
5
struct MyAnalysis;
impl DbgCallbacks for MyAnalysis { }
...
let mut dbg = Debugger::spawn(&["program.exe"], None);
dbg.run(&mut MyAnalysis);

Examples


Let’s now go over two examples of using rdbg.

Solving a reverse-engineering CTF challenge

This CTF challenge is a 32 bit executable. It accepts a password on argv, performs some checks in check_password and either prints "Wrong Password :(" or "Congrats! :)":

1
2
3
4
5
6
7
8
9
10
11
12
.text:004010DB         call    check_password
.text:004010E0         add     esp, 4
.text:004010E3         test    eax, eax
.text:004010E5         jz      short loc_4010FA
.text:004010E7         push    offset aCongrats ; "Congrats! :)\n"
.text:004010EC         call    puts
.text:004010F1         add     esp, 4
.text:004010F4         xor     eax, eax
.text:004010F6         jmp     short loc_40110C
.text:004010F8         jmp     short loc_40110C
.text:004010FA         push    offset aWrongPassword ; "Wrong password :(\n"
.text:004010FF         call    puts

Often, simple challenges rely on comparisons that stop as soon as an incorrect character is encountered. This means that if none of the characters are correct, the check fails immediately, if only the first character is correct, one check succeeds but the next one fails and so on. In other words, the more valid characters we provide, the further we will go in check_password before failing.

Therefore we can use the number of instructions executed during the password check as a side channel.

We can use rdbg to count the number of instructions executed in check_password. To do so, we will spawn the target binary under the debugger and single step the check_password function:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Spawn chall.exe under the debugger without providing any stdin (this 
// challenge accepts input on argv).
let mut dbg = Debugger::spawn(&["examples\\crackme_example\\chall.exe", pass], None);

// Start single stepping when we reach the `call check_password` instruction
dbg.register_breakpoint("chall.exe", 0x10DB, Box::new(|dbg, _pid, tid, _exception| {
    dbg.enable_single_stepping(tid);
}), false);

// Stop single stepping when we return from `check_password`
dbg.register_breakpoint("chall.exe", 0x10E0, Box::new(|dbg, _pid, tid, _exception| {
    dbg.disable_single_stepping(tid);
}), false);

We now need to count the number of single steps performed during the execution:

1
2
3
4
5
6
7
8
9
10
/// An analysis which counts the number of single steps during the debuggee's 
/// execution.
struct SingleStepCounter(usize);

impl DbgCallbacks for SingleStepCounter {
    // This function gets called on each single step
    fn single_step_cb(&mut self, _: &mut Debugger, _: u32, _: u32) {
        self.0 += 1;
    }
}

We can then run the target program and retrieve the result:

1
2
3
let mut single_step_counter = SingleStepCounter(0);
dbg.run(&mut single_step_counter);
let n_inst_executed = single_step_counter.0;

Finally we need to write some brute force logic that tries every character for each position and always picks the one that leads to executing the most instructions. I’ll spare you the details but you can find the code here.

Output:

1
2
3
4
5
6
7
C:\tools\rdbg>cargo run -p crackme_example
S__________
S3_________
S3c________
S3cR_______
S3cR3______
S3cR3t_____

Writing a crash analysis tool

Custom debuggers can also be useful to automate crash analysis. In this context, many callbacks in dbg_callbacks.rs can be interesting such as access_violation_cb, illegal_inst_cb, div_by_zero_cb or stack_overflow_cb but to keep this example short, we will only report access violations.

Let’s define a function to be called when an access violation is triggered. It will parse the exception record and display information about the crash:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/// An analysis which prints information about a crashing program
struct CrashAnalysis;
impl DbgCallbacks for CrashAnalysis {
    fn access_violation_cb(&mut self, dbg: &mut Debugger, _pid: u32, _tid: u32, 
                           exception: &EXCEPTION_RECORD, first_chance: bool) {

        let addr = exception.ExceptionAddress;        

        // Resolve the address to module + offset
        let (module, offset) = dbg.resolve_addr(addr);

        let details = if exception.NumberParameters > 1 {
            let a = exception.ExceptionInformation[1];
            let av_type = match exception.ExceptionInformation[0] {
                0 => "read",
                1 => "write",
                8 => "exec",
                _ => unreachable!(),
            };

            format!("Invalid {av_type} to {a:#x}")
        } else { String::new() };

        println!("Access Violation  @ {module}+{offset:#x} ({} chance). {}",
                 if first_chance { "first" } else { "second" }, details);
    }
}

We can then execute our crashing program under the debugger:

1
2
let mut dbg = Debugger::spawn(&["examples\\triage_example\\crash.exe"], None);
dbg.run(&mut CrashAnalysis);

Once again, the code for this example is available here

Output:

1
2
3
C:\tools\rdbg>cargo run -p triage_example
Access Violation @ crash.exe+0x1019 (first chance). Invalid write to 0xc
Access Violation @ crash.exe+0x1019 (second chance). Invalid write to 0xc

Conclusion

In this post, we introduced rdbg, a library for writing custom Windows debuggers in Rust. We then went over two use cases to show how one can use it to quickly automate program analysis tasks.

Don’t forget to check out the code of rdbg on Github. I hope you’ll find it useful and welcome any PR or feedback.

This post is licensed under CC BY 4.0 by the author.