自己动手编写一个Linux调试器系列之3 寄存器和内存 by lantie@15PB

自己动手编写一个Linux调试器系列之3 寄存器和内存 by lantie@15PB

目录

在上一篇文章中,我们向调试器添加了简单的地址断点。这一次,我们将添加读取寄存器和内存的功能,有了这个功能我们就可以观察寄存器状态和利用程序计数器(CIP)改变程序的执行流程了。


系列索引

  1. 准备工作
  2. 断点的设置
  3. 寄存器和内存
  4. ELF文件和调试信息
  5. 源码和信号
  6. 源码级单步
  7. 源码级断点
  8. 堆栈解除
  9. 处理变量
  10. 高级主题

设计和保存寄存器的结构

在我们编写读取寄存器代码之前,首先需要确定调试器支持什么平台,我们选择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, &regs);
    //...
}

又一次,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*>(&regs) + (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, &regs);
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [r](auto&& rd) { return rd.r == r; });

    *(reinterpret_cast<uint64_t*>(&regs) + (it - begin(g_register_descriptors))) = value;
    ptrace(PTRACE_SETREGS, pid, nullptr, &regs);
}

接下来是通过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 raxregister 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_readvprocess_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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,634评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,951评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,427评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,770评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,835评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,799评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,768评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,544评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,979评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,271评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,427评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,121评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,756评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,375评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,579评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,410评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,315评论 2 352

推荐阅读更多精彩内容