使用 Ptrace 去攔截和仿真 Linux 系統調用

使用 Ptrace 去攔截和仿真 Linux 系統調用

編譯自: http://nullprogram.com/blog/2018/06/23/

ptrace(2)(“ 進程跟蹤(process trace)”)系統調用通常都與調試有關。它是類 Unix 系統上通過原生調試器監測被調試進程的主要機制。它也是實現 strace ( 系統調用跟蹤(system call trace))的常見方法。使用 Ptrace,跟蹤器可以暫停被跟蹤進程, 檢查和設置寄存器和內存 ,監視系統調用,甚至可以 攔截((intercepting))系統調用。

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

問題是,在同一時間一個進程只能被一個跟蹤器附著,因此在那個進程的調試期間,不可能再使用諸如 GDB 這樣的工具去仿真一個外部操作系統。另外的問題是,仿真系統調用的開銷非常高。

在本文中,我們將專注於 x86-64 Linux 的 Ptrace ,並將使用一些 Linux 專用的擴展。同時,在本文中,我們將忽略掉一些錯誤檢查,但是完整的源代碼仍然會包含這些錯誤檢查。

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

strace

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

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

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 接口實質上是前綴到另一個命令之前。

$ strace [strace options] program [arguments]

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

pid_t pid = fork();

switch (pid) {

case -1: /* error */

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

case 0: /* child */

ptrace(PTRACE_TRACEME, 0, 0, 0);

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

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

}

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

waitpid(pid, 0, 0);

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

ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_EXITKILL);

剩餘部分就是一個簡單的、無休止的循環了,每循環一次捕獲一個系統調用。循環體總共有四步:

  1. 等待進程進入下一個系統調用。
  2. 輸出系統調用的一個描述。
  3. 允許系統調用去運行並等待返回。
  4. 輸出系統調用返回值。

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

ptrace(PTRACE_SYSCALL, pid, 0, 0);

waitpid(pid, 0, 0);

當 wait(2) 返回時,進行了系統調用的線程的寄存器中寫入了該系統調用的系統調用號及其參數。儘管如此,操作系統仍然沒有為這個系統調用提供服務。這個細節對後續操作很重要。

接下來的一步是採集系統調用信息。這是各個系統架構不同的地方。在 x86-64 上, 系統調用號是在 rax 中傳遞的 ,而參數(最多 6 個)是在 rdi、rsi、rdx、r10、r8 和 r9 中傳遞的。這些寄存器是由另外的 Ptrace 調用讀取的,不過這裡再也不需要 wait(2) 了,因為被跟蹤進程的狀態再也不會發生變化了。

struct user_regs_struct regs;

ptrace(PTRACE_GETREGS, pid, 0, ®s);

long syscall = regs.orig_rax;

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

syscall,

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

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

這裡有一個警告。由於 內核的內部用途 ,系統調用號是保存在 orig_rax 中而不是 rax 中。而所有的其它系統調用參數都是非常簡單明瞭的。

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

ptrace(PTRACE_GETREGS, pid, 0, ®s);

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

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

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

系統調用攔截

假設我們想使用 Ptrace 去實現如 OpenBSD 的 pledge(2) 這樣的功能,它是 一個進程 承諾(pledge)只使用一套受限的系統調用 。初步想法是,許多程序一般都有一個初始化階段,這個階段它們都需要進行許多的系統訪問(比如,打開文件、綁定套接字、等等)。初始化完成以後,它們進行一個主循環,在主循環中它們處理輸入,並且僅使用所需的、很少的一套系統調用。

在進入主循環之前,一個進程可以限制它自己只能運行所需要的幾個操作。如果 程序有缺陷 ,能夠通過惡意的輸入去利用該缺陷,這個承諾可以有效地限制漏洞利用的實現。

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

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

然而,我們不僅可以“搞亂”系統調用的參數,也可以改變系統調用號本身,將它修改為一個不存在的系統調用。返回時,在 errno 中 通過正常的內部信號 ,我們就可以報告一個“友好的”錯誤信息。

for (;;) {

/* Enter next system call */

ptrace(PTRACE_SYSCALL, pid, 0, 0);

waitpid(pid, 0, 0);

struct user_regs_struct regs;

ptrace(PTRACE_GETREGS, pid, 0, ®s);

/* Is this system call permitted? */

int blocked = 0;

if (is_syscall_blocked(regs.orig_rax)) {

blocked = 1;

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

ptrace(PTRACE_SETREGS, pid, 0, ®s);

}

/* Run system call and stop on exit */

ptrace(PTRACE_SYSCALL, pid, 0, 0);

waitpid(pid, 0, 0);

if (blocked) {

/* errno = EPERM */

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

ptrace(PTRACE_SETREGS, pid, 0, ®s);

}

}

這個簡單的示例只是檢查了系統調用是否違反白名單或黑名單。而它們在這裡並沒有差別,比如,允許文件以只讀而不是讀寫方式打開(open(2)),允許匿名內存映射但不允許非匿名映射等等。但是這裡仍然沒有辦法去動態撤銷被跟蹤進程的權限。

跟蹤器與被跟蹤進程如何溝通?使用人為的系統調用!

創建一個人為的系統調用

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

#define SYS_xpledge 10000

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

#define _GNU_SOURCE

#include

#define XPLEDGE_RDWR (1 << 0)

#define XPLEDGE_OPEN (1 << 1)

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

如果給它傳遞個參數 0 ,僅允許一些基本的系統調用,包括那些用於去分配內存的系統調用(比如 brk(2))。 PLEDGE_RDWR 位允許 各種 讀和寫的系統調用(read(2)、readv(2)、pread(2)、preadv(2) 等等)。PLEDGE_OPEN 位允許 open(2)。

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

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

/* Handle entrance */

switch (regs.orig_rax) {

case SYS_pledge:

register_pledge(regs.rdi);

break;

}

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

/* Handle exit */

switch (regs.orig_rax) {

case SYS_pledge:

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

break;

}

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

$ ./example

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

XPledging...

XPledge failed: Function not implemented

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

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

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

>$ ./xpledge ./example

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

XPledging...

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

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

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

可以將這種思路進一步發揚光大,比如,改變文件路徑或返回一個假的結果。一個跟蹤器可以很高效地 chroot 它的被跟蹤進程,通過一個系統調用將任意路徑傳遞給 root 從而實現 chroot 路徑。它甚至可以對用戶進行欺騙,告訴用戶它以 root 運行。事實上,這些就是 Fakeroot NG 程序所做的事情。

仿真外部系統

假設你不滿足於僅攔截一些系統調用,而是想攔截全部系統調用。你就會有了 一個打算在其它操作系統上運行的二進制程序 ,無需系統調用,這個二進制程序可以一直運行。

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

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

for (;;) {

ptrace(PTRACE_SYSEMU, pid, 0, 0);

waitpid(pid, 0, 0);

struct user_regs_struct regs;

ptrace(PTRACE_GETREGS, pid, 0, ®s);

switch (regs.orig_rax) {

case OS_read:

/* ... */

case OS_write:

/* ... */

case OS_open:

/* ... */

case OS_exit:

/* ... */

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

}

}

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

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

參見

  • 給 Linux 內核克隆實現一個 OpenBSD 承諾

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

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

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


分享到:


相關文章: