May 23, 2008

以 ptrace 系統呼叫來追蹤/修改行程

一月份南下分享主題為 [快快樂樂學 GNU Debugger] 的演講,當時為了說明 GNU Debugger (gdb) 在 Linux 運作的原理,提及 ptrace 系統呼叫,這是何以 gdb 能行使動態追蹤、分析,進而修改執行中行程 (process) 的關鍵。本文試著以簡要的案例,說明如何使用 ptrace 系統呼叫,達到類似 gdb 的行為。

在 MS-Windows 中,「攔截」或「追蹤」其他行程,是相當進階的議題,而且為了達到目的,往往得訴諸頗多 hacks,然而,在 UNIX 的世界裡,作業系統提供 ptrace 系統呼叫,允許我們優雅地進行這些動作。且讓我們問問男人 (雙關語,UNIX "manual",縮寫為 "man"),看 ptrace() 的描述:
    # man 2 ptrace
    The ptrace() system call provides a means by which a parent process may observe and control the execution of another process, and examine and change its core image and registers. It is primarily used to implement breakpoint debugging and system call tracing.

    The parent can initiate a trace by calling fork(2) and having the resulting child do a PTRACE_TRACEME, followed (typically) by an exec(3). Alternatively, the parent may commence trace of an existing process using PTRACE_ATTACH.
    ... (後略) ...
整理我們的初步認知:
  • ptrace 系統呼叫用以實做 gdb 一類可斷點 (breakpoint) 的追蹤除錯,或作系統呼叫的追蹤分析
  • ptrace 允許一個 parent process 去監控另一個 process 的執行,並得以檢驗 / 更改執行時期的系統 image (映射於虛擬記憶體) 和暫存器
  • 使用情境可透過 fork 系統呼叫去建立 child process (搭配 exec 系統呼叫) 或者直接追蹤某個已執行的 process
另外,依據此陳述,我們也可發現,在使用者層級 (user-level / user-space) 追蹤其他行程是可行的。而在實際「追蹤」前,我們應該要很清楚無論哪個行程,只要存取到系統服務,哪怕只是 "Hello World" 等級的應用程式想透過標準 C 函式庫呼叫 printf() 印列字元,都涉及系統呼叫 (system call,以下簡稱 "syscall") 的處理,在前年、去年的 [深入淺出 Hello World] 系列演講中,已對此做了系統性的探討,本文不再細究其原理,僅點出重點以銜接 ptrace 的角色。

對 Linux 來說,在 IA32 (32 位元的 x86 系統,本文也記作 i386) 上,一個行程欲使用 syscall 時,需要將相關參數推入至暫存器 (register),並觸發 0x80 (hex) 軟體中斷,就緒後,行程的控制權,就從 user-space 切換到 kernel-space,由核心來完成系統呼叫的具體動作。示意圖如下: (出處:〈Kernel command using Linux system calls〉, M. Tim Jones)

上圖以 getpid() 這個 syscall 為例,實際上,C 語言的應用程式並非直接呼叫 syscall 的,而需要透過 GNU glibc 一類 C-Library 裡頭 syscall wrapper 所提供的函式呼叫進行,其細部的實做就是在 eax 暫存器設定 getpid syscall 的編號 (定義於 syscall 表格的 _NR_getpid 項目),之後觸發 int 0x80。由上圖可見,執行權移轉到核心,核心由剛剛設定的 eax 暫存器作為索引,去 system_call_table[] 找到真正實做 getpid syscall 的進入點 (entry),去呼叫並在執行後,將控制權交回 user-space,對原本的行程來說,就是 getpid() 這個 C 函式的返回。

那麼,ptrace 這個 syscall 在何時現身?本文大幅略過平台相關的部份,僅探討 行為模式:在 syscall 真正呼叫前,Linux 核心會檢查目前的行程是否處於「被追蹤」(traced) 的狀態,若是,核心會暫停 (stop) 目前的行程,並將控制權交與欲追蹤 (主動去追蹤其他行程者) 的行程,讓跟蹤行程得以監控 traced process 的暫存器,當然也包含執行時期的 pc (Program Counter,在 IA32 上,就是 eip 暫存器)。

有了基礎概念後,我們就可實際作點事。筆者於兩年前撰寫過一篇文章 [SM 版 Hello World],談及 Linux/i386 上,如何實現 SMC (Self-Modifying Code),而標題的 "SM" 當然是指一個行程如 "Hello World" 者,如何自我修改執行時期的內容,也就是 Selt-Modifying,感覺是「自虐」,反而不若一般說 SM (施虐與受虐) 的語意。那麼,今天我們就來作個真正有 "SM" 能力的程式,展示動態追蹤 / 修改其他行程的行為,預想的情境為先讓一個無限迴圈的行程保持等待,然後另一個行程透過 ptrace syscall 去攔截該行程並修改執行內容 (是的,就是「施虐」的動作),最後讓被 ptrace 行程產生變化 (也就是「受虐」者)。

ptrace 的函式宣告如下:
long ptrace(enum __ptrace_request request,
            pid_t pid,
            void *addr,
            void *data);
首個引數規範 ptrace syscall 的具體行為與其使用模式,有以下值:
  • PTRACE_TRACEME
  • PTRACE_PEEKTEXT, PTRACE_PEEKDATA
  • PTRACE_PEEKUSER
  • PTRACE_POKETEXT, PTRACE_POKEDATA (*)
  • PTRACE_POKEUSER
  • PTRACE_GETREGS (*), PTRACE_GETFPREGS
  • PTRACE_GETSIGINFO
  • PTRACE_SETREGS (*), PTRACE_SETFPREGS
  • PTRACE_SETSIGINFO
  • PTRACE_SETOPTIONS
  • PTRACE_GETEVENTMSG
  • PTRACE_CONT
  • PTRACE_SYSCALL, PTRACE_SINGLESTEP
  • PTRACE_SYSEMU, PTRACE_SYSEMU_SINGLESTEP
  • PTRACE_KILL
  • PTRACE_ATTACH (*)
  • PTRACE_DETACH (*)
標注 (*) 者,為本文範例所用到的 request。其中 PTRACE_GETSIGINFO 與 PTRACE_SETSIGINFO 為 Linux 2.3.99-pre6 後所追加;PTRACE_SETOPTIONS 則為 2.4.6 後追加,引入若干新的 bit mask;PTRACE_GETEVENTMSG 為 2.5.46 後追加;PTRACE_SYSEMU 與 PTRACE_SYSEMU_SINGLESTEP 為 2.6.14 後追加,提供給 UML (User-Mode Linux) 一類的系統作為 syscall 模擬使用。由此可見 Linux 改版時,ptrace 也會隨著更動,注意,addr 與 data 這兩個引數在某些 request 會被忽略,詳情可參考 man-pages 描述。

咱們動手來寫程式,就取名為 [injector.c] 表示符合前述情境。以下為程式碼列表:
#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h> /* ptrace() */

#include <sys/wait.h>   /* wait() */
#include <sys/types.h>
#include <linux/user.h>   /* struct user_regs_struct */

/* _exit(1) implementation in shellcode */

static char shellcode[] =
    "\x31\xc0" 		/* xor  %eax,%eax */
    "\x40" 			/* inc  %eax */
    "\xcd\x80" 		/* int  $0x80 */ ;


#include <stdarg.h>
#define OUT_MSG(x, ...) printf("* " x "\n",## __VA_ARGS__)
#define ERR_MSG(x) printf("\t[Error] " x "\n")

int main(int argc, char *argv[])
{
    int pid, offset;
    struct user_regs_struct regs;

    OUT_MSG("Injector starts.");
    if (argc < 2) {
        ERR_MSG("PID required in parameter.");
        return -1;
    }

    pid = atoi(argv[1]);
    OUT_MSG("Attaching process (PID=%d)...", pid);
    if (ptrace(PTRACE_ATTACH, pid, NULL, NULL) < 0) {
        ERR_MSG("Fail to ptrace process");
        ptrace(PTRACE_DETACH, pid, NULL, NULL);
        return -1;
    }

    OUT_MSG("Process attached.");
    /* see if  a child has stopped (but not traced via ptrace(2)) */

    if (waitpid(pid, NULL, WUNTRACED) < 0) {
        ERR_MSG("WUNTRACED");
        exit(1);
    }

    OUT_MSG("Getting registers from process.");
    if (ptrace(PTRACE_GETREGS, pid, NULL, &regs) < 0) {
        ERR_MSG("Fail to get registers.");
        ptrace(PTRACE_DETACH, pid, NULL, NULL);
        exit(1);
    }

    OUT_MSG("Injecting shellcode into process...");
    for (offset = 0; offset < sizeof(shellcode); offset++) {
        if (ptrace(PTRACE_POKEDATA, pid,
                   regs.esp + offset,
                   *(int *) &shellcode[offset])) {
            ERR_MSG("Fail to inject.");
            ptrace(PTRACE_DETACH, pid, NULL, NULL);
            exit(1);
        }
    }

    regs.eip = regs.esp;
    regs.eip += 2;

    OUT_MSG("Adjust program counter (EIP) of process to 0x%x",
            (unsigned int) regs.eip);
    if (ptrace(PTRACE_SETREGS, pid, NULL, &regs) < 0) {
        ERR_MSG("Unable to set registers.");
        ptrace(PTRACE_DETACH, pid, NULL, NULL);
        exit(1);
    }

    OUT_MSG("Detach process (PID=%d).", pid);
    ptrace(PTRACE_DETACH, pid, NULL, NULL);
    OUT_MSG("Done");
    return 0;
}
既然有了「施虐」者程式,就再弄個「受虐」者程式,比方說 [dummy-loop-prog.c],以下是程式列表:
#include <unistd.h>
int main()
{
    while (1)
        sleep(1);
    return 0;
}
沒什麼好說,就是一直透過 sleep syscall 等待的小程式。一開始,我們讓 dummy-loop-prog 先執行,如下圖:

而當前述的 injector 執行時,我們將會發現:

injector 透過 ptrace syscall 去 attach 到執行中 dummy-loop-prog 的行程 (PID=943),先取得暫存器的值,當然,最有興趣的是 ESP,也就是指向目前的 stack frame 的指標,因為我們要注入 shellcode。一旦完成注入的動作後,需要將 EIP 暫存器的值調整,這時候 PID=943 的行程就被迫執行到 shellcode 的內容,也就是 _exit(1),這會使原本的無限迴圈因而終止,行程也會結束。

[injector.c] 的程式碼中,將 request 設定為 PTRACE_GETREGS 傳遞給 ptrace syscall 可得到被追蹤的行程現行的暫存器內容,保存於 user_regs_struct 結構體之中,而這定義於 <linux/user.h> 檔頭。而 request 設定為 PTRACE_PEEKDATA / PTRACE_POKEDATA 時,則可窺視 / 修改執行中行程的內容,就像筆者展示植入 shellcode 的行為一般。更甚者,request 設定為 PTRACE_SINGLESTEP 時,ptrace 允許對 child process 進行單步執行的動作,這會使得核心在 child process 的每條指令執行前,會先中斷等待著,而將控制權交給追蹤的行程,就好比 gdb 的行為一般,當然,能做的事情就更多了。

ptrace 系統呼叫無疑是對 Linux 底層運作機制作尋幽訪勝,一個相當強大且優雅的工具,若能善用,並搭配既有的工具如 gdb,將能如虎添翼。另外,由於 ptrace syscall 在若干應用,如 User-Mode-Linux 與 multi-threading 環境中,有先天設計的缺陷,RedHat 工程師 Roland McGrath 則展開 [utrace] 的新發展,目標是完全取代 ptrace,允許 user-space 行程透過 utrace syscall,得以掌握更多 Linux 核心的資訊,並引入名為 "tracing engine" 的新機制,可更深入地追蹤掌控行程的狀態。
由 jserv 發表於 May 23, 2008 02:07 AM
迴響

http://blog.chinaunix.net/u/5251/showart_517381.html
我写过的基于ptrace注入的dumper程序。

xiaosuo 發表於 May 23, 2008 03:33 PM

@xiaosuo

感謝分享!

jserv 發表於 May 25, 2008 04:40 AM

請問一下:
為什麼eip要加2
regs.eip += 2;

ken 發表於 November 5, 2008 10:43 PM
發表迴響









記住我的資訊?