基本上任何使用了一段时间 Linux 的人,最后都会知道并爱上 strace 命令。strace
是系统调用跟踪器,它跟踪程序执行的进入内核以与外面的世界交互的调用。如果你还不熟悉这个令人惊奇的多才多艺的工具,我建议你看一下我的朋友和合作伙伴 Greg Price 的出色的博客 blog post 中关于这一主题的内容,然后再回到这里。
我们都爱 strace,但你是否曾经好奇它是如何工作的呢?它是如何把它自己注入到内核和用户空间程序之间的呢?这篇博客将用大约 70 行 C 代码走查一个小小的 strace
实现。它的功能不会像真的那样好,但在这个过程中,你将了解关于它使用的核心接口所需了解的大部分内容。
在 Linux(还可能在其它一些 UNIX)上 strace
使用了被称为 [ptrace](http://linux.die.net/man/2/ptrace)
的有点神秘的接口,进程追踪接口。ptrace
允许一个进程监视另一个进程的状态,并深入调查(或甚至是控制)它的内部状态。
ptrace
是一个复杂的系统调用,它接收一个神奇的 “request” 首参数,然后依赖于它的值执行完全不同的事情。它通常的原型看起来像这样:
1
2long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
然而,由于不同的 request
值使用剩余的从 0 个到 3 个参数,glibc
中它的原型为可变参数函数,允许一个开发者只列出给定调用所需要的参数个数。
为了使一个进程跟踪另一个,它附到那个进程上,并临时变为那个进程的父进程。当一个进程被 ptrace
d,跟踪器可以请求它的子进程随时在各种事件发生时停下来,比如子进程执行了一个系统调用。当这发生时,内核将以 SIGTRAP
停止子进程。由于此时跟踪器是子进程的父进程,这样它就可以使用标准的 UNIX waitpid
系统调用观察到这一点。
我们的小型 strace
将只支持 strace
的 strace COMMAND
形式(对照 strace -p
),并且我们将只打印系统调用号和返回值 - 不解码名字或参数或任何其它事情。因此一次简单的运行可能看起来像下面这样:
1
2
3
4
5
6
7
8
9
10
11
12
13$ ./ministrace ls … syscall(6) = 0 syscall(54) = 0 syscall(54) = 0 syscall(5) = 3 syscall(221) = 1 syscall(220) = 272 syscall(220) = 0 syscall(6) = 0 syscall(197) = 0 syscall(192) = -1219706880
尽管不是世界上最有用的东西,但它展示了核心的跟踪工具。因此,让我们来看下代码:
1
2
3
4
5
6
7
8
9
10#include <sys/ptrace.h> #include <sys/reg.h> #include <sys/wait.h> #include <sys/types.h> #include <unistd.h> #include <stdlib.h> #include <stdio.h> #include <errno.h> #include <string.h>
我们从必要的头文件开始。sys/ptrace.h
定义了 ptrace
和 __ptrace_request
常量,我们还将需要 sys/reg.h
帮忙解码系统调用。更多相关的内容在后面。其它的你应该都认得出来。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15int do_child(int argc, char **argv); int do_trace(pid_t child); int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "Usage: %s prog argsn", argv[0]); exit(1); } pid_t child = fork(); if (child == 0) { return do_child(argc-1, argv+1); } else { return do_trace(child); } }
我们将从入口点开始。我们检查我们被传入了一个命令,然后我们通过 fork()
创建两个进程 - 一个用于执行被跟踪的程序,而另一个跟踪它。
1
2
3
4
5int do_child(int argc, char **argv) { char *args [argc+1]; memcpy(args, argv, argc * sizeof(char*)); args[argc] = NULL;
子进程从一些琐碎的参数整理开始,这是由于 execvp
想要一个由 NULL
终止的参数数组。
1
2
3
4
5
6ptrace(PTRACE_TRACEME); kill(getpid(), SIGSTOP); return execvp(args[0], args); }
接下来,我们仅执行提供的参数列表,但首先,我们需要启动跟踪进程,以使父进程可以开始在非常早期就开始跟踪新执行的程序。
如果子进程知道它想要被跟踪,它可以执行 PTRACE_TRACEME ptrace
请求,这将启动追踪。此外,这意味着下一个发送给这个进程的信号将停止它并通知它的父进程(通过 wait
),这样父进程就知道要开始跟踪了。因此,在执行了一个 TRACEME
之后,我们 SIGSTOP
我们自己,以使父进程可以通过 exec
调用继续我们的执行。
(你可能已经注意到了,strace COMMAND
输出总是以一个 execve
调用开始。现在你应该已经理解为什么了 —— 实际上,我们打算在 kill
返回后立即开始跟踪,因此我们看到了启动新程序的 execve
调用。)
1
2
3
4
5int wait_for_syscall(pid_t child); int do_trace(pid_t child) { int status, syscall, retval; waitpid(child, &status, 0);
与此同时,在父进程中,我们声明了稍后需要的函数的原型,并开始跟踪。我们立即开始 waitpid
在子进程上,一旦子进程给自己发送了上面的SIGSTOP
,它将返回,并准备好被跟踪。
1
2
3ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESYSGOOD);
我前面提到 ptrace
基本上把子进程上的所有事件都转为 SIGTRAP
。这很不方便,因为它意味着当你看到子进程由于 SIGTRAP
而停止时,没有很好的办法来知道它是由于它可能停止的多种原因中的哪种而停止的。
PTRACE SETOPTIONS
允许我们设置许多选项来控制我们要如何跟踪子进程。这里我们使用它来设置 PTRACE_O_TRACESYSGOOD
,这意味着当子进程由于系统调用相关的原因停止时,我们实际上会看到它以信号号SIGTRAP | 0x80
停止,这样我们可以简单地从其它停止中区分出系统调用导致地停止。由于(出于这个 demo 的目的),我们只关注系统调用,这还是非常方便的。
1
2
3
4while(1) { if (wait_for_syscall(child) != 0) break;
现在我们进入跟踪循环。wait_for_syscall
,在下面定义,将运行子进程直到进入或退出一个系统调用。如果它返回非 0,则子进程已经退出,我们终止循环。
1
2
3
4syscall = ptrace(PTRACE_PEEKUSER, child, sizeof(long)*ORIG_EAX); fprintf(stderr, "syscall(%d) = ", syscall);
否则,尽管,我们知道子进程进入了一个系统调用,这样我们需要解码系统调用号(以及潜在的参数,如果这是一个不那么简单的例子)。PTRACE_PEEKUSER ptrace
请求从子进程的 “user area” 读取一个字的数据,这是一个逻辑区域,它持有它所有的寄存器和其它的内部非内存状态。在 i386 上,系统调用号位于 %eax
。出于各种各样的技术原因,然而,内核在此时已经破坏了子进程的 %eax
,但它在一个不同的偏移量处保存了原始值,ORIG_EAX
,这来自于 sys/regs.h
。
1
2
3if (wait_for_syscall(child) != 0) break;
一旦我们有了系统调用号,我们再次 wait_for_syscall
,这应该会让我们停止在系统调用返回处。
1
2
3
4retval = ptrace(PTRACE_PEEKUSER, child, sizeof(long)*EAX); fprintf(stderr, "%dn", retval);
i386 上的返回值也是在 %eax
中传递的,因此这次我们可以直接读取它,并打印返回值,然后返回到循环的顶部并等待下一次系统调用。
1
2
3
4
5} return 0; }
一旦子进程退出,我们也返回。
1
2
3
4
5int wait_for_syscall(pid_t child) { int status; while (1) { ptrace(PTRACE_SYSCALL, child, 0, 0);
wait_for_syscall
是一个简单的辅助函数。我们使用 PTRACE_SYSCALL
来继续子进程,这允许一个停止的子进程继续执行直到下一次进入或退出一个系统调用。
1
2
3waitpid(child, &status, 0);
然后我们 waitpid
等待有趣的事情发生在子进程身上。
1
2
3
4if (WIFSTOPPED(status) && WSTOPSIG(status) & 0x80) return 0;
由于我们上面设置的 PTRACE_O_SYSGOOD
,我们可以通过检查被停止的子进程是否由一个最高位设置了的信号停止的来探测一个系统调用停止。如果是这样,我们就返回。
1
2
3
4
5
6if (WIFEXITED(status)) return 1; } }
如果子进程退出,我们就完成了;否则,它是因为我们不关心的原因而停止的(例如,execve
),因此我们循环再次启动它,直到它遇到系统调用。
这就是它的全部。如果你想下载并试用,你可以在 github 上找到我刚刚发布的版本。
让它更有用
虽然它可以工作,但我认为以前的版本并不是特别有用。你不得不手动解码系统调用号,且你无法获得任何系统调用参数。
把代码都包含在这篇博客中可能有点长,但我已经把一个稍微更实用的版本发布到了相同的 github 仓库的 master 。它包含一个 Python 脚本来扫描 Linux 源码以提取系统调用号及参数个数和类型,且它知道如何解码字符串参数,以使你可以看到文件名及 read
和 write
的数据。
读取参数很容易 —— 在 i386 上,它们在寄存器中传递,因此,对于每一个参数,只是另一次 PTRACE_GETUSER
。也许最有趣的片段就是 read_string
函数了,它用于从子进程中读取一个 NULL 结尾的字符串。(当然,以 NULL 结尾是不正确的 —— 真正的 strace 知道 read()
和 write()
的 count
参数,比如。但这已经足够做一个 demo 了。)
1
2char *read_string(pid_t child, unsigned long addr) {
read_string
接收一个要读取的子进程的进程 ID,及它打算读取的字符串的地址作为参数:
1
2
3
4
5char *val = malloc(4096); int allocated = 4096, read; unsigned long tmp;
我们需要一些变量。一个拷入字符串的缓冲区,我们已经拷贝的数据及分配的数据的计数器,及一个临时变量用于读取内存。
1
2
3
4
5
6
7while (1) { if (read + sizeof tmp > allocated) { allocated *= 2; val = realloc(val, allocated); }
我们在必要时增加缓冲。我们一次一个字地读取数据。
1
2
3
4
5
6
7tmp = ptrace(PTRACE_PEEKDATA, child, addr + read); if(errno != 0) { val[read] = 0; break; }
PTRACE_PEEKDATA
返回子进程在指定偏移量处的数据工作。因为它使用返回值,所以我们需要检查 errno
来判断它是否失败。如果它失败了(可能由于子进程传递了一个无效的指针),我们仅返回我们截止目前已经获得的字符串,确保在最后添加我们自己的 NULL。
1
2
3
4
5
6memcpy(val + read, &tmp, sizeof tmp); if (memchr(&tmp, 0, sizeof tmp) != NULL) break; read += sizeof tmp;
然后,将我们读到的数据附加起来就很简单了,如果我们发现一个终止 NULL 就跳出循环,否则循环读取另一个字。
1
2
3
4
5} return val; }
【原文】Write yourself an strace in 70 lines of code
最后
以上就是故意书包最近收集整理的关于用 70 行代码给你自己写一个 strace的全部内容,更多相关用内容请搜索靠谱客的其他文章。
发表评论 取消回复