当前位置: 首页 > 科技观察

使用Ptrace拦截和模拟Linux系统调用

时间:2023-03-16 14:37:58 科技观察

ptrace(2)(“processtraceprocesstrace”)系统调用通常与调试相关。它是类Unix系统上用于使用本机调试器监视调试进程的主要机制。也是一种常用的实现strace(系统调用跟踪)的方式。使用Ptrace,跟踪器可以暂停被跟踪的进程,检查和设置寄存器和内存,监视系统调用,甚至拦截系统调用。通过拦截功能,意味着跟踪器可以篡改系统调用参数,篡改系统调用的返回值,甚至可以拦截一些系统调用。言外之意是tracker本身可以提供系统调用服务。这非常有趣,因为这意味着跟踪器可以模拟一个完整的外部操作系统,所有这些都由Ptrace实现,而无需内核的任何帮助。问题在于一个进程一次只能由一个跟踪器附加,因此在调试该进程期间不再可能使用GDB等工具来模拟外部操作系统。另一个问题是模拟系统调用的开销非常高。在本文中,我们将重点关注用于x86-64Linux的Ptrace,并将使用一些特定于Linux的扩展。同时,在本文中,我们会忽略一些错误检查,但完整的源代码中仍然会包含这些错误检查。本文的现成示例代码位于:https://github.com/skeeto/ptrace-examplestrace在进入有趣的部分之前,让我们先回顾一下strace的基本实现。它不是DTrace,但strace仍然非常有用。Ptrace从未被标准化。它的界面在不同操作系统之间非常相似,尤其是在核心功能方面,但不同系统之间仍然存在细微差别。ptrace(2)的原型基本上应该如下所示,但具体类型可能有所不同。longptrace(intrequest,pid_tpid,void*addr,void*data);pid是被跟踪进程的ID。虽然一次只能将一个跟踪器附加到进程,但可以附加一个跟踪器来跟踪多个进程。请求字段选择特定的Ptrace函数,例如ioctl(2)接口。对于strace,只需要两个:PTRACE_TRACEME:进程被它的父进程跟踪。PTRACE_SYSCALL:继续跟踪,但在下一个系统调用进入或退出时停止。PTRACE_GETREGS:获取被跟踪进程的寄存器内容的副本。其他两个字段,addr和data,用作所选Ptrace函数的一般参数。通常,可以省略一个或全部,在这种情况下传递零个参数。strace接口本质上是另一个命令的前缀。$strace[straceoptions]program[arguments]一个最小化的strace不需要任何选项,所以它需要做的第一件事是——假设它至少有一个参数——一个fork(2)和exec(2)被跟踪的过程。但是在加载目标程序之前,新进程会通知内核目标程序将继续被其父进程跟踪。被跟踪的进程将被这个Ptrace系统调用挂起。pid_tpid=fork();switch(pid){case-1:/*错误*/FATAL("%s",strerror(errno));case0:/*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);剩下的就是一个简单的无限循环,每个循环捕获一个系统调用。循环体一共有四个步骤:等待进程进入下一次系统调用。打印系统调用的描述。允许系统调用运行并等待返回。输出系统调用返回值。PTRACE_SYSCALL请求用于等待下一个系统调用开始,并等待该系统调用退出。和以前一样,需要一个wait(2)来等待被跟踪的进程进入期望的状态。ptrace(PTRACE_SYSCALL,pid,0,0);waitpid(pid,0,0);当wait(2)返回时,系统调用的系统调用号及其参数被写入进行系统调用的线程的寄存器中。即便如此,操作系统仍然没有为这个系统调用提供服务。这个细节对后续操作很重要。下一步是收集系统调用信息。这是每个系统的架构不同的地方。在x86-64上,系统调用号在rax中传递,参数(最多6个)在rdi、rsi、rdx、r10、r8和r9中传递。这些寄存器由对Ptrace的另一个调用读取,但这里不再需要wait(2),因为被跟踪进程的状态再也不会改变。structuser_regs_structregs;ptrace(PTRACE_GETREGS,pid,0,®s);longsyscall=regs.orig_rax;fprintf(stderr,"%ld(%ld,%ld,%ld,%ld,%ld,%ld)",系统调用,(长)regs.rdi,(长)regs.rsi,(长)regs.rdx,(长)regs.r10,(长)regs.r8,(长)regs.r9);这里有一个警告。由于内核内部使用,系统调用号存储在orig_rax而不是rax中。所有其他系统调用参数都非常简单。接下来是另一个PTRACE_SYSCALL和wait(2),然后是另一个PTRACE_GETREGS来获取结果。结果保存在rax中。ptrace(PTRACE_GETREGS,pid,0,®s);fprintf(stderr,"=%ld\n",(long)regs.rax);这个简单程序的输出也很粗糙。这里的系统调用没有符号名称,所有参数都以数字形式输出,甚至是指向缓冲区的指针。更完整的strace输出将能够知道哪些参数是指针,并使用process_vm_readv(2)从被跟踪进程中读取哪些缓冲区,以便正确输出它们。然而,这些只是系统调用拦截的基础。Syscall拦截假设我们想使用Ptrace来实现类似OpenBSD的pledge(2)的东西,它是一个只使用一组受限的syscalls的进程质押。最初的想法是,许多程序通常都有一个初始化阶段,在这个阶段它们都需要进行大量的系统访问(例如,打开文件、绑定套接字等)。一旦初始化,它们就会进入主循环,在主循环中处理输入并仅使用所需的最少系统调用集。一个进程可以将自己限制在进入主循环之前它需要执行的少数操作。如果程序存在缺陷,则可以利用恶意输入来利用该缺陷。这个承诺可以有效地限制漏洞利用的实现。使用与strace相同的模型,但不是输出所有系统调用,我们可以阻止某些系统调用,或者如果行为不当则简单地终止被跟踪的进程。终止它很容易:只需在跟踪器中调用exit(2)即可。因此,也可以设置终止被跟踪进程。阻止系统调用并允许子进程继续运行只是黑客攻击。棘手的部分是系统调用一旦开始就无法中断。当跟踪器从入口处的wait(2)返回到系统调用时,从头开始停止系统调用的唯一方法是终止被跟踪的进程。但是,我们不仅可以“乱搞”系统调用的参数,还可以改变系统调用号本身,修改为不存在的系统调用。返回时,通过errno中的正常内部信号,我们可以报告“友好”错误消息。for(;;){/*进入下一个系统调用*/ptrace(PTRACE_SYSCALL,pid,0,0);waitpid(pid,0,0);结构user_regs_structregs;ptrace(PTRACE_GETREGS,pid,0,®s);/*这个系统调用是否被允许?*/int阻塞=0;如果(is_syscall_blocked(regs.orig_rax)){阻塞=1;regs.orig_rax=-1;//设置为无效的系统调用ptrace(PTRACE_SETREGS,pid,0,®s);}/*运行系统调用并在退出时停止*/ptrace(PTRACE_SYSCALL,pid,0,0);waitpid(pid,0,0);if(blocked){/*errno=EPERM*/regs.rax=-EPERM;//操作不允许ptrace(PTRACE_SETREGS,pid,0,®s);}}这个简单的例子只是检查系统调用是否违反了白名单或黑名单。他们在这里没有区别,例如允许文件以只读方式而不是读写方式打开(open(2)),允许匿名内存映射但不允许非匿名映射等。但是仍然没有办法动态撤销被跟踪进程的权限。跟踪器如何与被跟踪进程通信?使用人工系统调用!创建一个人工系统调用对于我的类似质押的系统调用——我可以通过调用xpledge()来区分它与真实的系统调用——我将10000设置为它的系统调用号,这是一个非常大的数字,在实际中从未使用过系统调用。#defineSYS_xpledge10000出于演示目的,我还构建了一个非常小的界面,这在实践中不是一个好主意。它与OpenBSD的pledge(2)有一些相似之处,后者使用字符串接口。事实上,设计一个健壮和安全的权限集是相当复杂的,如质押(2)手册页所示。下面是系统调用被跟踪进程的完整接口和实现:#define_GNU_SOURCE#include#defineXPLEDGE_RDWR(1<<0)#defineXPLEDGE_OPEN(1<<1)#definexpledge(arg)syscall(SYS_xpledge,arg)如果传递的参数为0,则只允许一些基本的系统调用,包括那些用于分配内存的调用(例如brk(2))。PLEDGE_RDWR位允许各种读写系统调用(read(2)、readv(2)、pread(2)、preadv(2)等)。PLEDGE_OPEN位启用open(2)。为了防止特权升级的发生,质押()拦截了自己——但这也防止了特权撤销,稍后会详细介绍。在xpledge跟踪器中,我需要检查这个系统调用:/*Handleentry*/switch(regs.orig_rax){caseSYS_pledge:register_pledge(regs.rdi);break;}OS将返回ENOSYS(functionnotyetimplemented),因为它不是真正的系统调用。为此,我在退出时用success(0)覆盖它。/*处理退出*/switch(regs.orig_rax){caseSYS_pledge:ptrace(PTRACE_POKEUSER,pid,RAX*8,0);break;}我写了一个简短的测试程序来打开/dev/urandom并进行读取操作,尝试提交后,再次尝试打开/dev/urandom,并确认它可以读取原始/dev/urandom文件描述符。在没有提交跟踪器的情况下运行会得到以下输出:$./examplefread("/dev/urandom")[1]=0xcd2508c7XPledging...XPledgefailed:Functionnotimplementedfread("/dev/urandom")[2]=0x0be4a986fread("/dev/urandom")[1]=0x03147604进行无效的系统调用不会使应用程序崩溃。它只是失败了,这是一种方便的返回方式。当它在跟踪器下运行时,它输出以下内容:>$./xpledge./examplefread("/dev/urandom")[1]=0xb2ac39c4XPledging...fopen("/dev/urandom")[2]:Operationnotpermittedfread("/dev/urandom")[1]=0x2e1bd1c4提交成功,第二个fopen(3)没有继续,因为跟踪器用EPERM阻止了它。您可以将此想法更进一步,例如,通过更改文件路径或返回错误结果。跟踪器可以通过系统调用将任意路径传递给root来有效地chroot其跟踪的进程以对路径进行chroot。它甚至可以欺骗用户告诉它以root身份运行。事实上,这正是FakerootNG程序所做的。模拟外部系统假设您不满足于仅拦截某些系统调用,而是想拦截所有系统调用。您将拥有一个旨在在其他操作系统上运行的二进制文件,无需系统调用,并且该二进制文件将继续运行。您可以使用我上面描述的方法来管理所有这些。跟踪器可以使用假的代替系统调用号,允许它失败,并为系统调用本身提供服务。但那将是非常低效的。它实质上为每个系统调用执行三种上下文切换:一种在进入时停止,一种使系统调用始终失败,一种在系统调用退出时停止。从2005年开始,Linux版本的PTrace有了针对该技术的更高效的操作:PTRACE_SYSEMU。PTrace仅在每次发出系统调用时停止一次,并且在允许被跟踪进程继续运行之前由跟踪器为系统调用提供服务。对于(;;){ptrace(PTRACE_SYSEMU,pid,0,0);waitpid(pid,0,0);结构user_regs_structregs;ptrace(PTRACE_GETREGS,pid,0,®s);switch(regs.orig_rax){caseOS_read:/*...*/caseOS_write:/*...*/caseOS_open:/*...*/caseOS_exit:/*...*//*...等等...*/}}从任何具有(足够)稳定的系统调用ABI(LCTT译注:ApplicationBinaryInterface)的系统,在相同架构的机器上运行二进制程序时,你只需要PTRACE_SYSEMU跟踪器、加载程序(用于替换exec(2))和二进制文件所需的任何系统库(或仅用于运行静态二进制文件)。事实上,这听起来像是一个有趣的周末项目。