实现一个简单的调试器
2023-2-24 11:27:0 Author: paper.seebug.org(查看原文) 阅读量:18 收藏

作者:RainSec
原文链接:https://mp.weixin.qq.com/s/1Ip1Ho4uE_rcywjo3HnH8Q

前言

以经典的GDB为例其项目代码共有十几万行代码,但是很多情况下只会使用到几个常用功能:单步,断点,查看变量,线程/进程切换。而GDB基本上是依赖于ptrace系统调用,主要用于编写调试程序。大部分实现思路参考Writing a Linux Debugger Part 2: Breakpoints (tartanllama.xyz)系列文章,强烈推荐阅读

目标功能:

  • 单步
  • 断点
  • 查看内存/寄存器
  • 查看汇编

ptrace 原理

先来看看ptrace系统调用的函数签名:

#include <sys/ptrace.h>

long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
/*DESCRIPTION
       The  ptrace()  system  call  provides  a  means  by  which one process (the
       "tracer") may observe and control the execution  of  another  process  (the
       "tracee"), and examine and change the tracee's memory and registers.  It is
       primarily used to implement breakpoint debugging and system call tracing.
       即ptrace系统调用提供给tracer控制,读取,修改另一个进程(tracee)的能力,由此可以实现断点和系统调用追踪

       A tracee first needs to be attached to the tracer.  Attachment  and  subse‐
       quent commands are per thread: in a multithreaded process, every thread can
       be individually attached to a (potentially different) tracer, or  left  not
       attached  and  thus  not debugged.  Therefore, "tracee" always means "(one)
       thread", never "a (possibly multithreaded) process".  Ptrace  commands  are
       always sent to a specific tracee using a call of the form
       即tracer通过ptrace进行附加(attach)和发送命令都是针对某一个线程的而不是进程
*/
  • request:调试者(tracer)要执行的操作,常见的有PTRACE_TRACEME,PTRACE_ATTACH,PTRACE_PEEKUSER,PTRACE_SINGLESTEP等
  • pid:被调试进程(tracee)pid
  • addr:要读写的内存地址
  • data:如果要向目标进程写入数据那么data就是我们数据地址;如果要读取目标进程数据那么data就是保留数据的地址

ptrace系统调用会根据不同的request完成不同功能如:

  • PTRACE_TRACEME:表示此进程即将被父进程trace,此时其他参数被忽略
  • PTRACE_PEEKTEXT, PTRACE_PEEKDATA:读取tracee在addr(虚拟内存空间)处的一个字,返回值就是读取到的字
  • PTRACE_PEEKUSER:读取tracee的USER area,其包含了该进程的寄存器以及其他信息
  • PTRACE_POKETEXT, PTRACE_POKEDATA:复制data所指向的一个字到tracee的addr(虚拟内存空间)处
  • PTRACE_POKEUSER:复制data所指的一个字带tracee的USER area
  • PTRACE_GETREGS, PTRACE_GETFPREGS:复制tracee通用寄存器或者浮点寄存器tracerdata所指的位置,addr被忽略
  • PTRACE_SETREGS, PTRACE_SETFPREGS:修改tracee的通用寄存器或者浮点寄存器
  • PTRACE_CONT:运行被暂停的tracee进程。如果data参数非0那么就表示data是传给tracee的信号数值
  • PTRACE_SYSCALL, PTRACE_SINGLESTEP:运行被暂停的tracee进程就像PTRACE_CONT功能,不同的是PTRACE_SYSCALL表示运行到下一个系统调用(进入或返回),PTRACE_SINGLESTEP表示仅运行一条指令便停止

以下是Linux-2.4.16内核的ptrace系统调用内部实现源码:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)     //asmlinkage是指明该函数用堆栈来传递参数
{
    struct task_struct *child;
    struct user * dummy = NULL;
    int i, ret;

    lock_kernel();
    ret = -EPERM;
    if (request == PTRACE_TRACEME) {        /*检查traced状态是否重复*/
        /* are we already being traced? */
        if (current->ptrace & PT_PTRACED)
            goto out;
        /* set the ptrace bit in the process flags. */
        current->ptrace |= PT_PTRACED;      //current指向当前进程(task_struct),因此PTRACE_TRACEME将当前进程设置为PT_PTRACED状态(traced)即被trace者(tracee)
        ret = 0;
        goto out;
    }
    ret = -ESRCH;
    read_lock(&tasklist_lock);              //调度链表上读锁
    child = find_task_by_pid(pid);          //获取目标pid进程结构体(task_struct)
    if (child)
        get_task_struct(child);
    read_unlock(&tasklist_lock);
    if (!child)
        goto out;

    ret = -EPERM;
    if (pid == 1)       /* you may not mess with init */
        goto out_tsk;
    /*就像gdb有直接启动并调试一个程序和附加一个进程并调试两个功能,也是基于ptrace的PTRACE_ATTACH让目标进程处于traced状态*/
    if (request == PTRACE_ATTACH) {
        ret = ptrace_attach(child);
        goto out_tsk;
    }

    ...
    /*这就是ptrace的主体,通过switch case和request完成,这里先了解部分*/
    switch (request) {
    /* when I and D space are separate, these will need to be fixed. */
    /*PTRACE_PEEKTEXT,PTRACE_PEEKDATA功能相同都是从虚拟地址addr中读取数据到data指针中*/
    case PTRACE_PEEKTEXT: /* read word at location addr. */ 
    case PTRACE_PEEKDATA: {
        unsigned long tmp;
        int copied;

        copied = access_process_vm(child, addr, &tmp, sizeof(tmp), 0);
        ret = -EIO;
        if (copied != sizeof(tmp))
            break;
        ret = put_user(tmp,(unsigned long *) data);
        break;
    }

    /* read the word at location addr in the USER area. */
    /*可以检查用户态内存区域(USER area),从USER区域中读取一个字节,偏移量为addr*/
    case PTRACE_PEEKUSR: {
        unsigned long tmp;

        ret = -EIO;
        if ((addr & 3) || addr < 0 || 
            addr > sizeof(struct user) - 3)
            break;

        tmp = 0;  /* Default return condition */
        if(addr < FRAME_SIZE*sizeof(long))
            tmp = getreg(child, addr);
        if(addr >= (long) &dummy->u_debugreg[0] &&
           addr <= (long) &dummy->u_debugreg[7]){
            addr -= (long) &dummy->u_debugreg[0];
            addr = addr >> 2;
            tmp = child->thread.debugreg[addr];
        }
        ret = put_user(tmp,(unsigned long *) data);
        break;
    }

    /* when I and D space are separate, this will have to be fixed. */
    /*PTRACE_POKETEXT和PTRACE_POKEDATA功能相同都是向虚拟地址addr写入来自data的数据*/
    case PTRACE_POKETEXT: /* write the word at location addr. */
    case PTRACE_POKEDATA:
        ret = 0;
        if (access_process_vm(child, addr, &data, sizeof(data), 1) == sizeof(data))
            break;
        ret = -EIO;
        break;

    case PTRACE_POKEUSR: /* write the word at location addr in the USER area */
        ret = -EIO;
        if ((addr & 3) || addr < 0 || 
            addr > sizeof(struct user) - 3)
            break;

        if (addr < FRAME_SIZE*sizeof(long)) {
            ret = putreg(child, addr, data);
            break;
        }
        /* We need to be very careful here.  We implicitly
           want to modify a portion of the task_struct, and we
           have to be selective about what portions we allow someone
           to modify. */

          ret = -EIO;
          if(addr >= (long) &dummy->u_debugreg[0] &&
             addr <= (long) &dummy->u_debugreg[7]){

              if(addr == (long) &dummy->u_debugreg[4]) break;
              if(addr == (long) &dummy->u_debugreg[5]) break;
              if(addr < (long) &dummy->u_debugreg[4] &&
                 ((unsigned long) data) >= TASK_SIZE-3) break;

              if(addr == (long) &dummy->u_debugreg[7]) {
                  data &= ~DR_CONTROL_RESERVED;
                  for(i=0; i<4; i++)
                      if ((0x5f54 >> ((data >> (16 + 4*i)) & 0xf)) & 1)
                          goto out_tsk;
              }

              addr -= (long) &dummy->u_debugreg;
              addr = addr >> 2;
              child->thread.debugreg[addr] = data;
              ret = 0;
          }
          break;
    /*都是让tracee继续运行,只是啥时候停止不同*/
    case PTRACE_SYSCALL: /* continue and stop at next (return from) syscall */
    case PTRACE_CONT: { /* restart after signal. */
        long tmp;

        ret = -EIO;
        if ((unsigned long) data > _NSIG)   //data为tracer传给tracee的信号数值,这里检查范围
            break;
        if (request == PTRACE_SYSCALL)
            child->ptrace |= PT_TRACESYS;   //设置PT_TRACESYS标志,为了在下一个系统调用处停止
        else
            child->ptrace &= ~PT_TRACESYS;  //清除PT_TRACESYS标志,不停止
        child->exit_code = data;
    /* make sure the single step bit is not set. 清除EFLAGS的单步标志(Trap Flag)*/
        tmp = get_stack_long(child, EFL_OFFSET) & ~TRAP_FLAG;
        put_stack_long(child, EFL_OFFSET,tmp);
        wake_up_process(child);             //唤醒进程
        ret = 0;
        break;
    }

/*
 * make the child exit.  Best I can do is send it a sigkill. 
 * perhaps it should be put in the status that it wants to 
 * exit.
 */
    case PTRACE_KILL: {
        long tmp;

        ret = 0;
        if (child->state == TASK_ZOMBIE)    /* already dead */
            break;
        child->exit_code = SIGKILL;
        /* make sure the single step bit is not set. */
        tmp = get_stack_long(child, EFL_OFFSET) & ~TRAP_FLAG;
        put_stack_long(child, EFL_OFFSET, tmp);
        wake_up_process(child);
        break;
    }
    /*设置单步运行很简单只需将eflags的Trap Flag置1即可*/
    case PTRACE_SINGLESTEP: {  /* set the trap flag. */
        long tmp;

        ret = -EIO;
        if ((unsigned long) data > _NSIG)
            break;
        child->ptrace &= ~PT_TRACESYS;
        if ((child->ptrace & PT_DTRACE) == 0) {
            /* Spurious delayed TF traps may occur */
            child->ptrace |= PT_DTRACE;
        }
        tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;    //Trap Flag置1
        put_stack_long(child, EFL_OFFSET, tmp);
        child->exit_code = data;
        /* give it a chance to run. */
        wake_up_process(child);
        ret = 0;
        break;
    }

    case PTRACE_DETACH:
        /* detach a process that was attached. */
        ret = ptrace_detach(child, data);
        break;
    /*读取所有通用寄存器值*/
    case PTRACE_GETREGS: { /* Get all gp regs from the child. */
        if (!access_ok(VERIFY_WRITE, (unsigned *)data, FRAME_SIZE*sizeof(long))) {
            ret = -EIO;
            break;
        }
        for ( i = 0; i < FRAME_SIZE*sizeof(long); i += sizeof(long) ) {
            __put_user(getreg(child, i),(unsigned long *) data);
            data += sizeof(long);
        }
        ret = 0;
        break;
    }
    /*设置所有通用寄存器值*/
    case PTRACE_SETREGS: { /* Set all gp regs in the child. */
        unsigned long tmp;
        if (!access_ok(VERIFY_READ, (unsigned *)data, FRAME_SIZE*sizeof(long))) {
            ret = -EIO;
            break;
        }
        for ( i = 0; i < FRAME_SIZE*sizeof(long); i += sizeof(long) ) {
            __get_user(tmp, (unsigned long *) data);
            putreg(child, i, tmp);
            data += sizeof(long);
        }
        ret = 0;
        break;
    }
    /*获取浮点寄存器值*/
    case PTRACE_GETFPREGS: { /* Get the child FPU state. */
        if (!access_ok(VERIFY_WRITE, (unsigned *)data,
                   sizeof(struct user_i387_struct))) {
            ret = -EIO;
            break;
        }
        ret = 0;
        if ( !child->used_math ) {
            /* Simulate an empty FPU. */
            set_fpu_cwd(child, 0x037f);
            set_fpu_swd(child, 0x0000);
            set_fpu_twd(child, 0xffff);
        }
        get_fpregs((struct user_i387_struct *)data, child);
        break;
    }
    /*设置浮点寄存器值*/
    case PTRACE_SETFPREGS: { /* Set the child FPU state. */
        if (!access_ok(VERIFY_READ, (unsigned *)data,
                   sizeof(struct user_i387_struct))) {
            ret = -EIO;
            break;
        }
        child->used_math = 1;
        set_fpregs(child, (struct user_i387_struct *)data);
        ret = 0;
        break;
    }

    case PTRACE_GETFPXREGS: { /* Get the child extended FPU state. */
        ...
    }

    case PTRACE_SETFPXREGS: { /* Set the child extended FPU state. */
        ...
    }

    case PTRACE_SETOPTIONS: {
        if (data & PTRACE_O_TRACESYSGOOD)
            child->ptrace |= PT_TRACESYSGOOD;
        else
            child->ptrace &= ~PT_TRACESYSGOOD;
        ret = 0;
        break;
    }

    default:
        ret = -EIO;
        break;
    }
out_tsk:
    free_task_struct(child);
out:
    unlock_kernel();
    return ret;
}

注意这个函数get_stack_long(proccess, offset)

/*
 * this routine will get a word off of the processes privileged stack. 
 * the offset is how far from the base addr as stored in the TSS.  
 * this routine assumes that all the privileged stacks are in our
 * data space.
 */   
static inline int get_stack_long(struct task_struct *task, int offset)
{
    unsigned char *stack;

    stack = (unsigned char *)task->thread.esp0;
    stack += offset;
    return (*((int *)stack));
}

其中task->thread.esp0是堆栈指针,通用的寄存器在堆栈中按顺序排放,通过偏移量0ffset便可以依次读取

PTRACE_TRACEME

当要调试一个进程时需要其进入被追踪状态(traced),有两种方法进入该状态:

  • 被调试进程主动调用ptrace(PTRACE_TRACEME, ...)进入traced状态
  • 调试进程调用ptrace(PTRACE_ATTACH, pid, ...)来使指定进程进入

总之被调试进程必须进入traced状态才能进行调试,因为Linux会对处于traced状态的进程进行特殊操作。以第一种方式来说明:

if (request == PTRACE_TRACEME) {
        /* are we already being traced? */
        if (current->ptrace & PT_PTRACED)
            goto out;
        /* set the ptrace bit in the process flags. */
        current->ptrace |= PT_PTRACED;
        ret = 0;
        goto out;
    }

只是将当前进程标记为PT_PTRACED状态,但是如果该进程接下来进行execve系统调用去执行一个外部程序时会暂停当前进程,并且发送SIGCHLD信号给父进程,父进程接收到该信号时就可以对被调试进程进行调试。

sys_execve() -> do_execve() -> load_elf_binary():

static int load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs)
{
    ...
    if (current->ptrace & PT_PTRACED)
        send_sig(SIGTRAP, current, 0);
    ...
}

对于处于traced状态的进程执行execve系统调用时会发送一个SIGTRAP给当前进程。这个信号将在do_signal函数处理:

int do_signal(struct pt_regs *regs, sigset_t *oldset) 
{
    for (;;) {
        unsigned long signr;

        spin_lock_irq(&current->sigmask_lock);
        signr = dequeue_signal(&current->blocked, &info);
        spin_unlock_irq(&current->sigmask_lock);

        // 如果进程被标记为 PTRACE 状态
        if ((current->ptrace & PT_PTRACED) && signr != SIGKILL) {   //除了SIGKILL信号,都将让tracee停止并通知tracer
            /* 让调试器运行  */
            current->exit_code = signr;
            current->state = TASK_STOPPED;   // 让自己进入停止运行状态
            notify_parent(current, SIGCHLD); // 发送 SIGCHLD 信号给父进程表示子进程"死亡(被替换)"
            schedule();                      // 让出CPU的执行权限
            ...
        }
    }
}

所以调试器使用这种方式调试某个程序时大致例程为:

image-20230129132429982

当父进程(调试进程)接收到 SIGCHLD 信号后,表示被调试进程已经标记为被追踪状态并且停止运行,那么调试进程就可以开始进行调试了。

PTRACE_SINGLESTEP

单步运行是最为常用的,当把tracee设置为单步运行模式时,tracee每执行一条指令CPU都会停止然后向父进程发送一个SIGCHLD信号,在ptrace中实现是将eflags设置trap_flag标志位:

case PTRACE_SINGLESTEP: {  /* set the trap flag. */
        long tmp;

        ret = -EIO;
        if ((unsigned long) data > _NSIG)
            break;
        child->ptrace &= ~PT_TRACESYS;
        if ((child->ptrace & PT_DTRACE) == 0) {
            /* Spurious delayed TF traps may occur */
            child->ptrace |= PT_DTRACE;
        }
        tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;
        put_stack_long(child, EFL_OFFSET, tmp);
        child->exit_code = data;
        /* give it a chance to run. */
        wake_up_process(child);
        ret = 0;
        break;
    }

能够这样做是基于X86 intel CPU提供一个硬件机制,就是当eflags的Trap Flag置为1时,CPU每执行一条指令都会产生一个异常然后Linux异常处理机制进程处理,由此会发送一个SIGTRAP信号给tracee;核心是:

tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;
put_stack_long(child, EFL_OFFSET, tmp);
  1. 获取进程的 eflags 寄存器的值,并且设置 Trap Flag 标志。
  2. 把新的值设置到进程的 eflags 寄存器中。

设置完寄存器后唤醒(wake_up_process)进程,让其进入运行状态:

image-20230129132429982

同样的当tracee执行完一条指令获取SIGTRAP信号,在do_signal函数处理信号时,由于current->ptrace & PT_PTRACED将停止执行并发送SIGCHLD信号给父进程tracer。父进程接收到SIGCHLD信号后就知道tracee停止,可以发送命令来读取或者修改tracee的内存数据或寄存器,或者通过调用 ptrace(PTRACE_CONT, child,...) 来让被调试进程进行运行等

Debugger 基本功能实现

实现一个简单的debugger,大致模型如下:主程序fork一个子程序去执行待调试程序;然后主程序循环等待用户输入命令,停止主程序停止并等待输入命令的条件就是子程序停止,这会在首次execute一个程序发生,以及单步(PTRACE_SINGLESTEP)或者断点发生。子程序很简单只需要调用execute系统调用启动一个新程序即可

image-20230208214054408

待实现的debugger有三个基本功能:单步,读写寄存器,读写内存。基于这三个功能再添加其他类似于gdb的功能。初始框架如下:

int main(int argc, char *argv[]){
    if(argc < 2){
        fprintf(stderr, "Expecting program name.\n");
        return -1;
    }

    const char *name = argv[1];
    pid_t pid = fork();
    if(pid == 0){
        //child process
        //execute tracee
        ptrace(PTRACE_TRACEME, 0, 0, 0);
        execl(name, name, NULL, NULL);
    }else if(pid > 0){
        //parent process
        //execute tracer
    }else{
        perror("fork.");
        return -1;
    }

    return 0;
}

子程序部分很简单调用exec族函数即可。

Parent

首先考虑使用一个结构体记录子进程的信息,然后父进程进入一个读取用户命令的循环,这里使用linenoise开源项目实现命令补全,命令记录等功能当然还需要处理命令:

/**
 * debugger uitls
*/
typedef struct Debugger{
    const char *d_name;
    int d_pid;
    Breakpoint *d_brks;         //记录断点
}Debugger;
void dbg_run(Debugger *dbg){
    int wait_status;
    char *cmd;
    waitpid(dbg->d_pid, &wait_status, 0);
    /*UI for start up*/
    while((cmd = linenoise("minidbg$ ")) != NULL){
        dbg_handle_command(dbg, cmd);
        linenoiseHistoryAdd(cmd);
        linenoiseFree(cmd);
    }
}

使用Debugger结构体记录程序状态,主要是子程序pid和之后的断点信息;linenoise("minidbg$ ")会打印minidbg$并等待输入,使用dbg_handle_command处理命令包括读写内存,寄存器,下断点等。linenoiseHistoryAdd(cmd)将命令添加到历史记录中

dbg_handle_command函数中大致结构为:很方便添加新功能,is_prefix辅助函数用于判断缩写指令

void dbg_handle_command(Debugger *dbg, char *cmd){
    char *lcmd = strdup(cmd);
    char *argv[8] = { 0 };    
    char *command;

    argv[0] = strtok(lcmd, " ");
    for(int i = 1; i < 8; i++){
        argv[i] = strtok(NULL, " ");
        if(argv[i] == NULL) break;
    }
    command = argv[0];
    if(command == NULL) return;
    if(is_prefix(command, "continue")){
        /*do_command*/
    }
    else{
        fprintf(stderr, "Unkown command: %s.\n", command);
    }

    return free(lcmd);
}

bool is_prefix(char *s, const char *ss){
    if(s == NULL || ss == NULL) return false;
    if(strlen(s) > strlen(ss)) return false;

    return !strncmp(s, ss, strlen(s));
}

读写寄存器

一个非常基础的功能,基于ptrace(PTRACE_GETREGS, ...)ptrace(PTRACE_SETREGS, ...)读写寄存器,为了保留寄存器信息在**<sys/user.h>**头文件中定义了如下结构体:

struct user_regs_struct
{
  __extension__ unsigned long long int r15;
  __extension__ unsigned long long int r14;
  __extension__ unsigned long long int r13;
  __extension__ unsigned long long int r12;
  __extension__ unsigned long long int rbp;
  __extension__ unsigned long long int rbx;
  __extension__ unsigned long long int r11;
  __extension__ unsigned long long int r10;
  __extension__ unsigned long long int r9;
  __extension__ unsigned long long int r8;
  __extension__ unsigned long long int rax;
  __extension__ unsigned long long int rcx;
  __extension__ unsigned long long int rdx;
  __extension__ unsigned long long int rsi;
  __extension__ unsigned long long int rdi;
  __extension__ unsigned long long int orig_rax;
  __extension__ unsigned long long int rip;
  __extension__ unsigned long long int cs;
  __extension__ unsigned long long int eflags;
  __extension__ unsigned long long int rsp;
  __extension__ unsigned long long int ss;
  __extension__ unsigned long long int fs_base;
  __extension__ unsigned long long int gs_base;
  __extension__ unsigned long long int ds;
  __extension__ unsigned long long int es;
  __extension__ unsigned long long int fs;
  __extension__ unsigned long long int gs;
};

配合ptrace可以直接按照以上结构体读写寄存器,所以一次读写至少是所有通用寄存器。根据结构体排序定义了如下数据结构体来记录寄存器信息:

/*utils.h*/
enum reg{
    en_rax, en_rbx, en_rcx, en_rdx,
    en_rdi, en_rsi, en_rbp, en_rsp,
    en_r8,  en_r9,  en_r10, en_r11,
    en_r12, en_r13, en_r14, en_r15,
    en_rip, en_rflags,    en_cs,
    en_orig_rax, en_fs_base,
    en_gs_base,
    en_fs, en_gs, en_ss, en_ds, en_es
};

struct reg_descriptor {
    enum reg r;
    char *name;
};
/*utils.c*/
const size_t n_regs = 27;
const struct reg_descriptor g_register_descriptors[] = {
    { en_r15, "r15" },
    { en_r14, "r14" },
    { en_r13, "r13" },
    { en_r12, "r12" },
    { en_rbp, "rbp" },
    { en_rbx, "rbx" },
    { en_r11, "r11" },
    { en_r10, "r10" },
    { en_r9, "r9" },
    { en_r8, "r8" },
    { en_rax, "rax" },
    { en_rcx, "rcx" },
    { en_rdx, "rdx" },
    { en_rsi, "rsi" },
    { en_rdi, "rdi" },
    { en_orig_rax, "orig_rax" },
    { en_rip, "rip" },
    { en_cs, "cs" },
    { en_rflags, "eflags" },
    { en_rsp, "rsp" },
    { en_ss, "ss" },
    { en_fs_base, "fs_base" },
    { en_gs_base, "gs_base" },
    { en_ds, "ds" },
    { en_es, "es" },
    { en_fs, "fs" },
    { en_gs, "gs" }
};

因为只能一次读写所有寄存器,因此要读写某个寄存器时先用ptrace把所有的读取出来在通过寄存器表查找g_register_descriptors,并且因为寄存器表和struct user_regs_struct结构体排序一致可以直接用表中的偏移读写结构体:

void set_register_value(pid_t pid, enum reg r, uint64_t value){
    struct user_regs_struct regs;
    int reg_descriptor_idx;
    ptrace(PTRACE_GETREGS, pid, NULL, &regs);

    /*locate reg_r`s index in user_regs_struct struct*/
    reg_descriptor_idx = -1;
    for(int i = 0; i < n_regs; i++){
        if(g_register_descriptors[i].r == r){
            reg_descriptor_idx = i;
            break;
        }
    }

    *(uint64_t *)((uint64_t *)&regs + reg_descriptor_idx) = value;
    ptrace(PTRACE_SETREGS, pid, NULL, &regs);

}

uint64_t get_register_value(pid_t pid, enum reg r){
    struct user_regs_struct regs;
    int reg_descriptor_idx;
    uint64_t ret = 0;
    ptrace(PTRACE_GETREGS, pid, NULL, &regs);

    /*locate reg_r`s index in user_regs_struct struct*/
    reg_descriptor_idx = -1;
    for(int i = 0; i < n_regs; i++){
        if(g_register_descriptors[i].r == r){
            reg_descriptor_idx = i;
            break;
        }
    }

    if(reg_descriptor_idx != -1){
        ret = *(uint64_t *)((uint64_t *)&regs + reg_descriptor_idx);
        return ret;
    }
    printf("[error] get_register_value(%d, %d)\n", pid, r);
    return ret;
}

/*辅助函数*/
char *get_register_name(enum reg r){
    for(int i = 0; i < n_regs; i++){
        if(g_register_descriptors[i].r == r)
            return g_register_descriptors[i].name;
    }
    return NULL;
}

enum reg get_register_from_name(char *name){
    for(int i = 0; i < n_regs; i++){
        if(!strcasecmp(name, g_register_descriptors[i].name)){
            return g_register_descriptors[i].r;
        }
    }
    return -1;      /*-1 is impossible in reg_descriptor->r*/
}

读写内存

读写内存和寄存器很类似,但使用ptrace一次性只能读写8字节(64位):ptrace(PTRACE_PEEKDATA, dbg->d_pid, address, NULL)需要提供子进程的虚拟内存地址(address)

uint64_t dbg_read_memory(Debugger *dbg, uint64_t address){
    return ptrace(PTRACE_PEEKDATA, dbg->d_pid, address, NULL);
}

void dbg_write_memory(Debugger *dbg, uint64_t address, uint64_t value){
    ptrace(PTRACE_POKEDATA, dbg->d_pid, address, value);
}

断点

断点其实有两种:硬断点和软断点。其中硬断点涉及到CPU架构数量有限比如x86结构提供4个硬件断点(断点寄存器),但可以检测读写执行三种情况。而软断点通过在指定位置插入断点指令,然后程序运行到此处执行断点指令让debugger获取SIGTRAP信号并停止运行,因此软断点可以有无数个;这里主要实现软断点,如x86的断点指令为int 3(机器码 0xcc),需要考虑断点插入,断点记录,触发断点后如何继续运行等

使用如下结构体存储断点信息:

/**
 * breakpoints utils
*/
typedef struct Breakpoint{
    int b_pid;
    unsigned long b_addr;           //map key
    int b_enabled;
    unsigned char b_saved_data;     //需要保存插入0xcc位置的数据
    UT_hash_handle hh;
}Breakpoint;

借助uthash开源项目实现一个hash表来记录断点信息,只需在结构体中包含UT_hash_handle成员即可;其提供宏HASH_FIND_PTR:

#define HASH_FIND_PTR(head,findptr,out) HASH_FIND(hh,head,findptr,sizeof(void *),out)

可以通过结构体中的b_addr作为key,其表头在初始化Debugger结构体时设置为NULL即可:

Debugger dbg;
dbg.d_brks = NULL;       /* important! initialize to NULL related to breakpoints` map*/

然后实现两个断点函数:brk_enable,brk_disable;分别进行插入断点和去除断点:

#include "utils.h"

void brk_enable(Breakpoint *bp){
    unsigned long data = ptrace(PTRACE_PEEKDATA, bp->b_pid, bp->b_addr, 0);
    bp->b_saved_data = data & 0xff;     //save LSB
    data = ((data & ~0xff) | INT3);
    ptrace(PTRACE_POKEDATA, bp->b_pid, bp->b_addr, data);
    bp->b_enabled = 1;
}

void brk_disable(Breakpoint *bp){
    unsigned long data = ptrace(PTRACE_PEEKDATA, bp->b_pid, bp->b_addr, 0);
    data = ((data & ~0xff) | bp->b_saved_data);
    ptrace(PTRACE_POKEDATA, bp->b_pid, bp->b_addr, data);
    bp->b_enabled = 0;
}

单步

单步运行时除了普通指令,需要考虑是否跳过函数调用(call)也就是需要步过的情况,还有如果当前为断点处单步时需要格外的断点处理。基于ptrace(PTRACE_SINGLESTEP, ...)单步步入时需要考虑两种情况,涉及断点;其他非断点情况直接PTRACE_SINGLESTEP单步运行即可

  • pc刚好触发一个断点,即执行了0xcc
  • pc即将触发一个断点

使用如下函数处理单步命令:

/*we can show UI here*/
void dbg_step_in(Debugger *dbg){
    uint64_t possible_pc = get_pc(dbg) - 1;    /*if this is breakpoint int 3 executed*/
    Breakpoint *bp = NULL;
    HASH_FIND_PTR(dbg->d_brks, &possible_pc, bp);
    if(bp != NULL && bp->b_enabled){
        /*step over breakpoint*/
        brk_disable(bp);
        set_pc(dbg, possible_pc);
        ptrace(PTRACE_SINGLESTEP, dbg->d_pid, NULL, NULL);
        wait_for_signal(dbg);
        brk_enable(bp);
    }else{
        ptrace(PTRACE_SINGLESTEP, dbg->d_pid, NULL, NULL);
        wait_for_signal(dbg);
    }

    show_UI(dbg);
}

但是后来发现一个bug:当断点设置在一个单机器码的指令处时(如 push rbp 0x55),以上逻辑会陷入死循环,因为每次单步时都会检测pc-1是否为断点;所以得想办法面对单机器码断点的情况避免该逻辑,不可能把所有单机器码指令全列出来然后比对,所以这里使用反编译引擎capstone。如果pc-1是个断点那么先判断pc-1处的指令长度是否为1,如果是那么设置一个静态flag表示已经步过一个单机器码的断点下一次单步时不再考虑pc-1:

/**
 * This function invoked in situation:
 * 1.PTRACE_SINGLESTEP the current instruction which maybe inserted a breakpoint OR maybe not
 * 2.already triggered a breakpoint(0xcc) PTRACE_SINGLESTEP the broken instruction
 * we can show UI here
*/
void dbg_step_in(Debugger *dbg){
    static bool one_machine_code_flag = false;
    uint64_t possible_pc, data;
    Breakpoint *bp = NULL;
    csh handle = 0;
    cs_insn* insn;
    size_t count;
    int child_status;

    if(!one_machine_code_flag){
        possible_pc = get_pc(dbg) - 1;          /*if this is breakpoint int 3 executed*/
        HASH_FIND_PTR(dbg->d_brks, &possible_pc, bp);
        if(bp != NULL && bp->b_enabled){
            brk_disable(bp);
            /*check for single machine code instruction*/
            data = ptrace(PTRACE_PEEKDATA, dbg->d_pid, possible_pc, NULL);  
            if (cs_open(CS_ARCH_X86, CS_MODE_64, &handle)) {
                printf("[error]: Failed to initialize capstone engine!\n");
                exit(-1);
            }
            cs_disasm(handle, (unsigned char*)&data, 8, 0x1000, 1, &insn);
            if(insn->size == 1){
                one_machine_code_flag = true;
            }else{
                one_machine_code_flag = false;
            }
            set_pc(dbg, possible_pc);
            ptrace(PTRACE_SINGLESTEP, dbg->d_pid, NULL, NULL);
            wait_for_signal(dbg);
            brk_enable(bp);
        }else{
            /*if we are here then this`s caused by PTRACE_SINGLESTEP and maybe we going to trigger a breakpoint or maybe not*/
            possible_pc += 1;
            one_machine_code_flag = false;
            HASH_FIND_PTR(dbg->d_brks, &possible_pc, bp);
            if(bp != NULL && bp->b_enabled){
                brk_disable(bp);
                ptrace(PTRACE_SINGLESTEP, dbg->d_pid, NULL, NULL);
                wait_for_signal(dbg);
                brk_enable(bp);
            }else{
                ptrace(PTRACE_SINGLESTEP, dbg->d_pid, NULL, NULL);
                wait_for_signal(dbg);
            }   
        }
    }else{
        /*the previous instruction is a single machine code instruction and breakpoint*/
        possible_pc = get_pc(dbg);      /*check current pc*/
        one_machine_code_flag = false;
        HASH_FIND_PTR(dbg->d_brks, &possible_pc, bp);
        if(bp != NULL && bp->b_enabled){
            brk_disable(bp);
            ptrace(PTRACE_SINGLESTEP, dbg->d_pid, NULL, NULL);
            wait_for_signal(dbg);
            brk_enable(bp);
        }else{
            ptrace(PTRACE_SINGLESTEP, dbg->d_pid, NULL, NULL);
            wait_for_signal(dbg);            
        }

    }
    show_UI(dbg);
}

步过主要用在函数调用上,在使用步过时主要考虑以下几种情况:

  • pc触发了需要步过的call指令上的断点,即pc执行了0xcc
  • pc即将步过的call指令上被插入了断点
  • 其他就是单步情况

这里步过一个call采用的方式是在call指令下一条指令下断点然后PTRACE_CONT。同样使用capstone计算call指令长度然后断下后面一条指令,这样需要考虑如果被step over的函数如果没有中断那么将触发call指令后面一条指令,那么INT3被执行还需让pc-1

/**
 * This function invoked in 4 situation:
 * 1.Just work as step in
 * 2.jump over a call but has triggered an breakpoint(0xcc)
 * 3.jump over a call but no breakpoint in current call instruction
 * 4.jump over a call but there is 0xcc in current call instruction
 * we can show UI here
*/
void dbg_step_over(Debugger *dbg){
    uint64_t possible_pc_prev = get_pc(dbg) - 1;        /*if this is breakpoint int 3 executed*/
    uint64_t possible_pc_currn = possible_pc_prev + 1;   /*if current instruction is breakpoint*/
    Breakpoint *bp_prev = NULL;
    Breakpoint *bp_currn = NULL;
    uint64_t data;
    uint64_t next_addr;

    /*Maybe stoped for triggered a breakpoint*/
    /*previous instruction. Jump over a call but has triggered an breakpoint(0xcc)*/
    HASH_FIND_PTR(dbg->d_brks, &possible_pc_prev, bp_prev);
    if(bp_prev != NULL && bp_prev->b_enabled && bp_prev->b_saved_data == 0xE8){     /*call`s op code is 0xE8*/
        /*call instruction has been triggered*/
        brk_disable(bp_prev);
        data = ptrace(PTRACE_PEEKDATA, dbg->d_pid, possible_pc_prev, NULL);
        csh handle = 0;
        cs_insn* insn;
        size_t count;
        int child_status;
        if (cs_open(CS_ARCH_X86, CS_MODE_64, &handle)) {
            printf("[error]: Failed to initialize capstone engine!\n");
            exit(-1);
        }
        cs_disasm(handle, (unsigned char*)&data, 8, possible_pc_prev, 1, &insn);
        next_addr = possible_pc_prev + insn->size;
        dbg_set_breakpoint_at_address(dbg, next_addr);
        set_pc(dbg, possible_pc_prev);
        continue_execution(dbg);                        /*Probably trigger another breakpoint in the function. So we need to disable it when stop*/
        brk_enable(bp_prev);

        HASH_FIND_PTR(dbg->d_brks, &next_addr, bp_prev);
        if(bp_prev != NULL && bp_prev->b_enabled){
            brk_disable(bp_prev);                       /*disable it*/
        }
        if((get_pc(dbg) - 1) == next_addr){             /*we stoped maybe because of triggering int3 below the call. So after continue we should check executed int3*/
            set_pc(dbg, next_addr);          
        }
        cs_free(insn, 1);
        cs_close(&handle);
        return;
    }else if(bp_prev != NULL && bp_prev->b_enabled && bp_prev->b_saved_data != 0xE8){
        /*normal instruction has been triggered. Just work as step in*/
        dbg_step_in(dbg);
        return;
    }

    /*stoped for PTRACE_SINGLESTEP*/
    /*current instruction. Jump over a call but there is 0xcc in current call instruction*/
    HASH_FIND_PTR(dbg->d_brks, &possible_pc_currn, bp_currn);
    if(bp_currn != NULL && bp_currn->b_enabled && bp_currn->b_saved_data == 0xE8){
        /*current instruction is breakpoint and it`s a function invoking*/
        brk_disable(bp_currn);
        data = ptrace(PTRACE_PEEKDATA, dbg->d_pid, possible_pc_currn, NULL);
        csh handle = 0;
        cs_insn* insn;
        size_t count;
        int child_status;
        if (cs_open(CS_ARCH_X86, CS_MODE_64, &handle)) {
            printf("[error]: Failed to initialize capstone engine!\n");
            exit(-1);
        }
        cs_disasm(handle, (unsigned char*)&data, 8, possible_pc_currn, 1, &insn);
        next_addr = possible_pc_currn + insn->size;
        dbg_set_breakpoint_at_address(dbg, next_addr);
        continue_execution(dbg);                        /*Probably trigger another breakpoint in the function. So we need to disable it when stop*/
        brk_enable(bp_currn);
        HASH_FIND_PTR(dbg->d_brks, &next_addr, bp_currn);
        if(bp_currn != NULL && bp_currn->b_enabled){
            brk_disable(bp_currn);                      /*disable it*/
        }
        if((get_pc(dbg) - 1) == next_addr){             /*we stoped maybe because of triggering int3 below the call. So after continue we should check executed int3*/
            set_pc(dbg, next_addr);          
        }
        cs_free(insn, 1);
        cs_close(&handle);
        return;
    }else if(bp_currn != NULL && bp_currn->b_enabled && bp_currn->b_saved_data != 0xE8){
        /*current instruction is a breakpoint but not a calling so we could just step over. Just work as step in */
        dbg_step_in(dbg);
        show_UI(dbg);
        return;
    }


    /*not breakpoint in current invoking OR current normal instruction*/
    data = ptrace(PTRACE_PEEKDATA, dbg->d_pid, possible_pc_currn, NULL);
    if((data & 0xff) == 0xE8){          
        /*Current instruction is a call.Set breakpoint at next instruction then continue*/
        csh handle = 0;
        cs_insn* insn;
        size_t count;
        int child_status;
        if (cs_open(CS_ARCH_X86, CS_MODE_64, &handle)) {
            printf("[error]: Failed to initialize capstone engine!\n");
            exit(-1);
        }
        cs_disasm(handle, (unsigned char*)&data, 8, possible_pc_currn, 1, &insn);
        next_addr = possible_pc_currn + insn->size;
        dbg_set_breakpoint_at_address(dbg, next_addr);
        continue_execution(dbg);
        HASH_FIND_PTR(dbg->d_brks, &next_addr, bp_currn);
        if(bp_currn != NULL && bp_currn->b_enabled){
            brk_disable(bp_currn);
        }
        if((get_pc(dbg) - 1) == next_addr){             /*we stoped maybe because of triggering int3 below the call. So after continue we should check executed int3*/
            set_pc(dbg, next_addr);          
        }
        cs_free(insn, 1);
        cs_close(&handle);
        return;
    }else
        dbg_step_in(dbg);           /*Current instruction is normal. Just work as step in*/
}

到这里已经具备基本功能了,可以在dbg_handle_command中添加命令支持:

void dbg_handle_command(Debugger *dbg, char *cmd){
    char *lcmd = strdup(cmd);
    char *argv[8] = { 0 };    
    char *command;

    argv[0] = strtok(lcmd, " ");
    for(int i = 1; i < 8; i++){
        argv[i] = strtok(NULL, " ");
        if(argv[i] == NULL) break;
    }
    command = argv[0];
    if(command == NULL) return;
    if(is_prefix(command, "continue")){
        continue_execution(dbg);
    }else if(is_prefix(command, "quit")){
        exit_debugger(dbg);
    }else if(is_prefix(command, "break")){      /*format: break/b [addr]*/
        if(argv[1] == NULL)
            puts("command break expect an address!");
        else{
            dbg_set_breakpoint_at_address(dbg, strtoul(argv[1], NULL, 16));
        }
    }else if(is_prefix(command, "register")){   /*format: reg/r dump OR reg/r read/write [reg] value(hex)*/
        if(is_prefix(argv[1], "dump"))
            dbg_dump_all_regs(dbg);
        else if(is_prefix(argv[1], "read")){
            printf("value:\t0x%08lx\n", get_register_value(dbg->d_pid, get_register_from_name(argv[2])));
        }else if(is_prefix(argv[1], "write")){
            set_register_value(dbg->d_pid, get_register_from_name(argv[2]), strtoul(argv[3], NULL, 16));
        }
    }else if(is_prefix(command, "memory")){     /*memory/m read [addr] OR write [addr] [value]*/
        if(is_prefix(argv[1], "read")){
            printf("value:\t0x%08lx\n", dbg_read_memory(dbg, strtoul(argv[2], NULL, 16)));
        }
        else if(is_prefix(argv[1], "write")){
            printf("0x%08lx\t->\t", dbg_read_memory(dbg, strtoul(argv[2], NULL, 16)));
            dbg_write_memory(dbg, strtoul(argv[2], NULL, 16), strtoul(argv[3], NULL, 16));
            printf("0x%08lx\n", dbg_read_memory(dbg, strtoul(argv[3], NULL, 16)));
        }
    }else if(is_prefix(command, "step")){       /*step in OR step over*/
        if(is_prefix(argv[1], "in")){
            dbg_step_in(dbg);
        }else if(is_prefix(argv[1], "over")){
            dbg_step_over(dbg);
        }else{
            puts("Usage: step in / step over");
        }
    }
    else{
        fprintf(stderr, "Unkown command: %s.\n", command);
    }

    return free(lcmd);
}

这些是目前完成的功能,还有进程和线程支持还未完成

汇编

一般debugger是要支持显示汇编的,这里实现的只是在每次单步和触发断点时打印寄存器信息和汇编。可以在每次单步或者触发断点时读取当前pc处的机器码借助capstone反汇编,但需要注意的是对于x86_64架构最长汇编指令为15字节但很少出现比较长的指令,所以实现汇编打印的时候每次仅读取16个字节进行反汇编并打印指令

/**
 * consider of the longest instruction is 15bytes(x86_64) then we read 16bytes everytime
 * and disassemble it with capstone engine
 * befor invoking show_asm the caller should make sure current pc is not a breakpoint
*/
void show_asm(Debugger *dbg){
    csh handle;
    cs_insn *insn;
    size_t count;
    uint8_t *code;
    size_t size = 15;
    uint64_t address;

    if(cs_open(CS_ARCH_X86, CS_MODE_64, &handle)){
        printf("[error] cs_open(%d, %d, 0x%08lx)\n", CS_ARCH_X86, CS_MODE_64, &handle);
        exit(-1);
    }
    code = calloc(1, 16);
    address = get_pc(dbg);
    *(uint64_t *)code = ptrace(PTRACE_PEEKDATA, dbg->d_pid, address, NULL);
    *((uint64_t *)code + 1) = ptrace(PTRACE_PEEKDATA, dbg->d_pid, address + 8, NULL);

    /*before we show assembly after pc we should consider if there is breakpoint in machine code behind*/
    Breakpoint *bp = NULL;
    for(uint64_t i = 0, tmp = address; i < size; i++){
        HASH_FIND_PTR(dbg->d_brks, &tmp, bp);
        if(bp != NULL && bp->b_enabled){    
            *((uint8_t *)code + i) = bp->b_saved_data;
        }
        tmp++;
    }

    puts("-------------------------[Assembly]-------------------------");
    insn = cs_malloc(handle);
    while(cs_disasm_iter(handle, (const uint8_t **)&code, &size, &address, insn)){
        if(size + insn->size == 15)
            printf("\e[96m0x%08lx:\t%s\t%s\t<======RIP\e[0m\n", insn->address, insn->mnemonic, insn->op_str);
        else
            printf("0x%08lx:\t%s\t%s\n", insn->address, insn->mnemonic, insn->op_str);  
    }
    cs_free(insn, 1);
    cs_close(&handle);
}

还有就是如果读取的15个字节中有断点(0xcc)那么反汇编结果是不准确的,因此先遍历是否存在断点并resotre原来的数据再进行反汇编。

效果

这里还没有实现多线程/进程调试的功能,源码,但也算有个调试器的架子了

image-20230210153302686

参考


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/2051/



文章来源: https://paper.seebug.org/2051/
如有侵权请联系:admin#unsafe.sh