I’ve been working on a profiler and while I cook up a complete project I thought I’d share some things I’ve learned. Specifically, I’ve learned a lot about ptrace and breakpoints.

This will be a pretty technical post so if that isn’t your thing (Mom) you may want to wait for a future post.

The complete code used in this post can be found here.

ptrace

Ptrace (short for process trace) is a system call and as described by the man page: “provides means by which one process may observe and control the execution of another”. This functionality is leveraged to build powerful tools like gdb, strace, valgrind, perf, and many more.

Ptrace has this signature:

1
2
long ptrace(enum __ptrace_request op, pid_t pid,
  void *addr, void *data);

The first argument is an enum option that may be one of 70 options and drastically changes the behavior and usage of the function.

Breakpoints

A breakpoint is a point in process execution that stops execution. In debuggers breakpoints are usually set in source code then when the program hits a breakpoint control is handed over to the user through some nice interface. In a profiler, a breakpoint may be used to collect statistics at some defined set of points like at the beginning and end of function calls.

Breakpoints are implemented by CPUs as instructions that cause a process to stop with a SIGTRAP. My Macbook has an ARM-based CPU, so the break instruction is brk #0 or 0xd4200000. On an x86 machine breakpoints are implemented with the int 3 instruction.

The plan

Implement breakpoints on a process using ptrace:

  1. Configure the child to be traced by its parent
  2. Find the instruction we want to stop on
  3. Set a breakpoint using PTRACE_PEEKTEXT and PTRACE_POKETEXT
  4. Start the child process and catch our breakpoint
  5. Disable the breakpoint
  6. Resume the child process

This is a simple demonstration of how ptrace can be used to stop at predefined points in a program.

1. Config child and parent

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int main(){
  pid_t child = fork();
  if (child == 0){
    if (ptrace(PTRACE_TRACEME, 0, (void*)NULL, (void*)NULL) < 0){
      perror("ptrace(TRACEME)");
      exit(errno);
    }
    raise(SIGSTOP);
    the_joker();
    exit(0);
  }
  ...

This first block of code creates a child process that calls ptrace with PTRACE_TRACEME which sets up this process to be traced by its parent. The child process then stops itself to give the parent a chance to setup breakpoints. By default calling exec will also stop a process that has called ptrace with PTRACE_TRACEME.

1
2
3
4
5
waitpid(child, &status, 0);
  if (!WIFSTOPPED(status)){
    fprintf(stderr, "whats he up to??");
    exit(1);
  }

Back in the parent process, we can wait for the child to stop.

2. Find a breakpoint

Now we’re ready to figure out where to insert our breakpoint. If we look at the child process code we can see that the joker is up to no good.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void the_joker(){
  printf("I'm going to break open arkham asylum and no one can stop me!\n");
  printf("collecting cards ...\n");
  printf("gathering cronies...\n");
  printf("plotting route...\n");
  printf("loading unmarked van...\n");
  printf("driving to asylum...\n");
  printf("melting gate with acid...\n");
  printf("escaping with inmates...\n");
  printf("we live in a society!!!\n");
}

For dramatic effect let’s stop him right before he melts down the gate with acid. It would be nice if we could just directly use a line number in ptrace, but it’s not that simple. Instead, we need an address to write our instruction to. This address can be computed by finding an offset in the compiled binary then adding the base address of our process. For this example I’ll use gdb to find an offset. In a more modular program, this would be done by reading DWARF debug information from the compiled binary.

1
2
3
4
5
6
7
8
$ gdb path/to/joker
(gdb) disassemble /m the_joker
...
26        printf("melting gate with acid...\n");
   0x0000000000000c44 <+80>:    adrp    x0, 0x1000 <main+908>
   0x0000000000000c48 <+84>:    add     x0, x0, #0x3c8
   0x0000000000000c4c <+88>:    bl      0xa60 <puts@plt>
...

Disassembling the binary with gdb shows us that each printf corresponds to three assembly instructions. I’m going to place a breakpoint on the call to puts at 0xc4c. After finding this offset I read the base address of the child process from /proc/<child_id>/maps which I won’t show here for brevity. Now we have a base address and offset so we can first save the original instruction and overwrite it with a brk #0 instruction.

3. Set breakpoint

On 64-bit machines ptrace reads and writes 64 bits at a time. This is necessary for reading and writing to 64-bit memory addresses and registers, but instructions are only 32-bit on ARM machines. This means that ptrace reads and writes 2 instructions at a time and that we’ll have to do some bitwise shenanigans to modify a single instruction.

1
2
3
4
5
  printf("finding lair...\n");
  size_t base_address = get_process_base_address(child);s
  printf("decyphering plans...\n");
  size_t offset = 0xc4c; 
  printf("placing tracker on unmarked van...\n\n");

First we can find the offset and base address, which together point to the instruction we want to overwrite.

1
2
3
4
5
  long breakpt = 0xd4200000;
  long original = ptrace(PTRACE_PEEKTEXT, child, offset + base_address, NULL);
  long break_instn = (0xffffffff00000000 & original) | breakpt;
  ptrace(PTRACE_POKETEXT, child, offset + base_address, break_instn);
  ...

Next, we define our breakpoint instruction as the lower 32 bits of a 64-bit number. We use a bitwise and to zero the lower 32 bits of the original instruction which represents the 1st instruction read from memory. Then or the breakpoint instruction in. Finally, we’re ready to write back to memory which is done with a call to ptrace with the PTRACE_POKETEXT option.

With the breakpoint set, we are ready to resume the child process and wait for our breakpoint to be hit.

4. Catch breakpoint

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
ptrace(PTRACE_CONT, child, NULL, NULL);

waitpid(child, &status, 0);
size_t pc_offset = get_pc(child) - base_address;
if (WIFSTOPPED(status)){
  int signal = WSTOPSIG(status);
  if (signal == SIGTRAP){
    printf("we got him [%lx]\n", pc_offset);
  } else { 
    fprintf(stderr, "we lost him\n");
    exit(2);
  }
} else { 
  fprintf(stderr,"we lost him\n");
  exit(3);
}

After waiting for the child process, the program counter is converted back to an offset which should match the number we set the breakpoint at.

5. Disable breakpoint

Usually, when our process hits a breakpoint we’d like to do something then restart our process and stop at the same point again the next time our process hits the breakpoint. To achieve this behavior we’ll need to rewrite the original instruction over the breakpoint instruction, execute it, then rewrite the original instruction with our breakpoint instruction.

This is another place where ARM and x86 differ. In ARM hitting a brk #0 sends a SIGTRAP before the instruction is executed. This means that the program counter is not moved and when the process is restarted it will execute the same instruction again. This is very convenient for our purposes because we don’t have to change the program counter or mess with registers.

1
2
3
4
5
printf("he's escaping\n\n");
ptrace(PTRACE_POKETEXT, child, offset + base_address, original);
ptrace(PTRACE_SINGLESTEP, child, NULL, NULL);
waitpid(child, &status, 0);
ptrace(PTRACE_POKETEXT, child, offset + base_address, break_instn);

Resume child process

After stepping over the breakpoint we can resume our child process.

1
ptrace(PTRACE_CONT, child, NULL, NULL);

In this case, we expect the child process to exit without hitting another breakpoint or stopping.

Conclusion

This program shows a simple demonstration of how ptrace can be used to interrupt processes at line numbers or offsets. Finding offsets has been simplified by using gdb but compiling with debug symbols enabled will expose a source/instruction mapping in the executable. Some mature tools like libdw and Go’s standard library exist to parse this debug info enabling a systematic approach to writing debuggers and profilers.

In this post, I’ve also glossed over nearly all error checking. System calls like ptrace, waitpid, and fork will return a flag value on an error which should be used to handle unexpected errors.

Thank you for reading. Stay tuned for future updates.