手写LSM内核模块:从零实现文件读操作拦截
发布时间:2026/10/21 8:59:00 锦皓数字建站

1. 为什么我决定自己动手实现一个LSM demo接触Linux安全模块LSM有一段时间了但一直觉得停留在“看文档改配置”的层面对底层机制缺少真正的体感。纸上得来终觉浅所以这次决定自己动手写一个简单的LSM demo把内核安全模块的加载、挂接、策略生效整条链路完整跑一遍。这个demo的目标很明确不做大而全的安全系统只实现一个能拦截特定文件读操作并打印日志的最小可运行模块用来验证LSM框架的核心流程。选择这个切入点是因为文件读操作是LSM最典型、最容易观察效果的场景——拦截点明确、回调简单、日志验证直观。这个demo适合谁看我的判断是对Linux内核机制有一定了解、但还没实际写过内核安全模块的开发者以及对系统安全、容器隔离、沙箱实现感兴趣的同学。整个过程不需要你有多深的内核功底但需要你具备基本的C语言能力、Linux操作经验以及做好反复编译内核模块的心理准备。先说清楚LSM到底是什么。Linux安全模块是一个内核级的框架它不自己实现安全策略而是在内核的关键操作路径上提供一系列钩子函数hook让安全模块比如SELinux、AppArmor能够挂接自己的检查逻辑。你可以把它理解成一座桥内核本来可以直接放行所有操作LSM在桥上设置了若干检查点每个安全模块都可以在这些检查点部署自己的“哨兵”哨兵有权放行或拦截。这种设计的好处是安全策略与内核主体逻辑解耦不同的安全模块可以按需加载甚至可以同时共存通过LSM的blob机制和hook优先级机制。在我动手之前有几个核心问题需要先搞清楚LSM的钩子是怎么注册的模块加载后内核怎么知道要调用我的回调我的安全策略应该放在哪里执行这些问题的答案直接决定了demo的实现路径。下面先梳理整体架构和设计思路。2. LS M的整体设计与核心思路拆解2.1 从系统调用到安全模块的完整调用链要理解LSM的工作方式首先要沿着系统调用的路径走一遍。以read操作举例用户态程序调用read()后内核执行到对应的vfs_read()在真正操作文件内容之前内核会经过一个关键的检查点——security_file_permission()。这个函数就是LSM框架暴露的钩子之一它会遍历所有已注册的LSM模块逐个调用它们注册的file_permission回调。如果任何一个模块返回非零值这次read操作就会被拒绝用户态会收到一个错误码通常是EPERM或EACCES。这个设计有几个值得注意的点钩子不是“某个模块私有”的而是所有LSM模块共享同一套钩子列表返回值的语义是约定好的0表示放行负的错误码表示拒绝安全检查发生在真正操作资源之前所以不会产生“操作到一半被拦”的中间状态我最初以为LSM的钩子调用是“先到先得”只要有一个模块放行就算通过。实际不是这样。每个钩子点上的检查是“与”的关系所有模块都放行才放行任何一个模块拒绝就拒绝。这意味着如果你同时加载了SELinux和自己的demo模块即使你的模块放行了SELinux如果拒绝操作照样被拦。这个语义我在写demo时特别注意因为调试过程中很容易出现“我的回调没生效”的错觉——很可能是其他安全模块先拦截了。2.2 为什么选择Linux Security Module框架而不是其他方案实现内核安全功能的方式不止LSM一种常见的选择还有直接修改内核源码在特定系统调用里硬编码检查逻辑使用内核审计框架audit做事后记录使用eBPF扩展的伯克利包过滤器在跟踪点挂载程序使用LSM框架挂接标准钩子每条路径都有各自适用的场景。直接改内核源码最灵活但维护成本极高每次内核版本升级都要重新适配而且很难做到与上游内核共存基本等于维护一个私有分支。eBPF方案很强大尤其适合观测和轻量拦截场景但LSM类型的eBPF程序依赖较新的内核版本5.7而且对一些深层安全语义的表达不如原生LSM模块直接。audit框架只能记录不能主动拦截所以这套demo的目标场景拦截某类文件访问没法直接用audit实现。LSM框架的优势在于不修改内核主线的代码以模块形式加载与内核版本的兼容性靠一套稳定的钩子接口来保证钩子函数的语义清晰非常适合做安全策略的快速原型验证。对于我的demoLSM是最贴合目标的路径——既能验证安全模块的加载机制又有明确的钩子接口可以依赖后续如果想扩展策略也只需在回调里加逻辑即可。2.3 关键技术前提与运行环境确认动手之前我列了一份环境清单建议你也先确认这些条件否则编译和加载环节会很折腾项目建议要求备注Linux内核版本4.7及以上低版本也可用但部分接口有差异内核源码与当前运行内核版本匹配编译模块需要内核头文件编译器gcc及make需与内核版本兼容内核配置启用LSM框架相关选项见下文说明系统权限root或可sudo加载模块需要特权我用的内核版本是6.x具体小版本号只要和源码对应就行编译环境是标准发行版内核加对应头文件包。注意如果发行版默认安装的内核没有启用某些LSM相关配置你的模块可能能编译但加载失败或者加载成功但钩子不触发。我建议在编译前就检查一下当前内核的config文件通常在/boot目录或/proc/config.gz中确认CONFIG_SECURITY和CONFIG_SECURITYFS是开启的。重要提示如果你运行的内核已经启用了SELinux或AppArmordemo模块加载后所有钩子都会参与调用链。由于这些模块的默认策略可能拦截大部分操作调试时要留意“系统行为异常不一定是你的模块导致的”。建议在隔离的虚拟机或专用测试机上进行实验不要在生产环境直接测试。2.4 最小可运行模块的模块结构规划我规划的demo模块包含四个核心部分模块初始化和退出函数负责注册和注销LSM钩子钩子回调函数实现具体的检查策略策略控制逻辑通过模块参数或procfs条目动态控制是否启用拦截方便测试日志输出用printk记录每次拦截或放行事件目标是在不影响系统正常工作的前提下对指定文件的读操作产生可观察的拦截效果。这样既能证明LSM框架确实生效又不会把测试环境搞挂方便反复实验。3. 核心细节解析与实操要点3.1 LSM钩子的注册与注销机制在旧版本内核中LSM模块通常通过security_add_hooks()函数注册钩子。而在较新的内核版本里官方更推荐使用结构化的方式定义一个security_hook_list数组然后通过security_add_hooks()或更现代的lsm_register_hooks()具体看你使用的分支挂载。在6.x内核中一般定义如下结构static struct security_hook_list demo_hooks[] { LSM_HOOK_INIT(file_permission, demo_file_permission), };LSM_HOOK_INIT宏的作用是把钩子函数的地址填充到对应的hook结构体里。file_permission是LSM框架暴露的钩子名demo_file_permission是我实现的回调函数。还需要定义一个表示LSM模块自身信息的结构体static struct lsm_id demo_lsm_id { .name demo_lsm, .id 42, // 需要选取一个未占用的LSM ID };不同的LSM模块在注册时往往会提供不同的优先级或顺序但作为demo直接使用默认顺序即可不需要刻意调整优先级。注销逻辑相对简单在模块退出函数中调用security_delete_hooks()或对应版本接口把之前注册的钩子列表移除即可。不过我实际测试发现如果模块被强制卸载rmmod -f内核可能来不及完整执行注销流程后续再加载同名模块可能出现状态残留。所以调试时尽量正常卸载不要图省事用强制参数。3.2 回调函数的核心逻辑与参数含义我们关注的file_permission钩子其函数签名是int demo_file_permission(struct file *file, int mask);第一个参数是当前操作涉及的文件对象第二个参数是操作掩码。mask中常见的标志位包括MAY_READ读、MAY_WRITE写、MAY_EXEC执行。我们demo只需关注MAY_READ标志。回调的编写要点在于判断“是否是我关心的文件”和“是否为读操作”然后决定返回0还是返回错误码。这里有一个容易犯的错误很多人以为回调返回“拒绝”后用户程序会立刻看到错误但实际上用户态拿到的是read系统调用的返回结果可能是EPERM操作不允许。要验证拦截是否生效需要在用户态测试程序里检查返回值并配合dmesg查看内核日志。为了避免无差别拦截导致系统异常demo里我加入了一个简单的路径匹配逻辑通过内核辅助函数获取文件路径然后与预置的目标路径比较。注意内核态不能直接使用用户态常用的字符串函数和路径解析方式需要用d_path等内核API而且要小心处理路径缓冲区的大小一般定义为PATH_MAX即4096字节。3.3 模块参数与动态策略控制写模块时我顺手实现了两个模块参数极大方便了调试enable_intercept布尔值控制是否真正拦截还是仅记录日志默认为truetarget_path字符串指定要监控的文件路径默认为空表示不限制路径这样一来加载模块时可以传入参数insmod demo_lsm.ko target_path/tmp/testfile enable_intercept1也可以在运行时通过/sys/module/demo_lsm/parameters/下的文件修改参数值如果模块允许写入。这个设计很实用因为调试初期你并不想真的拦截系统内所有文件的读取只想观察某个指定路径的访问情况。3.4 关于LSM ID与模块名称的注意事项新版本内核对LSM模块的标识有更严格的要求包括lsm_id结构体中的id必须唯一。如果你加载模块时遇到“LSM ID冲突”之类的错误多半是id选取和其它模块重复了。可以通过如下方式查看系统当前注册的LSM模块列表cat /sys/kernel/security/lsm如果你看到系统里已经有capability、selinux、apparmor等字样说明这些模块已经注册。新注册的demo模块会添加到这个列表末尾。注意有些发行版内核将某些LSM设为“不可禁用”即便你没有主动加载它们相关钩子也已经参与调用链。这对demo的调试没有实质影响但你要意识到系统中的安全策略是一个叠加的组合。3.5 为什么我选择在file_permission而非其他钩子做拦截LSM框架提供了大量钩子比如file_open、file_permission、mmap_file、path_mknod等。我最终选择了file_permission原因是它覆盖的调用路径广read、write、mmap等常见文件操作都会经过这个钩子它的语义清晰mask参数直接告诉你是什么操作它容易观察配合printk日志可以清楚看到每次操作发生的时间、进程、路径如果选择file_open也能达到类似效果但file_open只覆盖文件打开阶段对于一些已经打开文件描述符的后续读取操作就观察不到了。所以file_permission更适合“拦截文件读操作”这个目标。4. 实操过程与核心环节实现4.1 准备内核头文件与构建环境Demo模块编译必须依赖与当前内核匹配的头文件。这里分两种情况如果你的发行版内核是自己编译并安装的源码目录一般就在/usr/src/linux-$(uname -r)如果用的是发行版自带内核通常安装对应名称的kernel-devel或linux-headers包即可我用的环境是某Linux发行版直接安装headers包sudo apt install linux-headers-$(uname -r)执行完确认一下目录结构是否完整ls /usr/src/linux-headers-$(uname -r)关键是确认存在include目录、Makefile以及Module.symvers如果有。Headers包不完整会导致编译时找不到内核头文件或者生成模块时缺少符号信息编译报错时不要急着怀疑代码先检查环境。4.2 完整Makefile与源码编译创建一个工作目录比如demo_lsm里面放demo_lsm.c和Makefile。Makefile的写法有标准模板obj-m demo_lsm.o KERNEL_DIR ? /lib/modules/$(shell uname -r)/build all: make -C $(KERNEL_DIR) M$(PWD) modules clean: make -C $(KERNEL_DIR) M$(PWD) clean注意这里KERNEL_DIR指向的是内核构建目录通常就是符号链接到/usr/src/linux-headers-xxx。如果多版本内核并存最好显式指定KERNEL_DIR避免用uname -r拿到预期之外的版本。在源码中模块入口与退出函数必须用module_init和module_exit宏标注否则insmod时内核无法正确找到入口点。另外要给模块添加许可证声明建议写GPL否则内核会报“module license unspecified taints kernel”警告有些内核符号在非GPL模块下也无法链接。MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple LSM demo module);4.3 demo模块源码逐段解析下面给出一份能编译通过的最小源码按6.x内核接口编写我建议你通读一遍并理解每个字段的含义而不是直接复制粘贴#include linux/module.h #include linux/kernel.h #include linux/init.h #include linux/security.h #include linux/path.h #include linux/dcache.h #include linux/fs.h #include linux/namei.h #include linux/slab.h static char *target_path /tmp/testfile; module_param(target_path, charp, 0644); MODULE_PARM_DESC(target_path, Path to monitor); static bool enable_intercept true; module_param(enable_intercept, bool, 0644); MODULE_PARM_DESC(enable_intercept, Enable actual interception); static int demo_file_permission(struct file *file, int mask) { char *buf; char *path; int ret 0; if (!(mask MAY_READ)) return 0; if (!target_path[0]) return 0; buf (char *)__get_free_page(GFP_KERNEL); if (!buf) return 0; path d_path(file-f_path, buf, PAGE_SIZE); if (IS_ERR(path)) goto out; if (strcmp(path, target_path) 0) { printk(KERN_INFO demo_lsm: denied read on %s by pid %d\n, path, current-pid); if (enable_intercept) ret -EPERM; } out: free_page((unsigned long)buf); return ret; } static struct security_hook_list demo_hooks[] { LSM_HOOK_INIT(file_permission, demo_file_permission), }; static struct lsm_id demo_lsm_id { .name demo_lsm, .id 42, }; static int __init demo_lsm_init(void) { security_add_hooks(demo_hooks, ARRAY_SIZE(demo_hooks), demo_lsm_id); printk(KERN_INFO demo_lsm: module loaded\n); return 0; } static void __exit demo_lsm_exit(void) { security_delete_hooks(demo_hooks, ARRAY_SIZE(demo_hooks)); printk(KERN_INFO demo_lsm: module unloaded\n); } module_init(demo_lsm_init); module_exit(demo_lsm_exit);这段代码里有几个细节需要说明第一d_path返回的是从根目录开始的完整路径如“/tmp/testfile”而你传入的target_path也需要是绝对路径。曾经有人传入相对路径结果永远匹配不上半天查不出原因。第二每次回调都分配一页内存并调用d_path是有性能开销的在生产级模块中应该做缓存或更精细匹配demo里这样写是为了可读性和稳定性优先。第三strcmp比较路径时是大小写敏感的如果你要匹配的路径涉及不同大小写形式需要额外处理。4.4 编译、加载与验证流程编译和加载流程如下make sudo insmod demo_lsm.ko target_path/tmp/testfile enable_intercept1加载后查看内核日志确认初始化成功dmesg | tail -20如果没有看到“demo_lsm: module loaded”加载多半失败了用lsmod确认一下模块是否在列表里并用dmesg查看报错详情。接下来准备一个测试文件echo hello demo /tmp/testfile cat /tmp/testfile正常情况下cat会报错Operation not permitted。查看dmesg会看到我们打印的denied日志。为了确认拦截目标是精确的再创建一个不在监控列表中的文件echo hello world /tmp/otherfile cat /tmp/otherfile此时应该正常输出没有任何拦截日志——说明我们的路径过滤逻辑生效了。4.5 动态开关策略的验证方法如果你在测试过程中不想一直拦截可以动态修改模块参数。只要参数文件的权限允许写入0644表示所有者可读写其他用户可读root可以这样操作echo 0 /sys/module/demo_lsm/parameters/enable_intercept cat /tmp/testfile此时cat应该能正常读到内容同时dmesg里仍会打印“denied”日志因为我们选择在disable时只记录不拦截。再把开关打开恢复正常拦截。这种方式对调试非常有帮助尤其是判断“到底是LSM回调没触发还是触发了但没拦截”。注意模块参数中字符串类型的target_path通过sysfs修改时长度不能超过原始参数定义时的最大值否则写入会失败。实际测试中我发现如果一开始只给了很短的默认路径后续想改成更长的路径会报错。解决方法是编译时把模块参数的默认值或传入值预留足够长度或者干脆在代码里用固定大小字符数组承载。4.6 结合用户态测试的完整验证内核模块只完成了拦截功能用户态还需要配合做验证。我在测试机上写了个简单的C程序模拟“读取受保护文件”和“读取普通文件”两种场景#include stdio.h #include errno.h #include string.h #include fcntl.h #include unistd.h void read_file(const char *path) { int fd open(path, O_RDONLY); if (fd 0) { printf(open %s failed: %s\n, path, strerror(errno)); return; } char buf[64]; ssize_t n read(fd, buf, sizeof(buf)-1); if (n 0) { printf(read %s failed: %s\n, path, strerror(errno)); } else { buf[n] \0; printf(read %s success: %s\n, path, buf); } close(fd); } int main() { read_file(/tmp/testfile); read_file(/tmp/otherfile); return 0; }编译后运行预期输出是受保护文件read失败错误为Operation not permitted普通文件read成功。这里有一个有趣的细节即使open成功后续read也可能失败因为file_permission钩子会在每次read时触发。所以用户态程序拿到“open成功但read失败”的组合是完全可能的这点别被误导成“模块没拦截”。5. 开机自动加载与模块持久化5.1 把demo模块纳入开机加载流程临时加载只适合开发调试如果想要这个安全模块在系统启动后自动生效还需要在模块加载之前将模块文件复制到标准模块目录下并配置加载策略sudo cp demo_lsm.ko /lib/modules/$(uname -r)/extra/ sudo depmoddepmod会重新扫描内核模块依赖关系生成modules.dep等文件。之后用modprobe加载时系统才能找到这个模块。接着把加载命令写入开机启动配置常见的做法是在/etc/modules-load.d/下新建一个conf文件写入模块名不含.ko扩展名或者使用systemd的modprobe服务加载5.2 内核启动参数与LSM启用的关系这里要强调一个细节模块本身加载了并不意味着LSM框架一定会调用它的钩子。部分版本内核特别是启用了一些安全特性后要求LSM模块在启动早期通过安全子系统注册而简单模块用insmod在系统运行阶段加载也受支持。如果你想在启动时就加载可以通过内核引导参数禁用SELinux/AppArmor仅在测试环境这么做或者把模块做成启动早期加载的方式。对demo来说运行阶段insmod就足够了不需要折腾引导参数。5.3 卸载与状态清理卸载模块使用rmmod或modprobe -r。我的建议是卸载后检查一下dmesg确认security_delete_hooks执行正常没有残留日志或内核告警。如果在同一内核会话中反复加载/卸载模块每次加载都会尝试注册相同的LSM ID和钩子如果上次注销不完整本次会失败或产生重复注册。遇到这种情况先重启测试环境再试往往是最快的解决办法。6. 常见问题与排查技巧实录6.1 编译报错某些宏定义找不到编译LSM模块最常见的报错是找不到security_add_hooks、LSM_HOOK_INIT等符号或宏。原因通常是内核头文件路径不对编译器没有找到include/linux/security.h内核版本较老所用接口名称不同CONFIG_SECURITY未启用导致security.h中相关声明缺失我建议检查内核配置文件grep CONFIG_SECURITY /boot/config-$(uname -r)如果CONFIG_SECURITY未开启你需要重新编译内核或在当前内核上启用该选项否则LSM相关头文件无法提供完整声明。6.2 模块加载成功但钩子不触发看着dmesg里模块加载日志正常但访问目标文件时没有任何反应。这种情况我排查过几次最后发现的原因高度一致路径不匹配。要么是target_path传成了相对路径要么是实际文件路径与传入路径存在符号链接差异比如/tmp/testfile在系统里可能是某个tmpfs的实际路径解析后和d_path输出的路径不一致导致strcmp不相等。建议在回调里临时打印每次读操作的路径不判断是否匹配先把路径看明白再做过滤逻辑。这是最直接的排查手段。6.3 所有文件都被拦截或系统明显卡顿如果模块对整个系统的文件访问产生了广泛影响首先要检查是不是mask判断写错了。例如你希望只拦截MAY_READ但代码里判断条件写成了“if (mask MAY_READ) return -EPERM”而没有区分是否为目标文件。一旦register钩子生效所有读操作都会返回错误。解决思路很简单每一步检查都先落日志确认是“逻辑错了”还是“路径匹配范围过宽”。我在调试中经常用到的思路是先用enable_intercept0加载模块让它只打印日志不拦截观察一段时间系统是否正常确认匹配逻辑无误后再把拦截开关打开。这套流程可以极大降低调试风险也推荐给你。6.4 卸载模块时报错“Resource temporarily unavailable”出现这个提示通常意味着某个进程正在使用模块的相关状态或被引用。LSM模块本身一般不会被进程持有引用但如果你在回调里分配了资源没有释放或者有正在执行的回调上下文尚未结束就可能出现这种情况。常见处理方法是终止相关测试进程后等待片刻再卸载。如果实在无法卸载重启后再加载新版本模块即可不用在运行状态下死磕。6.5 常见错误速查表现象可能原因处理方法编译报错找不到security.h内核头文件不完整或路径错误安装匹配的headers包检查KERNEL_DIRinsmod报错Unknown symbol模块没有包含正确的内核符号或头文件版本不匹配检查Module.symvers核对内核版本加载成功但无日志钩子未注册或路径匹配不上或日志级别被抑制用sysctl或dmesg -n调整日志级别打点调试系统不稳定、大量拒绝mask判断过宽或路径匹配条件过于泛化先用只记录不拦截模式观察卸载时异常回调资源未释放或存在未完结上下文终止相关进程重启环境7. 从demo迈向真实安全模块的路线7.1 为demo补充合理的策略框架当前demo的路径匹配用的是字符串比较这在实际项目中不可扩展。真实安全模块通常需要基于inode的安全标记security blob而不是每次走路径解析基于用户、进程、文件组合的多维策略决策支持策略动态加载/更新而不是内核模块参数写死从LSM框架角度看如果要给文件系统对象打安全标签需要额外实现set和get安全标记相关的钩子。这会让模块复杂度上一个台阶但也是迈向真实安全模块必须走过的路。7.2 结合eBPF与LSM的混合方案如果你的目标是快速原型验证或轻量级监控eBPFLSM的路径可能更合适使用BPF LSM程序挂载到file_permission钩子可以用更灵活的方式加载和卸载策略不需要编译内核模块迭代速度快很多。但BPF LSM依赖较新的内核和BPF特性且对复杂状态管理能力有限。它适合做“快速实验”而传统的LSM模块适合做“深度定制”。两者并非互相替代而是互补关系。7.3 安全模块的测试方法论测试LSM模块我的一点体会是不能只测“模块能否拦截目标操作”还要测“模块是否放行了不该放行的操作”“模块对系统整体性能影响多大”“模块与其他安全模块叠加时的行为是否符合预期”。我建议写一个自动化的测试脚本覆盖以下场景目标文件读取被拦截非目标文件正常读取读写打开时的不同表现软链接指向目标文件时的行为删除、重命名目标文件时的行为并发读取时模块的稳定性8. 写在最后的经验小结8.1 调试LSM模块最大的障碍不是代码而是看不见的“叠加态”系统里往往存在多个安全模块同时工作你的模块只是其中一环。很多现象看似是“你的模块有问题”实际是另一个模块先拒绝了操作。我建议每次调试都先查看系统当前LSM列表再判断哪个环节拦截了操作。这种“叠加态”的排查思路比单纯盯着自己的代码更有效。8.2 先打印日志再优化性能开发demo阶段不要去纠结每次回调分配内存会不会拖慢系统。先把观察点铺开日志打全行为摸清楚后面再做缓存、路径匹配树、inode映射这些优化手段。性能优化放在功能稳定之后做能省你大量反复编译的时间。8.3 这个demo后续还能怎么扩展如果你顺着这条线继续玩下去我建议下一步尝试实现基于inode的安全标记也就是不仅拦截路径还能让不同文件携带不同的安全属性再往后可以尝试把策略决策部分放到用户态通过netlink或虚拟文件系统与内核模块交互形成完整的策略管理与执行分离。这样你已经不是在“写demo”了而是在设计一个内聚的安全子系统。回到最初的问题为什么我要自己动手实现一个LSM demo因为只有真正写过一遍注册、回调、策略、调试的完整流程你才会深刻理解LSM不只是内核里的“一堆钩子”而是一套严谨的安全扩展机制。这种理解光看文档是得不到的。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。