一、先整明白:这技术到底在干嘛?
咱们平时写的程序,调个 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【程序猿编码】
夜雨聆风