
文件访问控制这件事说简单简单说难也真难。我在做安全能力底层开发的这几年最常见的一个尴尬是你辛辛苦苦在用户态写了一大堆ACL、白名单但内核里一个提权漏洞就直接绕过全部规则。后来我开始研究基于eBPF和LSM的强制文件访问控制系统才发现把“谁能读、谁能写、谁能执行”落到内核的LSM钩子上才算是走了一条硬路径。这个方案不需要改内核源码不用重新编译内核策略还能动态下发非常适合做安全产品内核态能力、容器环境文件防篡改、主机合规加固。今天就把这套系统的架构设计和实现思路完整拆一遍从头到尾讲清楚为什么这么做、代码怎么写、坑在哪。1. 先从“为什么”说起DAC不够用MAC又太重1.1 传统文件访问控制的三层困境传统的Linux文件访问控制绝大多数人熟悉的都是DAC自主访问控制也就是权限位、属主、属组那一套。DAC的特点是“资源属主说了算”root拥有一切权限普通用户对自己创建的文件可以自由授权。这在单机个人场景没什么问题但在服务器和云环境里就非常麻烦只要一个服务被提权到root整个机器的文件都能读或者某个目录里的敏感文件被错误地赋予777权限任何人都能看。再往上走还有POSIX ACL和用户态审计但这些都属于“事后追责”或者“弱控制”。POSIX ACL本质上还是基于uid/gid做的手工扩张对进程行为、调用来源、文件类型没有深层次的区分。而真正的强制访问控制MACMandatory Access Control强调的是“中央策略说了算”普通用户和管理员都不能随意绕过。SELinux、AppArmor都是MAC的典型实现但这两者的问题是策略复杂度太高规则文件动辄几千行业务一变更就要重新配安全策略很多团队根本玩不转。我在生产环境里见过最典型的场景某台机器上SELinux一直处于Permissive状态因为Enforcing后各种服务起不来运维为了省事干脆半禁用。最后真正挡住攻击的不是内核安全模块而是防火墙和网络ACL。这说明MAC的“强行”落地只靠SELinux这种重量级方案对多数团队来说成本过高。我们需要的是一套轻量、可控、能按业务需求动态调整的强制访问控制机制这就引出了LSM和eBPF的组合。1.2 为什么这次选LSM eBPF而不是写内核模块LSMLinux Security Module从Linux 2.6开始就在内核里了它提供了一整套安全钩子。原来这些钩子只能被编译到内核的模块使用比如Yama、loadpin、SELinux后来eBPF的BPF LSM机制被合并进内核允许我们通过eBPF程序动态注册到LSM钩子上。这个能力等于把“可编程安全策略”塞进了内核却不需要写一个传统LKM可加载内核模块并费心维护内核版本兼容性。传统LKM可以干同样的事但我个人强烈不建议。写LKM意味着你要面对内核导出符号的变化、版本锁、安全模块API变更稍微升级内核就得重新适配。eBPF程序则通过CO-RECompile Once Run Everywhere机制可以在不同内核版本间迁移只要内核支持BPF LSM基本不用改代码。再加上eBPF的Verifier会做严格安全检查程序写崩了也会被拒绝加载不会把整个内核带崩这对生产环境来说是临界优势。安全社区对LSM的另一个常被忽略的点是LSM挂钩点本身是“强制”的内核在关键路径上一定会调用。你不需要去inline hook系统调用不需要碰sys_call_table只要从VFS、MM层下发过来的文件操作到了LSM钩子这里你的策略就有机会拦截。这种“内核官方支持、强制生效”的特性是FS文件系统相关安全方案最需要的。2. 系统架构设计与核心决策2.1 总体组成与数据流先交代这套系统的整体组成它不像传统安全产品那么复杂但角色划分得非常清楚。整个系统分成三块用户态策略管理进程负责接收管理员配置、编译路径规则、下发到eBPF Map、读取审计日志。内核态eBPF LSM程序挂载在LSM钩子上每次文件操作触发时查询策略Map返回允许或拒绝。审计通道通过另一个Perf/ringbuf Map把拦截事件、放行事件上报给用户态。数据流是这么走的用户在用户态配置“禁止非root进程写入/etc/shadow”策略管理进程把这条规则处理成(key, value)写入BPF Map。进程发起open(“/etc/shadow”, O_WRONLY)时VFS层调用security_file_open钩子内核执行我们的eBPF程序程序从openat参数中提取文件名和进程uid去Map里做精确查询。策略返回拒绝则系统调用直接返回EACCES并且审计事件通过ringbuf管道送到用户态日志系统。这个架构最妙的地方是eBPF程序本身不持有策略决策逻辑之外的东西全部业务逻辑留在用户态内核只做快速路径匹配。这样既保证了决策速度又避免了内核态跑复杂逻辑。2.2 LSM挂钩点选择不是越多越好设计这类系统时第一件事不是写代码而是确定你需要在哪些LSM钩子上拦截。文件访问控制相关的主要钩子有好几个钩子名触发场景适合拦截的操作security_file_permission读写、mmap等权限检查判断某文件是否可读可写security_file_open文件被打开时拦截open、O_WRONLY等受控访问security_mmap_file文件被映射到内存时防止恶意代码通过mmap加载security_path_truncatetruncate被调用时防止敏感文件被截断security_inode_unlink删除文件时做文件防删除保护有人喜欢把所有钩子都挂一遍以为全覆盖就是安全但其实每个钩子都会引入额外性能开销且不同钩子间的上下文复杂度完全不同。比如path_truncate拿到的path指针在早期内核版本里生命周期特别容易出问题file_open则已经拿到struct file指针从file-f_path拿到路径更稳定。我个人的取舍是把核心拦截逻辑放在security_file_open和security_mmap_file上因为对普通恶意操作来说90%的文件读写都必须先打开文件对“通过mmap绕过读写检测”这个常见姿势mmap_file也必须卡住。如果业务上还有“不允许任何进程修改某个文件”就再挂security_inode_unlink。其他钩子一般不加保持策略面清晰。2.3 策略模型与Map设计策略模型是这类系统最容易跑偏的部分。有人直接把全路径字符串塞进Map的key看起来简单但路径前缀、软链接、硬链接都能绕过实际效果很差。我建议把策略区域划分为“路径前缀”加“操作掩码”加“进程指纹”三层路径前缀比如“/etc/shadow”“/var/www/”等用目录级别做最长匹配。操作掩码可读、可写、可执行、可删除用bitmap表示。进程指纹可以是pid、uid、cgroup id或者是进程comm名称。早期实现优先用uid或cgroup id因为稳定性高。对于BPF Map类型策略表我用BPF_MAP_TYPE_HASH。Key可以设计为一个结构体包含路径哈希值、操作掩码、uid。Value就是一个状态值“ALLOW”或“DENY”也可以放一个优先级字段用来做覆盖。路径哈希我一般用内核的bpf_probe_read_kernel_str取出字符串再用一次简单的djb2或FNV哈希算出来。有人问为什么不用LPM Trie前缀匹配因为BPF LPM Trie的字符串key处理比较麻烦而hash在做精确匹配时更快。策略下发时用户态就以“路径操作uid”精确匹配的方式写表实际使用效果完全够。审计通道则是一块BPF_MAP_TYPE_RINGBUF事件结构体包含时间戳、进程pid、uid、操作结果、路径name。用ringbuf而不是perf_event_array优点是per-CPU缓冲不用手工管理丢记录问题用户态读起来也简单。3. 关键实现细节从原型到可运行的系统3.1 环境准备与内核配置检查先说环境这套系统对内核版本有硬性要求。eBPF LSM机制从Linux 5.4开始提供建议直接用5.10以上内核我实际测试用的是5.15和6.1稳定性都很好。编译eBPF程序需要clang版本不低于10llvm-strip以及libbpf开发库。如果是CentOS 8/RHEL 9可以直接安装对应的libbpf-develUbuntu 22.04下的libbpf-dev也很方便。另外不要忽略内核编译选项。你必须确认如下几个CONFIG开关都开着CONFIG_BPFy CONFIG_BPF_SYSCALLy CONFIG_BPF_LSMy CONFIG_DEBUG_INFO_BTFy CONFIG_LSM_ENABLE_BPFy # 部分内核版本叫CONFIG_BPF_LSM很多新手一开始就遇到“unknown attach type”或者“BPF_LSM is not enabled”基本都是这些配置没有开启。检查方式可以用cat /sys/kernel/btf/vmlinux | head -c 4 echo BTF ok grep BPF_LSM /boot/config-$(uname -r)另外还有个运行时开关需要手动开启sysctl -w kernel.bpf_lsm_enable1这个开关在5.10以后是默认开启的但有些发行版会默认关闭。忘记这一步程序能加载成功但LSM钩子一个都不会触发基本等于白干。3.2 eBPF LSM程序核心代码实现下面给一个最小可运行的eBPF LSM程序示例目标是阻止指定uid对某路径进行写操作。完整代码会稍长但核心逻辑就这么几块。// lsm_file_ctrl.bpf.c #include linux/bpf.h #include linux/version.h #include bpf/bpf_helpers.h #include bpf/bpf_tracing.h #include bpf/bpf_core_read.h #include lsm_file_ctrl.h char LICENSE[] SEC(license) GPL; struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1024); __type(key, struct policy_key); __type(value, __u32); } deny_policy SEC(.maps); struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024); } audit_buf SEC(.maps); SEC(lsm/file_open) int BPF_PROG(vfs_file_open, struct file *file) { struct policy_key key {}; __u32 *allow; umode_t mode; umask_t umask; struct path f_path; char filename[NAME_MAX] {}; // 只检查写打开 mode file-f_flags O_ACCMODE; if (mode ! O_WRONLY mode ! O_RDWR) return 0; // 获取路径这里做了路径长度保护 f_path file-f_path; long ret bpf_probe_read_kernel_str(filename, sizeof(filename), BPF_CORE_READ(f_path, dentry, d_name.name)); if (ret 0) return 0; key.path_hash str_hash(filename); key.uid bpf_get_current_uid_gid(); // 只取低32位 allow bpf_map_lookup_elem(deny_policy, key); if (allow *allow POLICY_DENY) { // 审计 struct audit_event *evt bpf_ringbuf_reserve(audit_buf, sizeof(*evt), 0); if (evt) { evt-pid bpf_get_current_pid_tgid() 32; evt-uid key.uid; evt-result AUDIT_DENY; bpf_probe_read_kernel_str(evt-path, sizeof(evt-path), filename); bpf_ringbuf_submit(evt, 0); } return -EACCES; } return 0; } static __always_inline __u32 str_hash(const char *str) { __u32 hash 5381; int c; while ((c *str)) hash ((hash 5) hash) c; return hash; }这段代码里有一些容易踩的细节。第一BPF_PROG宏要根据内核版本处理好函数参数定义5.10之后的LSM程序通常用这个宏来帮助生成正确的上下文。第二file-f_path.dentry-d_name.name需要完整读取如果不熟悉BPF CO-RE访问方式直接抄传统内核代码里的dentry-d_name很容易出现verifier报错。第三我在ringbuf事件里存的是字符串虽然运行效率不如存hash但排障时非常有用不用哈希反查路径。生产环境如果担心ringbuf过大可以改成存hash并在用户态建立映射。3.3 用户态策略下发与日志收集内核eBPF程序只是“执行者”真正需要频繁换的是用户态策略管理进程。这部分我用了libbpf的skeleton方式编译加载eBPF对象然后在C程序里通过bpf_map_update_elem直接操作deny_policy这个map。策略key的定义必须和内核态完全一样字节对齐也要一致。比如我用相对简单的结构体struct policy_key { __u32 path_hash; __u32 uid; };用户态填充时先计算路径hash再设置uid然后调用int ret bpf_map_update_elem(skel-maps.deny_policy.map_fd, key, val, BPF_ANY); if (ret 0) { perror(update deny_policy); }删除策略就用bpf_map_delete_elem。唯一要注意的是多线程或集群管理场景下多个管理进程同时写同一个Map必须加锁或者用独立的更新通道否则可能出现并发更新互相覆盖。日志收集同样简单每次bpf_ringbuf__poll()获取事件然后解析audit_event结构体输出到标准日志系统。我一般会写一个很薄的Go或者Python wrapper方便对接SIEM。3.4 编译加载与效果验证编译eBPF对象时我用clang -g -O2 -target bpf -D__TARGET_ARCH_x86生成.o文件再用bpftool gen skeleton生成skeleton头文件。这样C用户态只需include这个skeleton再调用lsm_file_ctrl_bpf__open_and_load()就能完成加载和attach。完整命令大致是clang -g -O2 -target bpf -D__TARGET_ARCH_x86_64 -c lsm_file_ctrl.bpf.c -o lsm_file_ctrl.bpf.o bpftool gen skeleton lsm_file_ctrl.bpf.o lsm_file_ctrl.skel.h clang -g -O2 -o lsm_file_ctrl main.c -lbpf当然如果你想快速验证而不整合用户态代码也可以直接用bpftool手动加载bpftool btf dump file lsm_file_ctrl.bpf.o format raw /tmp/btf_dump bpftool prog load lsm_file_ctrl.bpf.o /sys/fs/bpf/lsm_file_ctrl bpftool prog attach pinned /sys/fs/bpf/lsm_file_ctrl lsm file_open验证是否生效最简单的办法是用一个普通用户去写受保护文件比如echo hello /etc/protected_file -bash: /etc/protected_file: Permission denied同时dmesg或用户态日志里应该能看到对应的deny审计事件。如果发现hook没有触发优先检查是不是内核配置或者sysctl开关问题。4. 落地过程中的坑问题排查与优化实录4.1 挂载失败或者hook不触发的经典原因这套系统我做了好几个版本遇到的第一个大头就是“程序加载成功了但怎么试都没效果”。最后排查下来八成的可能性都是运行时开关没开。我特意把这一条放在前面因为官方文档容易忽略它。你执行sysctl -w kernel.bpf_lsm_enable1之后还要确认是当前命名空间生效最好写进/etc/sysctl.conf免得重启后又丢。另一个容易出问题的点是attach方式。有些教程会让你直接写SEC(lsm)然后通过libbpf自动attach到所有LSM钩子但真实情况是libbpf只会尝试你指定的function名称。我在上面的例子用了SEC(lsm/file_open)这样加载时会自动寻找名为file_open的LSM hook。如果你写错大小写、或者把file_open写成file_permission加载阶段可能不会报错但运行时永远不触发排查起来特别阴间。verifier报错也是常见问题。常见的报错是“R2 type map_key invalid”之类这通常是因为map key结构体某个字段类型不对或者你试图在lookup key里放一个没有完全初始化的结构体。解决办法是__builtin_memset清零整个key以后再给字段赋值。4.2 策略map更新为什么丢数据运维同学反馈说有时候策略下发后要过好几秒才生效甚至丢规则。后来我发现问题出在用户态进程和内核态map之间的事务性上。eBPF Map的更新不保证跨CPU的强一致如果你用一个分布式协调工具下发规则某个节点在写入Map时遇到BPF_EEXIST或者ENOMEM程序会直接reject用户态如果不重试就会“看起来丢了”。但其实还有个更隐蔽的坑如果你依赖BPF_F_LOCK做map并发那么策略更新和eBPF查询之间必须使用BPF_EXISTS和合适的flags。我在生产里推荐简单粗暴的策略——用户态更新Map时用BPF_ANY但增加一个“策略版本号”字段在value里。每次更新都递增版本号审计事件里也带上版本号这样就能判断追没追上策略。另外hash map在2^31加入时可能存在扩容虽然BPF的hash map设计时能自动扩容但扩容过程中lookup延迟会稍微变高极少数情况下还会报E2BIG。如果策略条目超过几千条建议按路径前缀拆成多个map分片减少单map锁竞争。4.3 与SELinux/AppArmor的叠加影响很多服务器上SELinux是开着的尤其RHEL系。BPF LSM和SELinux并不是“互斥”的关系而是叠加的。LSM框架内部靠security_hook_heads组织钩子SELinux是老牌模块它的file_open钩子被调用的优先级在某些内核版本里是排在较前面的。如果你的eBPF程序想对某个文件执行deny但SELinux先返回allow那你的deny逻辑仍有机会执行因为LSM的约定是所有有权限的模块都要依次调用只要有一个返回非0就拒绝。不过要注意如果SELinux本身已经deny了你的程序可能根本不会被调用到因为内核在遇到第一个deny时会短路返回。所以如果你开启了SELinux并处于Enforcing请务必测试两者叠加时是否符合预期。我建议初始阶段先让SELinux处于Permissive模式或者对测试目录单独做SELinux放行然后再验证eBPF策略。这里最容易出的问题是AppArmor和BPF LSM的加载顺序。AppArmor有同样的问题如果你的BPF程序在某个钩子上注册很晚可能在“全允许”阶段已经影响了行为。解决办法是强制把BPF LSM放在LSM顺序的最前面。在内核启动参数里设置lsm...,bpf确保BPF LSM在安全模块列表里靠前。这个参数太重要了我把它直接写进生产环境的grub配置。4.4 性能优化思路与实测建议安全方案如果性能损耗超过3%业务方就会抱怨。eBPF LSM的优势是策略查询走内核map不需要进程上下文切换。但如果你用bpf_probe_read_kernel_str做路径解析每次open都可能读一个字符串开销仍然存在。我做了两层优化。第一层是“路径快速哈希”在用户态下发策略时同时计算出路径的hash值并将hash和uid作为key。内核态不再需要完整读取字符串只读取dentry名称的前几个字节算hash即可。如果hash不匹配直接allow。这样大多数不敏感文件的open路径只会做一次hash开销在纳秒级。第二层是“拒绝短路”敏感文件通常很少被正常业务访问所以deny策略命中应该极快不需要再走审计逻辑我一般分成两条map查询路径一条专门查deny策略另一条才查allow策略。实测下来单节点每秒几万次文件open时eBPF策略检查带来的延迟几乎可以忽略纯open路径相比不加载策略时大约多出2%到3%的开销。如果你用perf record抓取会发现大部分时间还是耗在VFS层本身。5. 后续演进与个人经验5.1 从文件访问控制扩展到其他LSM能力这套架构搭好之后扩展空间非常大。LSM钩子不只覆盖文件系统还能覆盖网络、任务、IPC信号等场景。比如你可以用SEC(lsm/socket_connect)实现对网络连接的强制策略也可以用SEC(lsm/task_prctl)做进程提权防护拦截setuid之类的操作。因为底层还是同一套eBPF Map和ringbuf框架加一个钩子基本是写一个SEC函数的事用户态策略管理器只需要增加新的key类型。文件访问控制本身也可以从“读、写、执行”扩展到“ioctl控制、mmap保护、rename/truncate防护”。特别是容器环境配合cgroup id做进程指纹可以做到容器级文件隔离。这个比传统命名空间隔离更细因为你在内核层面对具体文件路径做了强制约束而不是只看namespace划分。5.2 我的几点个人心得最后说点个人体验。这套方案最大的优势是“渐进式上生产”。你不用一上来设计一套包罗万象的安全模型完全可以先保护两三个关键文件观察审计日志再逐步扩大策略面。因为eBPF允许动态加载卸载出问题马上回滚比SELinux改策略重载要灵活得多。如果非要说缺点那就是学习曲线陡需要对内核关键路径有一定理解。你至少要看得懂VFS层调用栈知道file_open和file_permission的差异。不过一旦上手你会发现自己看安全问题的视角完全变了——不再是“应用程序怎么躲”而是“内核路径上哪里能卡住”。这套系统我后来还加了定时策略校验、灰度下发、异常检测扩展但核心架构一直没变。如果你正准备做主机安全、零信任文件防护、或者合规审计工具建议从这里开始。