自己动手编写一个Linux调试器系列之3 寄存器和内存 by lantie@15PB
在上一篇文章中,我们向调试器添加了简单的地址断点。这一次,我们将添加读取寄存器和内存的功能,有了这个功能我们就可以观察寄存器状态和利用程序计数器(CIP)改变程序的执行流程了。
系列索引
- 准备工作
- 断点的设置
- 寄存器和内存
- ELF文件和调试信息
- 源码和信号
- 源码级单步
- 源码级断点
- 堆栈解除
- 处理变量
- 高级主题
设计和保存寄存器的结构
在我们编写读取寄存器代码之前,首先需要确定调试器支持什么平台,我们选择x86_64(即64位)。除了通用寄存器和专用寄存器之外,x86_64还提供了浮点寄存器和向量寄存器。为了简单起见,我将省略后两者,但如果你愿意,可以选择支持它们。x86_64还允许你访问的一些64位寄存器作为32位、16位、8位寄存器访问,但在这里我只支持64位。由于这样的简化,对于每个寄存器,我们只需要保存其名称,其DWARF寄存器编号和从ptrace
返回的结构里的位置即可。我定义了一个枚举来引用寄存器,然后我编写了一个全局局存起描述符数组,其中元素的顺序与ptrace
返回的寄存器结构中的顺序相同。
enum class reg {
rax, rbx, rcx, rdx,
rdi, rsi, rbp, rsp,
r8, r9, r10, r11,
r12, r13, r14, r15,
rip, rflags, cs,
orig_rax, fs_base,
gs_base,
fs, gs, ss, ds, es
};
constexpr std::size_t n_registers = 27;
struct reg_descriptor {
reg r;
int dwarf_r;
std::string name;
};
const std::array<reg_descriptor, n_registers> g_register_descriptors {{
{ reg::r15, 15, "r15" },
{ reg::r14, 14, "r14" },
{ reg::r13, 13, "r13" },
{ reg::r12, 12, "r12" },
{ reg::rbp, 6, "rbp" },
{ reg::rbx, 3, "rbx" },
{ reg::r11, 11, "r11" },
{ reg::r10, 10, "r10" },
{ reg::r9, 9, "r9" },
{ reg::r8, 8, "r8" },
{ reg::rax, 0, "rax" },
{ reg::rcx, 2, "rcx" },
{ reg::rdx, 1, "rdx" },
{ reg::rsi, 4, "rsi" },
{ reg::rdi, 5, "rdi" },
{ reg::orig_rax, -1, "orig_rax" },
{ reg::rip, -1, "rip" },
{ reg::cs, 51, "cs" },
{ reg::rflags, 49, "eflags" },
{ reg::rsp, 7, "rsp" },
{ reg::ss, 52, "ss" },
{ reg::fs_base, 58, "fs_base" },
{ reg::gs_base, 59, "gs_base" },
{ reg::ds, 53, "ds" },
{ reg::es, 50, "es" },
{ reg::fs, 54, "fs" },
{ reg::gs, 55, "gs" },
}};
如果你想自己查看寄存器的数据结构可以在/usr/include/sys/user.h中找到,DWARF寄存器编号取自System V x86_64 ABI。
现在我们可以编写一连串的函数来与寄存器进行交互。我们希望能读取寄存器、修改寄存器,从DWARF寄存器编号中检索一个值,并按名称查找寄存器,反之亦然。我们从get_register_value
开始:
uint64_t get_register_value(pid_t pid, reg r) {
user_regs_struct regs;
ptrace(PTRACE_GETREGS, pid, nullptr, ®s);
//...
}
又一次,ptrace
让我们轻松访问到了想要的数据。我们只是构造一个user_regs_struct
的实例,并将PTRACE_GETREGS
参数传入了ptrace
就可以完成。
现在我们要根据请求的寄存器来读取regs
。我们可以写一个大的switch语句,但是由于我们按照与user_regs_struct
相同的顺序排列了我们的g_register_descriptors
表,所以我们可以检索寄存器描述符的索引,并将user_regs_struct
作为 uint64_t
类型的数组访问。[注解1]
auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
[r](auto&& rd) { return rd.r == r; });
return *(reinterpret_cast<uint64_t*>(®s) + (it - begin(g_register_descriptors)));
由于user_regs_struct
是一个标准的布局类型(线性结构),所以转为 uint64_t
是安全的,但我认为指针计算在技术上是比较难看的。由于目前编译器还没有警告,再加上我也比较懒,所以就先这样做,但是如果您想保持最大的正确性,那么就编写一个大的switch语句吧。
set_register_value
是一样的,我们只需要获取位置,并在其位置上写入寄存器的值:
void set_register_value(pid_t pid, reg r, uint64_t value) {
user_regs_struct regs;
ptrace(PTRACE_GETREGS, pid, nullptr, ®s);
auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
[r](auto&& rd) { return rd.r == r; });
*(reinterpret_cast<uint64_t*>(®s) + (it - begin(g_register_descriptors))) = value;
ptrace(PTRACE_SETREGS, pid, nullptr, ®s);
}
接下来是通过DWARF寄存器号查找。 这一次我会检查一个错误条件,以防万一我们得到一些奇怪的DWARF信息:
uint64_t get_register_value_from_dwarf_register (pid_t pid, unsigned regnum) {
auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
[regnum](auto&& rd) { return rd.dwarf_r == regnum; });
if (it == end(g_register_descriptors)) {
throw std::out_of_range{"Unknown dwarf register"};
}
return get_register_value(pid, it->r);
}
到这几乎完成,现在还有对注册的寄存器名称的查找:
std::string get_register_name(reg r) {
auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
[r](auto&& rd) { return rd.r == r; });
return it->name;
}
reg get_register_from_name(const std::string& name) {
auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
[name](auto&& rd) { return rd.name == name; });
return it->r;
}
最后,我们将添加一个简单的函数来转储所有寄存器的内容:
void debugger::dump_registers() {
for (const auto& rd : g_register_descriptors) {
std::cout << rd.name << " 0x"
<< std::setfill('0') << std::setw(16) << std::hex << get_register_value(m_pid, rd.r) << std::endl;
}
}
如你所见,iostreams有一个非常简洁的接口,可以很好地输出十六进制数据。
如果你喜欢的话,可以自由地对I/O输出做格式控制。[注解2]
这给了我们足够的支持来在调试器的其余部分轻松地处理寄存器,因此我们现在可以将它添加到我们的UI中。
添加读取寄存器命令
我们需要在这里做的就是向handle_command
函数添加一个新命令。使用以下代码,用户将能够键入register read rax
,register write rax 0x42
,等等。
else if (is_prefix(command, "register")) {
if (is_prefix(args[1], "dump")) {
dump_registers();
}
else if (is_prefix(args[1], "read")) {
std::cout << get_register_value(m_pid, get_register_from_name(args[2])) << std::endl;
}
else if (is_prefix(args[1], "write")) {
std::string val {args[3], 2}; //assume 0xVAL
set_register_value(m_pid, get_register_from_name(args[2]), std::stol(val, 0, 16));
}
}
更好的封装代码
在设置断点时,我们已经从内存中读取和写入内存,因此只需添加一些函数来隐藏ptrace
调用即可。
uint64_t debugger::read_memory(uint64_t address) {
return ptrace(PTRACE_PEEKDATA, m_pid, address, nullptr);
}
void debugger::write_memory(uint64_t address, uint64_t value) {
ptrace(PTRACE_POKEDATA, m_pid, address, value);
}
你可能想要一次添加对读取和写入的支持,通过每次你想读另一个单词时递增地址即可。您还可以使用process_vm_readv
和process_vm_writev
或/ proc/<pid>/mem
而不是ptrace
。
现在我们将为UI添加命令:
else if(is_prefix(command, "memory")) {
std::string addr {args[2], 2}; //assume 0xADDRESS
if (is_prefix(args[1], "read")) {
std::cout << std::hex << read_memory(std::stol(addr, 0, 16)) << std::endl;
}
if (is_prefix(args[1], "write")) {
std::string val {args[3], 2}; //assume 0xVAL
write_memory(std::stol(addr, 0, 16), std::stol(val, 0, 16));
}
}
修补continue_execution函数
在测试我们的更改之前,我们现在可以执行一个更合理的版本的continue_execution
。 由于我们可以得到程序计数器(CIP),所以可以检查我们的断点映射,看看我们是否处于断点。 如果是这样,我们可以在继续之前禁用断点并重新切断它。
首先,为了清晰简洁,我们将添加几个帮助函数:
uint64_t debugger::get_pc() {
return get_register_value(m_pid, reg::rip);
}
void debugger::set_pc(uint64_t pc) {
set_register_value(m_pid, reg::rip, pc);
}
然后我们可以写一个函数来跳过一个断点:
void debugger::step_over_breakpoint() {
// - 1 because execution will go past the breakpoint
auto possible_breakpoint_location = get_pc() - 1;
if (m_breakpoints.count(possible_breakpoint_location)) {
auto& bp = m_breakpoints[possible_breakpoint_location];
if (bp.is_enabled()) {
auto previous_instruction_address = possible_breakpoint_location;
set_pc(previous_instruction_address);
bp.disable();
ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr);
wait_for_signal();
bp.enable();
}
}
}
首先,我们检查是否为当前PC的值设置了一个断点。 如果有的话,我们先把执行返回到断点之前,禁用它,重新执行原来的指令,然后再重新启用断点。
wait_for_signal
将封装我们通常的waitpid
模式:
void debugger::wait_for_signal() {
int wait_status;
auto options = 0;
waitpid(m_pid, &wait_status, options);
}
最后我们重写如下的continue_execution
:
void debugger::continue_execution() {
step_over_breakpoint();
ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);
wait_for_signal();
}
测试一下
现在我们可以读取和修改寄存器,可以使用我们的hello world程序进行调试测试。 作为第一个测试,请尝试再次在调用指令上设置断点,并从中继续。 你应该看到Hello world
被打印出来。 有趣的部分在输出调用之后设置一个断点,继续运行程序,然后将调用参数设置代码的地址写入程序计数器(rip
)并继续。 由于这个程序计数器的修改,你应该再次看到Hello world
被打印了。 为了防止你不确定断点的位置,以下是我最后一篇文章的objdump
输出:
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
你将要将程序计数器移回0x40093a
,以便正确设置esi和edi寄存器。
在下一篇文章中,我们将首先介绍DWARF信息,并在调试器中添加各种单步。 之后,我们编写的工具将拥有调试器的主要功能,我们可以通过单步代码,设置断点,修改数据等等使用工具。 和往常一样,如果您有任何疑问,请在下方发表评论!
你可以在这里找到这篇文章的代码。
注解1:你也可以重新排序寄存器表,并将其转换为基础类型以用作索引,但是我以现在的方式编写了,懒得了改变它了。
注解2:哈哈哈哈哈哈哈哈
说明
原文来自:https://blog.tartanllama.xyz/writing-a-linux-debugger-registers/
翻译来自:lantie@15PB 专注于信息安全教育 http://www.15pb.com.cn