ptrace冒险(2)

在Habré上已经写过关于通过ptrace拦截系统调用的信息; Alexa撰写了有关此详细文章的文章,我决定将其翻译。


从哪里开始


调试后的程序与调试器之间的通信使用信号进行。 这使本来就很困难的事情大大复杂化。 为了娱乐,您可以阅读man ptrace 的BUGS部分

至少有两种不同的方法可以开始调试:

  1. ptrace(PTRACE_TRACEME, 0, NULL, NULL)将使当前进程的父级为其调试器。 不需要父母的帮助; man温和地建议: “如果进程的父级不希望跟踪此请求,则该进程可能不应发出此请求。” (在其他地方,您是否看到过“可能不应该这个短语?)如果当前进程已经具有调试器,则调用将失败。
  2. ptrace(PTRACE_ATTACH, pid, NULL, NULL)将使当前进程成为pid的调试器。 如果pid已经具有调试器,则调用将失败。 SIGSTOP发送到调试的进程,直到调试器解冻它,它才会继续工作。

这两种方法是完全独立的。 您可以使用任何一个,但是将它们组合在一起没有任何意义。 重要的是要注意PTRACE_ATTACH并不是瞬时的:在调用ptrace(PTRACE_ATTACH)之后,通常调用waitpid(2)等待,直到PTRACE_ATTACH “起作用”。

您可以使用PTRACE_TRACEME在调试下启动子进程,如下所示:

 static void tracee(int argc, char **argv) { if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0) die("child: ptrace(traceme) failed: %m"); /*   ,   . */ if (raise(SIGSTOP)) die("child: raise(SIGSTOP) failed: %m"); /*  . */ execvp(argv[0], argv); /*     . */ die("tracee start failed: %m"); } static void tracer(pid_t pid) { int status = 0; /* ,       . */ if (waitpid(pid, &status, 0) < 0) die("waitpid failed: %m"); if (!WIFSTOPPED(status) || WSTOPSIG(status) != SIGSTOP) { kill(pid, SIGKILL); die("tracer: unexpected wait status: %x", status); } /*      ptrace,    . */ /* *  ,      *  ,      . *    --  API  ptrace! */ /*       PTRACE_SYSCALL. */ } /* (argc, argv) --    ,    . */ void shim_ptrace(int argc, char **argv) { pid_t pid = fork(); if (pid < 0) die("couldn't fork: %m"); else if (pid == 0) tracee(argc, argv); else tracer(pid); die("should never be reached"); } 

如果没有raise(SIGSTOP)调用,可能会发现execvp(3)将在父进程为此准备好之前执行; 然后调试器的操作(例如,拦截系统调用)将不会从过程开始就开始。

启动调试后,每个ptrace(PTRACE_SYSCALL, pid, NULL, NULL)将“解冻”调试的进程,直到第一次进入系统调用,然后直到系统调用离开。

远程汇编器


ptrace(PTRACE_SYSCALL)不向调试器返回任何信息; 他只是保证被调试的进程将在每次系统调用时停止两次。 要获取有关调试过程正在发生的情况的信息(例如,停止在哪个系统调用中),您需要爬入内核以struct user的形式存储在struct user中的寄存器副本,该格式取决于特定的体系结构。 (例如,在x86_64上,呼叫号码将在regs.orig_rax字段中,传递的第一个参数将在regs.rdi ,等等。)Alexa regs.rdi :“感觉就像您正在用C编写与远程处理器的寄存器一起工作的汇编代码。”

代替sys/user.h描述的结构,使用sys/reg.h定义的索引常量可能更方便:

 #include <sys/reg.h> /*    . */ long ptrace_syscall(pid_t pid) { #ifdef __x86_64__ return ptrace(PTRACE_PEEKUSER, pid, sizeof(long)*ORIG_RAX); #else // ... #endif } /*      . */ uintptr_t ptrace_argument(pid_t pid, int arg) { #ifdef __x86_64__ int reg = 0; switch (arg) { case 0: reg = RDI; break; case 1: reg = RSI; break; case 2: reg = RDX; break; case 3: reg = R10; break; case 4: reg = R8; break; case 5: reg = R9; break; } return ptrace(PTRACE_PEEKUSER, pid, sizeof(long) * reg, NULL); #else // ... #endif } 

在这种情况下,从调试器的角度来看,已调试过程的两个停止位置-在系统调用的入口处和在系统调用的出口处-都没有任何不同。 因此调试器本身必须记住每个被调试进程的状态:如果有多个,则没有人保证来自一个进程的一对信号将连续出现。

后裔


ptrace选项之一(即PTRACE_O_TRACECLONE )确保已调试进程的所有子级在退出 fork(2)时都将自动进行调试。 这里的另一个微妙之处在于,用于调试的后代成为调试器的“伪子级”,而waitpid不仅会响应停止“立即子级”,还会停止调试“伪子级”。 Man对此进行了警告: “不建议在调用waitpid(2)时设置WCONTINUED标志:“ continue”状态是按进程运行的,使用该状态可能会使Tracee的真实父项感到困惑。” -即 “假孩子”有两个父母可以等他们停下来。 对于调试器程序员来说,这意味着waitpid(-1)不仅将等待直接子级停止,还将等待任何已调试的进程。

讯号


(来自翻译人员的奖励内容:该信息不在英文文章中)
如开始时已经提到的,已调试程序和调试器之间的通信是使用信号进行的。 当调试器连接到某个进程时,该进程将接收到SIGSTOP ,然后在每次正在调试的进程中发生一些有趣的事情时,例如系统调用或接收外部信号时,都会接收SIGTRAP 。 相应地,每当其中一个被调试的进程(不一定是直接子进程)“冻结”或“冻结”时,调试器就会收到SIGCHLD

通过调用ptrace(PTRACE_SYSCALL) (在第一个信号或系统调用之前)或ptrace(PTRACE_CONT) (在第一个信号之前ptrace(PTRACE_CONT) ptrace(PTRACE_SYSCALL)调试过的过程进行ptrace(PTRACE_SYSCALL) 。 当SIGSTOP/SIGCONT信号也用于与调试无关的目的时, ptrace可能会出现问题:如果调试器“解冻”了接收到SIGSTOP的调试进程,则从外部看,好像该信号被忽略了; 如果调试器未对正在调试的进程进行“解冻”,则外部SIGCONT无法对其进行“解冻”。

现在有趣的部分:Linux禁止进程自行调试 ,但是当父级和子级相互调试时,Linux不会阻止创建循环。 在这种情况下,当一个进程接收到任何外部信号时,它会通过SIGTRAP “冻结”-然后将SIGCHLD发送到第二个进程,并且还会通过SIGTRAP “冻结”。 通过从外部发送SIGCONT不可能使这种“协同调试器”摆脱僵局。 唯一的方法是杀死( SIGKILL )子级,然后父级退出调试并“冻结”。 (如果杀死父母,孩子将与他同死。)如果孩子打开PTRACE_O_EXITKILL选项,则由他调试的父母也将死亡。

现在,您知道如何实现一对进程,当接收到任何信号时,它们将永久冻结并且仅一起死亡。 为什么在实践中可能有必要这样做,我不会解释:-)

Source: https://habr.com/ru/post/zh-CN439882/


All Articles