首页 > 代码精文 > C/C++ > Linux玩转ptrace
2015
04-09

Linux玩转ptrace

Linux 提供了一种很优雅的方式来实现上述所有行为:ptrace 系统调用。ptrace 提供了一种机制使得父进程可以观察和控制子进程的执行过程,ptrace 还可以检查和修改该子进程的可执行文件在内存中的镜像及该子进程所使用的寄存器中的值。这种用法通常来说,主要用于实现对进程插入断点和跟踪子进程的系统调用。

在本篇文章中,我们将学习怎么去拦截一个系统调用并且修改该系统调用的参数,在后续一篇文章中,我们将继续探讨 ptrace 的一些更深入的技术,如设置断点,在运行的子进程中插入代码等。我们将会查看进程的寄存器和数据段,并去修改其中的内容。我们还会介绍一种方式来在进程中插入代码,使得该进程能停下来,并执行我们插入的任意代码。

基础

操作系统通过一个叫做“系统调用”的标准机制来对上层提供服务。他们提供了一系列标准的API来让上层获取底层的硬件和服务,比如文件系统。当一个进程想要进行一个系统调用的时候。它会把该系统调用所需要用到的参数放到寄存器里,然后执行软中断指令0x80. 这个软中断就像是一个门,通过它就能进入内核模式,进入内核模式后,内核将会检查系统调用的参数,然后执行该系统调用。

在i386平台下(本文所有代码都基于i386), 系统调用的编号会被放在寄存器%eax中,而系统调用的参数会被依次放到%ebx,%ecx,%edx,%exi 和%edi中,比如说,对于下面的系统调用:

write(2, "Hello", 5)

编译后,它最后大概会被转化成下面这样子:

movl   $4, %eax
movl   $2, %ebx
movl   $hello,%ecx
movl   $5, %edx
int    $0x80

其中$hello 指向字符串"Hello"。

看完上面简单的例子,现在我们来看看 ptrace 又是怎样执行的。

首先,我们假设进程 A 要 ptrace 进程 B。在 ptrace 系统调用真正开始前,内核会检查一下我们将要 trace 的进程 B 是否当前已经正在被 traced 了,如果是,内核就会把该进程 B 停下来,并把控制权交给调用进程 A (任何时候,子进程只能被父进程这唯一一个进程所trace),这使得进程A有机会去检查和修改进程B的寄存器的值。

下面我们用一个例子来说明:

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>   /* For constants
                                   ORIG_EAX etc */
int main()
{   pid_t child;
    long orig_eax;
    child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "ls", NULL);
    }
    else {
        wait(NULL);
        orig_eax = ptrace(PTRACE_PEEKUSER,
                          child, 4 * ORIG_EAX,
                          NULL);
        printf("The child made a "
               "system call %ld\n", orig_eax);
        ptrace(PTRACE_CONT, child, NULL, NULL);
    }
    return 0;
}

当上面这段代码被编译执行后,除了shell 命令ls的输出外,还会输出下面一行:

The child made a system call 11

根据上面的输出,我们知道,在执行ls命令的时候,第11号系统调用被执行了,它是子进程中执行的第一个系统调用。

如果想查看一下各个系统调用编号对应的名字,可以参考头文件:/usr/include/asm/unistd.h.

正如你在上面的例子中所看到,ptrace 的使用流程一般是这样的:父进程 fork() 出子进程,子进程中执行我们所想要 trace 的程序,在子进程调用 exec() 之前,子进程需要先调用一次 ptrace,以 PTRACE_TRACEME 为参数。这个调用是为了告诉内核,当前进程已经正在被 traced,当子进程执行 execve() 之后,子进程会进入暂停状态,把控制权转给它的父进程(SIG_CHLD信号), 而父进程在fork()之后,就调用 wait() 等子进程停下来,当 wait() 返回后,父进程就可以去查看子进程的寄存器或者对子进程做其它的事情了。

当系统调用发生时,内核会把当前的%eax中的内容(即系统调用的编号)保存到子进程的用户态代码段中(USER SEGMENT or USER CODE),我们可以像上面的例子那样通过调用Ptrace(传入PTRACE_PEEKUSER作为第一个参数)来读取这个%eax的值,当我们做完这些检查数据的事情之后,通过调用ptrace(PTRACE_CONT),可以让子进程重新恢复运行。

ptrace的参数

ptrace总共有4个参数:

long ptrace(enum __ptrace_request request,
            pid_t pid,
            void *addr,
            void *data);

其中第一个参数决定ptrace的行为也决定了接下来其它3个参数是怎样被使用的,第1个参数可以取以下任意一个值:

PTRACE_TRACEME, 
PTRACE_PEEKTEXT, 
PTRACE_PEEKDATA, 
PTRACE_PEEKUSER, 
PTRACE_POKETEXT, 
PTRACE_POKEDATA, 
PTRACE_POKEUSER, 
PTRACE_GETREGS,
 PTRACE_GETFPREGS, 
PTRACE_SETREGS,
 PTRACE_SETFPREGS, 
PTRACE_CONT,
 PTRACE_SYSCALL, 
PTRACE_SINGLESTEP, 
PTRACE_DETACH

本文接下来会解释这些参数有什么不同的地方。

读取系统调用的参数

通过调用ptrace并传入PTRACE_PEEKUSER作为第一个参数,我们可以检查子进程中,保存了该进程的寄存器的内容(及其它一些内容)的用户态内存区域(USER area)。内核把寄存器的内容保存到这块区域,就是为了能够让父进程通过ptrace来读取。

下面举一个例子来说明一下:

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>
#include <sys/syscall.h>   /* For SYS_write etc */
int main()
{   pid_t child;
    long orig_eax, eax;
    long params[3];
    int status;
    int insyscall = 0;
    child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "ls", NULL);
    }
    else {
       while(1) {
          wait(&status);
          if(WIFEXITED(status))
              break;
          orig_eax = ptrace(PTRACE_PEEKUSER,
                     child, 4 * ORIG_EAX, NULL);
          if(orig_eax == SYS_write) {
             if(insyscall == 0) {
                /* Syscall entry */
                insyscall = 1;
                params[0] = ptrace(PTRACE_PEEKUSER,
                                   child, 4 * EBX,
                                   NULL);
                params[1] = ptrace(PTRACE_PEEKUSER,
                                   child, 4 * ECX,
                                   NULL);
                params[2] = ptrace(PTRACE_PEEKUSER,
                                   child, 4 * EDX,
                                   NULL);
                printf("Write called with "
                       "%ld, %ld, %ld\n",
                       params[0], params[1],
                       params[2]);
                }
          else { /* Syscall exit */
                eax = ptrace(PTRACE_PEEKUSER,
                             child, 4 * EAX, NULL);
                    printf("Write returned "
                           "with %ld\n", eax);
                    insyscall = 0;
                }
            }
            ptrace(PTRACE_SYSCALL,
                   child, NULL, NULL);
        }
    }
    return 0;
}

编译执行上面的代码,得到的输出和前一个例子的输出有些类似:

ppadala@linux:~/ptrace > ls
a.out        dummy.s      ptrace.txt
libgpm.html  registers.c  syscallparams.c
dummy        ptrace.html  simple.c
ppadala@linux:~/ptrace > ./a.out
Write called with 1, 1075154944, 48
a.out        dummy.s      ptrace.txt
Write returned with 48
Write called with 1, 1075154944, 59
libgpm.html  registers.c  syscallparams.c
Write returned with 59
Write called with 1, 1075154944, 30
dummy        ptrace.html  simple.c
Write returned with 30

在这个例子中,我们追踪了 write() 这个系统调用,由上面的输出我们可以看出,ls这个程序总共调用了3次write().
调用 ptrace 并传入参数:PTRACE_SYSCALL, 会使得子进程在每次进行系统调用及结束一次系统调用时都会被内核停下来,这一个过程就相当于做了一个ptrace(PTRACE_CONT) 调用,然后在每次系统调用前和系统调用后就停下来。

在前面一个例子中,我们用 PTRACE_PEEKUSER 来读取系统调用的参数,当系统调用结束后,该调用的返回值会被放在%eax中,像上面的例子展示的那样,这个值也是可以被读取的。

上面例子中:wait(&status), 变量status来标记子进程是否已经结束退出。这是个典型的用法以判断子进程是被 ptrace 停住还是已经运行结束了,关于这个wait()和WIFEXITED的更多细节,读者可以自行查看一下manual(man 2).

读取寄存器的值

如果你想在系统调用开始前或结束后读取多个寄存器的值,上面的代码实现起来会比较麻烦,ptrace提供了另一种方式来一次性读取所有的寄存器的内容,这就是参数:PTRACE_GETREGS的作用。

参看下面的例子:

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>
#include <sys/syscall.h>
int main()
{   pid_t child;
    long orig_eax, eax;
    long params[3];
    int status;
    int insyscall = 0;
    struct user_regs_struct regs;
    child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "ls", NULL);
    }
    else {
       while(1) {
          wait(&status);
          if(WIFEXITED(status))
              break;
          orig_eax = ptrace(PTRACE_PEEKUSER,
                            child, 4 * ORIG_EAX,
                            NULL);
          if(orig_eax == SYS_write) {
              if(insyscall == 0) {
                 /* Syscall entry */
                 insyscall = 1;
                 ptrace(PTRACE_GETREGS, child,
                        NULL, &regs);
                 printf("Write called with "
                        "%ld, %ld, %ld\n",
                        regs.ebx, regs.ecx,
                        regs.edx);
             }
             else { /* Syscall exit */
                 eax = ptrace(PTRACE_PEEKUSER,
                              child, 4 * EAX,
                              NULL);
                 printf("Write returned "
                        "with %ld\n", eax);
                 insyscall = 0;
             }
          }
          ptrace(PTRACE_SYSCALL, child,
                 NULL, NULL);
       }
   }
   return 0;
}

这个例子和前面一个例子几乎是一模一样的,除了读取寄存器的地方换成了PTRACE_GETREGS.在这里我们用到了user_regs_struct这个结构体,它被定义在中。

做点有趣的事情

好,有了前面的基础,现在我们可以来尝试做些有趣的事情了。下面我们将把子进程调用write时,传给 write() 的参数都给反转过来,看看会得到怎样的结果。

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>
#include <sys/syscall.h>
const int long_size = sizeof(long);
void reverse(char *str)
{   int i, j;
    char temp;
    for(i = 0, j = strlen(str) - 2;
        i <= j; ++i, --j) {
        temp = str&#91;i&#93;;
        str&#91;i&#93; = str&#91;j&#93;;
        str&#91;j&#93; = temp;
    }
}
void getdata(pid_t child, long addr,
             char *str, int len)
{   char *laddr;
    int i, j;
    union u {
            long val;
            char chars&#91;long_size&#93;;
    }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&#91;len&#93; = '\0';
}
void putdata(pid_t child, long addr,
             char *str, int len)
{   char *laddr;
    int i, j;
    union u {
            long val;
            char chars&#91;long_size&#93;;
    }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()
{
   pid_t child;
   child = fork();
   if(child == 0) {
      ptrace(PTRACE_TRACEME, 0, NULL, NULL);
      execl("/bin/ls", "ls", NULL);
   }
   else {
      long orig_eax;
      long params&#91;3&#93;;
      int status;
      char *str, *laddr;
      int toggle = 0;
      while(1) {
         wait(&status);
         if(WIFEXITED(status))
             break;
         orig_eax = ptrace(PTRACE_PEEKUSER,
                           child, 4 * ORIG_EAX,
                           NULL);
         if(orig_eax == SYS_write) {
            if(toggle == 0) {
               toggle = 1;
               params&#91;0&#93; = ptrace(PTRACE_PEEKUSER,
                                  child, 4 * EBX,
                                  NULL);
               params&#91;1&#93; = ptrace(PTRACE_PEEKUSER,
                                  child, 4 * ECX,
                                  NULL);
               params&#91;2&#93; = ptrace(PTRACE_PEEKUSER,
                                  child, 4 * EDX,
                                  NULL);
               str = (char *)calloc((params&#91;2&#93;+1)
                                 * sizeof(char));
               getdata(child, params&#91;1&#93;, str,
                       params&#91;2&#93;);
               reverse(str);
               putdata(child, params&#91;1&#93;, str,
                       params&#91;2&#93;);
            }
            else {
               toggle = 0;
            }
         }
      ptrace(PTRACE_SYSCALL, child, NULL, NULL);
      }
   }
   return 0;
}&#91;/java&#93;
上面的代码编译运行后,将得到这样的类似下面的结果:
&#91;java&#93;ppadala@linux:~/ptrace > ls
a.out        dummy.s      ptrace.txt
libgpm.html  registers.c  syscallparams.c
dummy        ptrace.html  simple.c
ppadala@linux:~/ptrace > ./a.out
txt.ecartp      s.ymmud      tuo.a
c.sretsiger     lmth.mpgbil  c.llacys_egnahc
c.elpmis        lmth.ecartp  ymmud

有趣吧!这个例子使用到了我们前面提到过的所有概念。在这当中,我们通过在ptrace中使用PTRACE_POKEDATA参数来改变子进程中的数据。这个 PTRACE_POKEDATA 用起来和 PTRACE_PEEKDATA 是一样的,不同之处只在于 PTRACE_POKEDATA 不仅可以读数据,还能往子进程里写数据。

单步执行

ptrace提供了一种手段使得我们可以像debugger一样单步执行子进程的代码,很酷?调用一下ptrace(PTRACE_SINGLESTEP) 就能完成这样的事情,这个调用会告诉内核,在子进程每执行完一条子令之后,就停一下。

下面的代码演示了怎么读取子进程中当前正在被执行的子令,为了让读者更好的理解发生了什么事情,我自己写了一个很简单的dummy程序来方便大家理解。

下面是一小段汇编代码:

.data
hello:
    .string "hello world\n"
.globl  main
main:
    movl    $4, %eax
    movl    $2, %ebx
    movl    $hello, %ecx
    movl    $12, %edx
    int     $0x80
    movl    $1, %eax
    xorl    %ebx, %ebx
    int     $0x80
    ret

我们用命令把它编译成可执行文件:

gcc -o dummy1 dummy1.s

然后我们将单步执行这个程序:

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <linux/user.h>
#include <sys/syscall.h>
int main()
{   pid_t child;
    const int long_size = sizeof(long);
    child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("./dummy1", "dummy1", NULL);
    }
    else {
        int status;
        union u {
            long val;
            char chars[long_size];
        }data;
        struct user_regs_struct regs;
        int start = 0;
        long ins;
        while(1) {
            wait(&status);
            if(WIFEXITED(status))
                break;
            ptrace(PTRACE_GETREGS,
                   child, NULL, &regs);
            if(start == 1) {
                ins = ptrace(PTRACE_PEEKTEXT,
                             child, regs.eip,
                             NULL);
                printf("EIP: %lx Instruction "
                       "executed: %lx\n",
                       regs.eip, ins);
            }
            if(regs.orig_eax == SYS_write) {
                start = 1;
                ptrace(PTRACE_SINGLESTEP, child,
                       NULL, NULL);
            }
            else
                ptrace(PTRACE_SYSCALL, child,
                       NULL, NULL);
        }
    }
    return 0;
}

编译运行上面的代码,输出的结果是:

hello world
EIP: 8049478 Instruction executed: 80cddb31
EIP: 804947c Instruction executed: c3

想要看明白这里做了什么事情,你可能需要先查一下Intel的手册,弄明白那些指令是干什么的。

对程序执行更复杂的单步操作,如加入断点等,我们还需要写一些更细致更复杂的代码。

最后编辑:
作者:leehom
本博客主要是把自己的经验记录于此,方便自己以后查阅及其他遇到类似问题的朋友参考。如果你有觉得不错的文章,可以注册会员发布文章或者邮箱发给我文章地址,谢谢!
捐 赠如果觉得文章还不错,请麻烦点下广告,算是赞助下本站服务器费用,谢谢!

留下一个回复