簡単なデバッガを自作する

作るもの

プログラムを指定したアドレスで止め、その時のレジスタ値を表示するデバッガをC言語で開発します。入出力のイメージは以下の通りです(デバッガの名前をmdbとしています)。

$ mdb <program> <address>
Program stopped at xxxxxxxxxxxx
Register
  rbp    xxxxxxxxxxxx
  rbx    xxxxxxxxxxxx
  rax    xxxxxxxxxxxx
  rcx    xxxxxxxxxxxx
  .
  .
  .

開発環境

予備知識

ptrace

ptraceはあるプロセスから他のプロセスを制御するためのシステムコールです。ptraceを利用すると、プロセスの実行を制御したり、メモリやレジスタを読み書きすることができます。制御する側のプロセスをtracer、制御される側のプロセスをtraceeと呼びます。ptraceは以下のように呼び出します。

ptrace(request, pid, addr, data);

requestはptraceの振る舞いを決めるenumです。pidは制御するプロセスのIDです。addrとdataの役割はrequestによって異なります。いくつか実行例を見てみましょう。

ptrace(PTRACE_PEEKTEXT, pid, addr, NULL);

PTRACE_PEEKTEXTはプロセスのメモリを読み取るrequestです。pidで指定したプロセスのaddr番地のメモリを読み出します。

ptrace(PTRACE_POKETEXT, pid, addr, data);

PTRACE_POKETEXTはプロセスのメモリに書き込むrequestです。pidで指定したプロセスのaddr番地にdataを書き込みます。

ptrace(PTRACE_ATTACH, pid, NULL, NULL);

PTRACE_ATTACHはpidで指定したプロセスをtraceeにするrequestです。ubuntuではデフォルトでPTRACE_ATTACHが実行できないようになっています。PTRACE_ATTACHされたプロセスは他のプロセスから様々な操作を実行できるようになるため、自由にPTRACE_ATTACHできるとセキュリティに問題が発生するためです。

ptrace(PTRACE_TRACEME, 0, NULL, NULL);

PTRACE_TRACEMEは他と違い、tracerではなくtraceeが実行するrequestです。親プロセス(= tracer)によってtraceされることを宣言します。PTRACE_TRACEMEを実行したプロセスに対して、tracerはPTRACE_PEEKTEXTやPTRACE_POKETEXTなど様々な操作を実行できるようになります。PTRACE_TRACEMEしたプロセスは、execveシステムコールを実行するたびにSIGTRAPで停止するようになります。

その他のrequestはubuntuのmanpageで調べることができます。 http://manpages.ubuntu.com/manpages/bionic/man2/ptrace.2.html

waitpid

waitpidは子プロセスが終了したり、シグナルによって停止・再開されるまで待つシステムコールです。tracerはwaitpidを使ってtraceeの状態変化を待ちます。

ASLR

ASLR(Address Space Layout Randomization)はOSが持つセキュリティ機能のひとつです。プログラムが実行されるたびにプロセスのメモリレイアウトがランダムに変化します。Linuxでは2005年にリリースされたkernel v2.6.12からASLRがデフォルトで有効化されました。webで見つけることができるデバッガ自作記事の中には、ASLRが有効化されていない環境を想定しているものがあります。そのような記事に記載されているサンプルコードは、ASLRが有効化されている環境ではうまく動きません。参考にするときは注意が必要です。

処理の流れ

開発するデバッガの処理の流れは以下の通りです。

  1. プロセスをforkする。
  2. 子プロセスがPTRACE_TRACEMEを実行する。
  3. 子プロセスがプログラムを実行する。
  4. 親プロセスは子プロセスの停止を待つ。
  5. 親プロセスは指定したアドレスで子プロセスが停止するようにメモリを書き換える(引数で指定されたアドレスをINT3命令に書き換える)。
  6. 親プロセスは子プロセスを再開しINT3で停止するまで待つ。
  7. 親プロセスは子プロセスのレジスタ値を標準出力する。
  8. 親プロセスは子プロセスの命令ポインタをひとつ戻す。
  9. 親プロセスは5で書き換えた子プロセスのメモリを元に戻す。
  10. 親プロセスは子プロセスを1ステップ進め、9で元に戻した命令を実行する。
  11. 5に戻る。

コード

デバッガ

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ptrace.h>
#include <sys/reg.h>
#include <sys/types.h>
#include <sys/user.h>
#include <sys/wait.h>
#include <unistd.h>

// /proc{pid}/mapsからプロセスのベースアドレスを調べる関数です
long baseAddr(pid_t pid) {
  char maps[32] = "/proc/";
  char id[10];
  sprintf(id, "%d", pid);
  strcat(maps, id);
  strcat(maps, "/maps");

  FILE *fp = fopen(maps, "r");
  if (!fp) {
    printf("Error: Failed to open map.\n");
    exit(1);
  }

  char base[32];
  for (int i = 0;; ++i) {
    base[i] = fgetc(fp);
    if (base[i] == '-') {
      base[i] = '\0';
      break;
    }
  }

  fclose(fp);

  return strtol(base, NULL, 16);
}

long addBreakpoint(pid_t pid, long addr) {
  long orig = ptrace(PTRACE_PEEKTEXT, pid, (void *)addr, NULL);
  if (orig == -1 && errno != 0) {
    printf("Error: Failed to peek. %s\n", strerror(errno));
    exit(1);
  }
  ptrace(PTRACE_POKETEXT, pid, (void *)addr,
         (orig & 0xFFFFFFFFFFFFFF00) | 0xCC); // 0xCCはINT3命令です
  return orig;
}

void moveInstructionPointer(pid_t pid, long addr) {
  ptrace(PTRACE_POKEUSER, pid, sizeof(long) * RIP, addr);
}

void store(pid_t pid, long addr, long orig) {
  ptrace(PTRACE_POKETEXT, pid, (void *)addr, (void *)orig);
}

void continueProcess(pid_t pid) { ptrace(PTRACE_CONT, pid, NULL, NULL); }

void stepIn(pid_t pid) {
  ptrace(PTRACE_SINGLESTEP, pid, NULL, NULL);
  int status;
  waitpid(pid, &status, 0);
}

void printRegister(pid_t pid) {
  struct user_regs_struct regs;
  ptrace(PTRACE_GETREGS, pid, 0, &regs);
  printf("Register\n");
  printf("  rbp    %llx\n", regs.rbp);
  printf("  rbx    %llx\n", regs.rbx);
  printf("  rax    %llx\n", regs.rax);
  printf("  rcx    %llx\n", regs.rcx);
  printf("  rdx    %llx\n", regs.rdx);
  printf("  rsi    %llx\n", regs.rsi);
  printf("  rdi    %llx\n", regs.rdi);
  printf("  rip    %llx\n", regs.rip);
  printf("  cs     %llx\n", regs.cs);
  printf("  eflags %llx\n", regs.eflags);
  printf("  rsp    %llx\n", regs.rsp);
  printf("  ss     %llx\n", regs.ss);
  printf("  ds     %llx\n", regs.ds);
  printf("  es     %llx\n", regs.es);
  printf("  fs     %llx\n", regs.fs);
  printf("  gs     %llx\n", regs.gs);
}

void tracer(pid_t pid, long addr) {
  int status = 0;
  waitpid(pid, &status, 0);
  addr = baseAddr(pid) + addr;

  while (1) {
    long orig = addBreakpoint(pid, addr);
    continueProcess(pid);
    waitpid(pid, &status, 0);
    printf("Program stopped at 0x%016lx\n", addr);
    printRegister(pid);
    moveInstructionPointer(pid, addr);
    store(pid, addr, orig);
    stepIn(pid);
  }
}

void tracee(char *prog) {
  ptrace(PTRACE_TRACEME, 0, 0, 0);
  execl(prog, prog, NULL);
}

int main(int argc, char **argv) {
  if (argc != 3) {
    printf("Usage: mdb <prog> <addr>\n");
    return 1;
  }

  char *prog = argv[1];
  long addr = strtol(argv[2], NULL, 0);

  pid_t pid = fork();
  if (pid > 0) {
    tracer(pid, addr);
  } else if (pid == 0) {
    tracee(prog);
  } else {
    printf("Error: Failed to fork.\n");
    return 1;
  }

  return 0;
}

デバッグするプログラム

#include <stdio.h>
#include <unistd.h>

// デバッガはこの関数を呼び出す直前でプログラムを停止させる
int increase(int num) { return num + 1; }

int main() {
  int count = 0;
  while (1) {
    count = increase(count);
    printf("%d\n", count);
    sleep(1);
  }
  return 0;
}

実行結果

$ gcc mdb.c -o mdb
$ gcc counter.c -o counter
$ nm counter | grep increase
000000000000068a T increase
$ ./mdb counter 0x68a
Program stopped at 0x00005560a1e3f68a
Register
  rbp    7ffe68a7d3f0
  rbx    0
  rax    0
  rcx    5560a1e3f6e0
  rdx    7ffe68a7d4e8
  rsi    7ffe68a7d4d8
  rdi    0
  rip    5560a1e3f68b
  cs     33
  eflags 202
  rsp    7ffe68a7d3d8
  ss     2b
  ds     0
  es     0
  fs     0
  gs     0
1

参考にした記事

th0x4c.github.io naotechnology.hatenablog.com