How Debuggers Work

Featured on Hashnode

In the last couple of days, I've been wondering how debuggers work and how can I implement a small debugger. I tried to watch some videos and read a few articles to understand better how they work. Then decided to write some notes on what I found.

Introduction

A debugger is an application that is used to test and debug other applications. Debuggers have a few functionalities or ‘common operations’, for example;

  • Breakpoints
  • Stepping
  • Viewing the stack trace/backtrace

To learn more about how debuggers work, we will need to learn a little bit about what ELF, DWARF, and ptrace are.

ELF

ELF is the main binary format for Linux objects and executables.

DWARF

DWARF is a debug info format. It uses a data structure called a Debugging Information Entry (DIE) to represent each variable, type, procedure, etc.

DWARF has a Line Number Table, which maps code locations to source code locations and vice versa, also specifies which instructions are part of function prologues and epilogues.

In assembly language programming, the function prologue is a few lines of code at the beginning of a function, which prepare the stack and registers for use within the function. Similarly, the function epilogue appears at the end of the function and restores the stack and registers to the state they were in before the function was called.

ptrace

#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

Is short for "process trace". It's a system call in Unix. By using ptrace a process can control another process. Therefore, a parent's process can manipulate the internal state of their child's process. For example; it can read and write memory, read and write registers, and allows tracing of its child process. ptrace is generally used by debuggers and other code analysis tools, mostly as aids to software development.

Breakpoints

According to Wikipedia, a breakpoint is an intentional stopping or pausing place in a program, put in place for debugging purposes. It is also sometimes simply referred to as a pause in the process's execution.

There are two types of breakpoints, hardware and software breakpoints.

Hardware Breakpoints

Hardware breakpoints only have four registers for writing breakpoint addresses into, which means they are limited in number. While using hardware breakpoints you'll set a special register which will result in a breakpoint. Then you can use other debug status registers to see the status of your breakpoints. Finally, you also have to debug control registers that you can use to control your breakpoint, so you can break on reading, writing, or executing.

Software Breakpoints

Software breakpoints, on the other hand, modify the running code in memory to introduce breaking instructions. Software breakpoints are unlimited so you can create as many breakpoints as you want but you can only set a breakpoint on executing an instruction.

Address Level Breakpoints

To learn more about how they work, let's take a look at this x86 Assembly;

55                     push %rsp
48 89 e5               mov %rsp, %rsp
48 83 ec 10            sub $0x10, %rsp

So if you want to set a breakpoint at the mov instruction in the second line and since we already know the address of this instruction, we will explicitly be telling the debugger to set a breakpoint right there.

What the debugger then does is it takes this first bite this 0x48 and it's going to set it off to the side remember it for later. The debugger is then going to replace the old value with 0xcc and this is an int3 instruction and this is going to trigger a software interrupt. So, it'll look like this.

cc 89 e5            int3

Finally, when the execution reaches this instruction, it'll stop and triggers an interrupt.

int3 Instruction

If we took a look at what that int3 instruction does exactly in the linux kernel traps.c.

static void do_int3_user(struct pt_regs *regs)
{
    if (do_int3(regs))
        return;

    cond_local_irq_enable(regs);
    do_trap(X86_TRAP_BP, SIGTRAP, "int3", regs, 0, 0, NULL);
    cond_local_irq_disable(regs);
}

We will find out what we are calling a SIGTRAP which is the signal for traces and breakpoint traps. That means when this line gets executed a UNIX signal will get sent to our process and our debugger then will know that we hit a breakpoint.

Source Level Breakpoints

We just learned how to use address level breakpoints work however, it's more likely to use a breakpoint to break on a certain function. Therefore, now we will be learning how to do this work.

For example, let's say that we want to break on main. We will need to get the address of this function. Therefore, we will use the DWARF table that we talked about earlier to look up the function's prologue. Then we can just use that address to do an address level breakpoint just as before.

If we want to add a breakpoint to an overloaded function, we will use its mangled name to lookup the DWARF table.

Line Level Breakpoints

To add a breakpoint at a certain line. For example, adding a breakpoint at line 4. I.e. program.cpp:4 . In this case, we will use the Line Number Table information.

In some cases, we might have multiple entries for the same line. For example, if we are doing more than one instruction on the same line. Taking the following example;

int foo = 4; foo += 5; std::cout << foo;

In this case, the breakpoint will be set at the beginning of the statement.

Finally, we will get the corresponding address to the line from the Line Number Table and we will do the same as discussed in Address Level Breakpoints.

Stepping

There are four types of stepping. Stepping over an assembly instruction, over a function call, in a function, or out of of a function.

Instruction Step

To do an instruction step on the UNIX system you can use ptrace. For example, you can do;

ptrace(PTRACE_SINGLESTEP, debuggee_pid, nullptr, nullptr);

Step In & Step Over

For stepping in a function we will need to set two breakpoints:

  • The first one is at the return address. That's because in case you're not stepping into a function and you're turning out, you don't want to just continue and never gain control over your debuggee again.
  • The second one is at the next instruction in the callee.

The question here is how the debugger will know what the next instruction in the callee is.

Step Out

For stepping in a function we will just need to set a breakpoint in the return address.

Read & Write Memory

Sometimes the developers might need the debugger to do a memory dump. To do that we can use also use ptrace. For example;

auto data = ptrace(PTRACE_POKEDATA, debuggee_pid, address, nullptr);

The problem here is that ptrace only read one word at a time. So, doing this for a huge portion of data is inefficient. Because we do a syscall and we have a contact switch down into kernel mode and then we come back up every single word.

So the solution here will be using process_vm_readv(); which can do multi-word reading and writing.

Stack Unwinding

Sometimes we might want to get the entire call stack trace for a function. We can use the stack back pointer to get the previous stack frame and use the return address of the function to lookup the DWARF information and since DWARF has info about where the function address lives, we then can find all of the information about our last stack frame. We will keep doing that until the frame pointer reaches 0.

Conclusion

In this post, we learned some basic concepts on how debuggers work. Of course, there's a lot more to learn however I think this is enough, for now, to just have a basic understanding of how they work.

If you caught any incorrect or incomplete information in this post, please reach out to me on Twitter.

Resources

Did you find this article valuable?

Support Hassan ElDesouky by becoming a sponsor. Any amount is appreciated!