Linux內核跟蹤:ftrace hook入門手冊(上)
一、什么是ftrace
ftrace(FunctionTracer)是Linux內核的一個跟蹤框架,它從2008年10月9日發布的內核版本2.6.27開始并入Linux內核主線[1]。官方文檔[2]中的描述大致翻譯如下:
ftrace是一個內部跟蹤程序,旨在幫助系統的開發人員和設計人員弄清楚內核內部發生的情況。它可以用于調試或分析在用戶空間之外發生的延遲和性能問題。雖然ftrace通常被認為是函數跟蹤程序,但它實際上是幾個不同的跟蹤實用程序的框架。…

圖1:ftrace是一個功能強大的內核函數追蹤框架[3]
使用ftrace需要目標Linux操作系統在編譯時啟用CONFIG_FUNCTION_TRACER內核配置選項(該選項默認啟用)。此時大部分非內聯內核函數的開頭會出現一個對mcount函數(或__fentry__函數,若gcc>=4.6且為x86架構)的調用。mcount函數本身只是一個簡單的返回指令,并沒有什么實際意義,但動態ftrace框架會在啟動時將所有對mcount的調用位置都填充為nop指令,這樣一來就在這些內核函數的開頭產生了足以容納一個call指令的空白區。這個空白區可以在需要的時候被替換為對ftrace相關函數的調用,從而實現對特定內核函數的調用追蹤,而不會過度影響其它內核函數的運行性能。
關于ftrace的詳細內部機制,受限于篇幅,本文不詳細介紹。但總之,通過ftrace框架,我們得以對大部分內核函數(尤其是各種系統調用)進行劫持,從而實現各種各樣的主機側訪問控制功能。由于不同版本的Linux內核機制差異較大,筆者在多個不同版本的CentOS和Ubuntu環境中進行了測試。如果您在實踐過程中遇到了其它環境適配的問題,不妨在評論區留言補充。
二、經典Hook方案
目前網絡上大多數公開的ftracehook實現方案原理上大同小異。感興趣的讀者可以參考以下鏈接:
https://www.apriorit.com/dev-blog/546-hooking-linux-functions-2
https://xcellerator.github.io/posts/linux_rootkits_02/
https://github.com/ilammy/ftrace-hook/

圖2:經典ftrace hook方案中的執行流程[4]
適當建議有余力的讀者首先了解一下上述經典方案,但跳過這個步驟并不會過多地影響您閱讀本文的其它內容。
三、環境準備
開始前請注意,安裝和卸載內核模塊通常需要root權限。以下所有操作方法默認都是在root用戶下進行的,如有需要請自行添加sudo或su -c。
3.1 安裝編譯環境
通過yum源進行安裝:
yum install gcc kernel-devel-$(uname -r)
成功安裝后,會在/usr/src/kernels/目錄內出現一個以當前內核版本和架構命名的子目錄,內含大量的C語言頭文件:

圖3:正確安裝情況下的kernels目錄
由于目前部分Linux內核函數/結構體的系統性文檔比較少,必要時可以在這里直接閱讀頭文件源碼。
另外推薦一個網站https://elixir.bootlin.com/linux/latest/source,可以非常方便直觀地閱讀和搜索各個版本的Linux內核源碼(該網站還有glibc、grab等源碼,如果需要的話)。
3.2 一個簡單的內核模塊
要制作一個Linux內核模塊,項目目錄需要至少兩個文件:一個.c文件,一個Makefile文件:

圖4:一個最簡單的Linux內核模塊項目目錄
HelloWorld.c:
#include static int __init Initialize(void){ pr_info("Hello, world!"); return 0;}static void __exit Finalize(void){ pr_info("Bye, world!");}module_init(Initialize);module_exit(Finalize);
內核模塊并沒有一般意義上的主函數,module_init和module_exit分別設置了模塊加載和卸載時所執行的函數。
需要注意,內核模塊應當盡量實現并設置module_init和module_exit函數,即使它們不包含實質上的業務邏輯。雖然不設置它們也可以正常構建得到.ko文件,但這可能產生一些預期之外的問題(例如,一個不定義/不設置module_exit函數的內核模塊,可能無法被正常卸載)。
Makefile:
obj-m += HelloWorld.oall: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modulesclean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
上文中的文件名前綴必須與.c文件一致(嚴格來說,是必須與gcc編譯所產生的.o文件名一致)。如果源文件位于子目錄內,此處也需要加上目錄前綴。
接下來我們切換到項目目錄內,執行構建:
make
正常運行會得到如下結果:

圖5:構建命令輸出
此時應該會產生一個.ko文件,就是我們剛剛制作的內核模塊的可執行文件了:

圖6:構建完畢的內核模塊
接下來我們安裝這個新的內核模塊:
insmod HelloWorld.ko
這個命令正常運行時不會產生任何輸出。
隨后,我們可以列出內核模塊:
lsmod
如果此前已經安裝成功,應該可以在列表中看到它:

圖7:列出內核模塊
類似地,我們也可以卸載已安裝的內核模塊:
rmmod HelloWorld
這個命令正常運行時也不會產生任何輸出。
特別注意,這個命令中并不包含“.ko”后綴,也不要求必須在項目目錄內執行。此外,一個正在使用中的內核模塊是不能被卸載的(比如,某個用戶進程打開了一個通往該內核模塊的Netlink連接)。
那么,此前代碼中通過pr_info輸出的信息跑到哪里去了呢?答案是位于Linux內核中的環緩沖區(ring buffer)。我們可以通過下面的命令訪問它:
dmesg /*一次性打印整個緩沖區*/dmesg --follow /*持續打印緩沖區,直到Ctrl+C中斷*/dmesg --clear /*清空緩沖區*/
就可以看到我們的模塊此前在加載和卸載時所產生的輸出信息了:

圖8:查看調試輸出
除了dmesg命令外,您也可以在/var/log/messages文件中找到這些輸出。
至此,我們就實現了一個簡單的內核模塊。
3.3 在內核模塊中包含多個源文件
實際操作中,我們的項目可能同時包含多個.c文件,例如這樣:

圖9:包含多個源文件的內核模塊項目
entry.c:
# include "function.h"static int __init Initialize(void){ pr_info("3+5=%d!",Add(3,5)); return 0;}static void __exit Finalize(void){}module_init(Initialize);module_exit(Finalize);
function.c:
#include "function.h"int Add(int a,int b){ return a+b;}
function.h:
#ifndef LIB_FUNCTION#define LIB_FUNCTION#include int Add(int a,int b);#endif
以上三個文件的內容都沒有什么特別之處。但下面的Makefile文件需要進行一些特別的調整。
Makefile:
obj-m += MultipleCFiles.oMultipleCFiles-objs := entry.o function.o all: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
接下來在工作目錄內正常使用make命令進行構建,即可得到MultipleCFiles.ko:

圖10:多個源文件構建內核模塊的運行結果
此處需要注意以下三點:
1、Makefile第一行“obj-m”后面的應當是一個不存在對應.c文件的名稱,它將成為最終構建輸出的.ko文件的名稱。如果使用實際存在的.c文件的名稱,make命令雖然也可能不報錯,但產生的.ko模塊會無法正常運行;
2、Makefile第二行“MultipleCFiles-objs”中“-objs”前面的部分應當與第一行中配置的名稱一致,否則make命令會報錯而無法生成.ko模塊;
3、如果希望將函數的聲明和定義分別放置在.h文件和.c文件中(就像上面例子中的Add函數一樣),那么該函數應當不加static修飾,否則它們無法被編譯器正確鏈接起來。此時雖然能夠產生.ko模塊但可能無法正常運行:

圖11:不正確的函數static修飾導致模塊無法安裝
四、Hook案例完整源代碼
FTraceHook.h:
#ifndef LIB_FTRACE_HOOK#define LIB_FTRACE_HOOK
#include #include #include #include #include
//關于系統調用符號解析的版本差異處理#if LINUX_VERSION_CODE>=KERNEL_VERSION(5,7,0)static size_t kallsyms_lookup_name(const char *name){ struct kprobe kp = { .symbol_name = name }; size_t retval;
if (register_kprobe(&kp) < 0) return 0; retval = (size_t)kp.addr; unregister_kprobe(&kp); return retval;}#endif
//關于ftrace框架的版本差異處理#if LINUX_VERSION_CODE#define FTRACE_OPS_FL_RECURSION 0#define ftrace_regs pt_regsstatic __always_inline struct pt_regs *ftrace_get_regs(struct ftrace_regs *fregs){ return fregs;}#endif
//關于系統調用函數簽名的版本差異處理#if defined(CONFIG_X86_64)&&(LINUX_VERSION_CODE>=KERNEL_VERSION(4,17,0))#define PTREGS_SYSCALL_STUBS 1#define SYSCALL_NAME(name) ("__x64_" name)#else#define PTREGS_SYSCALL_STUBS 0#define SYSCALL_NAME(name) (name)#endif
//關于ret指令機器碼的架構差異處理#if defined(CONFIG_X86_64)||defined(CONFIG_X86_32)#define RET_CODE 0xC3#else#error Unsupported architecture config?#endif
struct FTraceHook;struct FTraceHookContext;
struct FTraceHook{ const char *Symbol; bool (*Handler)(struct FTraceHookContext *); size_t SysCallEntry; struct ftrace_ops FTraceOPS;};struct FTraceHookContext{ struct FTraceHook *const Hook; struct pt_regs *const KernelRegisters; struct pt_regs *const UserRegisters; size_t *const SysCallNR; const size_t *const Arguments[6]; size_t *const ReturnValue;};
#define FTRACE_HOOK(_symbol,_handler) {.Symbol=SYSCALL_NAME(_symbol),.Handler=(_handler)}
struct pt_regs *GetUserRegisters(struct task_struct *task);size_t FTraceHookCallOriginal(struct FTraceHookContext *context);int FTraceHookInstall(struct FTraceHook *hook);int FTraceHookUninstall(struct FTraceHook *hook);int FTraceHookInitialize(struct FTraceHook *hooks, size_t hooks_size);int FTraceHookFinalize(struct FTraceHook *hooks, size_t hooks_size);
#endif
FTraceHook.c:
#include "FTraceHook.h"
static size_t RET_ADDRESS;
//獲取用戶線程原本的寄存器保存位置struct pt_regs *GetUserRegisters(struct task_struct *task){ //用戶線程原本的寄存器保存在內核棧地址最高處,為pt_regs結構體 //實際上,我們只是想要unwind_state里面的stack_info,但有些編譯環境中不知為何會報錯:ERROR: modpost: "get_stack_info" undefined!,有知情的讀者還請不吝賜教,非常感謝 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));}
//代理調用原始函數size_t FTraceHookCallOriginal(struct FTraceHookContext *context){#if PTREGS_SYSCALL_STUBS return ((asmlinkage size_t (*)(struct pt_regs *)) context->Hook->SysCallEntry)(context->UserRegisters);#else //asmlinkage的參數,傳多了似乎沒什么影響,有switch的功夫還不如多push幾個參數咧 return ((asmlinkage size_t (*)(size_t, size_t, size_t, size_t, size_t, size_t)) context->Hook->SysCallEntry)(*context->Arguments[0], *context->Arguments[1], *context->Arguments[2], *context->Arguments[3], *context->Arguments[4], *context->Arguments[5]);#endif}
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#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 if (!context.Hook->Handler(&context)) //返回false則阻止原始函數執行(直接返回到原始函數的調用方),其余情況不需要特殊操作,任由ftrace框架恢復執行流程即可 INSTRUCTION_POINTER = RET_ADDRESS;}
int FTraceHookInstall(struct FTraceHook *hook){ int err;
//使用kallsyms_lookup_name()在內核內存中查找地址。 hook->SysCallEntry = kallsyms_lookup_name(hook->Symbol); if (!hook->SysCallEntry) { pr_err("[FTraceHook] Unresolved symbol: %s", hook->Symbol); return ENOENT; } //我們的hook子程并不是通過修改ip跳轉過去的,可以使用ftrace自帶的防遞歸,而且實測效率還不錯 hook->FTraceOPS.func = FTraceHookHandler; hook->FTraceOPS.flags = FTRACE_OPS_FL_SAVE_REGS | FTRACE_OPS_FL_IPMODIFY | FTRACE_OPS_FL_RECURSION;
err = ftrace_set_filter_ip(&hook->FTraceOPS, hook->SysCallEntry, 0, 0); if (err) { pr_err("[FTraceHook] ftrace_set_filter_ip() failed: %d", err); return err; }
err = register_ftrace_function(&hook->FTraceOPS); if (err) { pr_err("[FTraceHook] register_ftrace_function() failed: %d", err); return err; }
pr_info("[FTraceHook] Installed hook '%s': %d", hook->Symbol, err); return err;}
int FTraceHookUninstall(struct FTraceHook *hook){ int err;
//注意與安裝過程相反的順序 err = unregister_ftrace_function(&hook->FTraceOPS); if (err) { pr_err("[FTraceHook] unregister_ftrace_function() failed: %d", err); return err; }
err = ftrace_set_filter_ip(&hook->FTraceOPS, hook->SysCallEntry, 1, 0); if (err) { pr_err("[FTraceHook] ftrace_set_filter_ip() failed: %d", err); return err; }
pr_info("[FTraceHook] Uninstalled hook '%s': %d", hook->Symbol, err); return err;}
int FTraceHookInitialize(struct FTraceHook *hooks, size_t hooks_size){ int err = 0; size_t i;
//隨便找一個ret指令的地址,基本上就用當前函數尾部的ret就好;如果求穩(比如擔心當前函數內存在復雜的跳轉等),可以另外定義一個空函數,注意避免選取內聯函數 RET_ADDRESS = (size_t)FTraceHookInitialize; while (* (unsigned char *) RET_ADDRESS != RET_CODE) ++RET_ADDRESS;
//安裝鉤子 for (i = 0; i < hooks_size && !err; ++i) err = FTraceHookInstall(hooks + i);
return err;}int FTraceHookFinalize(struct FTraceHook *hooks, size_t hooks_size){ int err = 0; size_t i;
for (i = 0; i < hooks_size; ++i) err = FTraceHookUninstall(hooks + i);
return err;}
Entry.c:
#include #include
#include "FTraceHook.h"
MODULE_LICENSE("GPL");//使用ftrace的模塊必須是GPL License,不然不能編譯MODULE_VERSION("0.01");
static int ReturnSwitch = 0;static bool MySysExecve(struct FTraceHookContext *context){ char filename[256];//用kmalloc+kfree也是可以的,但棧容量允許的情況下,時間效率還是局部變量比較快 //輸出第一個參數值 if (strncpy_from_user(filename, (char *)*context->Arguments[0], sizeof(filename)) >= 0) pr_info("[FTraceHook] PID %u calling sys_execve: %s", current->pid, filename);
//輪流測試兩種方法來恢復原系統調用流程 if (++ReturnSwitch % 2) { //模擬經典方案的機制,代理調用原始函數 *context->ReturnValue = FTraceHookCallOriginal(context); pr_info("[FTraceHook] execve() return: %ld", *context->ReturnValue); return false;//中止系統調用 } else { //優化方案的新機制,不重新push參數而直接恢復原系統調用流程,但此時無法獲取原系統調用流程的返回值 return true; }}
static struct FTraceHook GlobalHooks[] ={ FTRACE_HOOK("sys_execve", MySysExecve)};
static int __init Initialize(void){ int err = FTraceHookInitialize(GlobalHooks, ARRAY_SIZE(GlobalHooks)); if(err) pr_err("[FTraceHook] Failed to initialize ftrace hooks..."); return err;}static void __exit Finalize(void){ if(FTraceHookFinalize(GlobalHooks, ARRAY_SIZE(GlobalHooks))) pr_err("[FTraceHook] Failed to finalize ftrace hooks...");}
module_init(Initialize);module_exit(Finalize);
Makefile:
obj-m += FTraceHookExample.oFTraceHookExample-objs := Entry.o FTraceHook.o all: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
運行效果:

圖12:完整運行效果展示
五、對經典方案的優化
實際上,上面的代碼也參考自經典方案[4][5][6]中的內容,但進行了一些優化,比較重要的部分包括:
- 由于Linux內核4.17版本前后的系統調用函數簽名不同,經典方案中需要通過條件編譯的方式為每個hook定義兩個功能相同但簽名不同的hook子程:
- 如果hook子程本身邏輯簡單、數量少倒還好,否則每個hook子程都要寫兩份,實際使用中非常不便;
- 經典方案(至少前述三個參考鏈接實現)中大多沒有考慮不同位寬/架構的寄存器差異:
- 例如,hook子程中始終讀取pt_regs的成員di作為第一個參數,這在x86_64架構下沒有問題,但對x86_32(應為bx)、arm(應為uregs[0])等架構則是不適用的;
- 但是,如果在每個hook子程中都進行條件編譯,實際使用中也是非常不便的;
- 經典方案通過在回調函數(ftrace_set_filter_ip的第二個參數)中修改原始系統調用的ip(x86架構的指令指針寄存器,不是網際協議,后文皆同不再另行說明)來使得執行流程跳轉到hook子程,因此hook子程的函數簽名必須與原始系統調用一致:
- 這導致hook子程中需要系統調用參數之外的信息時邏輯變得很復雜,hook子程通常需要提前將一些必要的信息(例如,原始函數的真實地址等)通過另外的機制保存或傳遞,而無法封裝框架統一提供;
- 除此之外,對多個不同的系統調用使用同一個hook子程也會比較麻煩(因為不易確定原始系統調用函數的地址以進行代理,可能需要通過系統調用號重新查表等),尤其是對于業務上希望監控大量系統調用的場景;
- 如果hook子程中需要調用原始函數,通常需要將調用參數重新入棧(Linux系統調用多有asmlinkage修飾,即全部參數通過堆棧傳遞而不使用fastcall),而不易讓執行流程直接恢復到原始函數中。這將導致少許額外開銷,尤其是對于4.17以前的內核版本。
- 修改ip的跳轉方法導致經典方案中對hook子程的執行發生在ftrace相關函數返回之后(而非ftrace相關函數棧內),因此ftrace自帶的防遞歸功能無法作用于經典方案。為此,經典方案中自行實現了兩套防遞歸方法,但它們看上去都不是非常完善:
- 第一種方法通過within_module檢查直接調用方是否位于當前模塊中,這對于涉及多個模塊的調用(hook子程位于模塊A中,A調用了模塊B的函數,而模塊B嘗試調用被hook的原始函數)可能是不完善的;
- 第二種方法在執行遞歸調用時跳過系統調用開頭的“空白區”,這意味著需要對于所有調用原始函數的代碼進行修改。這不僅實現起來比較麻煩,而且同樣面臨多個模塊間調用時可能不完善的問題(因為難以修改其它模塊對原始函數的調用);
- (實現細節)關于FTRACE_OPS_FL_RECURSION標志的使用可能有誤
- ftrace框架的防遞歸選項在內核版本5.11前后發生了變化:
- 在5.10.113及以前版本中,默認會添加防遞歸檢查,除非ftrace_ops.flags設置了FTRACE_OPS_FL_RECURSION_SAFE標志;
- 而從5.11rc1開始,默認不會添加防遞歸檢查,除非ftrace_ops.flags設置了FTRACE_OPS_FL_RECURSION標志;
- 因此,這實際上是兩個功能相反的標志選項。第一個經典方案[4]中“#define FTRACE_OPS_FL_RECURSIONFTRACE_OPS_FL_RECURSION_SAFE”的寫法可能是不正確的,而第二個經典方案[5]沒有對這個標志進行版本差異處理;
- 不過,因為經典方案并不需要ftrace框架提供防遞歸檢查,所以這個錯誤應該不會造成什么實質上的影響。
連同這些優化的細節在內,本系列的下一篇文章中會著重講解上述代碼實現的各個細節原理。
六、后記
更多前沿資訊,還請繼續關注綠盟科技研究通訊。
如果您發現文中描述有不當之處,還請留言指出。在此致以真誠的感謝~
參考文獻
- KERNELNEWBIES.ORG. Linux kernel 2.6.27 [J/OL]2008,
- https://kernelnewbies.org/Linux_2_6_27.
- ROSTEDT S. ftrace - Function Tracer [J/OL]2008,
- https://www.kernel.org/doc/Documentation/trace/ftrace.txt.
- YEMELIANOV A. Kernel Tracing with Ftrace[J/OL] 2017,
- https://blog.selectel.com/kernel-tracing-ftrace/.
- ALEXEY LOZOVSKY S S. Hooking Linux KernelFunctions, Part 2: How to Hook Functions with FtraceIt [J/OL] 2018,
- https://www.apriorit.com/dev-blog/546-hooking-linux-functions-2.
- PHILLIPS H. Linux Rootkits Part 2: Ftrace and Function Hooking [J/OL] 2020,
- https://xcellerator.github.io/posts/linux_rootkits_02/.
- OLEKSII LOZOVSKYI M G, KRZYSZTOF ZDULSKI.ftrace-hook [J/OL] 2021,
- https://github.com/ilammy/ftrace-hook/.