威力巨大的系统调用——ptrace

Welcome!Welcome!欢迎大家来到系统分析章节!在这一章节中,祁祁会向大家介绍各种各样可以用来观察Linux系统行为的利器,这些利器不仅仅是工具,还包括有系统调用,伪文件系统等。古话说得好:“工欲善其事,必先利其器”,在Linux内核的庞大体系中没有工具辅助分析只会让自己陷入一次次的迷茫和自我怀疑中。如果你也想把Linux玩弄在掌心,那么就跟着祁祁一起进入这一章节的学习吧!

这一篇将要介绍的是一个系统调用——ptrace,这个系统调用是Linux程序调试工具gdb以及系统调用追踪器strace的基础,同时,Linux系统中TASK_TRACED进程状态也和这个系统调用有关。

一、前置知识

ptrace系统调用的概念很简单,看看官方手册就知道了。在介绍ptrace系统调用前,因为这是一个系统调用,所以,祁祁首先想要消除你对系统调用的疑问。如果这部分的内容你都比较熟悉,那么可以跳过这部分,直奔ptrace系统调用的学习。

1.1 用户态和内核态

用户态和内核态这两个名词与CPU特权级别分层机制密不可分。什么是CPU特权级别分层机制?简单来说是一种保护数据和系统安全性的手段,防止数据和系统因为用户的错误操作或者破坏者的攻击而出现异常,这种机制也常被称为Protection Rings(CPU保护环)

Protection Rings(CPU保护环)是由CPU硬件本身提供的保护机制,并不是操作系统提供的能力,其所涉及到的特权级别分层本质上是CPU指令集权限分级CPU指令集是CPU实现控制硬件的关键,每一条汇编语句构成一条CPU指令,多个CPU指令构成CPU指令集。CPU指令集是可以直接操作硬件的,为了防止不懂硬件操作的人误操作导致系统异常的问题,CPU对指令集进行了权限设定。我们以x86架构Intel的CPU环为例:

Intel x86架构CPU保护环图

可以看到,该类CPU提供4层权限级别,从Ring0到Ring3

  • Ring0权限最高,可以使用所有CPU指令集,可谓“好无阻拦,肆无忌惮”。
  • Ring3权限最低,只能使用受限的CPU指令集,称之“畏手畏脚,寸步难行”。

讲到这里,到底什么是用户态?什么是内核态呢?这里通过一个简单的银行保险库的例子,来简单介绍一下:

一个银行保险库的例子

首先我们先声明一些背景信息:

角色在Linux中的身份介绍
银行操作系统制定规则:客户不能直接接触保险库,柜台专员可以接触保险库
客户应用程序Linux使用者启动的用户程序
柜台专员内核程序Linux内核程序
保险库系统底层硬件Linux硬件设备,通过驱动程序和内核程序交互

结合背景我们来分析一下上图的两个流程:

场景场景内容在Linux中
1银行不允许客户直接从保险库中取出物件操作系统或者说系统设计者认为,应用程序会产生的系统行为(磁盘读写,内存管理等)是不可靠的,因此,不能让应用程序直接进行这些操作。
2客户寻找到柜台专员,由柜台专员从保险库取出物件并交给客户操作系统允许内核程序直接操作系统底层硬件,因为内核程序往往是可靠的。而应用程序想要操作系统底层硬件则只能通过由内核程序代为处理,并将返回值返回给应用程序。

通过上面的例子,我们可以得出下面两个结论:

  • 用户态是运行应用程序的地方,其所对应的CPU权限等级是Ring3,其能够执行的CPU指令集是有限的,只能完成一些简单的操作(比如,客户可以自己去ATM机取钱,银行没有限制)
  • 内核态是运行内核程序的地方,其所对应的CPU权限等级是Ring0,其能够使用所有的CPU指令集,能操作系统中的所有数据。

至此,相信对于用户态和内核态这两个概念你已经有了足够的了解。也明白一个事实:用户态不能直接操作系统硬件资源,只能通过内核态来代为完成。那么有一个问题:用户态怎样让内核态代为完成的呢?答案就是系统调用

1.2 系统调用

在上一小节中,我通过一个银行保险库的例子为你简单介绍了用户态和内核态的概念。在第二个流程中我们知道,客户可以通过柜台专员来获取保险库中的物件。实际上,我们将柜台专员比做内核程序而非系统调用,而系统调用我们可以理解成柜台专员的办公室房门,客户敲门让柜台专员替其取出物件,就像用户程序使用系统调用让内核程序替其完成高权限操作一样。

下面通过一个示例来看一下怎么使用系统调用。如果你曾通过C语言读取过文件,那么下面的代码你应该不陌生。下面是示例代码:

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main()
{
  int fd = open("/root/test.txt",  O_CREAT, O_RDONLY);
  char buf[1024]={"\0"};
  int len = read(fd, buf, 1024);
  printf("%s\n", buf);
  close(fd);
  return 0;
}

可以看到,上面代码中调用open()read()以及close()三个函数完成了一次读取文件内容的操作。这三个函数都是Linux提供的系统调用:为用户态的程序提供了操作存储在磁盘上的文件的入口。就像银行柜台专员能帮你取保险库里面的东西,系统调用能提供给用户态程序一个操作系统资源的入口

用户态的程序如果想要操作系统底层硬件,就只能通过系统调用让位于内核态的程序来帮助其完成系统底层硬件的操作,待内核态程序处理完成之后再将结果返回给用户态程序。用户态、系统调用、内核态和系统底层硬件之间的关系如下图所示:

用户态、系统调用、内核态和系统底层硬件之间关系图

稍微拓展一下下,由系统调用可以牵扯出一个概念——CPU上下文切换CPU上下文切换指的是CPU处理过程中的CPU寄存器和程序计数器pc数值的切换。1次系统调用会触发2次CPU上下文切换。哪两次?

  • 第一次:从用户态切换内核态,此时要对用户态的程序进行现场保存等一系列操作;
  • 第二次:内核态处理完后,从内核态返回用户态,此时要对用户态的程序进行现场恢复等一系列操作。

有了上述的前置知识,我们现在可以开始ptrace()系统调用的学习了。

二、ptrace()

ptrace,即process trace,从其名字上就能看出,ptrace()系统调用能提供追踪进程执行状态的功能。根据官方手册的介绍:ptrace()系统调用为一个进程提供了观察控制另一个进程的执行过程的能力,同时也提供检查改变另一个进程的内存值以及相关注册信息。其中,被控制的进程被称为tracee,控制进程被称为tracer

ptrace()官方手册介绍

ptrace能提供的功能,我只能说:牛逼!如果你不这么觉得,那我告诉你,Linux最常用的程序调试工具gdb以及我们后面会介绍的用来追踪系统调用的工具strace都是基于ptrace系统调用实现的。此外,ptrace系统调用也常被用在逆向工程里面。

2.1 函数签名

ptrace()系统调用的函数签名如下:

#include <sys/ptrace.h>       
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

对函数签名中的四个参数做如下说明:

  • request:要执行的操作类型;
  • pid:被追踪的目标进程ID;
  • addr:被监控的目标内存地址;
  • data:保存读取出或者要写入的数据。

对函数签名这里先简单了解即可,后续会有示例代码,到时候我们结合示例代码来使用ptrace系统调用。

2.2 进程状态之中止状态

在Linux系统中,进程常见的状态有下面一些:

  • S:Interruptible Sleeping,即可中断睡眠;
  • D:Uninterruptible Sleeping,即不可中断睡眠;
  • R:Running or Runnable,即运行状态;
  • Z:Zombie,即僵尸状态;
  • T:Stopped or Traced,即中止状态(注意是“中止”而非“终止”)。

这里,我们关注点放在T:Stopped or Traced这个类型上。因为Traced类型是由ptrace系统调用提供的一个进程状态。实际上,在某些Linux发行版中,这个类型的进程状态标识符是t而非T

2.2.1 Stopped状态

怎样让一个进程进入中止状态呢?举个例子,假设程序在运行过程中,现在要求用户输入,而有些时候,用户并不想立即输入,这时用户可以通过control + z的键盘组合键来中止这个输入的操作,让这个进程变成中止状态

  • 运行程序:
[root@localhost ~]# cat input.c
#include <stdio.h>

int main(void){
  int num = 0;
  printf("Input a number: ");
  scanf("%d", &num);


  printf("Number is %d\n", num);
  return 0;
}
[root@localhost ~]# gcc -o input input.c
[root@localhost ~]# ./input
Input a number:
  • 中止前进程状态:
[root@localhost ~]# ps -aux | grep input
root      7608  0.0  0.0   4220   356 pts/0    S+   11:20   0:00 ./input
  • 键入control + z后再查看进程状态:
[root@localhost ~]# ./input
Input a number: ^Z
[1]+  已停止               ./input
[root@localhost ~]# ps -aux | grep input
root      7608  0.0  0.0   4220   356 pts/0    T    11:20   0:00 ./input
  • 通过fg命令恢复进程状态:
[root@localhost ~]# fg
./input
123
Number is 123
  • 再查看进程状态,会发现刚才运行的程序已经退出了:
[root@localhost ~]# ps -aux | grep input

2.2.2 Traced状态

通过ptrace系统调用可以让一个进程进入Traced状态。我们结合ptrace函数签名来结合看一下:

#include <sys/ptrace.h>       
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

相关参数这里不再赘述。让一个进程进入Traced状态可以通过两种方式:

  • tracee进程调用ptrace系统调用,并在request参数处传递PTRACE_TRACEME这个值,表示想要被tracer进程追踪。通过这种方式的进程想要进入Traced状态有两种方式:
    • 主动调用exec系列的系统调用;
    • tracer发送进入Traced状态的相关信号。
  • tracer进程调用ptrace系统调用,并在request参数处传递PTRACE_ATTACH这个值,并给出tracee进程的pid,从而让tracee进程进入Traced状态。

如果你使用过strace,相信你肯定清楚strace工具是可以对已经在运行的进程进行追踪的,这里strace就是通过PTRACE_ATTACH的方式让目标进程进入Traced状态的。

三、示例代码

3.1 新手版本

首先我们从最简单的版本开始介绍ptrace系统调用的使用。下面的示例代码中,实现了通过ptrace系统调用来跟踪程序使用系统调用情况的功能。下面是示例代码:

#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <sys/types.h>
#include <stdio.h>
#include <sys/reg.h>

int main(void){
    pid_t child;
    long orig_rax;
    child = fork();
    if (child == 0){
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "ls", "-l", "-h", NULL);
    } else {
        wait(NULL);
        orig_rax = ptrace(PTRACE_PEEKUSER, child, 8 * ORIG_RAX, NULL);
        printf("Child process called a system call, id is %ld\n", orig_rax);
        ptrace(PTRACE_CONT, child, NULL, NULL);
    }

    return 0;
}

新手指南的好坏往往会直接影响新手对新知识的接受程度。所以为了能让大家对ptrace不留疑问,我这里对新手版本的代码进行详尽的讲解。搬好小板凳,小葵花宝宝课堂要开课啦~

3.1.1 创建子进程

创建子进程的代码块如下:

#include <unistd.h>
int main(void){
    pid_t child;
    child = fork();
    if (child == 0){
        // child process code block
    } else {
        // parent process code block
    }
    return 0;
}

上述代码通过fork()系统调用为当前进程创建子进程。父进程调用fork()系统调用后,会在同一位置创建一个一摸一样的子进程。也就是说,父进程在运行完child = fork()这条语句后,系统中就已经存在两个进程,他们的关系如下:

  • 父进程:返回的child值是子进程的pid;
  • 子进程:返回的child值是0。

两个进程都接收到了相应的child值后,根据紧接着的if...else...来执行各自的代码块。很显然,child == 0的代码块对应是子进程的内部逻辑。

3.1.2 跟踪子进程 —— 子进程部分

跟踪子进程的代码块如下:

#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <sys/types.h>
#include <stdio.h>
#include <sys/reg.h>

int main(void){
    pid_t child;
    child = fork();
    if (child == 0){
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "ls", "-l", "-h", NULL);
    } else {
        wait(NULL);
        // control child process
        ptrace(PTRACE_CONT, child, NULL, NULL);
    }
    return 0;
}

这一小节我们先聚焦在子进程代码块的部分:调用ptrace系统调用并传入PTRACE_TRACEME作为其操作类型,表示希望父进程对其进行追踪。并紧随其后调用execl系统调用来执行命令。看似简单的两条语句却蕴含着大大的内涵。因此这里需要深入来了解一下其工作流程:

  • ptrace系统调用语句:

代码块中的第一行ptrace系统调用执行完成后,子进程并不会进入Traced状态。之前我们有提到,子进程只有接收到相关信号后才会进入Traced状态。这里我们可以修改相关代码验证一下,修改代码部分如下:

if (child == 0){
    printf("Child process id: %d\n", getpid());
    ptrace(PTRACE_TRACEME, 0, NULL, NULL);
    sleep(10);
    execl("/bin/ls", "ls", "-l", "-h", NULL);
}

运行过程中,我们查看子进程状态:

[root@localhost ~]# ./ptrace_simple
Child process id: 22792

# 另一个窗口
[root@localhost ~]# ps -aux | grep 22792
root     22792  0.0  0.0   4216    88 pts/1    S+   15:09   0:00 ./ptrace_simple

可以看到子进程其实是Interruptible Sleeping(S)状态。也就是说,子进程即使调用ptrace并进入PTRACE_TRACEME状态是不会立马进入Traced状态的。而是通过使用execl系统调用来进入Traced状态。

  • execl系统调用语句:

execl语句可以将当前进程替换成一个新进程。在本例中,execl("/bin/ls", "ls", "-l", "-h", NULL);语句将原本的子进程替换成了一条ls命令。值得注意的是,如果execl系统调用的进程处于PTRACE_TRACEME状态的话,就会发送一个SIGTRAP信号给父进程,并让自身处于Traced状态。

SIGTRAP是一个信号,用于表示调试程序中的断点(breakpoint)。它是由程序中的断点触发或者由调试器发送给正在运行的程序的。它的含义是停止执行程序,以便进行调试操作。

这里我们验证一下execl对一个PTRACE_TRACEME状态下的进程的效果。修改后的代码如下:

#include <unistd.h>
#include <sys/ptrace.h>
#include <stdio.h>

int main(void){
    pid_t child;
    long orig_rax;
    child = fork();
    if (child == 0){
        printf("Child process id: %d\n", getpid());
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        sleep(10);
        execl("/bin/ls", "ls", "-l", "-h", NULL);
    } else {
        sleep(30);
    }
    return 0;
}

代码运行结果如下:

[root@localhost ~]# ps -aux | grep 30926
root     30926  0.0  0.0   4216    88 pts/1    S+   15:32   0:00 ./execl_example

# after 10 seconds...
[root@localhost ~]# ps -aux | grep 30926
root     30926  0.0  0.0    404     4 pts/1    t+   15:32   0:00 ls -l -h

可以发现,原本的子进程从./execl_example变成了ls -l -h,且复用一个PID。而且新的进程进入了t+(Traced stopped)状态,在等待tracer进程对其进行控制。

3.1.3 跟踪子进程 —— 父进程部分

介绍完子进程部分后,我们有两个疑问:

  • 父进程怎样向子进程发送信号?
  • execl系统调用给父进程发送SIGTRAP信号后,父进程怎样处理这个信号?

在这部分都会得到解答,到这部分,我们已经可以将所有的代码统一起来看了,下面是所有代码内容:

#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <sys/types.h>
#include <stdio.h>
#include <sys/reg.h>

int main(void){
    pid_t child;
    long orig_rax;
    child = fork();
    if (child == 0){
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "ls", "-l", "-h", NULL);
    } else {
        wait(NULL);
        orig_rax = ptrace(PTRACE_PEEKUSER, child, 8 * ORIG_RAX, NULL);
        printf("Child process called a system call, id is %ld\n", orig_rax);
        ptrace(PTRACE_CONT, child, NULL, NULL);
    }

    return 0;
}
  • wait系统调用:

wait系统调用是一个用来进行进程控制的系统调用,它可以用来阻塞父进程,当父进程接收到子进程传来信号或者子进程退出时,父进程才会继续运行。所以这里的wait系统调用很显然用来接收子进程调用execl时产生的SIGTRAP信号。

父进程接收到SIGTRAP信号,就意味着子进程执行execl系统调用已成功。也就意味着现在子进程已经进入了Traced状态,在等待父进程对其进行控制。

  • ptrace(PTRACE_PEEKUSER, child, 8 * ORIG_RAX, NULL)表达式:

父进程这里通过调用ptrace系统调用并使用PTRACE_PEEKUSER作为操作类型,这个操作类型的作用官方是这样描述的:读取tracee进程的USER字段中相关偏移量位置的值。 那么这里又引入一个新的名词——USER字段。

USER字段存放了一个进程的寄存器信息和其他信息,其定义在sys/user.h文件中,这里我们结合示例中的代码来看一下我们取的是什么值。相关代码如下:

#include <sys/user.h>
#include <sys/types.h>
#include <sys/reg.h>

long orig_rax;
orig_rax = ptrace(PTRACE_PEEKUSER, child, 8 * ORIG_RAX, NULL);

这部分代码中,有一个宏定义ORIG_RAX,这个宏定义在sys/reg.h文件中,我们可以看一下这个值对应的是什么内容:

# define ORIG_RAX 15

嗯~我们看到这是一个15的值,那这个15代表着什么呢?这时我们要结合USER字段的结构来分析了,USER字段的部分代码如下:

struct user_regs_struct
{
  __extension__ unsigned long long int r15;
  __extension__ unsigned long long int r14;
  __extension__ unsigned long long int r13;
  __extension__ unsigned long long int r12;
  __extension__ unsigned long long int rbp;
  __extension__ unsigned long long int rbx;
  __extension__ unsigned long long int r11;
  __extension__ unsigned long long int r10;
  __extension__ unsigned long long int r9;
  __extension__ unsigned long long int r8;
  __extension__ unsigned long long int rax;
  __extension__ unsigned long long int rcx;
  __extension__ unsigned long long int rdx;
  __extension__ unsigned long long int rsi;
  __extension__ unsigned long long int rdi;
  __extension__ unsigned long long int orig_rax;
  __extension__ unsigned long long int rip;
  __extension__ unsigned long long int cs;
  __extension__ unsigned long long int eflags;
  __extension__ unsigned long long int rsp;
  __extension__ unsigned long long int ss;
  __extension__ unsigned long long int fs_base;
  __extension__ unsigned long long int gs_base;
  __extension__ unsigned long long int ds;
  __extension__ unsigned long long int es;
  __extension__ unsigned long long int fs;
  __extension__ unsigned long long int gs;
};

struct user
{
  struct user_regs_struct	regs;
  // other fields
}

可以看到,user这个结构体的第一个字段就是所有寄存器的信息,即user_regs_struct结构体。而放眼望去整个user_regs_struct结构体全都是unsigned long long int类型的成员。我们再结合ptrace系统调用看看:

orig_rax = ptrace(PTRACE_PEEKUSER, child, 8 * ORIG_RAX, NULL);

可以看到,addr字段我们传的是8 * ORIG_RAX,其中8代表每个成员的大小(long long int在64位系统中所占用的大小),而ORIG_RAX(15)刚好对应在user_regs_struct字段中的orig_rax成员。到这里相信你能够理解这个地址所代表的含义了。那么ORIG_RAX这个寄存器里面存的是啥呢?答案是系统调用号

我们可以执行一下整个程序,下面是控制台输出内容:

[root@localhost ~]# ./ptrace_simple
Child process called a system call, id is 59
[root@localhost ~]# 总用量 96K
-rwxr-xr-x 1 root root 8.5K 828 15:48 execl_example
-rw-r--r-- 1 root root  360 828 15:48 execl_example.c
-rwxr-xr-x 1 root root 8.4K 828 11:20 input
-rw-r--r-- 1 root root  151 828 11:20 input.c
-rwxr-xr-x 1 root root 8.3K 828 16:21 long_long
-rw-r--r-- 1 root root  124 828 16:21 long_long.c
-rwxr-xr-x 1 root root 8.7K 828 10:58 ptrace_debug
-rw-r--r-- 1 root root 2.5K 828 11:08 ptrace_debug.c
-rwxr-xr-x 1 root root 8.5K 828 16:30 ptrace_simple
-rw-r--r-- 1 root root  646 828 16:30 ptrace_simple.c
-rwxr-xr-x 1 root root 8.6K 825 11:34 wait
-rw-r--r-- 1 root root  538 825 11:34 wait.c

我们可以看到,我这里拿到了子进程调用的第一个系统调用号59。如果你是64位的系统,那么可以在/usr/include/asm/unistd_64.h文件中查看59号系统调用是哪个函数:

[root@localhost ~]# grep "59" /usr/include/asm/unistd_64.h
#define __NR_execve 59
#define __NR_adjtimex 159
#define __NR_mknodat 259

可以看到,对应的是execve系统调用,这是符合预期的,因为execl底层就是execve系统调用。至此,我们算是弄清楚了父进程获取子进程运行状态信息的一个大致流程了。

  • ptrace(PTRACE_CONT, child, NULL, NULL)表达式:

父进程这里通过调用ptrace系统调用并使用PTRACE_CONT作为操作类型,这个操作类型的作用官方是这样描述的:恢复处于Traced状态的tracee进程。最后一个参数表示发送给tracee进程的信号。

执行到这条语句后,tracee进程便恢复其正常的运行中,不再中止。

3.1.4 小结

新手版本虽然代码量很少,但的确涉及到了比较多的知识点,这里做一个小总结:

  • 一个进程可以通过fork系统调用创建一个子进程,且其运行位置是一样的。通常通过if...else...语句来分别执行父进程和子进程的逻辑;
  • 子进程调用ptrace(PTRACE_TRACEME, ...)变成tracee进程后,通过使用execl系统调用会进入Traced状态并发出一个SIGTRAP信号给父进程;
  • 父进程通过wait等待子进程SIGTRAP信号,收到信号后则结束阻塞状态;
  • 通过PTRACE_PEEKUSER可以获取tracee进程的user结构体数据,该结构体存放进程运行时相关寄存器信息和其他信息,其中orig_rax寄存器存放系统调用号
  • 通过PTRACE_CONT可以恢复tracee进程的运行,结束其中止状态(Traced -> Running)。

3.2 入门版本

新手版本中我们大致了解了ptrace系统调用的使用方式以及它能够做的事情了。入门版本我们在新手版本的代码上做一些新功能的扩展。提供显示某个程序执行过程中所有涉及到的系统调用的功能,代码如下:

#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/reg.h>
#include <stdio.h>
#include <sys/wait.h>

int main(int argc, char **argv){
  pid_t child = fork();
  int status = 0;
  long orig_rax = 0;
  if (child == 0){
    ptrace(PTRACE_TRACEME, 0, NULL, NULL);
    execl("/bin/ls", "ls", "-l", "-h", NULL);
  } else {
    while(1){
      wait(&status);
      printf("Got signal %d\n", WSTOPSIG(status));
      if(WIFEXITED(status)) break;

      orig_rax = ptrace(PTRACE_PEEKUSER, child, 8 * ORIG_RAX, NULL);
      printf("Program called system call: %ld\n", orig_rax);
      ptrace(PTRACE_SYSCALL, child, NULL, NULL);
    }
  }
  return 0;
}

入门版本的代码其实就是在新手版本的基础上,添加了while循环结构,并使用PTRACE_SYSCALL作为循环体最后一行中ptrace系统调用的操作类型。下面我们还是对这份代码做一个简单介绍。

3.2.1 wait(&status)

与新手版本的代码中不同的是,这里我们定义了一个int status = 0,用来接收每一次wait结束阻塞时接收到的信号。wait系统调用的函数签名如下:

pid_t wait(int *_Nullable wstatus);

wait系统调用的wstatus参数是一个输出型参数,如果关心信号类型,可以设置一个指针变量,反之,如果不关心具体的信号是什么,设置成NULL即可。

3.2.2 sys/wait.h 宏定义

在我们通过wait(&status)获取到接收到的信号之后,我们可以通过sys/wait.h中定义的若干宏来对这个信号值进行处理,在上述代码中:

  • WSTOPSIG宏可以用来获取信号对应的编号,具体的编号可以根据kill -l展示出来的信号编号进行比对,比如编号为5的信号对应的是SIGTRAP信号:
[root@localhost ~]# kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX
  • WIFEXITED宏可以用来检测接收到的信号是否标志着子进程退出。其宏定义为:
# in sys/wait.h
# define WIFEXITED(status)	__WIFEXITED (__WAIT_INT (status))

# in bits/waitstatus.h
#define	__WIFEXITED(status)	(__WTERMSIG(status) == 0)

也就是说,如果接收到的信号编号为0,就意味着子进程退出。

3.2.3 PTRACE_SYSCALL

在这个版本的代码中我们不再使用新手版本的PTRACE_CONT作为操作对象,而是使用PTRACE_SYSCALL其与PTRACE_CONT有如下的关系:

  1. PTRACE_CONT功能类似,使子进程继续执行,其最后一个参数也和PTRACE_CONT一样,表示是否发送相应信号给子进程。
  2. 发生system call相关的事件 (这里所说的相关的事件指:system call开始system call结束) 时子进程需要通知父进程。要注意的是每次子进程被暂停后都需要重新调用PTRACE_SYSCALL以便下一次的 system call事件会被捕捉到。

根据PTRACE_SYSCALL的功能描述,我们通过一个while循环体来接收子进程每一次system call发出的信号,并在处理完成后再次通过PTRACE_SYSCALL来捕获下一次system call的信号,并当子进程退出时结束循环。

3.2.4 运行结果

运行入门版本的代码,会得到下面的结果:

[root@test-test-biz-hzmedia-worker-192-168-100-74 examples]# ./simple_strace
Got signal 5
Program called system call: 59
Got signal 5
Program called system call: 12
Got signal 5
Program called system call: 12
Got signal 5
Program called system call: 9
Got signal 5
Program called system call: 9
# ... ignore some output
Got signal 5
Program called system call: 4
Got signal 5
Program called system call: 4
Got signal 5
Program called system call: 1
-rw-r--r-- 1 root root 1.9K 828 17:00 monitor_signal.c
Got signal 5
Program called system call: 1
# ... ignore some output

根据这份运行结果,可以得出这份代码的如下特征:

  • 第一个系统调用的编号为59,对应是execve系统调用,这是子进程运行的第一个系统调用;
  • 除第一个系统调用外的其他系统调用都成对出现,比如12 129 9。这是因为PTRACE_SYSCALL会让子进程在每次系统调用进入和退出的时候都发出信号。
  • 父进程wait系统调用每次接收到的信号都是5)SIGTRAP

接下来,我们根据上面的特征来优化入门版本的代码。

3.3 入门进阶版本

接下来,我们要慢慢优化入门版本的代码了,让它提供更多的信息而不仅仅是打印一些系统调用号和信号编号。在这里我们根据入门版本中给出的代码特征来一一优化。

3.3.1 第一个系统调用

往往tracee进程都会通过ptrace + execve的方式将自身转变为一个处于中止状态的进程,而tracer进程往往会通过wait系统调用来接收由tracee进程发出的SIGTRAP信号。因此在这种场景下,第一个信号一定是对应execve这个系统调用的。而这并不是我们想要追踪的进程的系统调用。所以我们忽略这第一个信号:

#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/reg.h>
#include <stdio.h>
#include <sys/wait.h>

int main(int argc, char **argv){
  pid_t child = fork();
  int status = 0;
  long orig_rax = 0;
  if (child == 0){
    ptrace(PTRACE_TRACEME, 0, NULL, NULL);
    execl("/bin/ls", "ls", "-l", "-h", NULL);
  } else {
  
    wait(&status);
    ptrace(PTRACE_SYSCALL, child, NULL, NULL);

    while(1){
      // code block
    }
  }
  return 0;
}

我们在进入while循环体之前,通过wait系统调用先接受第一个信号,并通过PTRACE_SYSCALL的方式通知tracee进程发送所有有关系统调用的信号给到父进程。随后再进入循环体。

3.3.2 成对出现的系统调用

在入门版本中,我们会看到程序的输出中均已成对的系统调用出现,类似下面:

Got signal 5
Program called system call: 1
-rw-r--r-- 1 root root 1.9K 828 17:00 monitor_signal.c
Got signal 5
Program called system call: 1

我们不妨去看一下,系统调用号为1对应的是哪个系统调用:

[root@localhost ~]# egrep " 1$" /usr/include/asm/unistd_64.h
#define _ASM_X86_UNISTD_64_H 1
#define __NR_write 1

我们结合控制台输出来解释一下程序当时的行为:

  1. Program called system call: 1:程序开始调用write系统调用,准备向控制台写入数据;
  2. -rw-r--r-- 1 root root 1.9K 8月 28 17:00 monitor_signal.c:程序写入数据;
  3. Program called system call: 1:程序退出write系统调用。

那么,我们是不是可以充分利用PTRACE_SYSCALL的特性,将程序开始进行系统调用和结束系统调用时的相关信息打印出来?当然可以!于是便有了优化的方向,优化代码如下:

#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/reg.h>
#include <stdio.h>
#include <sys/wait.h>
#include <sys/syscall.h>

int main(int argc, char **argv){
  pid_t child = fork();
  int status = 0;
  long orig_rax = 0;
  long rax = 0;
  int insyscall = 0;
  long params[3];
  if (child == 0){
    ptrace(PTRACE_TRACEME, 0, NULL, NULL);
    execl("/bin/ls", "ls", "-l", "-h", NULL);
  } else {
    // ignore the first SIGTRAP signal
    wait(&status);
    ptrace(PTRACE_SYSCALL, child, NULL, NULL);

    // enter loop
    while(1){
      wait(&status);
      if(WIFEXITED(status)) break;

      orig_rax = ptrace(PTRACE_PEEKUSER, child, 8 * ORIG_RAX, NULL);

      // ** PART 1 **
      if (orig_rax != SYS_write) {
        ptrace(PTRACE_SYSCALL, child, NULL, NULL);
        continue;
      }
      // ** PART 1 END **

      printf("Got signal %d\n", WSTOPSIG(status));

      // ** PART 2 **
      /* Syscall entry */
      if(insyscall == 0) {
        insyscall = 1;
        params[0] = ptrace(PTRACE_PEEKUSER, child, 8 * RDI, NULL);
        params[1] = ptrace(PTRACE_PEEKUSER, child, 8 * RSI, NULL);
        params[2] = ptrace(PTRACE_PEEKUSER, child, 8 * RDX, NULL);
        printf("Write called with %ld, %ld, %ld\n", params[0], params[1], params[2]);
      } else {
      /* Syscall exit */
        rax = ptrace(PTRACE_PEEKUSER, child, 8 * RAX, NULL);
        printf("Write returned with %ld\n", rax);
        insyscall = 0;
      }
      // ** PART 2 END **

      ptrace(PTRACE_SYSCALL, child, NULL, NULL);
    }
  }

  return 0;
}

相比之前的代码,这份代码多出了两个部分的优化:只捕捉write系统调用显示write系统调用入参情况以及返回值。下面分别来解释说明一下优化内容:

  • 只捕捉write系统调用:利用sys/syscall.h中定义的系统调用号和我们从程序user结构体中获取的系统调用号比对。在sys/syscall.h中,write系统调用的定义为SYS_write这个宏。代码中通过if (orig_rax != SYS_write)来完成比对。

if (orig_rax != SYS_write)条件满足后,不能直接通过continue;跳过循环体,还需要向子进程发送追踪信号才行,不然子进程不会继续,父进程也会一直阻塞。这也是条件判断代码块中存在ptrace(PTRACE_SYSCALL, child, NULL, NULL);语句的原因。

  • 分别处理系统调用进入和退出:我们已经知道了,系统调用是成对出现的。因此在捕捉到write系统调用时,我们通过一个insyscall变量来标识这个系统调用是位于进入状态还是退出状态的。
  • 显示write系统调用入参情况:我们可以从ORIG_RAX这个寄存器中获取系统调用号,通过同样的方式,我们可以通过RDIRSIRDX寄存器分别获取系统调用的第1、2、3个参数。

实际上,Linux为64位机器提供了6个保存参数的寄存器,按照顺序他们分别是:RDI、RSI、RDX、RCX、R8和R9。

  • 显示write系统调用返回值:当insyscall变量为1时,说明程序已经进入系统调用,接下来的一次系统调用行为就是退出系统调用。这时,我们通过获取RAX寄存器中的值,可以获取系统调用的返回值。

ORIG_RAX寄存器保存系统调用号,RAX寄存器保存系统调用返回值。

3.3.3 子进程产生的信号一直是5) SIGTRAP

在入门版本的输出中,输出的Got signal内容一直都是5这个信号,也就是SIGTRAP。这个信号只能告诉tracer进程:tracee进程现在处于中止状态,等待tracer进程对其进行控制,而并不能告诉tracer进程到底是什么原因导致tracee进程进入中止状态的。那么问题来了:怎么让tracer进程知道tracee进入中止状态是不是由于其系统调用的行为导致的呢?

ptrace系统调用为我们提供了判别方式:通过PTRACE_SETOPTIONS操作传递PTRACE_O_TRACESYSGOODtracee进程,从而让tracee进程发送给tracer进程的信号编号(signal code)由5) SIGTRAP变成5 | 0x80,也就是133

0x80是操作系统规定的属于系统调用的中断号。

有关PTRACE_SETOPTIONS这个操作类型的说明可以详看官方手册。加上这部分优化后完整的代码如下:

#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/reg.h>
#include <stdio.h>
#include <sys/wait.h>
#include <sys/syscall.h>

int main(int argc, char **argv){
  pid_t child = fork();
  int status = 0;
  long orig_rax = 0;
  long rax = 0;
  int insyscall = 0;
  long params[3];
  if (child == 0){
    ptrace(PTRACE_TRACEME, 0, NULL, NULL);
    execl("/bin/ls", "ls", "-l", "-h", NULL);
  } else {
    // ignore the first SIGTRAP signal
    wait(&status);
    ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESYSGOOD);
    ptrace(PTRACE_SYSCALL, child, NULL, NULL);
    
    // enter loop
    while(1){
      wait(&status);
      if(WIFEXITED(status)) break;

      if (! (WSTOPSIG(status) & 0x80)) {
        ptrace(PTRACE_SYSCALL, child, NULL, NULL);
        continue;
      }

      orig_rax = ptrace(PTRACE_PEEKUSER, child, 8 * ORIG_RAX, NULL);

      if (orig_rax != SYS_write) {
        ptrace(PTRACE_SYSCALL, child, NULL, NULL);
        continue;
      }

      printf("Got signal %d\n", WSTOPSIG(status));

      /* Syscall entry */
      if(insyscall == 0) {
        insyscall = 1;
        params[0] = ptrace(PTRACE_PEEKUSER, child, 8 * RDI, NULL);
        params[1] = ptrace(PTRACE_PEEKUSER, child, 8 * RSI, NULL);
        params[2] = ptrace(PTRACE_PEEKUSER, child, 8 * RDX, NULL);
        printf("Write called with %ld, %ld, %ld\n", params[0], params[1], params[2]);
      } else {
      /* Syscall exit */
        rax = ptrace(PTRACE_PEEKUSER, child, 8 * RAX, NULL);
        printf("Write returned with %ld\n", rax);
        insyscall = 0;
      }

      ptrace(PTRACE_SYSCALL, child, NULL, NULL);
    }
  }

  return 0;
}

3.3.4 运行结果

运行代码,得到输出结果:

[root@localhost examples]# ./advanced
Got signal 133
Write called with 1, 140519834779648, 14
总用量 48K
Got signal 133
Write returned with 14
Got signal 133
Write called with 1, 140519834779648, 52
-rwxr-xr-x 1 root root 8.5K 829 15:00 advanced

通过输出结果我们可以验证代码优化的效果:

  • 输出的第一份内容就是ls命令的第一行内容总用量 48K,说明我们确实过滤出来了write系统调用的行为,没有输出其他的系统调用;
  • 结合write系统调用的参数分配情况,以Write called with 1, 140519834779648, 14为例,程序此时的入参情况为write(1, buf, 14)。很显然,当fd1时,对应的就是标准输出,而14也标识着总用量 48K这个字符串的长度。
  • 结合write系统调用的返回值,以Write returned with 14为例,在成功写入14个字节长度的数据后,write的返回值便是14,这和输出的结果是一致的。
  • 最后我们看一下每次获取到的信号编号,以Got signal 133为例,这个信号编号符合我们之前对tracee进程进行的设置,即标识tracee进程的中止原因是系统调用行为。

3.4 入门总结

在入门版本和入门进阶版本中,我们又更深入的通过使用ptrace来追踪进程的系统调用事件,其涉及的知识点我在这里稍微做一个总结:

  • 通过kill -l命令可以列举出系统中用于进程间通信的信号列表;
  • sys/wait.h中定义了许多处理信号的宏,例如WIFEXITEDWSTOPSIG
  • PTRACE_SYSCALL可以让tracee进程在每次系统调用时进入中止状态并发送信号给tracer进程;
  • 通过wait() + ptrace(PTRACE_SYSCALL) + while{}的方式可以跳过第一个系统调用;
  • sys/syscall.h中定义了系统调用号,可以利用从ORIG_RAX寄存器中获取的系统调用号进行比对,从而过滤出某些系统调用;
  • RAX寄存器中存放系统调用的返回值。RDIRSIRDX寄存器分别获取系统调用的第1、2、3个参数。
  • 通过ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESYSGOOD)可以修改tracee发送的有关系统调用行为的信号编号,计算方式为5 | 0x80 = 133

四、总结和扩展

呼~终于到了轻松的总结时刻了。在这篇文章中,我们了解到了什么是内核态,什么是用户态,并且了解到了什么是系统调用。紧接着就开始了ptrace系统调用的学习。了解到了ptrace系统调用会导致进程进入中止状态的另一种形式——Traced。随后我们便进入了ptrace系统调用的实操环节,在实操环节介绍了许许多多和系统调用相关的知识,包括不同的系统调用,包括Linux内核提供的许多相关宏定义。

在实操环节我们主要从追踪进程系统调用行为这个方面进行代码编写,这很像strace,这也是下一篇文章将要介绍的内容。实际上,gdb也是基于ptrace系统调用实现的,ptrace系统调用提供PTRACE_SINGLESTEP的操作类型,可以单行中止。你也可以通过ptrace系统调用来修改内存数据,能多的事情就更多了,比如Shell注入(狗头保命 :p)。

但由于这篇文章主要是ptrace的新手指南,所以内容上并没有涉及过多的应用,如想探究更花样的玩法,可参考下面的链接:


觉得这篇文章还不错的话,记得收藏文章,给祁祁点赞关注哦~

  • 14
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
在 Unix 系统上,可以通过调用 `ptrace` 系统调用来实现资源追踪功能。`ptrace` 可以用来对指定进程进行跟踪、修改、读取进程的寄存器和内存等操作。 下面是一个简单的 C 语言示例,演示如何使用 `ptrace` 打印指定进程的寄存器值: ```c #include <stdio.h> #include <unistd.h> #include <sys/ptrace.h> #include <sys/reg.h> #include <sys/wait.h> int main(int argc, char **argv) { pid_t child_pid; long orig_eax, eax; int status; child_pid = fork(); if (child_pid == 0) { /* 在子进程中执行 */ ptrace(PTRACE_TRACEME, 0, NULL, NULL); execl("/bin/ls", "ls", NULL); } else { /* 在父进程中执行 */ wait(&status); while (WIFSTOPPED(status)) { orig_eax = ptrace(PTRACE_PEEKUSER, child_pid, 4 * ORIG_EAX, NULL); printf("syscall %ld\n", orig_eax); ptrace(PTRACE_SYSCALL, child_pid, NULL, NULL); wait(&status); } } return 0; } ``` 在上面的代码中,我们首先使用 `fork` 创建一个子进程,并在子进程中使用 `ptrace(PTRACE_TRACEME, 0, NULL, NULL)` 让子进程开始被跟踪。然后子进程通过 `execl` 调用 `/bin/ls` 命令,开始执行。 在父进程中,我们使用 `wait` 等待子进程停止,并使用 `PTRACE_PEEKUSER` 读取子进程的寄存器值(`ORIG_EAX` 表示系统调用号),并打印该系统调用号。然后使用 `PTRACE_SYSCALL` 继续执行子进程,并等待子进程再次停止。 这样,我们就可以通过 `ptrace` 系统调用实现对指定进程的跟踪,并在需要的时候读取和修改其寄存器和内存等信息,从而实现资源追踪功能。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值