标签 ptrace 下的文章

ptrace(2)(“ 进程跟踪 process trace ”)系统调用通常都与调试有关。它是类 Unix 系统上通过原生调试器监测被调试进程的主要机制。它也是实现 strace 系统调用跟踪 system call trace )的常见方法。使用 Ptrace,跟踪器可以暂停被跟踪进程,检查和设置寄存器和内存,监视系统调用,甚至可以 拦截 intercepting 系统调用。

通过拦截功能,意味着跟踪器可以篡改系统调用参数,篡改系统调用的返回值,甚至阻塞某些系统调用。言外之意就是,一个跟踪器本身完全可以提供系统调用服务。这是件非常有趣的事,因为这意味着一个跟踪器可以仿真一个完整的外部操作系统,而这些都是在没有得到内核任何帮助的情况下由 Ptrace 实现的。

问题是,在同一时间一个进程只能被一个跟踪器附着,因此在那个进程的调试期间,不可能再使用诸如 GDB 这样的工具去仿真一个外部操作系统。另外的问题是,仿真系统调用的开销非常高。

在本文中,我们将专注于 x86-64 Linux 的 Ptrace,并将使用一些 Linux 专用的扩展。同时,在本文中,我们将忽略掉一些错误检查,但是完整的源代码仍然会包含这些错误检查。

本文中的可直接运行的示例代码在这里:https://github.com/skeeto/ptrace-examples

strace

在进入到最有趣的部分之前,我们先从回顾 strace 的基本实现来开始。它不是 DTrace,但 strace 仍然非常有用。

Ptrace 一直没有被标准化。它的接口在不同的操作系统上非常类似,尤其是在核心功能方面,但是在不同的系统之间仍然存在细微的差别。ptrace(2) 的原型基本上应该像下面这样,但特定的类型可能有些差别。

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

pid 是被跟踪进程的 ID。虽然同一个时间只有一个跟踪器可以附着到该进程上,但是一个跟踪器可以附着跟踪多个进程。

request 字段选择一个具体的 Ptrace 函数,比如 ioctl(2) 接口。对于 strace,只需要两个:

  • PTRACE_TRACEME:这个进程被它的父进程跟踪。
  • PTRACE_SYSCALL:继续跟踪,但是在下一下系统调用入口或出口时停止。
  • PTRACE_GETREGS:取得被跟踪进程的寄存器内容副本。

另外两个字段,addrdata,作为所选的 Ptrace 函数的一般参数。一般情况下,可以忽略一个或全部忽略,在那种情况下,传递零个参数。

strace 接口实质上是前缀到另一个命令之前。

$ strace [strace options] program [arguments]

最小化的 strace 不需要任何选项,因此需要做的第一件事情是 —— 假设它至少有一个参数 —— 在 argv 尾部的 fork(2)exec(2) 被跟踪进程。但是在加载目标程序之前,新的进程将告知内核,目标程序将被它的父进程继续跟踪。被跟踪进程将被这个 Ptrace 系统调用暂停。

pid_t pid = fork();
switch (pid) {
    case -1: /* error */
        FATAL("%s", strerror(errno));
    case 0:  /* child */
        ptrace(PTRACE_TRACEME, 0, 0, 0);
        execvp(argv[1], argv + 1);
        FATAL("%s", strerror(errno));
}

父进程使用 wait(2) 等待子进程的 PTRACE_TRACEME,当 wait(2) 返回后,子进程将被暂停。

waitpid(pid, 0, 0);

在允许子进程继续运行之前,我们告诉操作系统,被跟踪进程和它的父进程应该一同被终止。一个真实的 strace 实现可能会设置其它的选择,比如: PTRACE_O_TRACEFORK

ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_EXITKILL);

剩余部分就是一个简单的、无休止的循环了,每循环一次捕获一个系统调用。循环体总共有四步:

  1. 等待进程进入下一个系统调用。
  2. 输出系统调用的一个描述。
  3. 允许系统调用去运行并等待返回。
  4. 输出系统调用返回值。

这个 PTRACE_SYSCALL 请求被用于等待下一个系统调用时开始,和等待那个系统调用退出。和前面一样,需要一个 wait(2) 去等待被跟踪进程进入期望的状态。

ptrace(PTRACE_SYSCALL, pid, 0, 0);
waitpid(pid, 0, 0);

wait(2) 返回时,进行了系统调用的线程的寄存器中写入了该系统调用的系统调用号及其参数。尽管如此,操作系统仍然没有为这个系统调用提供服务。这个细节对后续操作很重要。

接下来的一步是采集系统调用信息。这是各个系统架构不同的地方。在 x86-64 上,系统调用号是在 rax 中传递的,而参数(最多 6 个)是在 rdirsirdxr10r8r9 中传递的。这些寄存器是由另外的 Ptrace 调用读取的,不过这里再也不需要 wait(2) 了,因为被跟踪进程的状态再也不会发生变化了。

struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, pid, 0, &regs);
long syscall = regs.orig_rax;

fprintf(stderr, "%ld(%ld, %ld, %ld, %ld, %ld, %ld)",
        syscall,
        (long)regs.rdi, (long)regs.rsi, (long)regs.rdx,
        (long)regs.r10, (long)regs.r8,  (long)regs.r9);

这里有一个警告。由于 内核的内部用途,系统调用号是保存在 orig_rax 中而不是 rax 中。而所有的其它系统调用参数都是非常简单明了的。

接下来是它的另一个 PTRACE_SYSCALLwait(2),然后是另一个 PTRACE_GETREGS 去获取结果。结果保存在 rax 中。

ptrace(PTRACE_GETREGS, pid, 0, &regs);
fprintf(stderr, " = %ld\n", (long)regs.rax);

这个简单程序的输出也是非常粗糙的。这里的系统调用都没有符号名,并且所有的参数都是以数字形式输出,甚至是一个指向缓冲区的指针也是如此。更完整的 strace 输出将能知道哪个参数是指针,并使用 process_vm_readv(2) 从被跟踪进程中读取哪些缓冲区,以便正确输出它们。

然而,这些仅仅是系统调用拦截的基础工作。

系统调用拦截

假设我们想使用 Ptrace 去实现如 OpenBSD 的 pledge(2) 这样的功能,它是 一个进程 承诺 pledge 只使用一套受限的系统调用。初步想法是,许多程序一般都有一个初始化阶段,这个阶段它们都需要进行许多的系统访问(比如,打开文件、绑定套接字、等等)。初始化完成以后,它们进行一个主循环,在主循环中它们处理输入,并且仅使用所需的、很少的一套系统调用。

在进入主循环之前,一个进程可以限制它自己只能运行所需要的几个操作。如果 程序有缺陷,能够通过恶意的输入去利用该缺陷,这个承诺可以有效地限制漏洞利用的实现。

使用与 strace 相同的模型,但不是输出所有的系统调用,我们既能够阻塞某些系统调用,也可以在它的行为异常时简单地终止被跟踪进程。终止它很容易:只需要在跟踪器中调用 exit(2)。因此,它也可以被设置为去终止被跟踪进程。阻塞系统调用和允许子进程继续运行都只是些雕虫小技而已。

最棘手的部分是当系统调用启动后没有办法去中断它。当跟踪器在入口从 wait(2) 中返回到系统调用时,从一开始停止一个系统调用的仅有方式是,终止被跟踪进程。

然而,我们不仅可以“搞乱”系统调用的参数,也可以改变系统调用号本身,将它修改为一个不存在的系统调用。返回时,在 errno通过正常的内部信号,我们就可以报告一个“友好的”错误信息。

for (;;) {
    /* Enter next system call */
    ptrace(PTRACE_SYSCALL, pid, 0, 0);
    waitpid(pid, 0, 0);

    struct user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, 0, &regs);

    /* Is this system call permitted? */
    int blocked = 0;
    if (is_syscall_blocked(regs.orig_rax)) {
        blocked = 1;
        regs.orig_rax = -1; // set to invalid syscall
        ptrace(PTRACE_SETREGS, pid, 0, &regs);
    }

    /* Run system call and stop on exit */
    ptrace(PTRACE_SYSCALL, pid, 0, 0);
    waitpid(pid, 0, 0);

    if (blocked) {
        /* errno = EPERM */
        regs.rax = -EPERM; // Operation not permitted
        ptrace(PTRACE_SETREGS, pid, 0, &regs);
    }
}

这个简单的示例只是检查了系统调用是否违反白名单或黑名单。而它们在这里并没有差别,比如,允许文件以只读而不是读写方式打开(open(2)),允许匿名内存映射但不允许非匿名映射等等。但是这里仍然没有办法去动态撤销被跟踪进程的权限。

跟踪器与被跟踪进程如何沟通?使用人为的系统调用!

创建一个人为的系统调用

对于我的这个类似于 pledge 的系统调用 —— 我可以通过调用 xpledge() 将它与真实的系统调用区分开 —— 我设置 10000 作为它的系统调用号,这是一个非常大的数字,真实的系统调用中从来不会用到它。

#define SYS_xpledge 10000

为演示需要,我同时构建了一个非常小的接口,这在实践中并不是个好主意。它与 OpenBSD 的 pledge(2) 稍有一些相似之处,它使用了一个 字符串接口事实上,设计一个健壮且安全的权限集是非常复杂的,正如在 pledge(2) 的手册页面上所显示的那样。下面是对被跟踪进程的系统调用的完整接口实现:

#define _GNU_SOURCE
#include <unistd.h>

#define XPLEDGE_RDWR (1 << 0)
#define XPLEDGE_OPEN (1 << 1)

#define xpledge(arg) syscall(SYS_xpledge, arg)

如果给它传递个参数 0 ,仅允许一些基本的系统调用,包括那些用于去分配内存的系统调用(比如 brk(2))。 PLEDGE_RDWR 位允许 各种 读和写的系统调用(read(2)readv(2)pread(2)preadv(2) 等等)。PLEDGE_OPEN 位允许 open(2)

为防止发生提升权限的行为,pledge() 会拦截它自己 —— 但这样也防止了权限撤销,以后再细说这方面内容。

在 xpledge 跟踪器中,我需要去检查这个系统调用:

/* Handle entrance */
switch (regs.orig_rax) {
    case SYS_pledge:
        register_pledge(regs.rdi);
        break;
}

操作系统将返回 ENOSYS(函数尚未实现),因为它不是一个真实的系统调用。为此在退出时我用一个 success(0) 去覆写它。

/* Handle exit */
switch (regs.orig_rax) {
    case SYS_pledge:
        ptrace(PTRACE_POKEUSER, pid, RAX * 8, 0);
        break;
}

我写了一小段测试程序去打开 /dev/urandom,做一个读操作,尝试去承诺后,然后试着第二次打开 /dev/urandom,然后确认它能够读取原始的 /dev/urandom 文件描述符。在没有承诺跟踪器的情况下运行,输出如下:

$ ./example
fread("/dev/urandom")[1] = 0xcd2508c7
XPledging...
XPledge failed: Function not implemented
fread("/dev/urandom")[2] = 0x0be4a986
fread("/dev/urandom")[1] = 0x03147604

做一个无效的系统调用并不会让应用程序崩溃。它只是失败,这是一个很方便的返回方式。当它在跟踪器下运行时,它的输出如下:

>$ ./xpledge ./example
fread("/dev/urandom")[1] = 0xb2ac39c4
XPledging...
fopen("/dev/urandom")[2]: Operation not permitted
fread("/dev/urandom")[1] = 0x2e1bd1c4

这个承诺很成功,第二次的 fopen(3) 并没有进行,因为跟踪器用一个 EPERM 阻塞了它。

可以将这种思路进一步发扬光大,比如,改变文件路径或返回一个假的结果。一个跟踪器可以很高效地 chroot 它的被跟踪进程,通过一个系统调用将任意路径传递给 root 从而实现 chroot 路径。它甚至可以对用户进行欺骗,告诉用户它以 root 运行。事实上,这些就是 Fakeroot NG 程序所做的事情。

仿真外部系统

假设你不满足于仅拦截一些系统调用,而是想拦截全部系统调用。你就会有了 一个打算在其它操作系统上运行的二进制程序,无需系统调用,这个二进制程序可以一直运行。

使用我在前面所描述的这些内容你就可以管理这一切。跟踪器可以使用一个假冒的东西去代替系统调用号,允许它失败,以及为系统调用本身提供服务。但那样做的效率很低。其实质上是对每个系统调用做了三个上下文切换:一个是在入口上停止,一个是让系统调用总是以失败告终,还有一个是在系统调用退出时停止。

从 2005 年以后,对于这个技术,PTrace 的 Linux 版本有更高效的操作:PTRACE_SYSEMU。PTrace 仅在每个系统调用发出时停止一次,在允许被跟踪进程继续运行之前,由跟踪器为系统调用提供服务。

for (;;) {
    ptrace(PTRACE_SYSEMU, pid, 0, 0);
    waitpid(pid, 0, 0);

    struct user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, 0, &regs);

    switch (regs.orig_rax) {
        case OS_read:
            /* ... */

        case OS_write:
            /* ... */

        case OS_open:
            /* ... */

        case OS_exit:
            /* ... */

        /* ... and so on ... */
    }
}

从任何具有(足够)稳定的系统调用 ABI(LCTT 译注:应用程序二进制接口),在相同架构的机器上运行一个二进制程序时,你只需要 PTRACE_SYSEMU 跟踪器、一个加载器(用于代替 exec(2)),和这个二进制程序所需要(或仅运行静态的二进制程序)的任何系统库即可。

事实上,这听起来有点像一个有趣的周末项目。

参见


via: http://nullprogram.com/blog/2018/06/23/

作者:Chris Wellons 选题:lujun9972 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

你也许用过调速器检查过你的代码,但你知道它们是如何做到的吗?

调试器是大多数(即使不是每个)开发人员在软件工程职业生涯中至少使用过一次的那些软件之一,但是你们中有多少人知道它们到底是如何工作的?我在悉尼 linux.conf.au 2018 的演讲中,将讨论从头开始编写调试器……使用 Rust

在本文中,术语 调试器 debugger 跟踪器 tracer 可以互换。 “ 被跟踪者 Tracee ”是指正在被跟踪器跟踪的进程。

ptrace 系统调用

大多数调试器严重依赖称为 ptrace(2) 的系统调用,其原型如下:

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

这是一个可以操纵进程几乎所有方面的系统调用;但是,在调试器可以连接到一个进程之前,“被跟踪者”必须以请求 PTRACE_TRACEME 调用 ptrace。这告诉 Linux,父进程通过 ptrace 连接到这个进程是合法的。但是……我们如何强制一个进程调用 ptrace?很简单!fork/execve 提供了在 fork 之后但在被跟踪者真正开始使用 execve 之前调用 ptrace 的简单方法。很方便地,fork 还会返回被跟踪者的 pid,这是后面使用 ptrace 所必需的。

现在被跟踪者可以被调试器追踪,重要的变化发生了:

  • 每当一个信号被传送到被跟踪者时,它就会停止,并且一个可以被 wait 系列的系统调用捕获的等待事件被传送给跟踪器。
  • 每个 execve 系统调用都会导致 SIGTRAP 被传递给被跟踪者。(与之前的项目相结合,这意味着被跟踪者在一个 execve 完全发生之前停止。)

这意味着,一旦我们发出 PTRACE_TRACEME 请求并调用 execve 系统调用来实际在被跟踪者(进程上下文)中启动程序时,被跟踪者将立即停止,因为 execve 会传递一个 SIGTRAP,并且会被跟踪器中的等待事件捕获。我们如何继续?正如人们所期望的那样,ptrace 有大量的请求可以用来告诉被跟踪者可以继续:

  • PTRACE_CONT:这是最简单的。 被跟踪者运行,直到它接收到一个信号,此时等待事件被传递给跟踪器。这是最常见的实现真实世界调试器的“继续直至断点”和“永远继续”选项的方式。断点将在下面介绍。
  • PTRACE_SYSCALL:与 PTRACE_CONT 非常相似,但在进入系统调用之前以及在系统调用返回到用户空间之前停止。它可以与其他请求(我们将在本文后面介绍)结合使用来监视和修改系统调用的参数或返回值。系统调用追踪程序 strace 很大程度上使用这个请求来获知进程发起了哪些系统调用。
  • PTRACE_SINGLESTEP:这个很好理解。如果您之前使用过调试器(你会知道),此请求会执行下一条指令,然后立即停止。

我们可以通过各种各样的请求停止进程,但我们如何获得被调试者的状态?进程的状态大多是通过其寄存器捕获的,所以当然 ptrace 有一个请求来获得(或修改)寄存器:

  • PTRACE_GETREGS:这个请求将给出被跟踪者刚刚被停止时的寄存器的状态。
  • PTRACE_SETREGS:如果跟踪器之前通过调用 PTRACE_GETREGS 得到了寄存器的值,它可以在参数结构中修改相应寄存器的值,并使用 PTRACE_SETREGS 将寄存器设为新值。
  • PTRACE_PEEKUSERPTRACE_POKEUSER:这些允许从被跟踪者的 USER 区读取信息,这里保存了寄存器和其他有用的信息。 这可以用来修改单一寄存器,而避免使用更重的 PTRACE_{GET,SET}REGS 请求。

在调试器仅仅修改寄存器是不够的。调试器有时需要读取一部分内存,甚至对其进行修改。GDB 可以使用 print 得到一个内存位置或变量的值。ptrace 通过下面的方法实现这个功能:

  • PTRACE_PEEKTEXTPTRACE_POKETEXT:这些允许读取和写入被跟踪者地址空间中的一个字。当然,使用这个功能时被跟踪者要被暂停。

真实世界的调试器也有类似断点和观察点的功能。 在接下来的部分中,我将深入体系结构对调试器支持的细节。为了清晰和简洁,本文将只考虑 x86。

体系结构的支持

ptrace 很酷,但它是如何工作? 在前面的部分中,我们已经看到 ptrace 跟信号有很大关系:SIGTRAP 可以在单步跟踪、execve 之前以及系统调用前后被传送。信号可以通过一些方式产生,但我们将研究两个具体的例子,以展示信号可以被调试器用来在给定的位置停止程序(有效地创建一个断点!):

  • 未定义的指令:当一个进程尝试执行一个未定义的指令,CPU 将产生一个异常。此异常通过 CPU 中断处理,内核中相应的中断处理程序被调用。这将导致一个 SIGILL 信号被发送给进程。 这依次导致进程被停止,跟踪器通过一个等待事件被通知,然后它可以决定后面做什么。在 x86 上,指令 ud2 被确保始终是未定义的。
  • 调试中断:前面的方法的问题是,ud2 指令需要占用两个字节的机器码。存在一条特殊的单字节指令能够触发一个中断,它是 int $3,机器码是 0xCC。 当该中断发出时,内核向进程发送一个 SIGTRAP,如前所述,跟踪器被通知。

这很好,但如何我们才能胁迫被跟踪者执行这些指令? 这很简单:利用 ptracePTRACE_POKETEXT 请求,它可以覆盖内存中的一个字。 调试器将使用 PTRACE_PEEKTEXT 读取该位置原来的值并替换为 0xCC ,然后在其内部状态中记录该处原来的值,以及它是一个断点的事实。 下次被跟踪者执行到该位置时,它将被通过 SIGTRAP 信号自动停止。 然后调试器的最终用户可以决定如何继续(例如,检查寄存器)。

好吧,我们已经讲过了断点,那观察点呢? 当一个特定的内存位置被读或写,调试器如何停止程序? 当然你不可能为了能够读或写内存而去把每一个指令都覆盖为 int $3。有一组调试寄存器为了更有效的满足这个目的而被设计出来:

  • DR0DR3:这些寄存器中的每个都包含一个地址(内存位置),调试器因为某种原因希望被跟踪者在那些地址那里停止。 其原因以掩码方式被设定在 DR7 寄存器中。
  • DR4DR5:这些分别是 DR6DR7 过时的别名。
  • DR6:调试状态。包含有关 DR0DR3 中的哪个寄存器导致调试异常被引发的信息。这被 Linux 用来计算与 SIGTRAP 信号一起传递给被跟踪者的信息。
  • DR7:调试控制。通过使用这些寄存器中的位,调试器可以控制如何解释 DR0DR3 中指定的地址。位掩码控制监视点的尺寸(监视1、2、4 或 8 个字节)以及是否在执行、读取、写入时引发异常,或在读取或写入时引发异常。

由于调试寄存器是进程的 USER 区域的一部分,调试器可以使用 PTRACE_POKEUSER 将值写入调试寄存器。调试寄存器只与特定进程相关,因此在进程抢占并重新获得 CPU 控制权之前,调试寄存器会被恢复。

冰山一角

我们已经浏览了一个调试器的“冰山”:我们已经介绍了 ptrace,了解了它的一些功能,然后我们看到了 ptrace 是如何实现的。 ptrace 的某些部分可以用软件实现,但其它部分必须用硬件来实现,否则实现代价会非常高甚至无法实现。

当然有很多我们没有涉及。例如“调试器如何知道变量在内存中的位置?”等问题由于空间和时间限制而尚未解答,但我希望你从本文中学到了一些东西;如果它激起你的兴趣,网上有足够的资源可以了解更多。

想要了解更多,请查看 linux.conf.au 中 Levente Kurusa 的演讲 Let's Write a Debugger!,于一月 22-26 日在悉尼举办。


via: https://opensource.com/article/18/1/how-debuggers-really-work

作者:Levente Kurusa 译者:stephenxs 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出