歡迎光臨
每天分享高質量文章

使用 Ptrace 去攔截和模擬 Linux 系統呼叫 | Linux 中國

ptrace(2)(“行程跟蹤”)系統呼叫通常都與除錯有關。它是類 Unix 系統上透過原生除錯器監測被除錯行程的主要機制。它也是實現 strace(系統呼叫跟蹤)的常見方法。
— Chris Wellons


致謝
編譯自 | 
http://nullprogram.com/blog/2018/06/23/
 
 作者 | Chris Wellons
 譯者 | qhwdw ?共計翻譯:145 篇 貢獻時間:287 天

ptrace(2)(“行程跟蹤process trace”)系統呼叫通常都與除錯有關。它是類 Unix 系統上透過原生除錯器監測被除錯行程的主要機制。它也是實現 strace[1]系統呼叫跟蹤system call trace)的常見方法。使用 Ptrace,跟蹤器可以暫停被跟蹤行程,檢查和設定暫存器和記憶體[2],監視系統呼叫,甚至可以攔截intercepting系統呼叫。

透過攔截功能,意味著跟蹤器可以篡改系統呼叫引數,篡改系統呼叫的傳回值,甚至阻塞某些系統呼叫。言外之意就是,一個跟蹤器本身完全可以提供系統呼叫服務。這是件非常有趣的事,因為這意味著一個跟蹤器可以模擬一個完整的外部作業系統,而這些都是在沒有得到核心任何幫助的情況下由 Ptrace 實現的。

問題是,在同一時間一個行程只能被一個跟蹤器附著,因此在那個行程的除錯期間,不可能再使用諸如 GDB 這樣的工具去模擬一個外部作業系統。另外的問題是,模擬系統呼叫的開銷非常高。

在本文中,我們將專註於 x86-64 Linux 的 Ptrace[3],並將使用一些 Linux 專用的擴充套件。同時,在本文中,我們將忽略掉一些錯誤檢查,但是完整的原始碼仍然會包含這些錯誤檢查。

本文中的可直接執行的示例程式碼在這裡:https://github.com/skeeto/ptrace-examples

strace

在進入到最有趣的部分之前,我們先從回顧 strace 的基本實現來開始。它不是 DTrace[5],但 strace 仍然非常有用。

Ptrace 一直沒有被標準化。它的介面在不同的作業系統上非常類似,尤其是在核心功能方面,但是在不同的系統之間仍然存在細微的差別。ptrace(2) 的原型基本上應該像下麵這樣,但特定的型別可能有些差別。

  1. long ptrace(int request, pid_t pid, void *addr, void *data);

pid 是被跟蹤行程的 ID。雖然同一個時間只有一個跟蹤器可以附著到該行程上,但是一個跟蹤器可以附著跟蹤多個行程。

request 欄位選擇一個具體的 Ptrace 函式,比如 ioctl(2) 介面。對於 strace,只需要兩個:

◈ PTRACE_TRACEME:這個行程被它的父行程跟蹤。
◈ PTRACE_SYSCALL:繼續跟蹤,但是在下一下系統呼叫入口或出口時停止。
◈ PTRACE_GETREGS:取得被跟蹤行程的暫存器內容副本。

另外兩個欄位,addr 和 data,作為所選的 Ptrace 函式的一般引數。一般情況下,可以忽略一個或全部忽略,在那種情況下,傳遞零個引數。

strace 介面實質上是字首到另一個命令之前。

  1. $ strace [strace options] program [arguments]

最小化的 strace 不需要任何選項,因此需要做的第一件事情是 —— 假設它至少有一個引數 —— 在 argv 尾部的 fork(2) 和 exec(2) 被跟蹤行程。但是在載入標的程式之前,新的行程將告知核心,標的程式將被它的父行程繼續跟蹤。被跟蹤行程將被這個 Ptrace 系統呼叫暫停。

  1. pid_t pid = fork();

  2. switch (pid) {

  3.    case -1: /* error */

  4.        FATAL("%s", strerror(errno));

  5.    case 0:  /* child */

  6.        ptrace(PTRACE_TRACEME, 0, 0, 0);

  7.        execvp(argv[1], argv + 1);

  8.        FATAL("%s", strerror(errno));

  9. }

父行程使用 wait(2) 等待子行程的 PTRACE_TRACEME,當 wait(2) 傳回後,子行程將被暫停。

  1. waitpid(pid, 0, 0);

在允許子行程繼續執行之前,我們告訴作業系統,被跟蹤行程和它的父行程應該一同被終止。一個真實的 strace 實現可能會設定其它的選擇,比如: PTRACE_O_TRACEFORK

  1. ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_EXITKILL);

剩餘部分就是一個簡單的、無休止的迴圈了,每迴圈一次捕獲一個系統呼叫。迴圈體總共有四步:

1. 等待行程進入下一個系統呼叫。
2. 輸出系統呼叫的一個描述。
3. 允許系統呼叫去執行並等待傳回。
4. 輸出系統呼叫傳回值。

這個 PTRACE_SYSCALL 請求被用於等待下一個系統呼叫時開始,和等待那個系統呼叫退出。和前面一樣,需要一個 wait(2) 去等待被跟蹤行程進入期望的狀態。

  1. ptrace(PTRACE_SYSCALL, pid, 0, 0);

  2. waitpid(pid, 0, 0);

當 wait(2) 傳回時,進行了系統呼叫的執行緒的暫存器中寫入了該系統呼叫的系統呼叫號及其引數。儘管如此,作業系統仍然沒有為這個系統呼叫提供服務。這個細節對後續操作很重要。

接下來的一步是採集系統呼叫資訊。這是各個系統架構不同的地方。在 x86-64 上,系統呼叫號是在 rax 中傳遞的[6],而引數(最多 6 個)是在 rdirsirdxr10r8 和 r9 中傳遞的。這些暫存器是由另外的 Ptrace 呼叫讀取的,不過這裡再也不需要 wait(2)了,因為被跟蹤行程的狀態再也不會發生變化了。

  1. struct user_regs_struct regs;

  2. ptrace(PTRACE_GETREGS, pid, 0, &regs);

  3. long syscall = regs.orig_rax;

  4. fprintf(stderr, "%ld(%ld, %ld, %ld, %ld, %ld, %ld)",

  5.        syscall,

  6.        (long)regs.rdi, (long)regs.rsi, (long)regs.rdx,

  7.        (long)regs.r10, (long)regs.r8,  (long)regs.r9);

這裡有一個警告。由於 內核的內部用途[7],系統呼叫號是儲存在 orig_rax 中而不是 rax中。而所有的其它系統呼叫引數都是非常簡單明瞭的。

接下來是它的另一個 PTRACE_SYSCALL 和 wait(2),然後是另一個 PTRACE_GETREGS 去獲取結果。結果儲存在 rax 中。

  1. ptrace(PTRACE_GETREGS, pid, 0, &regs);

  2. fprintf(stderr, " = %ld\n", (long)regs.rax);

這個簡單程式的輸出也是非常粗糙的。這裡的系統呼叫都沒有符號名,並且所有的引數都是以數字形式輸出,甚至是一個指向緩衝區的指標也是如此。更完整的 strace 輸出將能知道哪個引數是指標,並使用 process_vm_readv(2) 從被跟蹤行程中讀取哪些緩衝區,以便正確輸出它們。

然而,這些僅僅是系統呼叫攔截的基礎工作。

系統呼叫攔截

假設我們想使用 Ptrace 去實現如 OpenBSD 的 pledge(2)[8] 這樣的功能,它是 一個行程承諾pledge只使用一套受限的系統呼叫[9]。初步想法是,許多程式一般都有一個初始化階段,這個階段它們都需要進行許多的系統訪問(比如,開啟檔案、系結套接字、等等)。初始化完成以後,它們進行一個主迴圈,在主迴圈中它們處理輸入,並且僅使用所需的、很少的一套系統呼叫。

在進入主迴圈之前,一個行程可以限制它自己只能執行所需要的幾個操作。如果 程式有缺陷[10],能夠透過惡意的輸入去利用該缺陷,這個承諾可以有效地限制漏洞利用的實現。

使用與 strace 相同的模型,但不是輸出所有的系統呼叫,我們既能夠阻塞某些系統呼叫,也可以在它的行為異常時簡單地終止被跟蹤行程。終止它很容易:只需要在跟蹤器中呼叫 exit(2)。因此,它也可以被設定為去終止被跟蹤行程。阻塞系統呼叫和允許子行程繼續執行都只是些雕蟲小技而已。

最棘手的部分是當系統呼叫啟動後沒有辦法去中斷它。當跟蹤器在入口從 wait(2) 中傳回到系統呼叫時,從一開始停止一個系統呼叫的僅有方式是,終止被跟蹤行程。

然而,我們不僅可以“搞亂”系統呼叫的引數,也可以改變系統呼叫號本身,將它修改為一個不存在的系統呼叫。傳回時,在 errno 中 透過正常的內部訊號[11],我們就可以報告一個“友好的”錯誤資訊。

  1. for (;;) {

  2.    /* Enter next system call */

  3.    ptrace(PTRACE_SYSCALL, pid, 0, 0);

  4.    waitpid(pid, 0, 0);

  5.    struct user_regs_struct regs;

  6.    ptrace(PTRACE_GETREGS, pid, 0, &regs);

  7.    /* Is this system call permitted? */

  8.    int blocked = 0;

  9.    if (is_syscall_blocked(regs.orig_rax)) {

  10.        blocked = 1;

  11.        regs.orig_rax = -1; // set to invalid syscall

  12.        ptrace(PTRACE_SETREGS, pid, 0, &regs);

  13.    }

  14.    /* Run system call and stop on exit */

  15.    ptrace(PTRACE_SYSCALL, pid, 0, 0);

  16.    waitpid(pid, 0, 0);

  17.    if (blocked) {

  18.        /* errno = EPERM */

  19.        regs.rax = -EPERM; // Operation not permitted

  20.        ptrace(PTRACE_SETREGS, pid, 0, &regs);

  21.    }

  22. }

這個簡單的示例只是檢查了系統呼叫是否違反白名單或黑名單。而它們在這裡並沒有差別,比如,允許檔案以只讀而不是讀寫方式開啟(open(2)),允許匿名記憶體對映但不允許非匿名對映等等。但是這裡仍然沒有辦法去動態撤銷被跟蹤行程的許可權。

跟蹤器與被跟蹤行程如何溝通?使用人為的系統呼叫!

建立一個人為的系統呼叫

對於我的這個類似於 pledge 的系統呼叫 —— 我可以透過呼叫 xpledge() 將它與真實的系統呼叫區分開 —— 我設定 10000 作為它的系統呼叫號,這是一個非常大的數字,真實的系統呼叫中從來不會用到它。

  1. #define SYS_xpledge 10000

為演示需要,我同時構建了一個非常小的介面,這在實踐中並不是個好主意。它與 OpenBSD 的 pledge(2) 稍有一些相似之處,它使用了一個 字串介面[12]事實上,設計一個健壯且安全的許可權集是非常複雜的,正如在 pledge(2) 的手冊頁面上所顯示的那樣。下麵是對被跟蹤行程的系統呼叫的完整介面實現:

  1. #define _GNU_SOURCE

  2. #include <unistd.h>

  3. #define XPLEDGE_RDWR (1 << 0)

  4. #define XPLEDGE_OPEN (1 << 1)

  5. #define xpledge(arg) syscall(SYS_xpledge, arg)

如果給它傳遞個引數 0 ,僅允許一些基本的系統呼叫,包括那些用於去分配記憶體的系統呼叫(比如 brk(2))。 PLEDGE_RDWR 位允許 各種[13] 讀和寫的系統呼叫(read(2)readv(2)pread(2)preadv(2) 等等)。PLEDGE_OPEN 位允許 open(2)

為防止發生提升許可權的行為,pledge() 會攔截它自己 —— 但這樣也防止了許可權撤銷,以後再細說這方面內容。

在 xpledge 跟蹤器中,我需要去檢查這個系統呼叫:

  1. /* Handle entrance */

  2. switch (regs.orig_rax) {

  3.    case SYS_pledge:

  4.        register_pledge(regs.rdi);

  5.        break;

  6. }

作業系統將傳回 ENOSYS(函式尚未實現),因為它不是一個真實的系統呼叫。為此在退出時我用一個 success(0) 去覆寫它。

  1. /* Handle exit */

  2. switch (regs.orig_rax) {

  3.    case SYS_pledge:

  4.        ptrace(PTRACE_POKEUSER, pid, RAX * 8, 0);

  5.        break;

  6. }

我寫了一小段測試程式去開啟 /dev/urandom,做一個讀操作,嘗試去承諾後,然後試著第二次開啟 /dev/urandom,然後確認它能夠讀取原始的 /dev/urandom 檔案描述符。在沒有承諾跟蹤器的情況下執行,輸出如下:

  1. $ ./example

  2. fread("/dev/urandom")[1] = 0xcd2508c7

  3. XPledging...

  4. XPledge failed: Function not implemented

  5. fread("/dev/urandom")[2] = 0x0be4a986

  6. fread("/dev/urandom")[1] = 0x03147604

做一個無效的系統呼叫並不會讓應用程式崩潰。它只是失敗,這是一個很方便的傳回方式。當它在跟蹤器下執行時,它的輸出如下:

  1. >$ ./xpledge ./example

  2. fread("/dev/urandom")[1] = 0xb2ac39c4

  3. XPledging...

  4. fopen("/dev/urandom")[2]: Operation not permitted

  5. fread("/dev/urandom")[1] = 0x2e1bd1c4

這個承諾很成功,第二次的 fopen(3) 並沒有進行,因為跟蹤器用一個 EPERM 阻塞了它。

可以將這種思路進一步發揚光大,比如,改變檔案路徑或傳回一個假的結果。一個跟蹤器可以很高效地 chroot 它的被跟蹤行程,透過一個系統呼叫將任意路徑傳遞給 root 從而實現 chroot 路徑。它甚至可以對使用者進行欺騙,告訴使用者它以 root 執行。事實上,這些就是 Fakeroot NG[14] 程式所做的事情。

模擬外部系統

假設你不滿足於僅攔截一些系統呼叫,而是想攔截全部系統呼叫。你就會有了 一個打算在其它作業系統上執行的二進製程式[15],無需系統呼叫,這個二進製程式可以一直執行。

使用我在前面所描述的這些內容你就可以管理這一切。跟蹤器可以使用一個假冒的東西去代替系統呼叫號,允許它失敗,以及為系統呼叫本身提供服務。但那樣做的效率很低。其實質上是對每個系統呼叫做了三個背景關係切換:一個是在入口上停止,一個是讓系統呼叫總是以失敗告終,還有一個是在系統呼叫退出時停止。

從 2005 年以後,對於這個技術,PTrace 的 Linux 版本有更高效的操作:PTRACE_SYSEMU。PTrace 僅在每個系統呼叫發出時停止一次,在允許被跟蹤行程繼續執行之前,由跟蹤器為系統呼叫提供服務。

  1. for (;;) {

  2.    ptrace(PTRACE_SYSEMU, pid, 0, 0);

  3.    waitpid(pid, 0, 0);

  4.    struct user_regs_struct regs;

  5.    ptrace(PTRACE_GETREGS, pid, 0, &regs);

  6.    switch (regs.orig_rax) {

  7.        case OS_read:

  8.            /* ... */

  9.        case OS_write:

  10.            /* ... */

  11.        case OS_open:

  12.            /* ... */

  13.        case OS_exit:

  14.            /* ... */

  15.        /* ... and so on ... */

  16.    }

  17. }

從任何具有(足夠)穩定的系統呼叫 ABI(LCTT 譯註:應用程式二進位制介面),在相同架構的機器上執行一個二進製程式時,你只需要 PTRACE_SYSEMU 跟蹤器、一個載入器(用於代替 exec(2)),和這個二進製程式所需要(或僅執行靜態的二進製程式)的任何系統庫即可。

事實上,這聽起來有點像一個有趣的週末專案。

參見

◈ 給 Linux 核心克隆實現一個 OpenBSD 承諾[16]

via: http://nullprogram.com/blog/2018/06/23/

作者:Chris Wellons[18] 選題:lujun9972 譯者:qhwdw 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

贊(0)

分享創造快樂

© 2024 知識星球   網站地圖