乐于分享
好东西不私藏

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

不碰内核源码也能"改"内核?聊聊 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;

unsignedlong address;
structftrace_opsops;
};

staticintfh_resolve_hook_address(struct ftrace_hook *hook)
{
 hook->address = lookup_name(hook->name);

if (!hook->address) {
  pr_debug("unresolved symbol: %s\n", hook->name);
return -ENOENT;
 }

#if USE_FENTRY_OFFSET
 *((unsignedlong*) hook->original) = hook->address + MCOUNT_INSN_SIZE;
#else
 *((unsignedlong*) hook->original) = hook->address;
#endif

return0;
}

staticvoid notrace fh_ftrace_thunk(unsignedlong ip, unsignedlong parent_ip,
  struct ftrace_ops *ops, struct ftrace_regs *fregs)

{
structpt_regs *regs = ftrace_get_regs(fregs);
structftrace_hook *hook = container_of(ops, structftrace_hook, ops);

#if USE_FENTRY_OFFSET
 regs->ip = (unsignedlong)hook->function;
#else
if (!within_module(parent_ip, THIS_MODULE))
  regs->ip = (unsignedlong)hook->function;
#endif
}


intfh_install_hook(struct ftrace_hook *hook)
{
int err;

 err = fh_resolve_hook_address(hook);
if (err)
return err;

 hook->ops.func = fh_ftrace_thunk;
 hook->ops.flags = FTRACE_OPS_FL_SAVE_REGS
                 | FTRACE_OPS_FL_RECURSION
                 | FTRACE_OPS_FL_IPMODIFY;

 err = ftrace_set_filter_ip(&hook->ops, hook->address, 0, 0);
if (err) {
  pr_debug("ftrace_set_filter_ip() failed: %d\n", err);
return err;
 }

 err = register_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);
return err;
 }

return0;
}


voidfh_remove_hook(struct ftrace_hook *hook)
{
int err;

 err = unregister_ftrace_function(&hook->ops);
if (err) {
  pr_debug("unregister_ftrace_function() failed: %d\n", err);
 }

 err = ftrace_set_filter_ip(&hook->ops, hook->address, 1, 0);
if (err) {
  pr_debug("ftrace_set_filter_ip() failed: %d\n", err);
 }
}


intfh_install_hooks(struct ftrace_hook *hooks, size_t count)
{
int err;
size_t i;

for (i = 0; i < count; i++) {
  err = fh_install_hook(&hooks[i]);
if (err)
goto error;
 }

return0;

error:
while (i != 0) {
  fh_remove_hook(&hooks[--i]);
 }

return err;
}


voidfh_remove_hooks(struct ftrace_hook *hooks, size_t count)
{
size_t i;

for (i = 0; i < count; i++)
  fh_remove_hook(&hooks[i]);
}

#ifndef CONFIG_X86_64
#error Currently only x86_64 architecture is supported
#endif

#if defined(CONFIG_X86_64) && (LINUX_VERSION_CODE >= KERNEL_VERSION(4,17,0))
#define PTREGS_SYSCALL_STUBS 1
#endif


#if !USE_FENTRY_OFFSET
#pragma GCC optimize("-fno-optimize-sibling-calls")
#endif

#ifdef PTREGS_SYSCALL_STUBS
static asmlinkage long(*real_sys_clone)(struct pt_regs *regs);

static asmlinkage longfh_sys_clone(struct pt_regs *regs)
{
long ret;

 pr_info("clone() before\n");

 ret = real_sys_clone(regs);

 pr_info("clone() after: %ld\n", ret);

return ret;
}
#else
static asmlinkage long(*real_sys_clone)(unsignedlong clone_flags,
unsignedlong newsp, int __user *parent_tidptr,
int __user *child_tidptr, unsignedlong tls)
;

static asmlinkage longfh_sys_clone(unsignedlong clone_flags,
unsignedlong newsp, int __user *parent_tidptr,
int __user *child_tidptr, unsignedlong tls)

{
long ret;

 pr_info("clone() before\n");

 ret = real_sys_clone(clone_flags, newsp, parent_tidptr,
  child_tidptr, tls);

 pr_info("clone() after: %ld\n", ret);

return ret;
}
#endif

staticchar *duplicate_filename(constchar __user *filename)
{
char *kernel_filename;

 kernel_filename = kmalloc(4096, GFP_KERNEL);
if (!kernel_filename)
returnNULL;

if (strncpy_from_user(kernel_filename, filename, 4096) < 0) {
  kfree(kernel_filename);
returnNULL;
 }

return kernel_filename;
}

#ifdef PTREGS_SYSCALL_STUBS
static asmlinkage long(*real_sys_execve)(struct pt_regs *regs);

static asmlinkage longfh_sys_execve(struct pt_regs *regs)
{
long ret;
char *kernel_filename;

 kernel_filename = duplicate_filename((void*) regs->di);

 pr_info("execve() before: %s\n", kernel_filename);

 kfree(kernel_filename);

 ret = real_sys_execve(regs);

 pr_info("execve() after: %ld\n", ret);

return ret;
}
#else
static asmlinkage long(*real_sys_execve)(constchar __user *filename,
constchar __user *const __user *argv,
constchar __user *const __user *envp)
;

static asmlinkage longfh_sys_execve(constchar __user *filename,
constchar __user *const __user *argv,
constchar __user *const __user *envp)

{
long ret;
char *kernel_filename;

 kernel_filename = duplicate_filename(filename);

 pr_info("execve() before: %s\n", kernel_filename);

 kfree(kernel_filename);

 ret = real_sys_execve(filename, argv, envp);

 pr_info("execve() after: %ld\n", ret);

return ret;
}
#endif


#ifdef PTREGS_SYSCALL_STUBS
#define SYSCALL_NAME(name) ("__x64_" name)
#else
#define SYSCALL_NAME(name) (name)
#endif

#define HOOK(_name, _function, _original) \
 {     \
  .name = SYSCALL_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)
{
int err;

 err = fh_install_hooks(demo_hooks, ARRAY_SIZE(demo_hooks));
if (err)
return err;

 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->address = lookup_name("sys_clone");

拿到地址后,顺手把原始函数的指针保存一份,方便后面调用。

第二步:定义"钩子"结构

代码里定义了一个 ftrace_hook 结构体,把必要信息打包:

structftrace_hook {
constchar *name;      // 要挂钩的函数名
void *function;        // 你的替换函数
void *original;        // 原始函数指针的存放位置
unsignedlong address; // 解析出来的地址
structftrace_opsops;// ftrace 需要的状态
};

第三步:写回调函数——这是真正"改道"的地方

staticvoid notrace fh_ftrace_thunk(unsignedlong ip, unsignedlong parent_ip,
        struct ftrace_ops *ops, struct ftrace_regs *fregs)

{
structpt_regs *regs = ftrace_get_regs(fregs);
structftrace_hook *hook = container_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.func = fh_ftrace_thunk;
hook->ops.flags = FTRACE_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 安全地复制一份。

七、测试运行

加载模块

sudo insmod ftrace_hook.ko

如果没有任何报错,说明加载成功了

看效果

开两个终端:

终端 1 - 实时监控内核日志:

sudo dmesg -w

终端 2 - 随便执行点命令触发系统调用:

ls
pwd
echo"hello"

然后回头看终端 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。


卸载模块

sudo rmmod 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【程序猿编码】

相关学习资料