不碰内核源码也能“改“内核?聊聊 ftrace 函数挂钩那点事儿

发布时间:2026/7/21 23:54:45

不碰内核源码也能“改“内核?聊聊 ftrace 函数挂钩那点事儿 一、先整明白这技术到底在干嘛咱们平时写的程序调个printf、发个网络请求底层其实都在麻烦操作系统内核帮忙干活。内核里有一大堆服务窗口——也就是系统调用比如clone创建进程、execve执行程序这些。那如果我想在内核处理这些请求的时候插一脚自己的逻辑——比如记录个日志、拦下可疑操作、或者改改参数——该咋办最笨的办法是改内核源码重新编译重启机器。这太折腾了而且生产环境谁让你随便重启这时候就轮到ftrace 函数挂钩登场了。简单说它就是一个动态拦截器你写一个内核模块塞进去不用改内核源码不用重启就能让内核在执行某个函数时先或后执行你写的代码。完事儿还能继续走原来的逻辑仿佛什么都没发生。听起来有点像黑客技术其实正经用途多得很安全审计、行为监控、动态调试、甚至内核热补丁底层都是这套思路。二、核心思路在函数门口改道要理解这玩意儿怎么工作的得先知道内核函数被调用时CPU 在干啥。在 x86_64 架构上内核编译时默认会在每个函数开头塞一段小逻辑跟-pg编译选项有关也就是mcount机制。这段逻辑原本是用来做性能分析的——ftrace 本身就是 Linux 里一个很强的跟踪框架。正常情况下ftrace 只是看看记录一下函数 A 被调用了耗时多少然后放行。但聪明的人们发现既然已经能在函数门口拦住 CPU 了那能不能直接告诉 CPU“你别去原来那个函数了去我指定的那个地址”答案是能。而且方法就是直接改 CPU 的指路牌——指令指针寄存器rip。当 ftrace 的回调被触发时内核会把当前的寄存器状态保存在struct pt_regs里传给你。你只需要把regs-ip也就是下一条要执行的指令地址改成你自己函数的地址CPU 就会乖乖转去执行你的代码。这就是整个技术的灵魂借 ftrace 的跟踪能力做劫持的事儿。三、代码里都在忙什么逐段拆解...structftrace_hook{constchar*name;void*function;void*original;unsignedlongaddress;structftrace_opsops;};staticintfh_resolve_hook_address(structftrace_hook*hook){hook-addresslookup_name(hook-name);if(!hook-address){pr_debug(unresolved symbol: %s\n,hook-name);return-ENOENT;}#ifUSE_FENTRY_OFFSET*((unsignedlong*)hook-original)hook-addressMCOUNT_INSN_SIZE;#else*((unsignedlong*)hook-original)hook-address;#endifreturn0;}staticvoidnotracefh_ftrace_thunk(unsignedlongip,unsignedlongparent_ip,structftrace_ops*ops,structftrace_regs*fregs){structpt_regs*regsftrace_get_regs(fregs);structftrace_hook*hookcontainer_of(ops,structftrace_hook,ops);#ifUSE_FENTRY_OFFSETregs-ip(unsignedlong)hook-function;#elseif(!within_module(parent_ip,THIS_MODULE))regs-ip(unsignedlong)hook-function;#endif}intfh_install_hook(structftrace_hook*hook){interr;errfh_resolve_hook_address(hook);if(err)returnerr;hook-ops.funcfh_ftrace_thunk;hook-ops.flagsFTRACE_OPS_FL_SAVE_REGS|FTRACE_OPS_FL_RECURSION|FTRACE_OPS_FL_IPMODIFY;errftrace_set_filter_ip(hook-ops,hook-address,0,0);if(err){pr_debug(ftrace_set_filter_ip() failed: %d\n,err);returnerr;}errregister_ftrace_function(hook-ops);if(err){pr_debug(register_ftrace_function() failed: %d\n,err);ftrace_set_filter_ip(hook-ops,hook-address,1,0);returnerr;}return0;}voidfh_remove_hook(structftrace_hook*hook){interr;errunregister_ftrace_function(hook-ops);if(err){pr_debug(unregister_ftrace_function() failed: %d\n,err);}errftrace_set_filter_ip(hook-ops,hook-address,1,0);if(err){pr_debug(ftrace_set_filter_ip() failed: %d\n,err);}}intfh_install_hooks(structftrace_hook*hooks,size_tcount){interr;size_ti;for(i0;icount;i){errfh_install_hook(hooks[i]);if(err)gotoerror;}return0;error:while(i!0){fh_remove_hook(hooks[--i]);}returnerr;}voidfh_remove_hooks(structftrace_hook*hooks,size_tcount){size_ti;for(i0;icount;i)fh_remove_hook(hooks[i]);}#ifndefCONFIG_X86_64#errorCurrently only x86_64 architecture is supported#endif#ifdefined(CONFIG_X86_64)(LINUX_VERSION_CODEKERNEL_VERSION(4,17,0))#definePTREGS_SYSCALL_STUBS1#endif#if!USE_FENTRY_OFFSET#pragmaGCCoptimize(-fno-optimize-sibling-calls)#endif#ifdefPTREGS_SYSCALL_STUBSstaticasmlinkagelong(*real_sys_clone)(structpt_regs*regs);staticasmlinkagelongfh_sys_clone(structpt_regs*regs){longret;pr_info(clone() before\n);retreal_sys_clone(regs);pr_info(clone() after: %ld\n,ret);returnret;}#elsestaticasmlinkagelong(*real_sys_clone)(unsignedlongclone_flags,unsignedlongnewsp,int__user*parent_tidptr,int__user*child_tidptr,unsignedlongtls);staticasmlinkagelongfh_sys_clone(unsignedlongclone_flags,unsignedlongnewsp,int__user*parent_tidptr,int__user*child_tidptr,unsignedlongtls){longret;pr_info(clone() before\n);retreal_sys_clone(clone_flags,newsp,parent_tidptr,child_tidptr,tls);pr_info(clone() after: %ld\n,ret);returnret;}#endifstaticchar*duplicate_filename(constchar__user*filename){char*kernel_filename;kernel_filenamekmalloc(4096,GFP_KERNEL);if(!kernel_filename)returnNULL;if(strncpy_from_user(kernel_filename,filename,4096)0){kfree(kernel_filename);returnNULL;}returnkernel_filename;}#ifdefPTREGS_SYSCALL_STUBSstaticasmlinkagelong(*real_sys_execve)(structpt_regs*regs);staticasmlinkagelongfh_sys_execve(structpt_regs*regs){longret;char*kernel_filename;kernel_filenameduplicate_filename((void*)regs-di);pr_info(execve() before: %s\n,kernel_filename);kfree(kernel_filename);retreal_sys_execve(regs);pr_info(execve() after: %ld\n,ret);returnret;}#elsestaticasmlinkagelong(*real_sys_execve)(constchar__user*filename,constchar__user*const__user*argv,constchar__user*const__user*envp);staticasmlinkagelongfh_sys_execve(constchar__user*filename,constchar__user*const__user*argv,constchar__user*const__user*envp){longret;char*kernel_filename;kernel_filenameduplicate_filename(filename);pr_info(execve() before: %s\n,kernel_filename);kfree(kernel_filename);retreal_sys_execve(filename,argv,envp);pr_info(execve() after: %ld\n,ret);returnret;}#endif#ifdefPTREGS_SYSCALL_STUBS#defineSYSCALL_NAME(name)(__x64_name)#else#defineSYSCALL_NAME(name)(name)#endif#defineHOOK(_name,_function,_original)\{\.nameSYSCALL_NAME(_name),\.function(_function),\.original(_original),\}staticstructftrace_hookdemo_hooks[]{HOOK(sys_clone,fh_sys_clone,real_sys_clone),HOOK(sys_execve,fh_sys_execve,real_sys_execve),};staticintfh_init(void){interr;errfh_install_hooks(demo_hooks,ARRAY_SIZE(demo_hooks));if(err)returnerr;pr_info(module loaded\n);return0;}module_init(fh_init);staticvoidfh_exit(void){fh_remove_hooks(demo_hooks,ARRAY_SIZE(demo_hooks));pr_info(module unloaded\n);}module_exit(fh_exit);If you need the complete source code, please add the WeChat number (c17865354792)咱们结合代码看看一个完整的挂钩模块是怎么搭起来的。整个过程可以分成四步找地址 → 填钩子 → 改道 → 防递归。第一步找到你要拦的函数在哪内核函数编译后都是二进制地址你得先知道目标函数的门牌号。代码里用了两种办法老内核 5.7直接调用kallsyms_lookup_name(sys_clone)查内核符号表。新内核≥ 5.7内核把这张表藏起来了那就临时注册一个kprobe让它帮忙查地址查完就注销。// 伪代码示意hook-addresslookup_name(sys_clone);拿到地址后顺手把原始函数的指针保存一份方便后面调用。第二步定义钩子结构代码里定义了一个ftrace_hook结构体把必要信息打包structftrace_hook{constchar*name;// 要挂钩的函数名void*function;// 你的替换函数void*original;// 原始函数指针的存放位置unsignedlongaddress;// 解析出来的地址structftrace_opsops;// ftrace 需要的状态};第三步写回调函数——这是真正改道的地方staticvoidnotracefh_ftrace_thunk(unsignedlongip,unsignedlongparent_ip,structftrace_ops*ops,structftrace_regs*fregs){structpt_regs*regsftrace_get_regs(fregs);structftrace_hook*hookcontainer_of(ops,structftrace_hook,ops);if(!within_module(parent_ip,THIS_MODULE))regs-ip(unsignedlong)hook-function;}这段是精华ftrace_get_regs拿到寄存器快照。container_of从ftrace_ops反推出我们自己的ftrace_hook结构。关键一行regs-ip hook-function直接把 CPU 的下一步指向了我们的函数。within_module(parent_ip, THIS_MODULE)是在检查调用者是不是我自己如果是就别再改了否则会死循环后面细说。第四步注册到 ftrace 框架hook-ops.funcfh_ftrace_thunk;hook-ops.flagsFTRACE_OPS_FL_SAVE_REGS// 要保存寄存器|FTRACE_OPS_FL_RECURSION// 关掉 ftrace 自带的递归保护我们自己管|FTRACE_OPS_FL_IPMODIFY;// 允许修改指令指针ftrace_set_filter_ip(hook-ops,hook-address,0,0);// 只盯这个地址register_ftrace_function(hook-ops);// 上线这里几个标志位很重要SAVE_REGS不保存寄存器你改啥IPMODIFY告诉 ftrace 我要改ip别拦我。RECURSION关掉 ftrace 自己的防递归因为我们要改ip它那个机制反而碍事。四、流程原理图咱们把一次完整的挂钩过程画清楚用户进程调用 clone() │ ▼ ┌─────────────────┐ │ 进入内核态 │ │ 走到 sys_clone │ │ 函数入口处 │ └────────┬────────┘ │ ▼ ┌─────────────────┐ │ mcount 触发 │ │ ftrace 回调 │ │ fh_ftrace_thunk │ └────────┬────────┘ │ ▼ [检查 parent_ip] │ ┌────┴────┐ ▼ ▼ 是模块内 不是模块内 自己人 别人调的 │ │ ▼ ▼ 直接放行 修改 regs-ip 不干预 指向 fh_sys_clone │ │ └────┬────┘ ▼ ┌─────────────────┐ │ 执行 fh_sys_clone │ │ 你的自定义逻辑 │ │ 打印日志等... │ └────────┬────────┘ │ ▼ ┌─────────────────┐ │ 调用 real_sys_clone│ │ 保存的原始函数 │ │ 真正干活的逻辑 │ └────────┬────────┘ │ ▼ 返回结果 │ ▼ 回到用户态五、那个防递归到底在防啥这是最容易踩坑的地方。假设你已经把sys_clone挂上了你的替换函数叫fh_sys_clone。fh_sys_clone里面为了完成工作又调用了real_sys_clone。但你想过没有real_sys_clone本质上就是sys_clone本身啊所以 CPU 走到real_sys_clone门口ftrace 又触发了又把regs-ip改成fh_sys_clone……于是fh_sys_clone又执行一遍又调用real_sys_clone……无限套娃内核直接栈溢出挂掉。代码里的解决办法很巧妙看parent_ip调用者的地址。如果调用者就在咱们这个内核模块内部within_module(parent_ip, THIS_MODULE)说明这次触发是自己人调用引起的直接放行不改道。这就打破了循环只有外部调用才会被拦截内部调用走原路。六、系统调用的变脸问题代码里有一大堆#ifdef PTREGS_SYSCALL_STUBS这是因为在 Linux 4.17 之后x86_64 的系统调用入口变了。以前sys_clone参数是散的clone_flags,newsp…后来统一改成只传一个struct pt_regs *regs寄存器里的参数自己拆。代码里做了兼容处理新内核从regs-di里拿第一个参数比如execve的文件名。老内核直接接参数。这也是为什么代码里duplicate_filename要专门从用户态拷字符串到内核态——内核里不能直接操作用户空间的指针得用strncpy_from_user安全地复制一份。七、测试运行加载模块sudoinsmod ftrace_hook.ko如果没有任何报错说明加载成功了看效果开两个终端终端 1 - 实时监控内核日志sudodmesg-w终端 2 - 随便执行点命令触发系统调用lspwdechohello然后回头看终端 1你会看到类似输出[ 123.456789] ftrace_hook: module loaded [ 145.123456] ftrace_hook: clone() before [ 145.123789] ftrace_hook: clone() after: 12345 [ 145.234567] ftrace_hook: execve() before: /usr/bin/ls [ 145.235012] ftrace_hook: execve() after: 0每执行一个命令就会触发一次execve每创建一个新进程比如ls本身可能 fork就会触发clone。卸载模块sudormmod ftrace_hook再看dmesg[ 200.987654] ftrace_hook: module unloaded总结ftrace 原本只是个看门的跟踪工具但借助修改寄存器的能力它成了一个守门的利器。整个方案最精妙的地方在于完全没有修改内核的任何代码只是借用了既有的跟踪基础设施就实现了对内核执行流的动态控制。当然玩内核模块有风险建议在虚拟机里折腾。而且内核版本迭代很快kallsyms藏起来了、ftrace_regs结构变了、系统调用传参方式改了……这些坑代码里都已经帮你踩了一遍读的时候多留意那些#if LINUX_VERSION_CODE的条件编译那就是一本活脱脱的内核 API 变迁史。搞懂了这套机制你对 Linux 内核的执行流程、中断处理、系统调用这些概念基本就打通任督二脉了。Welcome to follow WeChat official account【程序猿编码】

相关新闻