x86Linux
下载 Linenoise(命令行解析) 以及libelfin(分析调试信息).
在运行调试器之前,我们首先需要运行被调试的程序.此外还需要调试器与被调试的进程之间进行通信.自然的,我们想到使用经典的fork-exec 模式进行软件开发.
91 auto pid = fork();
2 if (pid == 0) {
3 //子进程
4 //运行被调试的程序
5 }
6 else if (pid >= 1) {
7 //父进程
8 //运行调试器
9 }
GDB主要使用ptrace
系统调用来完成对子进程状态的控制以及检查.
21long ptrace(enum __ptrace_request request, pid_t pid,
2 void *addr, void *data);
其接口设计较为繁琐,采用一个枚举类型的request
值来表明欲要执行的操作, void*
类型的addr
指明被调试进程的地址用于特殊操作. data
指向一些特殊操作所需资源
具体信息请查阅man pages.
11man ptrace
子进程从fork
返回后, 需要将request设置为PTRACE_TRACEME
,告知父进程调试开始,系统调用的其余参数因无用均被忽略.
接着执行execl
syscall加载被调试的程序.至此,子进程执行调试代码,直至调试终止.
自此,我们已经成功运行了子进程,接下来的任务就是如何控制并读取子进程的状态了.
为此,我们创建一个debugger
类来实现这一操作.
111class debugger {
2public:
3 debugger (std::string prog_name, pid_t pid)
4 : m_prog_name{std::move(prog_name)}, m_pid{pid} {}
5
6 void run();
7
8private:
9 std::string m_prog_name;
10 pid_t m_pid;
11};
调试器的设计遵循REPL模式. 在run
函数中,父进程需要等待子进程启动完毕,在读取到EOF
之前,一直在循环中不断执行指令并输出结果.
141void debugger::run() {
2 int wait_status;
3 auto options = 0;
4 //等待子进程收到SIGTRAP信号
5 waitpid(m_pid, &wait_status, options);
6 char* line = nullptr;
7 //使用linenoise库进行命令行解析
8 while((line = linenoise("minidbg> ")) != nullptr) {
9 //分析指令
10 handle_command(line);
11 linenoiseHistoryAdd(line);
12 linenoiseFree(line);
13 }
14}
该调试器的指令与gdb类似,在gdb中,我们使用continue cont c
命令之一来恢复进程的执行, 在我的实现中,为了代码简单起见,只实现最简单的单个字母c
. 其余命令类似.
111void debugger::handle_command(const std::string& line) {
2 auto args = split(line,' ');
3 auto command = args[0];
4
5 if (is_prefix(command, "continue")) {
6 continue_execution();
7 }
8 else {
9 std::cerr << "Unknown command\n";
10 }
11}
在continue_execution
中,我们选择让进程继续执行.
61void debugger::continue_execution() {
2 ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);
3 int wait_status;
4 auto options = 0;
5 waitpid(m_pid, &wait_status, options);
6}
断点主要分为两种类型,软件和硬件.硬件断点主要是通过设置ISA指定的寄存器产生中断,然而软件断点包括修改正在运行的程序代码.在本教程中,我们只关心软件断点,因为他们更为简单,灵活,可以设置任意多的软件断点.在x86架构下,在同一时间仅能设置4个不同的硬件断点,但其功能更为强大,可以对指定的地址进行读写.
在上文中提到,软件断点需要通过对正在执行的代码进行修改而得到,这引出了如下问题:
如何修改代码
为了设置断点,我们需要将代码的内容修改为什么
怎么通知父进程(调试器)
解决第一个问题需要用到ptrace
syscall,除了上文的作用外,其还可以读写特定的地址.
在x86架构下,为了让子进程在断点处停止(halt)并且通知父进程.需要将断点处的指令替换为int 3
. 在x86架构中,操作系统启动时会初始化中断向量表,其中注册了对各种中断的处理函数,例如缺页,保护错,非法的错作数等等.
当CPU执行到int 3
这一指令时,控制权被转移给断点中断函数,其向进程发送SIGTRAP
信号.从而产生断点.
如果你还记得上文中父进程通过waitpid
等待子进程处理完成,那么我们也可以通过此方法通知父进程子进程踩到了断点.
181class breakpoint {
2public:
3 breakpoint(pid_t pid, std::intptr_t addr)
4 : m_pid{pid}, m_addr{addr}, m_enabled{false}, m_saved_data{}
5 {}
6
7 void enable();
8 void disable();
9
10 auto is_enabled() const -> bool { return m_enabled; }
11 auto get_address() const -> std::intptr_t { return m_addr; }
12
13private:
14 pid_t m_pid;
15 std::intptr_t m_addr;
16 bool m_enabled;
17 uint8_t m_saved_data; //data which used to be at the breakpoint address
18};
我们主要关注enable
以及disable
这两个方法.
如前文所说,我们需要将断点处的指令替换为int 3
其16进制值为0xcc
.同时也需要存储原来地址处的指令以待之后恢复.
xxxxxxxxxx
91void breakpoint::enable() {
2 auto data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
3 m_saved_data = static_cast<uint8_t>(data & 0xff); //save bottom byte
4 uint64_t int3 = 0xcc;
5 uint64_t data_with_int3 = ((data & ~0xff) | int3); //set bottom byte to 0xcc
6 ptrace(PTRACE_POKEDATA, m_pid, m_addr, data_with_int3);
7
8 m_enabled = true;
9}
PTRACE_PEEKDATA
表明当前syscall需要读取被调试进程的地址,并且返回从该地址起64bit的值.我们需要将返回值的最低字节hack为int 3
代表的值,最后写回对应的内存地址. //TODO 变长指令问题.
disable
函数将原指定写回
xxxxxxxxxx
71void breakpoint::disable() {
2 auto data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
3 auto restored_data = ((data & ~0xff) | m_saved_data);
4 ptrace(PTRACE_POKEDATA, m_pid, m_addr, restored_data);
5
6 m_enabled = false;
7}
添加存储断点的区域.
添加增加断点函数.
向parser中添加break
指令.
//TODO
#TODO
#TODO
在本章节中,我们将会hack内存和寄存器,进一步拓展及完善调试器的功能.
我们首先需要向代码中添加x86各寄存器的枚举类型.(目前支持gpr和s(special)pr).由于兼容原因,x86允许用户访问一些通用寄存器的低8/16/32位,为了简单起见,我并不打算实现此模式.
xxxxxxxxxx
481enum class reg {
2 rax, rbx, rcx, rdx,
3 rdi, rsi, rbp, rsp,
4 r8, r9, r10, r11,
5 r12, r13, r14, r15,
6 rip, rflags, cs,
7 orig_rax, fs_base,
8 gs_base,
9 fs, gs, ss, ds, es
10};
11
12constexpr std::size_t n_registers = 27;
13
14struct reg_descriptor {
15 reg r;
16 int dwarf_r;
17 std::string name;
18};
19
20const std::array<reg_descriptor, n_registers> g_register_descriptors {{
21 { reg::r15, 15, "r15" },
22 { reg::r14, 14, "r14" },
23 { reg::r13, 13, "r13" },
24 { reg::r12, 12, "r12" },
25 { reg::rbp, 6, "rbp" },
26 { reg::rbx, 3, "rbx" },
27 { reg::r11, 11, "r11" },
28 { reg::r10, 10, "r10" },
29 { reg::r9, 9, "r9" },
30 { reg::r8, 8, "r8" },
31 { reg::rax, 0, "rax" },
32 { reg::rcx, 2, "rcx" },
33 { reg::rdx, 1, "rdx" },
34 { reg::rsi, 4, "rsi" },
35 { reg::rdi, 5, "rdi" },
36 { reg::orig_rax, -1, "orig_rax" },
37 { reg::rip, -1, "rip" },
38 { reg::cs, 51, "cs" },
39 { reg::rflags, 49, "eflags" },
40 { reg::rsp, 7, "rsp" },
41 { reg::ss, 52, "ss" },
42 { reg::fs_base, 58, "fs_base" },
43 { reg::gs_base, 59, "gs_base" },
44 { reg::ds, 53, "ds" },
45 { reg::es, 50, "es" },
46 { reg::fs, 54, "fs" },
47 { reg::gs, 55, "gs" },
48}};
现在你可以写一些函数来读写这些寄存器了
xxxxxxxxxx
51uint64_t get_register_value(pid_t pid, reg r) {
2 user_regs_struct regs;
3 ptrace(PTRACE_GETREGS, pid, nullptr, ®s);
4 //...
5}
ptrace
在这里用于读取寄存器的值.