资讯动态

LSM安全钩子实战:从零实现Linux文件打开拦截模块

发布时间:2026/10/21 12:23:52 来源:尧图企业网站定制
Linux 安全模块LSM这套框架最劝退人的地方不是代码量而是它把“安全”拆成了几十个钩子散落在内核从进程创建到文件落盘的每一条关键路径上。我第一次翻security/目录时看到满屏的security_xxx()包装函数第一反应是“这玩意儿到底从哪下手”。后来才想明白LSM 的本质其实很朴素内核在几个关键路口装了摄像头和闸机你要做的只是写一个模块挂到某个闸机上决定放行还是拦下。这篇就从这个思路出发带你从零写一个真正能加载、能生效、能验证的最小 LSM demo顺带把过程中容易踩的坑一次性讲清楚。不管你是刚学内核模块的新手还是已经写过驱动想往安全子系统延伸的老手这篇都能让你少走几晚弯路。1. 先把 LSM 这件事讲明白它到底在拦什么1.1 DAC 管不住的场景才是 LSM 的战场传统 Unix 权限模型DAC自主访问控制靠的是“文件属主 权限位”这套组合拳rwxr-xr-x一贴谁读谁写谁执行一目了然。这套机制在单机、单用户的年代够用但它有个天生的盲区——它只认“你是谁”不认“你在干什么”。root 用户一旦拿到 shell理论上可以对系统里任何文件做任何事权限位形同虚设。再比如一个被授予了读写权限的服务进程如果被攻破它照样能把自己能碰的文件全读一遍DAC 对此毫无办法。LSM 要解决的正是这类“权限位本身没错但场景不对”的问题。它不替换 DAC而是在 DAC 检查之后再补一层强制访问控制MAC。SELinux、AppArmor、Smack 这些都是建立在 LSM 框架之上的具体策略实现。打个比方DAC 是小区门禁刷卡就进LSM 是进楼之后每层楼门口的保安看你今天是不是真的该来这一层。两层叠在一起才构成了现代 Linux 的安全基线。理解这一点很关键因为它决定了你写 LSM 模块时的定位你写的不是“权限系统”而是“权限之上的补充判断”。这意味着你的钩子函数几乎总是先假设 DAC 已经放行然后决定要不要再拦一道。想清楚这个定位后面写代码时对返回值的取舍就不会纠结。1.2 钩子机制在内核关键路径上开一个“检查口”LSM 的实现方式说出来简单得有点意外内核在关键函数里插入一行security_xxx()调用这个调用会遍历一条挂在全局链表上的钩子列表把当前上下文交给每个注册过的 LSM 依次判断。以文件打开为例do_dentry_open()里会调security_file_open(file)这个包装函数最终走到call_int_hook(file_open, 0, file)遍历所有注册了file_open钩子的 LSM任何一个返回非零值打开操作就失败。这套设计的精妙之处在于“解耦”。内核主路径完全不关心谁在检查、检查什么它只负责在正确的位置发出通知并收集结果具体的判断逻辑全部下沉到各个 LSM 模块里。新增一个安全策略不需要改动fs/open.c一行代码只需要挂个钩子。这种“埋点 回调”的模式和前端的事件监听、后端的中件间拦截器是同一个思路只是跑在内核态。钩子的覆盖面非常广从task_alloc、task_free这种进程生命周期事件到inode_permission、file_open这种文件访问事件再到sb_mount、sb_umount这种挂载事件甚至socket_connect、socket_bind这种网络事件都有。你要做的第一件事就是想清楚自己的需求落在哪个钩子上。选错钩子代码写得再漂亮也不会生效。1.3 堆叠与顺序一个检查点可以站好几个“哨兵”早期 LSM 有个很反直觉的限制同一时刻只能有一个“主”LSM 生效。你想同时开 SELinux 和 AppArmor不行二选一。这个限制在后来被打破了内核引入了 LSM 堆叠stacking机制允许多个 LSM 同时注册钩子按CONFIG_LSM里配置的顺序依次执行。顺序这件事值得多说两句。所有钩子按注册顺序串成链表遍历时任何一个返回非零就短路退出。这意味着排在前面的 LSM 有“一票否决权”后面的根本不会被调用。对于内置 LSM顺序由CONFIG_LSM这个编译期字符串决定典型值是landlock,lockdown,yama,integrity,apparmor之类。而对于我们这种运行时加载的模块security_add_hooks()通常是往链表尾部追加所以基本排在最后执行。这个细节在实际调试时很有用。如果你写了个钩子明明注册成功却从来不触发先别怀疑代码去看看是不是前面某个 LSM 已经把这个操作拦掉了——你的钩子压根没机会跑。判断方法很简单在钩子函数第一行加一句pr_info如果连这行都不打印那就是被短路了。1.4 为什么第一个 demo 选 file_open 而不是 inode_permission钩子那么多第一个 demo 挑哪个最合适我的建议是file_open。原因有三个。第一语义最直观。file_open就是“有人要打开文件了”你返回-EACCES就是“不许开”用户态立刻能看到Permission denied验证链路最短。第二参数简单钩子签名是int (*file_open)(struct file *file)一个参数拿到手file-f_inode和file-f_path都在里面想拿路径、拿 inode 都方便。第三触发频率适中不像inode_permission那样每次路径解析都要走一遍调试时日志不会被刷爆。相比之下inode_permission覆盖面更广open、exec、目录遍历都会触发但也更容易误伤一不小心就把整台机器的ls干掉了。作为第一个 demo先用file_open建立完整的“注册—生效—验证”闭环等跑通了再换钩子扩展这个节奏最稳。2. 动手前的准备环境、头文件与三个核心结构体2.1 编译环境与内核源码的对应关系写内核模块第一件要确认的事是头文件和运行内核版本严格对应。/lib/modules/$(uname -r)/build这个软链接必须指向当前内核对应的源码树或 headers 包否则编译出来加载时会报Invalid module format。先跑一句uname -r看版本再ls -l /lib/modules/$(uname -r)/build确认软链接存在且指向有效目录。如果这一步就不通后面全白搭。常见情况是系统只装了linux-headers-$(uname -r)这足够了但如果你要用的结构体在 headers 里被裁剪过就得装完整源码。判断方法很简单grep -rn struct security_hook_list /lib/modules/$(uname -r)/build/include/能不能搜到搜不到就说明头文件不全。另外强烈建议在虚拟机或可随时重启的测试机上做这件事。LSM 钩子一旦注册卸载路径并不总是干净的后面第 5 章会详细说这个问题。在一台随时能重启的机器上折腾心态会稳很多。2.2 security_hook_list、lsm_id、lsm_info 三件套LSM 模块的核心数据结构其实就三个把这三个搞明白代码基本就通了。第一个是struct security_hook_list它描述“一个钩子挂载项”struct security_hook_list { struct hlist_node list; struct hlist_head *head; union security_list_options hook; const struct lsm_id *lsmid; };head指向全局钩子链表的某个头比如file_open对应的那条链hook是一个联合体存的就是你写的函数指针list负责把这一项串进链表。你不需要手动填这些字段用LSM_HOOK_INIT(file_open, demo_file_open)这个宏编译器会帮你把head和hook都算好。第二个是struct lsm_id它是模块的身份标识struct lsm_id { const char *name; u64 id; };name就是你的 LSM 名字调试日志、/sys/kernel/security/lsm里显示的就是它。id字段是较新内核才有的用于在堆叠场景下做唯一标识。第三个是struct lsm_info只对内置 LSM 有意义。它通过DEFINE_LSM(name)宏定义一个结构体放进.init.rodata.lsm_info段内核启动时由security_init()统一遍历并调用各自的.init。这里有个新手必踩的坑这个段只在启动时被扫一次模块加载时早就扫完了所以在模块里用DEFINE_LSM是完全无效的。模块必须自己在init函数里调security_add_hooks()。这一点很多网上抄来的代码都写错了照着抄会发现“编译通过、加载成功、就是不生效”。2.3 模块化 LSM 与内建 LSM 的关键区别这两条路线的差异比想象中大得多动手前必须想清楚走哪条。模块化路线的优点是快改一行编译一次insmod立刻验证不用重编内核。缺点是它依赖security_add_hooks()这个符号对模块可见而部分内核版本里这个函数带__init标记所在的内存段在启动完成后就被回收了模块即使链接成功调用时也可能直接跳进一片已释放的内存。此外还有卸载问题——钩子挂在全局链表上模块一卸载链表里的函数指针就指向了已释放的代码段下一次触发就是内核崩溃。内建路线是把你的代码编进内核镜像跟着security_init()一起初始化没有符号可见性问题也没有卸载问题想撤只能重启。代价是每次改动都要重编内核即便增量编译首次全量构建也要花不少时间和磁盘空间。我的建议是先用模块路线把逻辑跑通确认钩子选对了、判断逻辑写对了等要长期使用或者要上真实环境再按第 5 章的方法转成内建。两条路线的代码 90% 是共用的迁移成本不高。2.4 版本差异速查表内核 API 在这块改动比较频繁同一个 demo 在不同版本上编译报错是常态。下面这张表是我踩过之后整理的动手前对着看一眼能省不少查错时间。内核版本区间security_add_hooks第三参数lsm_id结构备注5.x 早期至 6.3char *lsm直接传名字字符串无此结构老教程大多是这个写法6.4 至 6.7const struct lsm_id *只有.name字段需要自己定义 lsm_id 变量6.8 及以后const struct lsm_id *.name.id需要引用LSM_ID_*常量不确定自己手上是哪种情况最靠谱的办法是直接看源码别猜grep -rn security_add_hooks /lib/modules/$(uname -r)/build/include/linux/lsm_hooks.h grep -rn struct lsm_id /lib/modules/$(uname -r)/build/include/linux/lsm_hooks.h这两条命令的输出会直接告诉你函数原型和结构体定义。我在代码里用LINUX_VERSION_CODE做了条件编译但如果你用的是发行版打了补丁的内核版本号可能和实际 API 对不上最终还是以grep结果为准。这一点务必记住比记版本号可靠得多。3. 写一个能跑的最小 demo拦截指定路径的 open3.1 需求边界先划清楚动手写代码之前先把需求说死避免写着写着发散。这个 demo 的目标是拦截对某一个固定路径的打开操作其他一切照旧。具体约定如下。目标路径定为/tmp/lsm_demo_target模块加载时如果这个文件不存在钩子照常注册但处于“空转”状态不拦截任何东西。这样设计是为了避免模块加载失败也方便你验证“目标不存在时系统行为完全正常”。拦截动作是返回-EACCES用户态看到的错误是Permission denied比-EPERM更贴合“访问被拒绝”的语义。判断依据用 inode 指针比较而不是路径字符串比较。这一点很关键原因在第 4 章详细展开简单说就是字符串比较每次 open 都要拼路径性能开销大而且容易受软链接、挂载点影响inode 比较是一次指针相等判断快到可以忽略。日志方面每次拦截打一条pr_info带上 pid 和进程名方便验证时确认是谁被拦了。日志量要控制只在实际拦截时打正常放行不打否则dmesg会被刷爆。3.2 完整代码与逐段拆解下面是完整代码我把文件命名为lsm_demo_main.c可以对着注释一段段看。#include linux/module.h #include linux/kernel.h #include linux/init.h #include linux/fs.h #include linux/namei.h #include linux/path.h #include linux/dcache.h #include linux/errno.h #include linux/sched.h #include linux/lsm_hooks.h #include linux/security.h #include linux/version.h #define DEMO_NAME lsm_demo #define DEMO_TARGET /tmp/lsm_demo_target static struct inode *demo_target_inode; static int demo_file_open(struct file *file) { if (!demo_target_inode || !file) return 0; if (file-f_inode ! demo_target_inode) return 0; pr_info(%s: deny open(%s) pid%d comm%s\n, DEMO_NAME, DEMO_TARGET, task_pid_nr(current), current-comm); return -EACCES; } static struct security_hook_list demo_hooks[] { LSM_HOOK_INIT(file_open, demo_file_open), }; #if LINUX_VERSION_CODE KERNEL_VERSION(6, 8, 0) static const struct lsm_id demo_lsmid { .name DEMO_NAME, .id LSM_ID_UNDEF, }; #elif LINUX_VERSION_CODE KERNEL_VERSION(6, 4, 0) static const struct lsm_id demo_lsmid { .name DEMO_NAME, }; #endif static int __init demo_init(void) { struct path p; int ret; ret kern_path(DEMO_TARGET, LOOKUP_FOLLOW, p); if (ret) { pr_warn(%s: cannot resolve %s (%d), hook idle\n, DEMO_NAME, DEMO_TARGET, ret); } else { demo_target_inode igrab(p.dentry-d_inode); path_put(p); if (!demo_target_inode) pr_warn(%s: igrab failed\n, DEMO_NAME); } #if LINUX_VERSION_CODE KERNEL_VERSION(6, 4, 0) security_add_hooks(demo_hooks, ARRAY_SIZE(demo_hooks), demo_lsmid); #else security_add_hooks(demo_hooks, ARRAY_SIZE(demo_hooks), DEMO_NAME); #endif pr_info(%s: loaded, target%s\n, DEMO_NAME, DEMO_TARGET); return 0; } static void __exit demo_exit(void) { if (demo_target_inode) { iput(demo_target_inode); demo_target_inode NULL; } pr_info(%s: exit\n, DEMO_NAME); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(Minimal LSM demo: deny open on a fixed path);逐段说几个关键点。钩子函数demo_file_open里的两个提前返回是刻意设计的。第一行判空是为了应对目标文件没找到、demo_target_inode为 NULL 的情况这时候钩子应该完全透明第二行比较 inode不等就直接放行。只有两个条件都满足才拦截。这种“默认放行、例外拦截”的写法比“默认拦截、例外放行”安全得多出问题时后果可控。LSM_HOOK_INIT(file_open, demo_file_open)这个宏的第一个参数是钩子名不带security_前缀就是lsm_hooks.h里union security_list_options的成员名。写成security_file_open会直接编译报错。第二个参数是你的函数签名必须和钩子原型严格一致file_open的原型是int (*)(struct file *)参数类型差一个const都可能编不过。kern_path()在初始化时把目标路径解析成struct path然后从 dentry 里取 inode。这里没有直接保存struct path而是取完 inode 就path_put()只留 inode 的长引用。这是因为path会按住挂载点长期持有容易和卸载操作打架只留 inode 引用更轻量。igrab()而不是ihold()是因为igrab会检查 inode 是否正在被销毁正在销毁时返回 NULL而ihold不做这个检查。虽然kern_path刚返回时 inode 必然有效用ihold也不会错但igrab更保险养成习惯没坏处。3.3 Makefile 与编译过程Makefile 很标准内核模块的老三样obj-m lsm_demo.o lsm_demo-y : lsm_demo_main.o KDIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean这里用了lsm_demo-y做复合对象是为了让最终生成的lsm_demo.ko名字干净同时源文件名可以叫lsm_demo_main.c。如果只有一个源文件也可以直接obj-m lsm_demo.o加lsm_demo.c但养成复合对象的习惯后面加文件不用改结构。编译命令就一句make。第一次编译可能会碰到几个典型错误先提前打个预防针。报implicit declaration of function security_add_hooks说明头文件没包含全重点检查linux/lsm_hooks.h和linux/security.h是不是都在。报unknown type name struct lsm_id说明内核版本低于 6.4把lsm_id相关代码整段删掉走char *那条分支。报LSM_ID_UNDEF undeclared说明是 6.4 到 6.7 之间的版本把.id那一行删掉。编译成功后目录里会出现lsm_demo.ko用modinfo lsm_demo.ko能看基本信息确认license: GPL就对了。LSM 相关符号基本都是 GPL-only 导出MODULE_LICENSE写错会直接加载失败。3.4 加载、验证、观察验证流程分四步一步都不能省。第一步准备目标文件echo hello lsm | sudo tee /tmp/lsm_demo_target第二步加载模块并看日志sudo insmod lsm_demo.ko dmesg | tail -n 5正常应该看到lsm_demo: loaded, target/tmp/lsm_demo_target。如果看到的是cannot resolve ... (-2)说明文件没建成功去检查路径。第三步验证拦截cat /tmp/lsm_demo_target预期输出cat: /tmp/lsm_demo_target: Permission denied同时dmesg里多一条deny open(...) pidxxx commcat。如果cat成功了说明钩子没生效跳到第 5 章排查。第四步验证不误伤cat /tmp/lsm_demo_other 2/dev/null || echo other file untouched ls /tmp其他文件该能读能列说明钩子只拦了目标没扩大打击面。这一步很重要很多 demo 写完只验证“拦住了”不验证“没多拦”结果上线才发现把系统搞挂了。确认三条都符合预期这个最小 demo 就算跑通了。但先别急着rmmod第 5 章会解释为什么。4. 让 demo 从“能跑”到“能用”三个必须处理的细节4.1 路径比较的性能陷阱很多教程写 LSM demo 喜欢在钩子里拼路径用dentry_path_raw()或者d_path()拿到字符串再strcmp。这种写法直观但性能代价很大。file_open是高频钩子系统里任何一个进程打开任何文件都会走一遍你在这里做一次内存分配加路径拼接等于给全系统的文件打开操作加了一道税。我做过一个粗略对比在一台日常使用的虚拟机上每次 open 都拼路径并比较ls -R一个中等目录树的耗时大概增加 20% 到 30%。这个数字在测试机上无所谓在真实环境里就是不可接受的。用 inode 指针比较就没这个问题。file-f_inode是现成的字段比较两个指针是否相等一条指令的事。内核里大量代码都是这么干的比如各种if (inode xxx_inode)的判断。不过 inode 比较有个副作用需要知道它认的是“这个文件”不是“这个路径”。如果文件被重命名/tmp/lsm_demo_target改名成/tmp/whatever钩子照样拦截因为 inode 没变。反过来如果原来的文件被删掉在同一个路径上新建一个文件新文件的 inode 不同钩子就不拦了。这两种行为在“保护某个具体文件”的场景下是合理的但如果你需要的是“保护某个路径”那就得换思路比如在inode_rename、inode_unlink这些钩子上再做一层校验。4.2 inode 引用计数与悬垂指针igrab()拿到的引用必须在模块退出时iput()还回去否则 inode 永远无法被回收等于人为制造了一个内存泄漏。更严重的是反过来的情况如果没拿引用就直接存 inode 指针文件被删之后 inode 结构会被释放你保存的指针就悬空了下一次 open 触发钩子时读到的是已释放内存轻则误判重则内核崩溃。igrab()和ihold()的区别就在这里。igrab内部会先检查I_FREEING标志如果 inode 正在被销毁就返回 NULL调用方可以安全处理ihold不做检查直接加计数调用者必须自己保证 inode 有效。在模块初始化阶段kern_path成功后 inode 一定有效理论上ihold够用但既然igrab更安全且开销差别可以忽略没有理由不用它。还有一个容易忽略的点kern_path()返回的struct path如果忘了path_put()会按住整个挂载点导致umount失败。我在代码里拿到 inode 后立刻path_put()就是为了不背上这个包袱。这个习惯建议从一开始就养成别等到出问题再回头找。4.3 钩子里的上下文约束与返回值约定LSM 钩子运行在内核态而且经常在持有锁、处于原子上下文、甚至中断上下文的情况下被调用。这意味着钩子函数里能干的事非常受限。首先不能睡眠。kmalloc(GFP_KERNEL)、mutex_lock、copy_from_user这些可能引起调度的操作都不能用要用也只能用GFP_ATOMIC版本或者无锁结构。file_open这个钩子的上下文相对宽松但也不是所有路径都保证可睡眠保守起见按“不可睡眠”来写。其次不能做太重的活。钩子在关键路径上你的每一纳秒都会累加到全系统的操作延迟上。读文件、发网络请求、做复杂计算这些都应该挪到别的地方钩子里只做快速判断。返回值约定也要记牢。返回 0 表示放行返回负的 errno 表示拒绝。不同钩子对返回值的处理略有差异file_open返回-EACCES会让open()系统调用直接失败错误码原样传给用户态。有些钩子只认 0 和非 0具体返回什么错误码不影响结果但为了语义清晰还是按场景选权限不足用-EACCES参数非法用-EINVAL资源不足用-ENOMEM。有个细节值得提一句不要返回正数。某些钩子的调用点会把这个返回值当作“已经处理过”的标志返回正数可能导致后续逻辑被跳过行为非常诡异。统一用 0 和负数不会有问题。5. 常见问题排查实录5.1 编译期报错对照表编译阶段的问题基本集中在版本差异和头文件上整理成表方便对照。报错信息根本原因处理方式implicit declaration of security_add_hooks头文件缺失补linux/lsm_hooks.h、linux/security.hunknown type name struct lsm_id内核低于 6.4删掉 lsm_id 定义改用字符串参数LSM_ID_UNDEF undeclared内核在 6.4 至 6.7 之间删掉.id字段赋值too few arguments to security_add_hooks版本分支写错检查条件编译的版本判断section mismatch警告调用了__init段函数确认模块路线是否可行必要时转内建Unknown symbol security_add_hooks符号未导出转内建路线见 5.3section mismatch这个警告值得单独说。它出现说明你调用的函数被标记了__init编译器在提醒你“这个函数可能在你还想用的时候已经被回收了”。对模块来说这不是警告而是预警因为内核的__init段在启动完成后就释放了你的模块即使加载成功运行时调用这个地址就是访问已释放内存。遇到这个警告模块路线基本可以判定走不通老老实实转内建。5.2 加载成功但不生效的排查顺序最常见的困惑insmod没报错dmesg也打印了loaded但去cat目标文件照样能读。这时候按下面的顺序排查基本能定位到问题。第一步看钩子有没有真的注册上。在demo_init里security_add_hooks之后加一句打印demo_hooks[0].head的值如果为 NULL说明LSM_HOOK_INIT没匹配到钩子名通常是钩子名拼错了。第二步看钩子函数有没有被调用。在demo_file_open第一行无条件打一条日志注意是无条件放在判空之前。如果这条

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑