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

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

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

简介


任何一个写过比helloworld更加复杂的程序的人都应该已经使用过调试器了(如果你还没有用过,就放下你手头的事来学习一下吧)。然而,虽然这些工具被人们广泛使用,但目前却没有较多的资料[^1]来告诉我们他们的工作原理以及如何编写一个调试器,尤其是与其他工具链(比如编译器)相比。在我的这一系列文章中,我们将学习调试器的原理以及如何自己编写一个调试器。

我们将会提供以下的功能:

启动,暂停,继续执行

在不同地点设置断点

内存地址

程序源码

函数入口点

向内存或寄存器读取和写入值

单步执行

指令

步入函数

跳出函数

跳过函数

打印当前的代码地址

打印函数调用堆栈

打印变量的值

在最后一章,我会指出如何添加以下功能:

远程调试

共享库和动态加载

表达式执行

多线程调试

在此项目中,我会把重点放在C和C++上,但它也同样能够工作在被编译成机器码且输出标准DWARF调试信息的其他语言上(如果你还不知道那是什么,不用担心,我们马上会说到)。此外,我只关注于如何让程序运行起来且在大多数情况下运行,因此为了简便,我将避开鲁棒的错误处理。


准备工作


在我们开始之前,我们先配置好环境。在这系列教程中,我将使用两个依赖工具:Linenoise用于处理我们的命令行输入,libelfin则用于解析调试信息。你也可以使用传统的libdwarf 来替代libelfin,但是界面交互没有那么好,而且libelfin还提供了基本完备的DWARF 表达式执行器,能够在你需要读取变量值的时候节省大量时间。确认你使用的是我的fork的libelfin的fbreg分支,因为我对x86下的变量读取做了一些额外的支持。

一旦你在你的系统上安装或是在你喜欢的系统上编译好了这些依赖工具,我们就可以开始了。我在CMake文件中把它们设置为和我的其余一些代码一起编译。


运行可执行文件


在我们开始调试之前,我们需要启动被调试端(debugee),通过经典的fork/exec模式来完成。

int main(int argc, char* argv[]) {
    if (argc < 2) {
        std::cerr << "Program name not specified";
        return -1;
    }

    auto prog = argv[1];

    auto pid = fork();
    if (pid == 0) {
        //我们在子进程中
        //执行被调试端

    }
    else if (pid >= 1)  {
        //我们在父进程中
        //执行调试器
    }

我们通过调用fork来将我们的程序分离成两个进程。如果我们在子进程中,fork会返回0;如果我们在父进程中,fork会返回子进程的pid。

如果我们在子进程中,我们希望他变成我们需要调试的程序。

ptrace(PTRACE_TRACEME, 0, nullptr, nullptr); 
execl(prog, prog, nullptr);

此处我们第一次遇到了ptrace,它将成为我们编写调试器过程中最好的伙伴。ptrace允许我们用过读取寄存器,内存,单步执行等方法来控制另一个进程。他的API非常简单,你需要提供一个枚举值给这个函数来指明你想进行的操作,后面的一些参数是使用还是被忽略取决于你所提供的值。下面是ptrace的原形:

long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

request是我们对被调试进程的操作;pid是被调试进程的进程ID;addr是一个内存地址,将在一些call中被用于指定被调试进程的地址;data与request的值有关;返回值一般是一些错误信息,因此你需要在你的代码中检测它。这里我为了简便就略过了,你可以通过查看ptrace的man手册来了解更多信息。

上面的代码中,我们使用的request是PTRACE_TRACEME。表示这个进程提出要求它的父进程来调试它。它的参数会被忽略因为API就是这样设计的。

接着,我们调用了execl,这是许多exec族函数中的一个。我们执行指定的程序,把他的名字作为命令行参数传递,并使用一个nullptr来终止这个列表。如果你需要,你可以传递其他执行你程序所需的参数。

当我们完成这些后,我们就完成了子进程的设置;在我们结束它之前它会一直运行下去。


添加调试器循环


现在我们已经启动了子进程,我们想要和它交互。因此,我们创建了debugger类,提供了一个循环来监听用户的输入,然后从父进程的main函数中启动。

else if (pid >= 1)  {
    //parent
    debugger dbg{prog, pid};
    dbg.run();
}

class debugger {
public:
    debugger (std::string prog_name, pid_t pid)
        : m_prog_name{std::move(prog_name)}, m_pid{pid} {}

    void run();

private:
    std::string m_prog_name;
    pid_t m_pid;
};

在run函数中,我们需要一直等到子进程完成启动,然后从linenoise 中读取输入知道我们读到EOF(ctrl+d)。

void debugger::run() {
    int wait_status;
    auto options = 0;
    waitpid(m_pid, &wait_status, options);

    char* line = nullptr;
    while((line = linenoise("minidbg> ")) != nullptr) {
        handle_command(line);
        linenoiseHistoryAdd(line);
        linenoiseFree(line);
    }
}

当被调试进程启动完成,他将会发送SIGTRAP信号,表示这是一个跟踪或是遇到断点。我们通过watpid函数来等待直到收到这个信号。

当我们知道这个进程准备好被调试后,我们监听用户的输入,linenoise 函数会自己显示一个提示符并处理用户的输入。这意味着我们不需要做太多工作就能拥有一个拥有历史记录和导航的命令行。当我们获取到用户输入后,我们把命令发送到相应的处理函数中(我们马上会看到),然后我们将这个命令添加到 linenoise 历史并释放资源。


处理输入


我们的命令将和gdb和lldb保持相似。用户想要继续运行程序只需要输入continue或是cont或是c即可。如果他们想要在一个地址上设置断点,他们可以写break 0xDEADBEEF,0xDEADBEEF是用户期望的地址的16进制格式。让我们为这些命令添加支持。

void debugger::handle_command(const std::string& line) {
    auto args = split(line,' ');
    auto command = args[0];

    if (is_prefix(command, "continue")) {
        continue_execution();
    }
    else {
        std::cerr << "Unknown command\n";
    }
}

split 和is_prefix 是一对有用的小函数。

std::vector<std::string> split(const std::string &s, char delimiter) {
    std::vector<std::string> out{};
    std::stringstream ss {s};
    std::string item;

    while (std::getline(ss,item,delimiter)) {
        out.push_back(item);
    }

    return out;
}

bool is_prefix(const std::string& s, const std::string& of) {
    if (s.size() > of.size()) return false;
    return std::equal(s.begin(), s.end(), of.begin());
}

我们为debugger类添加continue_execution函数。

void debugger::continueexecution() 
{ 
    ptrace(PTRACECONT, m_pid, nullptr, nullptr);
    int wait_status;
    auto options = 0;
    waitpid(m_pid, &wait_status, options);
}

到此,continue_execution函数将使用ptrace来告知被调试进程继续执行,然后用waitpid函数直到它收到信号。


总结


现在应该有能力编译一些C或C++程序,通过调试器运行他们,看它能否停在入口点以及从调试中继续执行。在下一篇文章中,我们讲学习如何让我们的调试器设置断点,如果你遇到了任何问题,可以通过下面的评论告诉我。

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

[^1]: 这些是一些已经公开的资料,如果你需要的话。1 2 3 4


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

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

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

评论 0

评论前必须登录!

登陆 注册