[譯] 玩轉ptrace (一)

twoon發表於2013-12-16

[本文翻譯自這裡: http://www.linuxjournal.com/article/6100?page=0,0,作者:Pradeep Padaia]

 

你是否曾經想過怎樣才能攔截系統呼叫?你是否曾經想過通過修改一下系統呼叫的引數來耍一把核心?你是否想過偵錯程式是怎樣把一個程式停下來,然後把控制權轉移給你的?如果你以為這些都是通過複雜的核心程式設計來實現的,那你就錯了,事實上,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;
}

 

當把上面這段程式碼編譯執行後,終端上除了命令 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),這是個典型的用於判斷子程式是被 ptrace 停住還是已經執行結束了的用法,變數 status 用於標記子程式是否已經結束退出,關於這個 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這個結構體,它被定義在<linux/user.h>中。

 

做點有趣的事情

好,有了前面的基礎,現在我們可以來嘗試做些有趣的事情了。下面我們將把子程式呼叫 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[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 中使用 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 的手冊,弄明白那些指令是幹什麼的。對程式執行更復雜的單步操作,如加入斷點等,我們還需要寫一些更細緻更復雜的程式碼,在下一篇文章中,我們會展示一下怎麼對程式加入斷點。

 

 

相關文章