使用 ptrace,第一部分

作者:Pradeep Padala

您是否曾经想过系统调用是如何被拦截的?您是否曾经尝试通过更改系统调用参数来欺骗内核?您是否曾经想过调试器如何停止正在运行的进程并让您控制该进程?

如果您正在考虑使用复杂的内核编程来完成任务,请重新考虑。Linux 提供了一种优雅的机制来实现所有这些事情:ptrace(进程跟踪)系统调用。 ptrace 提供了一种机制,父进程可以通过该机制观察和控制另一个进程的执行。它可以检查和更改其核心映像和寄存器,主要用于实现断点调试和系统调用跟踪。

在本文中,我们将学习如何拦截系统调用并更改其参数。在本文的第二部分中,我们将学习高级技术——设置断点和将代码注入到正在运行的程序中。我们将深入了解子进程的寄存器和数据段并修改其内容。我们还将描述一种注入代码的方法,以便可以停止进程并执行任意指令。

基础知识

操作系统通过称为系统调用的标准机制提供服务。它们为访问底层硬件和低级服务(例如文件系统)提供标准的 API。当进程想要调用系统调用时,它将系统调用的参数放入寄存器中,并调用软中断 0x80。此软中断就像内核模式的入口,内核将在检查参数后执行系统调用。

在 i386 架构(本文中的所有代码都是 i386 特定的)上,系统调用号放在 %eax 寄存器中。此系统调用的参数按顺序放入 %ebx、%ecx、%edx、%esi 和 %edi 寄存器中。例如,调用

write(2, "Hello", 5)

大致可以翻译成

movl   $4, %eax
movl   $2, %ebx
movl   $hello,%ecx
movl   $5, %edx
int    $0x80
其中 $hello 指向文字字符串“Hello”。

那么 ptrace 在哪里发挥作用呢?在执行系统调用之前,内核会检查进程是否正在被跟踪。如果是,内核会停止该进程,并将控制权交给跟踪进程,以便它可以检查和修改被跟踪进程的寄存器。

让我们用一个例子来澄清这个解释,说明进程是如何工作的

#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;
}

运行时,此程序打印

The child made a system call 11
以及 ls 的输出。系统调用号 11 是 execve,它是子进程执行的第一个系统调用。作为参考,系统调用号可以在 /usr/include/asm/unistd.h 中找到。

正如您在示例中看到的,进程 fork 一个子进程,子进程执行我们要跟踪的进程。在运行 exec 之前,子进程使用第一个参数调用 ptrace,该参数等于 PTRACE_TRACEME。这告诉内核该进程正在被跟踪,当子进程执行 execve 系统调用时,它将控制权交给其父进程。父进程使用 wait() 调用等待来自内核的通知。然后,父进程可以检查系统调用的参数或执行其他操作,例如查看寄存器。

当系统调用发生时,内核会保存 eax 寄存器的原始内容,其中包含系统调用号。我们可以通过使用第一个参数 PTRACE_PEEKUSER 调用 ptrace 从子进程的 USER 段读取此值,如上所示。

在我们完成检查系统调用后,子进程可以使用第一个参数 PTRACE_CONT 调用 ptrace 继续执行,这将使系统调用继续执行。

ptrace 参数

ptrace 使用四个参数调用

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

第一个参数决定 ptrace 的行为以及如何使用其他参数。request 的值应为 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_PEEKUSER 作为第一个参数调用 ptrace,我们可以检查 USER 区域的内容,其中存储了寄存器内容和其他信息。内核将寄存器的内容存储在此区域中,供父进程通过 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 发出三个 write 系统调用。使用 PTRACE_SYSCALL 作为第一个参数调用 ptrace,使内核在每次系统调用进入或退出时停止子进程。它相当于执行 PTRACE_CONT 并在下一个系统调用进入/退出时停止。

在前面的示例中,我们使用 PTRACE_PEEKUSER 来查看 write 系统调用的参数。当系统调用返回时,返回值放在 %eax 中,可以如该示例所示读取。

wait 调用中的 status 变量用于检查子进程是否已退出。这是检查子进程是否被 ptrace 停止或能够退出的典型方法。有关 WIFEXITED 等宏的更多详细信息,请参阅 wait(2) 手册页。

读取寄存器值

如果您想在 syscall 进入或退出时读取寄存器值,则上面显示的过程可能很麻烦。使用 PTRACE_GETREGS 作为第一个参数调用 ptrace 将在单个调用中放置所有寄存器。

获取寄存器值的代码如下所示

#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 调用 ptrace。在这里,我们利用 <linux/user.h> 中定义的 user_regs_struct 来读取寄存器值。

做有趣的事情

现在是做一些有趣的事情的时候了。在以下示例中,我们将反转传递给 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[i];
        str[i] = str[j];
        str[j] = temp;
    }
}
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] = '\0';
}
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()
{
   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[3];
      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[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);
               str = (char *)calloc((params[2]+1)
                                 * sizeof(char));
               getdata(child, params[1], str,
                       params[2]);
               reverse(str);
               putdata(child, params[1], str,
                       params[2]);
            }
            else {
               toggle = 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
txt.ecartp      s.ymmud      tuo.a
c.sretsiger     lmth.mpgbil  c.llacys_egnahc
c.elpmis        lmth.ecartp  ymmud
此示例使用了之前讨论的所有概念,以及更多概念。在其中,我们使用带有 PTRACE_POKEDATA 的 ptrace 调用来更改数据值。它的工作方式与 PTRACE_PEEKDATA 完全相同,不同之处在于它既读取又写入子进程在系统调用参数中传递的数据,而 PEEKDATA 仅读取数据。
单步执行

ptrace 提供了单步执行子进程代码的功能。调用 ptrace(PTRACE_SINGLESTEP,..) 告诉内核在每个指令处停止子进程,并让父进程接管控制。以下示例显示了一种读取执行系统调用时正在执行的指令的方法。我创建了一个小的虚拟可执行文件,以便您了解正在发生的事情,而不是处理 libc 发出的调用。

这是 dummy1.s 的列表。它是用汇编语言编写的,并编译为 gcc -o dummy1 dummy1.s

.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

单步执行上述代码的示例程序是

#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 的手册才能理解这些指令字节。对于更复杂的进程(例如设置断点)使用单步执行需要仔细的设计和更复杂的代码。

在第二部分中,我们将了解如何插入断点以及如何将代码注入到正在运行的程序中。

本文和第二部分(将在下个月的期刊中印刷)的所有示例代码都可以在 Linux Journal FTP 站点上作为 tar 存档提供 [ftp.linuxjournal.com/pub/lj/listings/issue103/6011.tgz]。

Playing with ptrace, Part I
电子邮件:ppadala@cise.ufl.edu

Pradeep Padala 目前正在佛罗里达大学攻读硕士学位。他的研究兴趣包括网格和分布式系统。可以通过电子邮件 p_padala@yahoo.com 或通过他的网站 (www.cise.ufl.edu/~ppadala) 与他联系。

加载 Disqus 评论