安全路透社
当前位置:安全路透社 > 安全客 > 正文

【技术分享】看我如何编写一个Linux 调试器(二):断点

http://p2.qhimg.com/t011ac6af58cf5b377a.jpg

传送门【技术分享】看我如何编写一个Linux 调试器(一):准备工作

简介


第一部分中我们写了一个小型的进程启动器作为我们的调试器,在这一篇中,我们将学习在x86 linux下断点是如何运作的以及为我们的工具添加设置断点的功能。


断点是怎么形成的?


断点的类型有两种:硬件断点和内存断点。硬件断点通常通过设置架构指定的寄存器来设置中断,而内存断点则是通过修改正在执行的代码来设置中断。这篇文章我们把精力集中在内存断点上,因为它们较为简单且没有数量限制。在x86上,你在同一时刻最多只能设置4个硬件断点,但它们不仅能在代码执行到此处的时候断下,还能在被读取或是被写入的时候触发。

前面说内存断点是通过修改当前正在执行的代码来实现的,那么问题是:

– 我们如何修改代码?

– 如何修改代码才能设置断点?

– 如何让调试器注意到?

第一个问题的答案很显然,ptrace。我们之前使用它来设置我们的程序来跟踪以及继续执行,但我们也可以使用它来读取和写入内存。

当执行到断点位置的时候,我们的修改要让处理器暂停并向调试器发送信号。在x86上这是通过将需要下断的地址上的指令设置为int 3来实现的。x86上有一个中断向量表(interrupt vector table),操作系统通过中断向量表能够为许多事件注册处理函数,比如缺页中断(page faults),保护错误(protection faults),无效操作码(invalid opcodes)等。它有点像注册错误的回调函数,但是在硬件层面实现的。当处理器执行到int 3指令时,控制权就被传递给了断点中断处理程序(breakpoint interrupt handler),就Linux来说,是给进程发送SIGTRAP信号。下图展现了这个过程,当把mov指令的第一个字节覆盖为0xcc,即int 3的机器码。

http://p8.qhimg.com/t0151cc900ec7bdce0a.png

最后一个问题是如何让调试器注意到这个中断。如果你还记得上一篇中我们使用waitpid函数来监听被调试端发送的信号的方法,我们在此处也可以用同样的方法来处理:设置断点,让程序继续执行,调用waitpid直到收到SIGTRAP信号。然后就可以通过打印当前代码的位置或改变图形界面中选中的行来将这个断点传达给用户。

实现内存断点


我们实现一个breakpoint类来表现一个断点断在某个位置,然后根据需求选择启用或是停用这个断点。

class breakpoint {
public:
    breakpoint(pid_t pid, std::intptr_t addr)
        : m_pid{pid}, m_addr{addr}, m_enabled{false}, m_saved_data{}
    {}
    void enable();
    void disable();
    auto is_enabled() const -> bool { return m_enabled; }
    auto get_address() const -> std::intptr_t { return m_addr; }
private:
    pid_t m_pid;
    std::intptr_t m_addr;
    bool m_enabled;
    uint8_t m_saved_data; //存储断点地址
};

这些代码多数只是跟踪状态,真正实现部分在enable和disable函数中。

正如我们上面了解到的,我们需要将用户给定地址上的指令修改为int 3,即0xcc。我们还要保存那条指令原本的机器码,以便后续恢复这行代码。而且我们不能忘记去执行这行代码。

void breakpoint::enable() {
    auto data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
    m_saved_data = static_cast<uint8_t>(data & 0xff); //保存最低一字节
    uint64_t int3 = 0xcc;
    uint64_t data_with_int3 = ((data & ~0xff) | int3); //将最低一字节改为0xcc
    ptrace(PTRACE_POKEDATA, m_pid, m_addr, data_with_int3);
    m_enabled = true;
}

PTRACE_PEEKDATA这个request告诉ptrace如何去读取被调试程序的内存。我们传递给它一个pid和地址,然后它将指定地址上的64位长度的值返回给我们。 (m_saved_data & ~0xff)将返回数据的最低字节置零,然后我们通过OR指令把int 3和最低字节置零的指令做或操作,从而得到能产生中断的指令。最后,我们通过PTRACE_POKEDATA将这条指令写入内存原位置来设置断点。

disable比较简单,但也有点巧妙。因为`ptrace`的内存操作针对于words而不是一个字节,因此我们要先把words读回来,然后将最低一字节还原,再将words写回内存。

void breakpoint::disable() {
    auto data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
    auto restored_data = ((data & ~0xff) | m_saved_data);
    ptrace(PTRACE_POKEDATA, m_pid, m_addr, restored_data);
    m_enabled = false;
}

给调试器添加断点


为了能通过用户界面设置断点,我们需要对debugger类做三处修改。

1. 为debugger添加断点数据储存结构体

2. 添加一个set_breakpoint_at_address函数

3. 给handle_command函数添加break指令

我把断点存在std::unordered_map<std::intptr_t, breakpoint>类型的结构体中,因此能够简洁迅速地检测给定的地址上是否已经有断点,如果有就取回这个断点对象。

class debugger {
    //...
    void set_breakpoint_at_address(std::intptr_t addr);
    //...
private:
    //...
    std::unordered_map<std::intptr_t,breakpoint> m_breakpoints;
}

set_breakpoint_at_address函数中我们将创建一个新的断点,启用它,把它加入结构体中,并向用户打印一条信息。你喜欢的话可以将所有的信息取出然后就可以像一个命令行工具一样使用你的调试器。为了简洁,我把它们都整合到了一起。

void debugger::set_breakpoint_at_address(std::intptr_t addr) {
    std::cout << "Set breakpoint at address 0x" << std::hex << addr << std::endl;
    breakpoint bp {m_pid, addr};
    bp.enable();
    m_breakpoints[addr] = bp;
}

现在我们在对命令处理程序做补充以便调用我们的新函数。

void debugger::handle_command(const std::string& line) {
    auto args = split(line,' ');
    auto command = args[0];
    if (is_prefix(command, "cont")) {
        continue_execution();
    }
    else if(is_prefix(command, "break")) {
        std::string addr {args[1], 2}; //粗暴认定用户在地址前加了"0x"
        set_breakpoint_at_address(std::stol(addr, 0, 16));
    }
    else {
        std::cerr << "Unknown command\n";
    }
}

我只是简单的删除了字符串中的前两个字符并对结果调用`std::stol`,你也可以在提高一下解析的鲁棒性。std::stol可以设置转换的基数,因此读入一个十六进制数据是很简单的。

从断点恢复执行


如果你尝试从断点恢复执行,你会发现啥都没发生。这是因为断点依然存在于内存中,因此这个断点被重复命中。简单的解决方法是禁用它,单步,重新启用,然后恢复运行。但是此外我们还需要修改程序计数器(program counter)指到断点的前面。因此我打算把这个留到下一篇讲解完寄存器的操作后在做介绍。

测试


当然,如果我们不知道把断点设置在什么位置,那这个功能并非很有用。以后我们会给调试器添加通过函数名或是源码行设置断点的方法,但现在我们只能时候来实现这一点。

为测试你的调试器,最简单的方法是写一个helloworld程序并通过`std::cerr`输出(避免缓存),并在输出的call上设置一个断点。如果你对被调试端使用continue,程序会断下并没有任何输出。你可以重启程序并在输出call的后面设置断点,然后你会看到成功的输出了消息。

找到这个地址的其中一个方法是使用objdump。如果你打开一个终端并执行 

objdump -d <your program>

你应该会看到程序的反汇编代码。接着你就能找到`main`函数并定位到你想要设置断点的call指令。例如现在我编一个helloworld程序,对他反汇编,并得到了main函数的反汇编代码:

0000000000400936 <main>:
  400936:55                   push   rbp
  400937:48 89 e5             mov    rbp,rsp
  40093a:be 35 0a 40 00       mov    esi,0x400a35
  40093f:bf 60 10 60 00       mov    edi,0x601060
  400944:e8 d7 fe ff ff       call   400820 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
  400949:b8 00 00 00 00       mov    eax,0x0
  40094e:5d                   pop    rbp
  40094f:c3                   ret

就如你所见的那样,如果想要没有输出,我们要将断点设置在0x400944;想要看到输出就要在`0x400949`处设置断点。

总结


你现在有了一个能启动程序并设置断点的调试器。下一次我们将添加对内存和寄存器进行读写的功能。如果你有任何问题,请在博客下面留言。

你可以在这里找到本文的代码。


原文链接:https://blog.tartanllama.xyz/writing-a-linux-debugger-breakpoints/

未经允许不得转载:安全路透社 » 【技术分享】看我如何编写一个Linux 调试器(二):断点

赞 (0)
分享到:更多 ()

评论 0

评论前必须登录!

登陆 注册