自己动手编写一个Linux调试器系列之2 断点的设置 lantie@15PB
在本系列的第一篇文章中,我们编写了一个小型进程启动器作为调试器的基础。在这篇文章中,我们将学习如何在x86 Linux上断点的工作原理,并继续编写我们的工具增加设置断点的功能。
系列索引
- 准备工作
- 断点的设置
- 寄存器和内存
- ELF文件和调试信息
- 源码和信号
- 源码级单步
- 源码级断点
- 堆栈解除
- 处理变量
- 高级主题
断点是如何形成的
有两种类型的断点:硬件断点和软件断点。硬件断点通常需要设置处理器中寄存器的值以产生断点,而软件断点则需要修改正在执行的代码。本文将以软件断点为主,因为它更简单,而且想要多少都可以,在X86上,同时只能设置4个硬件断点,不过硬件断点既可以在读取或写入给定地址触发,也可以在执行时触发。
我上面说过,软件断点是通过修改执行代码来实现的,所以问题来了:
- 我们如何修改代码?
- 我们做什么修改才能设置断点?
- 调试器如何收到通知?
第一个问题的答案当然是ptrace
。我们之前使用它来跟踪和继续执行程序,现在也可以使用它来读写内存。
我们修改代码之后必须使处理器在执行断点地址时停止并发送信号通知程序。在x86上,是通过使用 int 3
指令覆盖这个地址上的指令来实现的。x86具有中断向量表,操作系统可以使用它来注册各种事件的处理程序,例如页面错误,保护故障和无效操作码。这有点像注册错误处理回调,但是是硬件级别的。当处理器执行 int 3
指令时,系统会执行断点中断处理程序,在Linux系统下,进程会产生一个SIGTRAP
的信号。你可以在下图中看到此过程,其中我们用0xcc
覆盖mov
指令的第一个字节,这是 int 3
的指令编码。
最后一个需要解决的问题是调试器如何通知用户断点已经触发。如果你还记得上一篇文章,我们可以使用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; //data which used to be at the breakpoint address
};
这大多数只是跟踪状态,真正的难点在enable
和disable
函数中。
如上所述,我们需要使用编码为0xcc
的 int 3
指令来替换当前在给定地址处的指令。我们还想保存以前在该地址中的内容,以便以后可以恢复代码。我们不想忘记执行用户的代码。
void breakpoint::enable() {
auto data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
m_saved_data = static_cast<uint8_t>(data & 0xff); //save bottom byte
uint64_t int3 = 0xcc;
uint64_t data_with_int3 = ((data & ~0xff) | int3); //set bottom byte to 0xcc
ptrace(PTRACE_POKEDATA, m_pid, m_addr, data_with_int3);
m_enabled = true;
}
ptrace的PTRACE_PEEKDATA
参数可以实现读取跟踪进程的内存。我们给它一个进程ID和地址,它给我们返回目前在该地址的64位。 (m_saved_data&〜0xff)将该数据的底部字节置零,然后按位或与我们的int 3
指令设置断点。 最后,我们通过使用PTRACE_POKEDATA
参数将新数据覆盖那部分内存来设置断点。
disable
函数更容易,但仍然有些麻烦。由于ptrace
内存请求是对整个字而不是字节操作,因此,我们需要首先读取要恢复的字,然后用原始数据覆盖低字节并将其写回内存中。
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
类进行三个更改,以支持通过用户界面设置断点:
- 向
debugger
添加存储断点的数据结构 - 编写一个
set_breakpoint_at_address
函数 - 在我们的
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}; //naively assume that the user has written 0xADDRESS
set_breakpoint_at_address(std::stol(addr, 0, 16));
}
else {
std::cerr << "Unknown command\n";
}
}
以上代码中,我简单地删除了字符串的前两个字符,并在结果上调用了std :: stol
,感觉这样使解析更加健壮了。 std :: stol
有选择的将一个基数转换为十六进制读取会比较方便。
从断点恢复程序运行
如果你尝试这样做,你可能会注意到,如果你从断点继续运行程序,没有任何反应。这是因为断点仍然设置在内存中,所以它会不停重复触发断点。比较简单恢复程序运行的解决方案是禁用断点,单步,重新启用它,然后继续。 不幸的是,我们还需要修改程序计数器(x86下是EIP)以指出断点之前的位置,所以我们将留下这个直到下一个关于操作寄存器的文章。
测试一下
当然,如果你不知道要设置断点的地址,那么设置断点并不是很有用。在将来,我们将添加在函数名或源代码行上设置断点的功能,但是现在,我们可以手工完成它。
测试调试器的一种简单方法是编写一个hello world程序,它将写入std::err
(避免缓冲),并在调用输出操作时设置断点。如果你继续进行调试,那么希望执行将停止而不打印任何东西。然后您可以重新启动调试器,并在调用后设置一个断点,并且您应该看到正在成功打印的消息。
找到地址的一种方法是使用objdump。如果您打开一个shell并执行objdump -d <您的程序>
,那么您应该看到程序的反汇编代码。然后,您应该能够找到main
并找到您想要设置断点的调用指令的地方。例如,我构建了一个hello world示例,将其反汇编,并将其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/
翻译来自:lantie@15PB 专注于信息安全教育 http://www.15pb.com.cn