Making debugger in Golang (part III)

Michał Łowicki
golangspec

--

So far we’ve learned how to single-step ptraced process (tracee) and get some debugging info out of binary (read it here if you haven’t done it yet). Now it’s time to set a breakpoint at desired place, wait until reached and then investigate process state.

Let’s start with assembly code used before:

section .data
msg db "hello, world!", 0xA
len equ $ - msg
section .text
global _start
_start:
mov rax, 1 ; write syscall (https://linux.die.net/man/2/write)
mov rdi, 1 ; stdout
mov rsi, msg
mov rdx, len
; Passing parameters to `syscall` instruction described in
; https://en.wikibooks.org/wiki/X86_Assembly/Interfacing_with_Linux#syscall
syscall
mov rax, 60 ; exit syscall (https://linux.die.net/man/2/exit)
mov rdi, 0 ; exit code
syscall

The goal set a breakpoint at:

mov     rdi, 1

so the trap will be activated before running this instruction. Then we’ll check that RDI register stores 0, run single step and confirm that register stores 1.

Breakpoint

For x86 processors there’s a INT instruction which generates software interrupt. These interrupts are used f.ex. to do syscall on Linux. x86–64 introduced dedicated syscall instruction which is faster this is why I’ve used it above but we could achieve the same with regular int. To set a breakpoint we’ll use int 3 which has an opcode 0xCC:

The INT 3 instruction generates a special one byte opcode (CC) that is intended for calling the debug exception handler. (This one byte form is valuable because it can be used to replace the first byte of any instruction with a breakpoint, including other one byte instructions, without over-writing other code).

(Intel® 64 and IA-32 Architectures Software Developer’s Manual)

We’ll use 0xCC to replace with it instruction at desired spot. Once that place will be reached we will:

  1. investigate process state,
  2. replace 0xCC back with its original value,
  3. decrease program counter by 1,
  4. run a single step.

The first question is where to put 0xCC. We don’t know where exactly in memory instruction mov rdi, 1 will be stored. It’ll be the size of mov rax, 1 bytes further than the first instruction of the program since it’s a second instruction. Instruction lengths on x86 vary which makes the whole task more difficult. Location of the first instruction can be checked by stopping the program before processing any instruction (we’ve done that before). The length of the first instruction can be retrieved with a help of objdump program:

> nasm -f elf64 -o hello.o src/github.com/mlowicki/hello/hello.asm && ld -o /go/bin/hello hello.o
> objdump -d -M intel /go/bin/hello
/go/bin/hello: file format elf64-x86-64Disassembly of section .text:00000000004000b0 <_start>:
4000b0: b8 01 00 00 00 mov eax,0x1
4000b5: bf 01 00 00 00 mov edi,0x1
4000ba: 48 be d8 00 60 00 00 movabs rsi,0x6000d8
4000c1: 00 00 00
4000c4: ba 0e 00 00 00 mov edx,0xe
4000c9: 0f 05 syscall
4000cb: b8 3c 00 00 00 mov eax,0x3c
4000d0: bf 00 00 00 00 mov edi,0x0
4000d5: 0f 05 syscall

From above we see that the size of first instruction is 5 bytes (4000b5 — 4000b0). So for the address of first instruction we need to add 5 bytes and put 0xCC there. Let’s see it in action:

package mainimport (
"flag"
"log"
"os"
"os/exec"
"syscall"
)
func step(pid int) {
err := syscall.PtraceSingleStep(pid)
if err != nil {
log.Fatal(err)
}
}
func cont(pid int) {
err := syscall.PtraceCont(pid, 0)
if err != nil {
log.Fatal(err)
}
}
func setPC(pid int, pc uint64) {
var regs syscall.PtraceRegs
err := syscall.PtraceGetRegs(pid, &regs)
if err != nil {
log.Fatal(err)
}
regs.SetPC(pc)
err = syscall.PtraceSetRegs(pid, &regs)
if err != nil {
log.Fatal(err)
}
}
func getPC(pid int) uint64 {
var regs syscall.PtraceRegs
err := syscall.PtraceGetRegs(pid, &regs)
if err != nil {
log.Fatal(err)
}
return regs.PC()
}
func setBreakpoint(pid int, breakpoint uintptr) []byte {
original := make([]byte, 1)
_, err := syscall.PtracePeekData(pid, breakpoint, original)
if err != nil {
log.Fatal(err)
}
_, err = syscall.PtracePokeData(pid, breakpoint, []byte{0xCC})
if err != nil {
log.Fatal(err)
}
return original
}
func clearBreakpoint(pid int, breakpoint uintptr, original []byte) {
_, err := syscall.PtracePokeData(pid, breakpoint, original)
if err != nil {
log.Fatal(err)
}
}
func printState(pid int) {
var regs syscall.PtraceRegs
err := syscall.PtraceGetRegs(pid, &regs)
if err != nil {
log.Fatal(err)
}
log.Printf("RAX=%d, RDI=%d\n", regs.Rax, regs.Rdi)
}
func main() {
flag.Parse()
input := flag.Arg(0)
cmd := exec.Command(input)
cmd.Args = []string{input}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{Ptrace: true}
err := cmd.Start()
if err != nil {
log.Fatal(err)
}
err = cmd.Wait()
log.Printf("State: %v\n", err)
pid := cmd.Process.Pid
breakpoint := uintptr(getPC(pid) + 5)
original := setBreakpoint(pid, breakpoint)
cont(pid)
var ws syscall.WaitStatus
_, err = syscall.Wait4(pid, &ws, syscall.WALL, nil)
clearBreakpoint(pid, breakpoint, original)
printState(pid)
setPC(pid, uint64(breakpoint))
step(pid)
_, err = syscall.Wait4(pid, &ws, syscall.WALL, nil)
printState(pid)
}

Set of helpers are placed at the top. Functions setPC and getPC are there to manipulate program counter. It’s worth to note that PC register holds the next instruction to be executed. If we’re stopping the process before running anything then PC hods an address of memory where program’s first instruction is placed. Functions to manipulate breakpoints (setBreakpoint and clearBreakpoint) are juggling the memory by either inserting 0xCC or removing it respectively. Output is as follows:

> go install github.com/mlowicki/breakpoint
> breakpoint /go/bin/hello
2017/07/16 21:06:33 State: stop signal: trace/breakpoint trap
2017/07/16 21:06:33 RAX=1, RDI=0
2017/07/16 21:06:33 RAX=1, RDI=1

It looks fine. When process reaches trap, RDI register isn’t set (holds 0). After single step (running the 2nd instruction) register RDI has desired value set by instruction:

mov     rdi, 1

We’ve accomplished the task defined at the very top of this story. It requires extra work like calculating instructions lengths but we’ll fix that at some point so no worries.

REPL

Now it’s time to create the basic structure of our debugger. It’s a simple program which handles in a loop commands like “set a breakpoint at”, “go single step”.

package mainimport (
"bufio"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"strings"
"syscall"
)
func initTracee(path string) int {
cmd := exec.Command(path)
cmd.Args = []string{path}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{Ptrace: true}
err := cmd.Start()
if err != nil {
log.Fatal(err)
}
err = cmd.Wait()
// Process should be stopped here because of trace/breakpoint trap
if err == nil {
log.Fatal("Program exited")
}
return cmd.Process.Pid
}
func main() {
flag.Parse()
_ = initTracee(flag.Arg(0))
for {
reader := bufio.NewReader(os.Stdin)
fmt.Print("> ")
command, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
fmt.Println()
break
}
log.Fatal(err)
}
command = command[:len(command)-1] // get rid of ending newline character
if strings.HasPrefix(command, "register ") {
fmt.Println("register...")
} else if strings.HasPrefix(command, "breakpoint ") {
fmt.Println("breakpoint...")
} else if command == "help" {
fmt.Println("help...")
} else if command == "step" {
fmt.Println("step...")
} else if command == "continue" {
fmt.Println("continue")
} else {
fmt.Println("unknown command")
}
}
}

This is the backbone for our debugger. It doesn’t provide too much (yet) but already has the necessary logic to provide elementary REPL environment. We know more or less how to handle step, continue, help and register commands and we’ll implement these gaps soon. The one which isn’t that obvious for Golang is a breakpoint command. It’ll thoroughly explained in the upcoming post why it’s a bit harder than expected and how to overcome that extra complexity.

Click ❤ below to help others discover this story. Please follow me if you want to get updates about new posts or boost work on future stories.

--

--

Michał Łowicki
golangspec

Software engineer at Datadog, previously at Facebook and Opera, never satisfied.