1. 程式人生 > >Ptrace--Linux中一種程式碼注入技術的應用

Ptrace--Linux中一種程式碼注入技術的應用

Ptrace–Linux中一種程式碼注入技術的應用

  在以往的工作中,曾遇到以下需求:可以隨意的開啟或是遮蔽已執行程序的輸出。   通過查詢相關部落格以及開源專案,最終選擇ptrace作為最終的實現手段。從理解到最終應用的過程中,以下部落格:Playing with ptrace (作者:Pradeep Padaia)起到了答疑解惑的作用,本文介紹了該博文的主要內容,並在此基礎上加入了個人的一些理解與修改。   此外,原文程式碼是在i386上執行,而本人所用為64位機,因此對原有程式碼進行了移植。

一、摘要

  你曾對系統呼叫是怎樣被中斷的而感到好奇麼?你曾嘗試通過改變系統呼叫的引數,來愚弄核心麼?你曾經想過偵錯程式是如何暫停正在執行的程序,轉而由你獲得控制權麼?   如果你正在嘗試用複雜的核心程式設計來完成任務,可以重新考慮使用Linux提供的一種優雅的機制——ptrace

系統呼叫,來完成這些任務。ptrace提供了一種使得 Tracer 可以觀察並控制 Tracee 的機制。該機制可以檢測並改變 Tracee 的核心映象以及暫存器,其主要被用來實現斷點除錯以及對系統呼叫的追蹤。   在本篇文章中,我們將會了解如何阻止一個系統呼叫,同時改變它的引數。而在下篇文章中,我們將學習更高階的技術——向一個執行中的程式中設定斷點或是注入一段程式碼。如此一來,我們就可以一窺 Tracee 的暫存器以及資料段,並修改內容。此外,本文也會介紹使得 Tracee 可以被停止並執行任意指令的方式。

二、基礎

  作業系統通過一組被稱為系統呼叫的標準機制來提供服務。系統呼叫提供了標準的API用以訪問底層的硬體及服務(例如檔案系統)。當一個程序想要執行一個系統呼叫時,它將系統呼叫的引數傳入暫存器,並呼叫0x80軟中斷。該軟中斷就像是進入核心模式的一扇大門,核心會在檢測完引數後,執行相應的系統呼叫。在i386架構上,系統呼叫號是被放在%eax

暫存器中,而系統呼叫相應的引數則是按序被放在%ebx,%ecx, %edx, %esi以及%edi。例如:

write(2, "Hello", 5)

  大致會轉化為以下彙編語句:

movl   $4, %eax			//在i386中__NR_write的系統呼叫號是4
movl   $2, %ebx
movl   $hello,%ecx
movl   $5, %edx
int    $0x80

  其中 $hello 指向了字串 "Hello"。   那麼 ptrace應出現在哪裡呢?在執行系統呼叫之前,核心會檢測該程序是否被追蹤。對於已被追蹤的程式—— Tracee ,核心會暫停該程式,並將控制權給到 Tracer

,因此 Tracer 就可以檢測和修改 Tracee 的暫存器了。

  如果是64位系統則有所不同, 使用者層的應用使用暫存器%rdi, %rsi, %rdx, %rcx, %r8 以及 %r9來傳參,而核心介面用%rdi, %rsi, %rdx, %r10, %r8 以及 %r10來傳參. 並且用syscall指令而不是80軟中斷來進行系統呼叫. 相同之處是都用暫存器%rax來儲存呼叫號和返回值.   更多關於32位和64位彙編指令的區別可以參考stack overflow關於[What are the calling conventions for UNIX & Linux system calls on i386 and x86-64]的總結, 因為我當前環境是64位Linux,所以下文的操作都以64位系統為例. ——Linux Hook 筆記

  我們通過一個簡單的例子來闡明其是怎樣工作的:

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/reg.h>   /* For constants  ORIG_RAX etc */
#include <stdio.h>
int main()
{   pid_t child;
    long orig_rax;
    child = fork();
    if(child == 0) 
    {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        printf("Try to call: execl\n");
        execl("/bin/ls", "ls", NULL);
        printf("child exit\n");
    }
    else 
    {
        wait(NULL);
        orig_rax = ptrace(PTRACE_PEEKUSER,
                          child, 8 * ORIG_RAX,
                          NULL);
        printf("The child made a "
               "system call %ld\n", orig_rax);
        ptrace(PTRACE_CONT, child, NULL, NULL);
        printf("Try to call:ptrace\n");
    }
    return 0;
}

  程式輸出如下: Try to call: execl The child made a system call 59 Try to call:ptrace Press to close this window… main.o Makefile Ptarce

注:   1.原始碼在移植到64位機時進行了修改,具體原因見:將ptrace移植到64位機

  系統呼叫59是指exeve,它是子程序執行的第一個系統呼叫。作為參考,64位機的系統呼叫號可以在 /usr/include/asm/unistd_64.h路徑下找到。   正如你在例子中看到的,父程序fork了一個子程序,由子程序執行了我們想要追蹤的程序。在執行execl()前,子程序呼叫了ptrace(使用PTRACE_TRACEM作為第一個引數)。該操作告訴核心,這個程序將會被追蹤,而當子程序執行execv系統呼叫時,它就將控制權移交給了它的父程序(父程序通過wiat()呼叫獲取通知)。   接下來,父程序就可以檢測子程序系統呼叫的引數,或是做些其它的事情,例如檢視子程序的暫存器等等。   當系統呼叫發生的時候,核心將%eax暫存器中原始的系統呼叫號儲存下來。正如上邊所展示的那樣,我們可以呼叫ptrace(將PTRACE_TRACEME作為第一個引數)從子程序的使用者段中讀出該值。   當我們檢查完系統呼叫後,父程序可以呼叫ptrace(將PTRACE_CONT作為第一個引數),使子程序得以繼續執行。   在系統呼叫追蹤中,常見的流程如下圖所示: 系統呼叫追蹤流程圖

三、ptrace 引數

  ptrace的函式宣告如下:

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

  第一個引數決定了code的行為以及後續的引數是如何被使用的。具體的值可以是以下中的一個:

第一個引數 說明
PTRACE_TRACEME  指明該程序將要被其父程序追蹤。後續的引數可以被忽略。  該請求只可以被 Tracee 使用;剩餘的請求則只能被Tracer使用。其餘的請求中,pid指定了 Tracee 的程序ID。
PTRACE_PEEKTEXT  從 Tracee 儲存空間中的addr地址處讀取一個字,並將其作為返回值。由於Linux不區分文字與資料段的地址空間,因此PTRACE_PEEKTEXT,PTRACE_PEEKTEXT這兩個請求無異(data將被忽略)。
PTRACE_PEEKDATA  同上
PTRACE_PEEKUSER  在 Tracee 使用者空間(儲存了關於程序的暫存器,以及其他資訊)的偏移地址——addr處讀取一個字。該值將作為結果返回。因架構而異,偏移地址addr通常是是字對齊的(data將被忽略)。
PTRACE_POKETEXT  將一個字長的資料data複製到 Tracee 儲存空間中的地址addr去。由於Linux不區分文字與資料段的地址空間,PTRACE_PEEKTEXT,PTRACE_PEEKTEXT因此這兩個請求無異
PTRACE_POKEDATA  同上
PTRACE_POKEUSER  將一個字長的資料data複製到 Tracee 使用者空間的偏移地址addr處去。對於該請求而言,偏移地址addr通常是要求位元組對其的。 為了維護核心的完整性,針對使用者區的某些修改,通常是禁止的。
PTRACE_GETREGS  複製 Tracee 的通用暫存器內容給data。具體的資料格式可以參考<sys/user.h>(其中addr是被忽略的)。 注意:在SPARC系統中dataaddr的語意是反轉的——data被忽略,暫存器資料被儲存在addr地址處。PTRACE_GETREGS以及PTRACE_GETFPREGS並非在所有的架構上都有實現。
PTRACE_GETFPREGS 用法與PTRACE_GETREGS相同,只是取 Tracee 浮點暫存器的值。
PTRACE_SETREGS  取 Tracer data地址處的資料,用以修改 Tracee 通用暫存器的值。至於PTRACE_POKEUSER,針對一些通用暫存器的修改可能是禁止的(addr是被忽略的)。 注意:在SPARC系統中dataaddr的語意是反轉的——data被忽略,Tracee 的暫存器從addr地址處獲取資料。PTRACE_SETREGSPTRACE_SETFPREGS並非在所有的裝置上都有實現。
PTRACE_SETFPREGS  用法與PTRACE_SETREGS相同,只是修改 Tracee 浮點暫存器的值。
PTRACE_CONT  重新啟動已被停止的 Tracee —— 使其繼續執行。如果data是非空的,它被解釋為要傳送給 Tracee 的訊號,否則,不傳送任何訊號。換句話說,Tracer 可以控制是否要傳送一個訊號給到 Traceeaddr是被忽略的)。
PTRACE_SYSCALL、PTRACE_SINGLESTEP  像PTRACE_CONT一樣,使得已經停止的 Tracee 重新執行,但是 Tracee 會在每次進入或是離開一個系統呼叫,或是執行完一條訊號指令後被停止。  從 Tracer 的角度而言, Tracee 就如同受到了SIGTRAP被停止了一樣。因此PTRACE_SYSCALL一種用法是:在進入系統呼叫時檢測引數;在離開此次呼叫時,檢測其返回值。data引數的用法與PTRACE_CONT中的一致(addr是被忽略的)。
PTRACE_DETACH   像PTRACE_CONT一樣,使 Tracee 繼續執行,但會使 Tracee 脫離被追蹤狀態。在Linux中,無論是使用何種方式建立追蹤狀態的,都可以通過此方式使 Tracee 擺脫追蹤狀態(addr是被忽略的)。

四、讀取系統呼叫引數

  使用PTRACE_PEEKUSER作為呼叫ptrace的第一個引數,我們能夠檢測包含暫存器內容以及其它資訊的使用者區域——USER area。核心將暫存器資訊儲存在該區域,以便父程序(Tracer)能夠通過ptrace檢測它。   具體的使用方式詳見下例:

#include <sys/wait.h>
#include <unistd.h>     /* For fork() */
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>   /* For constants ORIG_RAX etc */
#include <sys/user.h>
#include <sys/syscall.h> /* SYS_write */
#include <stdio.h>
int main() {
    pid_t child;
    long orig_rax;
    int status;
    int iscalling = 0;
    struct user_regs_struct regs;

    child = fork();
    if(child == 0)
    {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "ls", "-l", "-h", NULL);
    }
    else
    {
        while(1)
        {
            wait(&status);
            if(WIFEXITED(status))
            {
                break;
            }
            orig_rax = ptrace(PTRACE_PEEKUSER,
                              child, 8 * ORIG_RAX,
                              NULL);
            if(orig_rax == SYS_write)
            {
                ptrace(PTRACE_GETREGS, child, NULL, &regs);			//獲取暫存器引數
                if(!iscalling)			//進入系統呼叫
                {
                    iscalling = 1;			
                    printf("[Enter SYS_write call] with regs.rdi [%ld], regs.rsi[%ld], regs.rdx[%ld], regs.rax[%ld], regs.orig_rax[%ld]\n",
                            regs.rdi, regs.rsi, regs.rdx,regs.rax, regs.orig_rax);
                }
                else			//離開此次系統呼叫
                {
                    printf("[Leave SYS_write call] return regs.rax [%ld], regs.orig_rax [%ld]\n", regs.rax, regs.orig_rax);
                    iscalling = 0;
                }
            }
            ptrace(PTRACE_SYSCALL, child, NULL, NULL);
        }
    }
    return 0;
}

  程式的輸出如下所示: [Enter SYS_write call] with regs.rdi [1], regs.rsi[140309977006080], regs.rdx[10], regs.rax[-38], regs.orig_rax[1] total 40K [Leave SYS_write call] return regs.rax [10], regs.orig_rax [1] [Enter SYS_write call] with regs.rdi [1], regs.rsi[140309977006080], regs.rdx[49], regs.rax[-38], regs.orig_rax[1] -rw-r–r--. 1 root root 7.5K Oct 7 16:56 main.o [Leave SYS_write call] return regs.rax [49], regs.orig_rax [1] [Enter SYS_write call] with regs.rdi [1], regs.rsi[140309977006080], regs.rdx[51], regs.rax[-38], regs.orig_rax[1] -rw-r–r--. 1 root root 17K Oct 6 16:58 Makefile [Leave SYS_write call] return regs.rax [51], regs.orig_rax [1] [Enter SYS_write call] with regs.rdi [1], regs.rsi[140309977006080], regs.rdx[49], regs.rax[-38], regs.orig_rax[1] -rwxr-xr-x. 1 root root 11K Oct 7 16:56 Ptarce [Leave SYS_write call] return regs.rax [49], regs.orig_rax [1] Press to close this window…   在以上程式中,我們追蹤了 Traceewrite系統呼叫,可以看到在此次的ls -l -h命令中,共發生了四次write呼叫。在讀取暫存器的時候,我們可以使用之前介紹的PTRACE_PEEKUSER引數來獲取某個暫存器的值,也可以直接使用PTRACE_GETREGS請求,將所有暫存器的值讀到結構體user_regs_struct中,該結構體的定義在sys/user.h中。   在上例中,從wait呼叫獲取的狀態值被用來檢測 Tracee 是否已經退出。這是用來檢測程序是被ptarce停止還是退出的典型用法。關於WIFEXITED巨集的用法,可以參見the wait(2) man page  需要引起我們注意的一點是:從程式的返回值中我們可以發現,無論是在進入還是退出系統呼叫,%orig_rax暫存器中儲存的都是系統呼叫號,而 %.rax則是在呼叫返回時,儲存了返回值。關於該內容具體的介紹,詳見Why is orig_eax provided in addition to eax?。文中主要講解了在32位機中已經有了 %eax暫存器的情況下,為何還需要 %orig_eax。這兩個暫存器其實就對應了64位機中的 %rax以及 %orig_rax,以下是援引文中的一段話

  Ptrace needs to be able to read both all registers state before syscall and the return value of syscall; but the return value is written to %eax. Then original eax, used before syscall will be lost. To save it, there is a orig_eax field.   系統希望ptrace能夠讀取系統呼叫前各暫存器的值,同時包括呼叫的返回值;但是在系統呼叫後,返回值被寫在了%eax暫存器中,而原來寫在%eax中的系統呼叫號,則被丟棄了。為了能夠儲存呼叫號,因此才有了%orig_eax ——Why is orig_eax provided in addition to eax?

五、一些有趣的嘗試

  現在是時候找點樂子了。在接下來的例子中,我們會嘗試將傳給write系統呼叫的字串進行倒置。

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/user.h>
#include <sys/reg.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
using namespace std;


const int long_size = sizeof(long);
void reverse(char *str)
{   int i, j;
    char temp;
    for(i = 0, j = strlen(str) - 2; //跳過結尾的 '\0'
        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 * 8,
                          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 * 8,
                          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 * 8, data.val);
        ++i;
        laddr += long_size;
    }
    j = len % long_size;
    if(j != 0) 
    {
        memcpy(data.chars, laddr, j);
        ptrace(PTRACE_POKEDATA, child,
               addr + i * 8, data.val);
    }
}
int main()
{
    pid_t child;
    printf("******Get Arch Info Begin******\n");
    if ( -1 == system("cat /proc/version "))
    {
        exit(-1);
    }
    printf("******Get Arch Info End******\n");
    printf("sizeof(long) is [%d]\n", sizeof(long));
    child = fork();

    if(child == 0)
    {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execlp("/bin/ls", "ls", NULL);
        printf("child exit\n");
    }
    else
    {
        long orig_rax;
        long params[3];
        int status;
        char *str, *laddr;
        int toggle = 0;   //用來判斷是進入還是離開系統呼叫
        while(1)
        {
            wait(&status);
            if(WIFEXITED(status))  //子程序是否退出
            {
                break;
            }
            orig_rax = ptrace(PTRACE_PEEKUSER,
                              child, 8 * ORIG_RAX,
                              NULL);   //獲取系統呼叫號
            if(orig_rax == SYS_write)
            {
                if(toggle == 0)
                {
                    toggle = 1;
                    //獲取傳遞給系統呼叫的引數:正如前文所介紹的X86_64而言,使用者層的應用使用%rdi,%rsi,%rdx暫存器,依次儲存系統呼叫的引數
                    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("[Enter SYS_write call] with regs.rdi [%ld], regs.rsi[0X%X], regs.rdx[%ld], regs.orig_rax[%ld]\n",
                           params[0], params[1],  params[2],orig_rax);
                    str = (char *)calloc((params[2]+1), sizeof(char));
                    getdata(child, params[1], str,
                            params[2]);
                    printf("Original str is: [%s]\n", str);
                    reverse(str);
                    putdata(child, params[1], str,
                            params[2]);
                }
                else
                {
                    toggle = 0;
                }
            }
            ptrace(PTRACE_SYSCALL, child, NULL, NULL);
        }
    }
    return 0;
}

  程式的輸出如下所示:

Get Arch Info Begin Linux version 2.6.32-431.el6.x86_64 ([email protected]) (gcc version 4.4.7 20120313 (Red Hat 4.4.7-4) (GCC) ) #1 SMP Fri Nov 22 03:15:09 UTC 2013 Get Arch Info End sizeof(long) is [8] [Enter SYS_write call] with regs.rdi [1], regs.rsi[0X994CE000], regs.rdx[54], regs.orig_rax[1] Original str is: [Eclipse hello.asm Reverse Reverse.cpp~ Test.cpp ] ppc.tseT ~ppc.esreveR esreveR msa.olleh espilcE [Enter SYS_write call] with regs.rdi [1], regs.rsi[0X994CE000], regs.rdx[70], regs.orig_rax[1] Original str is: [gnome-terminal.desktop hello.asm~ Reverse.cpp Test Test.cpp~ ] ~ppc.tseT tseT ppc.esreveR ~msa.olleh potksed.lanimret-emong

  該示例程式在使用了前文已討論的概念的同時,還使用了其它一些概念。我們使用PTRACE_POKEDATA作為引數來呼叫ptrace,用以改變 Tracee 資料段的值;使用PTRACE_PEEKDATA作為引數來呼叫ptrace用以獲取 Tracee 資料段的值。

六、單步除錯

  ptrace提供了單步除錯 Tracee 程式碼的功能。呼叫ptrace(使用PTRACE_SINGLESTEP作為引數)告知核心:在 Tracee 執行每條指令時,都被停止,並讓 Tracer 獲得控制權。下述例程展示了一種讀取正在執行的指令的方式。   為了讓讀者更好的理解發生了什麼事情,以一個簡單的程式作為被除錯的程式:

#include <stdio.h>
int main(void)
{
	printf("Hello World\n");
	return 0;
}

  原文此處是使用一段彙編程式碼進行舉例,但是對應彙編無法在x86_64上執行,因此此處使用相似的C程式代替。   我們使用下述程式碼對其進行單步除錯:

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/user.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <string.h>
int main()
{   pid_t child;
    const int long_size = sizeof(long);
    child = fork();
    if(child == 0)
    {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("./hello", "hello", 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.rip,
                             NULL);
                printf("RIP: %lx Instruction "
                       "executed: %lx\n",
                       regs.rip, ins);
            }

            //%orig_rax儲存了系統呼叫號
            if(regs.orig_rax == SYS_write)
            {
                start = 1;
                ptrace(PTRACE_SINGLESTEP, child,
                       NULL, NULL);
            }
            else
            {
                ptrace(PTRACE_SYSCALL, child,
                       NULL, NULL);
            }
        }
    }
    return 0;
}

  執行後,程式的輸出如下所示: Hello World RIP: 3ac1adb790 Instruction executed: 3173fffff0013d48 RIP: 3ac1adb796 Instruction executed: e808ec8348c33173 RIP: 3ac1aad028 Instruction executed: e076fffff0003d48   程式中對於%RIP指標的用法,本人還不夠了解,此處僅貼出相應程式碼(在x86_64上測試通過),具體的原理,還有待進一步查證。   倘若想要理解指令的意思,可能需要我們去檢視相應的使用者手冊。要想對複雜的程式實現單步除錯,則相求更周密的設計以及更復雜的程式碼。   以上即為本篇文章第一部分的內容。在下篇文章中,我們將瞭解如何插入斷點,以及在正在執行的程式中注入一段程式碼。   上述所有的程式都在x86_64上測試通過。本人所學有限,對於文中翻譯或是解釋不當之處,希望各位批評指正,感謝。

參考與連結

文件資訊

註釋

Tracer:追蹤程序 Tracee:被追蹤,被觀察程序

相關文章

暫無