Linux核心跟蹤:ftrace hook入門手冊(下)

語言: CN / TW / HK

閱讀: 17

一、前情提要

在前一篇文章(http://mp.weixin.qq.com/s/H8tpt7aWR6pvMAWi5-qVww)中,我們對部分ftrace hook經典方案中的實現細節進行了優化。本文會深入說明這些優化的原理和目的。

二、核心版本的差異

目前的ftrace hook實現中,總是需要使用大量條件編譯以解決Linux核心的版本差異問題。其中較為關鍵的一個差異點,就是Linux核心從4.17版本開始修改了系統呼叫過程中的函式簽名,這對ftrace hook的實現造成了較大的困擾。

下為4.16版本Linux核心原始碼/arch/x86/entry/common.c [1] ,尤其關注第287行,可見該版本Linux核心在執行系統呼叫時會將暫存器結構體中的6個引數展開來呼叫sys_call_table[nr]:

圖1:Linux核心4.16版本do_syscall_64函式實現

而在4.17.0版本中,同樣在第287行,可見已經改用單個引數(指向整個暫存器結構體的指標)來呼叫sys_call_table[nr]:

圖2:Linux核心4.17版本do_syscall_64函式實現

而如前一篇文章所述,ftrace hook是通過編譯時處理,在各個核心函式實現程式碼的開頭插樁call指令,所以ftrace hook介入系統呼叫是在do_syscall_64之後:

圖3:ftrace hook子程中列印的部分核心呼叫堆疊(上為棧頂,下為棧底)

因此ftrace中直接使用的hook子程在獲取系統呼叫引數時,必須考慮這種差異才行。

三、經典方案的缺陷

針對這個問題,在筆者找到的幾乎所有經典方案 [ 2] 中,都通過條件編譯定義了兩套hook子程,分別適用於4.17版本前後的兩種情況:

圖4:經典方案中條件編譯兩個hook子程

但這樣實現的話,相同的功能都要寫兩套,程式碼開發和維護都十分不便。以一般的程式設計思路,我們可以封裝定義一個形式上的hook子程函式(後簡稱外套子程),在這個外套子程中將傳遞到系統呼叫的函式統一結構後,再呼叫實際實現業務功能hook子程(後簡稱業務子程)。

然而事情並沒有這麼簡單。經典方案通常針對x86架構,並不是在ftrace_set_filter_ip所設定的過濾器函式中呼叫hook子程,而是在這個過濾器函式中修改EIP/RIP暫存器到hook子程的入口地址。hook子程並非在ftrace框架內呼叫,而是在ftrace框架返回到系統呼叫時跳轉到hook子程(而沒有回到真正的系統呼叫函式)。

這種做法的好處是,hook子程在執行流程上直接替代了原有的系統呼叫函式,兩者可以使用完全相同的函式簽名處理業務,有點類似於修改系統呼叫表的hook方法。

hook子程可以直接定義與系統呼叫函式相同的形式引數來獲取系統呼叫引數值,而返回時也會直接返回到系統呼叫函式的直接呼叫方(參考下圖 [ 3] ):

圖5:經典方案中的hook執行流程

然而,由於Linux核心模組通常為純C語言實現,缺少將引數值或者其它資訊繫結到回撥函式的原生支援。ftrace_set_filter_ip所設定的過濾器函式中姑且可以根據第三個引數所指向的地址來找到與當前hook例項有關的資訊(即程式碼中的“container_of(ops,…)”)。但如果我們通過修改RIP跳轉到外套子程,那就意味著所有的ftrace hook都會跳轉到同一個外套子程,而此時外套子程所接收到的引數實際上是由系統呼叫函式的直接呼叫方(如do_syscall_64)提供的,我們很難在過濾器函式中修改或傳遞更多的引數給外套子程——結果導致在同時存在多個hook目標的情況下,外套子程內部難以確定應該呼叫哪個業務子程。

當然,並非完全沒有方法來解決這個問題。我們可以將業務子程繫結到系統呼叫號,然後在外套子程中根據系統呼叫號(x86架構是AX)來找到對應的業務子程;還可以在過濾器函式中將額外資訊存放在返回值暫存器(x86架構還是AX)中,而不影響其它執行流程。

四、優化方案

不過,最為簡單的優化方法,還是在過濾器函式內直接呼叫業務子程。經典方案中設定IP暫存器來進行跳轉的根本目的,大概也只是為了讓hook子程獲取系統呼叫引數和執行返回邏輯。接下來,我們將會在過濾器函式內直接獲取當前系統呼叫的引數,並設定它的返回值。

首先是引數值的獲取。Linux系統呼叫的大致過程是,使用者程式將系統呼叫的實際引數設定到特定的暫存器中,然後通過中斷指令(int 30)切換到核心空間並實際執行系統呼叫過程。此時,使用者空間的暫存器會以pt_regs結構體的形式,儲存在當前核心棧空間的最高地址處。取得這個地址的方法有很多,前一篇文章中的程式碼可供參考:

struct pt_regs *GetUserRegisters(struct task_struct *task)

{

struct unwind_state state;

task = task ? : current;

unwind_start(&state, task, NULL, NULL);

return (struct pt_regs *)(((size_t)state.stack_info.end) – sizeof(struct pt_regs));

}

或者下面的方法經驗證也是可以的:

#if LINUX_VERSION_CODE>=KERNEL_VERSION(4,11,0)

#include <linux/sched/task_stack.h>

#endif

struct pt_regs *GetUserRegisters(struct task_struct *task)

{

return (struct pt_regs *)(task_stack_page(task ? : current) + THREAD_SIZE) – 1;

}

獲取到使用者暫存器內容後,即可從中讀取出系統呼叫的引數了。作為對經典方案的優化之一,我們可以在此處加入對架構和位寬等因素導致引數暫存器約定差異的處理:

{

struct pt_regs *kernel_regs = ftrace_get_regs(fregs);

struct pt_regs *user_regs = GetUserRegisters(NULL);

#if PTREGS_SYSCALL_STUBS

#define argument_regs user_regs

#else

#define argument_regs kernel_regs

#endif

#if defined(CONFIG_X86_64)

#define INSTRUCTION_POINTER kernel_regs->ip

struct FTraceHookContext context =

{

.Hook = container_of(ops, struct FTraceHook, FTraceOPS),

.KernelRegisters = kernel_regs,

.UserRegisters = user_regs,

.SysCallNR = &argument_regs->ax,

.Arguments =

{

&argument_regs->di,

&argument_regs->si,

&argument_regs->dx,

&argument_regs->r10,

&argument_regs->r8,

&argument_regs->r9

},

.ReturnValue = &argument_regs->ax

};

#elif defined(CONFIG_X86_32)

#define INSTRUCTION_POINTER kernel_regs->ip

struct FTraceHookContext context =

{

.Hook = container_of(ops, struct FTraceHook, FTraceOPS),

.KernelRegisters = kernel_regs,

.UserRegisters = user_regs,

.SysCallNR = &argument_regs->ax,

.Arguments =

{

&argument_regs->bx,

&argument_regs->cx,

&argument_regs->dx,

&argument_regs->si,

&argument_regs->di,

&argument_regs->bp

},

.ReturnValue = &argument_regs->ax

};

#else

#error Unsupported architecture config?

#endif

context.Hook->Handler(&context);

…其它hook業務流程…

}

然後是返回流程和返回值的設定。如果過濾器函式正常返回,ftrace框架會讓執行流程回到系統呼叫函式實現的開頭。如果我們不希望這樣,可以在程式碼中隨便尋找一個返回指令(x86中為0xC3),然後在過濾器函式中修改IP暫存器到這個返回指令的位置即可:

#define RET_CODE 0xC3

#else

#error Unsupported architecture config?

#endif

static size_t RET_ADDRESS;

//在過濾器函式中

static void notrace FTraceHookHandler(size_t ip, size_t parent_ip, struct ftrace_ops *ops, struct ftrace_regs *fregs)

{

struct pt_regs *kernel_regs = ftrace_get_regs(fregs);

struct pt_regs *user_regs = GetUserRegisters(NULL);

#if PTREGS_SYSCALL_STUBS

#define argument_regs user_regs

#else

#define argument_regs kernel_regs

#endif

…其它hook業務流程…

if (希望跳過真實系統呼叫函式的執行而立即返回)

{

argument_regs->ax = 返回值;

kernel_regs->ip = RET_ADDRESS;

}

}

//在初始化函式中

int FTraceHookInitialize(struct FTraceHook *hooks, size_t hooks_size)

{

//隨便找一個ret指令的地址,基本上就用當前函式尾部的ret就好;如果求穩(比如擔心當前函式記憶體在複雜的跳轉等),可以另外定義一個空函式,注意避免選取行內函數

RET_ADDRESS = (size_t)FTraceHookInitialize;

while (* (unsigned char *) RET_ADDRESS != RET_CODE)

++RET_ADDRESS;

…其它初始化流程…

}

這樣一來,我們就可以順利地獲取系統呼叫的引數、順利地設定系統呼叫的返回值,因而沒有必要再通過修改IP暫存器的方法跳轉到hook子程了。

由於改在過濾器函式中呼叫hook子程,我們不僅可以輕易地根據過濾器函式的第三個引數確定hook例項資訊,而且也不必再強制要求hook子程的函式簽名保持與原始系統呼叫函式一致了。過濾器函式封裝過程中,可以一站式解決大量的版本差異處理問題,包括對指令架構和位寬差異的處理等。

除此之外,由於優化方案中可以直接使用ftrace框架自帶的防遞迴機制,經典方案中花費大量程式碼實現但仍然有所不足的防遞迴機制也就可以省略了。

五、後記

實際上,相比於eBPF等使用者空間的終端監控方法,ftrace hook這樣的核心模組實現終究屬於比較沉重的方案,尤其是開發過程中需要進行大量的系統適配處理和測試。

但相應地,ftrace hook可以實現很多eBPF中難以實現的功能,尤其是對系統呼叫的阻斷等。如果您需要非常深入地監測和控制Linux主機上的應用活動,那麼ftrace hook也不失為一種不錯的選擇。

更多前沿資訊,還請繼續關注綠盟科技研究通訊。

如果您發現文中描述有不當之處,還請留言指出。在此致以真誠的感謝~

參考文獻

[1] BOOTLIN. common.c – arch/x86/entry/common.c – Linux source code (v4.16.18) – Bootlin [Z]. 2022

[2] PHILLIPS H. Linux Rootkits Part 2: Ftrace and Function Hooking [J/OL] 2020, http://xcellerator.github.io/posts/linux_rootkits_02/ .

[3] OLEKSII LOZOVSKYI M G, KRZYSZTOF ZDULSKI. ftrace-hook [J/OL] 2021, http://github.com/ilammy/ftrace-hook/.

版權宣告

本站“技術部落格”所有內容的版權持有者為綠盟科技集團股份有限公司(“綠盟科技”)。作為分享技術資訊的平臺,綠盟科技期待與廣大使用者互動交流,並歡迎在標明出處(綠盟科技-技術部落格)及網址的情形下,全文轉發。

上述情形之外的任何使用形式,均需提前向綠盟科技(010-68438880-5462)申請版權授權。如擅自使用,綠盟科技保留追責權利。同時,如因擅自使用部落格內容引發法律糾紛,由使用者自行承擔全部法律責任,與綠盟科技無關。