
在Habré上已经
写过关于通过
ptrace
拦截系统调用的信息; Alexa撰写了有关此详细文章的文章,我决定将其翻译。
从哪里开始
调试后的程序与调试器之间的通信使用信号进行。 这使本来就很困难的事情大大复杂化。 为了娱乐,您可以阅读
man ptrace
的BUGS部分 。
至少有两种不同的方法可以开始调试:
ptrace(PTRACE_TRACEME, 0, NULL, NULL)
将使当前进程的父级为其调试器。 不需要父母的帮助; man
温和地建议: “如果进程的父级不希望跟踪此请求,则该进程可能不应发出此请求。” (在其他地方,您是否看到过“可能不应该 ? ”这个短语?)如果当前进程已经具有调试器,则调用将失败。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); } } 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
选项,则由他调试的父母也将死亡。
现在,您知道如何实现一对进程,当接收到任何信号时,它们将永久冻结并且仅一起死亡。 为什么在实践中可能有必要这样做,我不会解释:-)