导语:我将在本文重点介绍x86-64上的Linux的Ptrace,并将利用一些特定的Linux扩展。
ptrace (process trace)系统调用通常与调试相关,它是本地调试器监视类unix系统上调试的主要机制,同时也是实现strace系统调用跟踪的常用方法。使用Ptrace,跟踪器可以暂停跟踪、检查和设置寄存器和内存、监视系统调用,甚至拦截系统调用。
通过拦截,跟踪器可以改变系统调用参数,改变系统调用返回值,甚至阻止某些系统调用。这意味着跟踪器可以完全服务于系统调用本身,这特别有趣,因为它还意味着跟踪器可以模拟整个操作系统,而且是在Ptrace之外的内核没有任何特殊帮助的情况下完成的。
不过也有其他问题出现,比如一个进程一次只能连接一个跟踪器,因此不可能在模拟一个操作系统的同时,也用GDB之类的工具对该进程进行调试,还有就是模拟系统调用的成本会更高。
我将在本文重点介绍x86-64上的Linux的Ptrace,并将利用一些特定的Linux扩展。不过为了节省篇幅,我省略了错误检查的过程,但是完整的源代码清单将包含这些检查。
点击以下链接,你可以找到运行代码:https://github.com/skeeto/ptrace-examples
strace
让我先回顾一下strace的一个基本实现过程,按照strace官网的描述, strace是一个可用于诊断、调试和教学的Linux用户空间跟踪器。我用它来监控用户空间进程和内核的交互,比如系统调用、信号传递、进程状态变更等。strace底层使用内核的ptrace特性来实现其功能,它能作为一种动态跟踪工具,能够帮助运维高效地定位进程和服务故障。它像是一个侦探,通过系统调用的蛛丝马迹,告诉你异常的真相。
虽然Ptrace从未被标准化,但它的接口在不同的操作系统中是相似的,特别是在其核心功能上。ptrace原型通常看起来如下所示,尽管具体的类型可能不同。
long ptrace(int request, pid_t pid, void *addr, void *data);
Pid代表tracee的进程ID,虽然tracee一次只能连接一个跟踪器,但是一个跟踪器可以同时连接到许多跟踪器。
请求字段选择一个特定的Ptrace函数,就像ioctl接口一样。对于strace,只需要3个运行参数:
· PTRACE_TRACEME:此进程由其父进程跟踪。
· PTRACE_SYSCALL:继续,但在下一个系统调用入口或出口处停止。
· PTRACE_GETREGS:获取tracee寄存器的副本。
另外两个字段addr和data作为所选Ptrace函数的泛型参数,经常被省略,如果是这样,我可以输入0。
strace接口实质上是另一个命令的前缀:
$ strace [strace options] program [arguments]
由于最简单的strace命令没有任何选项,所以要做的第一件事,就是假设它至少有一个参数是fork和exec,exec是argv末尾的tracee进程。但是在加载目标程序之前,新进程将通知内核它将被它的父进程跟踪,此时这个Ptrace系统调用将暂停tracee。
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等待子进程的PTRACE_TRACEME。当wait返回时,将暂停子进程。
waitpid(pid, 0, 0);
在允许子进程继续之前,我会告诉操作系统应该终止tracee及其父进程。真正的strace实现可能需要设置其他选项,比如PTRACE_O_TRACEFORK。
ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_EXITKILL);
剩下的就是一个简单的循环过程,该过程一次只能捕获一个系统调用。具体步骤如下:
1.等待进程进入下一个系统调用;
2.输出系统调用代理;
3.允许系统调用执行并等待返回;
4.输出系统调用返回值;
PTRACE_SYSCALL请求用于等待下一个系统调用开始,以及等待系统调用退出。前面说过,需要wait来等待tracee进入所需的状态。
ptrace(PTRACE_SYSCALL, pid, 0, 0);waitpid(pid, 0, 0);
当wait返回时,进行系统调用的线程的寄存器中有系统调用号及其参数。但是,此时操作系统还没有为这个系统调用提供服务,这个细节会很重要,稍后再说。
接着是收集系统调用信息,在x86-64上,系统调用号在rax中传递,参数(最多6个)在rdi、rsi、rdx、r10、r8和r9中传递。尽管不需要wait,读取寄存器则是另一个Ptrace调用,因为tracee没有改变状态。
struct user_regs_struct regs;ptrace(PTRACE_GETREGS, pid, 0, ®s);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_SYSCALL和wait,然后是另一个PTRACE_GETREGS来获取结果,结果存储在rax中。
ptrace(PTRACE_GETREGS, pid, 0, ®s);fprintf(stderr, " = %ld\n", (long)regs.rax);
这个输出非常简单,系统调用没有符号名称,并且每个参数都以数字方式输出,即使它是指向缓冲区的指针。而更完整的strace则知道哪些参数是指针,并使用process_vm_readv从tracee中读取这些缓冲区,以便正确地输出它们。
然而,这个简单地输出确实为系统调用拦截打下了一个基础。
系统调用拦截
假设我希望使用Ptrace来实现OpenBSD的pledge,其中一个进程只保证使用一组受限的系统调用。许多程序通常都有一个初始化阶段,其中需要大量的系统访问(打开文件、绑定套接字等)。初始化之后,它们进入一个主循环,在主循环中处理输入信息,此时只需要少量的系统调用。
在进入此主循环之前,进程可以将自身限制为所需的少数操作。如果程序有一个允许被错误输入利用的漏洞,那么一个进程只保证使用一组受限的系统调用的思想,会极大地限制系统调用拦截。
使用相同的strace模型,而不是输出所有系统调用,我可以阻止某些系统调用,或者在tracee发生故障时终止它。终止很容易的,只需在跟踪器中调用exit。因为exit也会终止tracee,所以阻止系统调用并允许子调用继续进行操作是一个比较棘手的问题。
如果一旦系统调用启动,就无法中止它。当跟踪器从系统调用入口处的wait返回时,阻止系统调用发生的惟一方法是终止跟踪器。
然而,我不仅可以篡改系统调用参数,还可以更改系统调用号,将其转换为不存在的系统调用。作为回报,我可以通过正常的带内信令报告errno中的EPERM错误。
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, ®s); /* 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, ®s); } /* 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, ®s); }}
这个简单的示例只检查了白名单或系统调用的黑名单,更多细致的检查还没有,比如为什么允许文件以只读方式打开(open)而不是以可写方式打开,为什么允许匿名内存映射但不允许非匿名映射等等。
创建一个人工系统调用
我将新创建的pledge系统调用,命名为xpledge(),系统调用号为10000,以区别于真正的系统调用。
#define SYS_xpledge 10000
为了演示,我构建了一个非常小的接口,这在实践中并不是很好用。它与使用字符串接口的OpenBSD pledge没有什么共同之处。实际上,设计强大且安全的权限集是非常复杂的。以下是tracee系统调用的整个接口和实现:过程
#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)。PLEDGE_RDWR位允许各种读写系统调用(read、readv、pread、preadv等),PLEDGE_OPEN允许open。
为了防止权限被重新升级,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进行读取的小测试程序,该程序会先尝试pledge,然后再尝试打开/dev/urandom,最后确认它可以从原始的/dev/urandom文件描述符中进行读取。在没有pledge跟踪器的情况下运行时,输出如下:
$ ./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
Pledge虽然是成功的,但是第二个fopen并没有出现,这是因为跟踪器的EPERM阻止了它。
可以进一步设想,假如更改文件路径或返回虚假结果,跟踪器是否可以更改它的跟踪对象,并在通过系统调用的任何路径的根目录中添加一些跟踪路径,甚至欺骗进程,伪装成根用户运行。事实上,这正是Fakeroot NG程序的工作原理。
模拟系统
如果你的目的是拦截所有系统调用,由于已经有一个二进制程序要在另一个操作系统上运行,所以它调用的任何系统都不会工作。
虽然你可以使用我以上所描述的方法来管理所有系统调用,然而跟踪器总是将系统调用号替换为一个假的调用号,为系统调用本身提供服务,这种方式效率低下。自2005年以来, PTrace出现,PTrace每次系统调用只停止一次,并且在允许tracee继续之前由跟踪器来为该系统调用提供服务。
for (;;) { ptrace(PTRACE_SYSEMU, pid, 0, 0); waitpid(pid, 0, 0); struct user_regs_struct regs; ptrace(PTRACE_GETREGS, pid, 0, ®s); switch (regs.orig_rax) { case OS_read: /* ... */ case OS_write: /* ... */ case OS_open: /* ... */ case OS_exit: /* ... */ /* ... and so on ... */ }}
要从具有稳系统调用ABI的任何系统运行相同系统结构的二进制文件,只需使用PTRACE_SYSEMU跟踪器、一个加载程序(取代exec)以及二进制文件所需的任何系统库(只运行静态二进制文件也可以)。