[monitor] 9. Linux ptrace(程序调试器原理)

1、ptrace概念

你一定知道linux下大名鼎鼎的程序调试工具gdb,但你可能没有听说过ptrace。Ptrace是linux一个标准系统调用,是gdb实现程序调试的核心。

1

ptrace能让一个进程实现对另一个进程的调试,主进程可以对被调试进程进行一系列控制动作:可以让被调试进程在进入/退出系统调用时断点,可以对被调试进程的任何位置插入调试断点,可以控制被调试进程单步执行,可以读取/写入被调试进程的寄存器,可以读取/写入被调试进程的堆栈内容。
根据ptrace提供的这一系列功能,gdb实现了各个调试功能。还有一系列的调试工具利用ptrace的功能,如strace、ltrace、pstask等等。

2、ptrace实现

ptrace的函数原型如下:

2

实现ptrace的不同功能是由request命令字来指定的,常用的命令字有PTRACE_TRACEME、PTRACE_ATTACH、PTRACE_PEEKUSR、PTRACE_PEEKDATA、PTRACE_POKEDATA、PTRACE_SYSCALL、PTRACE_CONT、PTRACE_SINGLESTEP、PTRACE_GETFPREGS、PTRACE_SETFPREGS。

我们逐条来分析ptrace命令字的功能实现。

2.1、PTRACE_TRACEME

一个进程接受ptrace命令调试之前必须先进入trace模式,PTRACE_TRACEME命令让被调试进程本身进入trace模式。

3
4

2.2、PTRACE_ATTACH

PTRACE_TRACEME命令让被调试进程本身进入trace模式。PTRACE_ATTACH是调试进程让被调试进程进入trace模式。

5
6
7
8

2.3、PTRACE_SYSCALL

PTRACE_SYSCALL设置被调试进程在进入/退出系统调用时被断掉,即进程暂停运行并发送信号给调试主进程。

9
10
11
12
13
14
15
16
17

2.4、PTRACE_CONT

PTRACE_CONT使已经被调试器暂停掉或者断掉的进程继续执行。

18
19

2.5、PTRACE_SINGLESTEP

PTRACE_SINGLESTEP设置进程的标志寄存器为单步模式并让被调试进程继续执行。被调试进程执行完一条指令后,触发int1异常,并发信号给控制进程,把控制权交给主进程。

在内核探针kprobe中也使用了int1单步机制,可以参考相关实现。

20
21

下面是被调试进程单步执行完后,进入int1异常的处理过程。

22
23
24
25

在本进程的信号处理函数中将本进程置为停工,并发送信号给父进程。

26
27

2.6、PTRACE_PEEKDATA

PTRACE_PEEKDATA读取进程虚拟地址空间的任意数据。

28
29
30

2.7、PTRACE_POKEDATA

PTRACE_POKEDATA设置进程虚拟地址空间的任意数据。

31

2.7.1、设置断点

PTRACE_POKEDATA可以用来实现gdb的设置断点功能,具体的设置方法可以参考如下方法。

调试器是怎么设置断点的呢?通常是将当前将要执行的指令替换成trap指令,于是被调试的程序就会在这里停滞,这时调试器就可以察看被调试程序的信息了。被调试程序恢复运行以后调试器会把原指令再放回来。这里是一个例子:

#include sys/ptrace.h>
#include sys/types.h>
#include sys/wait.h>
#include unistd.h>
#include linux/user.h>
const int long_size = sizeof(long);
void getdata(pid_t child, long addr,
             char *str, int len)
{
    char *laddr;
    int i, j;
    union u ...{
            long val;
            char chars[long_size];
    }data;
    i = 0;
    j = len / long_size;
    laddr = str;
    while(i  j) ...{
        data.val = ptrace(PTRACE_PEEKDATA, child,
                          addr + i * 4, NULL);
        memcpy(laddr, data.chars, long_size);
        ++i;
        laddr += long_size;
    }
    j = len % long_size;
    if(j != 0) ...{
        data.val = ptrace(PTRACE_PEEKDATA, child,
                          addr + i * 4, NULL);
        memcpy(laddr, data.chars, j);
    }
    str[len] = '';
}
void putdata(pid_t child, long addr,
             char *str, int len)
{
    char *laddr;
    int i, j;
    union u ...{
            long val;
            char chars[long_size];
    }data;
    i = 0;
    j = len / long_size;
    laddr = str;
    while(i  j) ...{
        memcpy(data.chars, laddr, long_size);
        ptrace(PTRACE_POKEDATA, child,
               addr + i * 4, data.val);
        ++i;
        laddr += long_size;
    }
    j = len % long_size;
    if(j != 0) ...{
        memcpy(data.chars, laddr, j);
        ptrace(PTRACE_POKEDATA, child,
               addr + i * 4, data.val);
    }
}
int main(int argc, char *argv[])
{
    pid_t traced_process;
    struct user_regs_struct regs, newregs;
    long ins;
    /**//* int 0x80, int3 */
    char code[] = ...{0xcd,0x80,0xcc,0};
    char backup[4];
    if(argc != 2) ...{
        printf("Usage: %s  ",
               argv[0], argv[1]);
        exit(1);
    }
    traced_process = atoi(argv[1]);
    ptrace(PTRACE_ATTACH, traced_process,
           NULL, NULL);
    wait(NULL);
    ptrace(PTRACE_GETREGS, traced_process,
           NULL, &regs);
    /**//* Copy instructions into a backup variable */
    getdata(traced_process, regs.eip, backup, 3);
    /**//* Put the breakpoint */
    putdata(traced_process, regs.eip, code, 3);
    /**//* Let the process continue and execute
       the int 3 instruction */
    ptrace(PTRACE_CONT, traced_process, NULL, NULL);
    wait(NULL);
    printf("The process stopped, putting back "
           "the original instructions ");
    printf("Press  to continue ");
    getchar();
    putdata(traced_process, regs.eip, backup, 3);
    /**//* Setting the eip back to the original
       instruction to let the process continue */
    ptrace(PTRACE_SETREGS, traced_process,
           NULL, &regs);
    ptrace(PTRACE_DETACH, traced_process,
           NULL, NULL);
    return 0;
}

上面的程序将把三个byte的内容进行替换以执行trap指令,等被调试进程停滞以后,我们把原指令再替换回来并把eip修改为原来的值。下面的图中演示了指令的执行过程

    1. 进程停滞后
    1. 替换入trap指令

32

  • 3.断点成功,控制权交给了调试器
    1. 继续运行,将原指令替换回来并将eip复原

33

2.8、PTRACE_PEEKUSR

PTRACE_PEEKUSR读取单个寄存器。

34
35
36

2.9、PTRACE_POKEUSR

PTRACE_POKEUSR修改单个寄存器。
注意配置命令最好在进程暂停的情况下操作。

37
38

2.10、PTRACE_GETREGS

PTRACE_GETREGS获取所有cpu寄存器的内容。

39
40

2.11、PTRACE_SETREGS

PTRACE_SETREGS设置所有cpu寄存器的内容。
注意配置命令最好在进程暂停的情况下操作。

41
42

3、ptrace应用

3.1、gdb

从上面的实现看到ptrace可以对被调试进程进行一系列控制动作:可以让被调试进程在进入/退出系统调用时断点,可以对被调试进程的任何位置插入调试断点,可以控制被调试进程单步执行,可以读取/写入被调试进程的寄存器,可以读取/写入被调试进程的堆栈内容。

gdb利用ptrace的这些特性实现了对进程的调试功能。

3.2、strace

strace也是一个常用的调试工具,strace的功能是追踪程序的系统调用。我们不去分析strace的源码,而是用一段简单代码来说明strace的实现原理。如有这么一段程序:

HelloWorld.c:
#include <stdio.h>
int main(){
    printf("Hello World!/n");
    return 0;
}

编译后,用strace跟踪: strace ./HelloWorld。可以看到形如:

43

这就是在执行HelloWorld中,系统所执行的系统调用,以及他们的返回值。

下面我们用ptrace来研究一下它是怎么实现的。

    switch(pid = fork())
    {
    case -1:
        return -1;
    case 0: //子进程
        ptrace(PTRACE_TRACEME,0,NULL,NULL);
        execl("./HelloWorld", "HelloWorld", NULL);
    default: //父进程
        wait(&val); //等待并记录execve
        if(WIFEXITED(val))
            return 0;
        syscallID=ptrace(PTRACE_PEEKUSER, pid, ORIG_EAX*4, NULL);
        printf("Process executed system call ID = %ld/n",syscallID);
        ptrace(PTRACE_SYSCALL,pid,NULL,NULL);
        while(1)
        {
            wait(&val); //等待信号
            if(WIFEXITED(val)) //判断子进程是否退出
                return 0;
            if(flag==0) //第一次(进入系统调用),获取系统调用的参数
            {
                syscallID=ptrace(PTRACE_PEEKUSER, pid, ORIG_EAX*4, NULL);
                printf("Process executed system call ID = %ld ",syscallID);
                flag=1;
            }
            else //第二次(退出系统调用),获取系统调用的返回值
            {
                returnValue=ptrace(PTRACE_PEEKUSER, pid, EAX*4, NULL);
                printf("with return value= %ld/n", returnValue);
                flag=0;
            }
            ptrace(PTRACE_SYSCALL,pid,NULL,NULL);
        }
    }

在上面的程序中,fork出的子进程先调用了ptrace(PTRACE_TRACEME)表示子进程让父进程跟踪自己。然后子进程调用execl加载执行了HelloWorld。而在父进程中则使用wait系统调用等待子进程的状态改变。子进程因为设置了PTRACE_TRACEME而在执行系统调用被系统停止(设置为TASK_TRACED),这时父进程被唤醒,使用ptrace(PTRACE_PEEKUSER,pid,…)分别去读取子进程执行的系统调用ID(放在ORIG_EAX中)以及系统调用返回时的值(放在EAX中)。然后使用 ptrace(PTRACE_SYSCALL,pid,…)指示子进程运行到下一次执行系统调用的时候(进入或者退出),直到子进程退出为止。

程序的执行结果如下:

Process executed system call ID = 11
Process executed system call ID = 45 with return value= 134520832
Process executed system call ID = 192 with return value= -1208934400
Process executed system call ID = 33 with return value= -2
Process executed system call ID = 5 with return value= -2

其中,11号系统调用就是execve,45号是brk,192是mmap2,33是access,5是open…经过比对可以发现,和strace的输出结果一样。当然strace进行了更详尽和完善的处理,我们这里只是揭示其原理,感兴趣的同学可以去研究一下strace的实现。

3.3、ltrace

ltrace用来最终程序运行过程中对库函数的调用。我们用ltrace来调试上一个HelloWorld程序。

44

ltrace其实也是基于ptrace。我们知道,ptrace能够主要是用来跟踪系统调用,那么它是如何跟踪库函数呢?

首先ltrace打开elf文件,对其进行分析。在elf文件中,出于动态连接的需要,需要在elf文件中保存函数的符号,供连接器使用。具体格式,大家可以参考elf文件的格式。这样ltrace就能够获得该文件中,所有系统调用的符号,以及对应的执行指令。然后,ltrace将该指令所对应的4个字节,替换成断点。这样在进程执行到相应的库函数后,就可以通知到了ltrace,ltrace将对应的库函数打印出来之后,继续执行子进程。实际上ltrace与strace使用的技术大体相同,但ltrace在对支持fork和clone方面,不如strace。strace在收到frok和clone等系统调用后,做了相应的处理,而ltrace没有。

3.4、pstack

pstack用来显示运行中函数的堆栈调用情况。

45

其是实质上也是用ptrace来实现的,首先用PTRACE_ATTACH停住被查看程序,然后尝试从”/proc/pid/exe”中解析出程序elf中的符号表,再通过PTRACE_PEEKUSER读出程序的堆栈指针,通过PTRACE_PEEKDATA读出堆栈的数据,根据堆栈数据在符号表中查询,解析出程序的整个堆栈调用关系。随后PTRACE_DETACH恢复程序的运行。

posted @ 2017-10-14 14:52  pwl999  阅读(597)  评论(0)    收藏  举报