ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Landlock LSM 深度解析:用文件系统路径规则构建轻量级沙箱

Landlock LSM 深度解析:用文件系统路径规则构建轻量级沙箱 1. 先说说我为什么盯上这个特性我在一台长期跑 5.15 内核的服务器上折腾沙箱方案时偶然翻到内核安全模块列表里多了个生面孔Landlock。第一反应是“又来了个和 SELinux 抢饭碗的 LSM 吧”但仔细读完设计文档后我发现自己之前的判断完全错了。Landlock 不是又一个给系统管理员用的强制访问控制框架它更像是给普通进程开发人员准备的一把“自我约束锁”——你不需要 root 权限不需要容器运行时也不需要额外编译内核模块就能让当前进程主动放弃一部分文件系统访问能力。这解决了什么实际问题举个最典型的场景你的程序要解析一个不可信的第三方插件或者你要跑一段来自网络的代码正常做法是塞进虚拟机或者 Docker 容器里太笨重。用 seccomp 去过滤系统调用又太底层你得把 openat、execve 这一串 syscall 全部理清楚稍微漏一个就白搭。Landlock 的思路则非常直接给当前进程以及它的所有子进程划定一条“文件系统可达性”红线——哪些路径能读、哪些路径能写、哪些目录不能遍历内核在路径解析的时候直接拦下来。如果你写过沙箱类的小工具或者做过浏览器渲染进程隔离、多租户插件系统这类对“不可信代码”头疼的事这篇文章值得读到底。我会从原理入手再给出一套在 Linux 5.15 上可以直接动手验证的 C 代码示例最后把我在实践中踩过的一些坑也一并交代清楚。2. 理解 Landlock 的定位它和传统 LSM 根本不是一回事2.1 一次小事故引出的设计动机我在项目里维护过一套自动化构建系统里面有一个功能是从插件市场拉取第三方模块然后在宿主机上直接执行它的安装脚本。正常情况下这没问题直到某个插件作者在脚本里写死了rm -rf /某个路径虽然那次没造成实际损失但足够让人出一身冷汗。事后我调研了一圈加固方案给整个构建进程加 SELinux 域成本太高团队里没人玩得转策略编写。塞进 Docker构建过程需要访问宿主机上的缓存目录和 Unix socket改造成本也不小。用 seccomp 白名单试过一次之后放弃了光是处理路径相关的 syscall 变体就够写一本书。Landlock 的出现正好补上这个空档。它允许“不可信代码即将执行之前”由可信的父进程先调用landlock_restrict_self()把自己锁起来然后才 fork / exec 子进程。因为子进程天然继承父进程的 Landlock 限制所以后续就算脚本里真写了删除命令内核在路径解析阶段直接返回 EACCES。更关键的是这个限制是“不可撤销”的即使攻击者拿到了当前进程的代码执行权限也没法把红线悄悄撤掉。2.2 与 SELinux、AppArmor、Seccomp 的分工我整理了一个对比表格方便你理解各个安全机制到底管到哪一层安全机制工作层级谁来决定策略是否需要特权典型用途SELinuxLSM文件、进程、网络对象的完整标签体系系统管理员集中策略需要 root且通常需要重新标记文件系统整个系统的强制访问控制AppArmorLSM基于路径的进程 Profile系统管理员按程序配置需要 root限制单个守护进程的文件访问Seccomp系统调用层开发者进程内主动设置普通用户即可无附加 flag 时封禁危险 syscall如execve、ptraceLandlockLSM路径解析时做可达性判断进程自己普通用户主动设置普通用户即可给不可信子进程划定文件访问边界看到区别了吗SELinux 和 AppArmor 是“外部给你定规则”Landlock 是“自己给自己定规则”。landlock 约束的对象是“当你尝试打开某条路径时内核是否允许这个动作发生”它在security_file_open、security_path_mkdir这类 LSM hook 上做检查而不是在系统调用分发层做过滤所以它和 seccomp 天然互补seccomp 管“你能不能发起这个系统调用”Landlock 管“你发起之后内核放不放过这个路径”。2.3 LSM Hook 与权限模型一个进程一个 RulesetLandlock 的权限模型用一句话概括就是一个进程只能维护一个“规则集”ruleset规则集内包含若干条“访问规则”rule每条规则描述“某个对象上的某种访问权限是否被拒绝”。内核通过 LSM hook 在每次路径解析时检查当前进程的 ruleset一旦动作落在规则限制范围之外就返回权限错误。这里有个容易混淆的概念Landlock 规则集限制的是“你能够访问哪些路径”而不是“你能调用哪些系统调用”。也就是说open(/etc/shadow, O_RDONLY)会不会失败取决于你的 ruleset 里是否给/etc/shadow所在文件系统或父目录加上了读限制。五层目录之外的地方照常访问这才是它名字里“Land”陆地/路径的含义——它画的是疆界不是天网。3. 核心 API 与设计思路解析3.1 三个系统调用搞定全部功能Landlock 在用户态的入口非常收敛Linux 5.15 上实际只用三个系统调用landlock_create_ruleset创建一个空的规则集并声明你关心哪些访问类型。landlock_add_rule向规则集里添加具体规则比如“禁止写 /data/tmp”。landlock_restrict_self将规则集应用到当前进程此后不可修改、不可回退。这套 API 设计得非常像“内核里的防火墙”。第一步是创建一张空的 iptables 表第二步是往表里插规则第三步是把表 apply 到网卡上。只是 Landlock 里每张表只能 apply 一次之后这张表就“冻结”了。3.2 关键数据结构与权限位枚举理解权限位是写对规则的前提。Linux 5.15 的 UAPI 头文件linux/landlock.h中文件系统访问权限位主要有权限位含义LANDLOCK_ACCESS_FS_EXECUTE对文件执行权限LANDLOCK_ACCESS_FS_WRITE_FILE写文件内容LANDLOCK_ACCESS_FS_READ_FILE读文件内容LANDLOCK_ACCESS_FS_READ_DIR列出目录条目LANDLOCK_ACCESS_FS_REMOVE_DIR删除目录LANDLOCK_ACCESS_FS_REMOVE_FILE删除文件LANDLOCK_ACCESS_FS_MAKE_CHAR创建设备文件需谨慎LANDLOCK_ACCESS_FS_MAKE_DIR创建目录LANDLOCK_ACCESS_FS_MAKE_REG创建普通文件LANDLOCK_ACCESS_FS_MAKE_SOCK创建 Unix socketLANDLOCK_ACCESS_FS_MAKE_FIFO创建命名管道LANDLOCK_ACCESS_FS_MAKE_BLOCK创建块设备文件LANDLOCK_ACCESS_FS_MAKE_SYM创建符号链接有一条非常重要的原则官方文档里反复强调过但很多人第一次写的时候还是会犯迷糊你在handled_access_fs里列出的权限位表示“我关心这些权限并且全部拒绝”。比如你声明处理了READ_FILE | WRITE_FILE那么默认规则下所有路径的这两个操作都会被拒。你需要再通过landlock_add_rule对特定路径allowed_access指定“哪些权限网开一面”。这和我们平时写白名单、黑名单的直觉正好相反初次使用特别容易踩坑。3.3 为什么 Landlock 只允许限制越来越严这是整个设计里最“安全”的地方也是很多新用户最容易不适应的一点。一个进程一旦调用了landlock_restrict_self它的规则集就永久锁定。后续只能用新的规则集进一步收窄权限叠加不能放宽更不能删除。我拿生活中的场景打个比方这就像你在机场安检后进入隔离区可以继续往身上加限制比如把手机关机但不可能再走出隔离区拿回托运行李。攻击者就算突破了你的程序边界也只能在已经被限制的范围内动作没办法反过来让限制失效。内核之所以这样设计一方面是为了防止 TOCTOU 竞态——如果权限可以动态放宽攻击者完全可以等待时机劫持进程后重新放宽规则另一方面也简化了内核的权限检查逻辑只管从“严格”往“更严格”走不用维护复杂的撤销路径。3.4 继承机制锁住自己就能锁住整个进程树Landlock 对进程的限制会在fork、clone、execve后自动继承。这一点至关重要你只要在父进程里先landlock_restrict_self之后 fork 出来的所有子进程、子进程再启动的其他程序全部活在同样一套规则集里。所以常规用法是父进程解析不可信输入的元信息创建 landlock ruleset配置好读写边界landlock_restrict_self将自己锁住fork()一个子进程exec真正要跑的不可信程序。子进程哪怕想叛逆也跳不出父进程画好的圈。4. 动手实战在 Linux 5.15 上写出第一个 Landlock 沙箱4.1 环境准备确认内核支持 Landlock首先确认你的内核是不是 5.15 或更高版本uname -r我的测试机输出是5.15.60-1-lts没问题。接着看当前启用了哪些 LSMcat /sys/kernel/security/lsm如果输出里包含landlock说明发行版已经在内核配置里打开了 Landlock 并注册到了 LSM 钩子链上。如果只有capability,selinux这类内容可以检查内核编译选项grep CONFIG_SECURITY_LANDLOCK /boot/config-$(uname -r)正常开启会看到CONFIG_SECURITY_LANDLOCKy。如果没开就得重新编译内核或者换一个支持 Landlock 的发行版内核。顺带提醒一句LSM 列表里landlock最好放在前面否则某些发行版默认的lsmselinux,apparmor启动参数可能会影响它的自动启用实测在 Arch 和 Ubuntu 22.04 上默认都是 OK 的。我的建议是如果只是想快速体验直接装一台 Ubuntu 22.04 的虚拟机内核 5.15开箱即用。4.2 最小复现限制当前进程写文件下面这个例子演示最核心的行为先用 Landlock 允许读/tmp但明确拒绝写/tmp下的文件。这里注意为了让效果直观我只在规则里放行READ_FILE | READ_DIR不加任何写权限位。#define _GNU_SOURCE #include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include sys/syscall.h #include linux/landlock.h #include sys/prctl.h #include errno.h #ifndef SYS_landlock_create_ruleset #define SYS_landlock_create_ruleset 444 #endif #ifndef SYS_landlock_add_rule #define SYS_landlock_add_rule 445 #endif #ifndef SYS_landlock_restrict_self #define SYS_landlock_restrict_self 446 #endif static int create_ruleset(void) { struct landlock_ruleset_attr attr { .handled_access_fs LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_REMOVE_DIR | LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_CHAR | LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_FIFO | LANDLOCK_ACCESS_FS_MAKE_BLOCK | LANDLOCK_ACCESS_FS_MAKE_SYM, }; int fd syscall(SYS_landlock_create_ruleset, attr, sizeof(attr), 0); if (fd 0) { perror(landlock_create_ruleset); exit(1); } return fd; } static void add_path_rule(int ruleset_fd, const char *path, __u64 allowed_access) { struct landlock_path_beneath_attr path_attr { .allowed_access allowed_access, .parent_fd open(path, O_PATH | O_CLOEXEC), }; if (path_attr.parent_fd 0) { perror(open path); exit(1); } if (syscall(SYS_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, path_attr, 0)) { perror(landlock_add_rule); exit(1); } close(path_attr.parent_fd); } static void apply_restriction(int ruleset_fd) { if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror(prctl(PR_SET_NO_NEW_PRIVS)); exit(1); } if (syscall(SYS_landlock_restrict_self, ruleset_fd, 0)) { perror(landlock_restrict_self); exit(1); } } int main(void) { const char *target_dir /tmp/landlock_demo; system(mkdir -p /tmp/landlock_demo); int ruleset_fd create_ruleset(); add_path_rule(ruleset_fd, target_dir, LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR); apply_restriction(ruleset_fd); printf([] Landlock 已生效仅允许读取 %s\n, target_dir); int fd open(/tmp/landlock_demo/test.txt, O_RDWR | O_CREAT, 0644); if (fd 0) { printf([-] 创建/写文件被拒绝: %s\n, strerror(errno)); } else { printf([] 居然成功了这不科学\n); close(fd); } fd open(/tmp/landlock_demo/test.txt, O_RDONLY); if (fd 0) { printf([-] 读取也被拒绝: %s\n, strerror(errno)); } else { printf([] 读取正常符合预期\n); close(fd); } return 0; }编译并运行gcc -o landlock_demo landlock_demo.c ./landlock_demo我在 5.15 内核上的输出结果[] Landlock 已生效仅允许读取 /tmp/landlock_demo [-] 创建/写文件被拒绝: Operation not permitted [] 读取正常符合预期这个结果显示了两件事第一Landlock 成功拦截了写操作第二我们在创建 ruleset 时把READ_FILE | READ_DIR放进了allowed_access所以读操作不受影响。这里有个非常实用的细节创建规则时路径必须已经存在。内核需要拿到parent_fd对应的目录 inode如果目录不存在open(path, O_PATH)会直接失败。所以实战里如果要限制一个尚未创建的目录需要先创建好空目录再上锁。4.3 实战限制子进程只能读固定目录光限制自己还不够真实场景通常是限制一个未知程序。我写了一个更贴近实战的模板父进程创建规则集后限制自己然后 fork 子进程并执行外部命令。由于继承机制子进程里的ls、cat都在 Landlock 的约束范围内运行。// 省略 create_ruleset / add_path_rule / apply_restriction 等函数 // 与上一节代码完全相同仅修改 main 逻辑 int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, 用法: %s 命令 [参数...]\n, argv[0]); return 2; } int ruleset_fd create_ruleset(); add_path_rule(ruleset_fd, /tmp/landlock_demo, LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE); apply_restriction(ruleset_fd); printf([] 父进程已受限准备执行: %s\n, argv[1]); pid_t pid fork(); if (pid 0) { execvp(argv[1], argv[1]); perror(execvp); return 127; } int status; waitpid(pid, status, 0); if (WIFEXITED(status)) printf([] 子进程退出码: %d\n, WEXITSTATUS(status)); return 0; }运行一个试图写/tmp的 shell 命令来验证./landlock_exec sh -c echo hello /tmp/landlock_demo/evil.txt输出[] 父进程已受限准备执行: sh sh: 1: cannot create /tmp/landlock_demo/evil.txt: Operation not permitted [] 子进程退出码: 2子进程里触发的写操作被内核直接挡下。如果你把命令换成cat /tmp/landlock_demo/test.txt又能正常输出内容。这种“半开放”的沙箱模型非常适合构建插件系统给插件提供数据目录的读权限但不给它写任何路径的能力。4.4 权限规划心得实际使用时不要把handled_access_fs里的权限全部一股脑声明。你处理得越多默认拒绝的范围就越大但也意味着你需要为每个合法路径逐一放行配置量会成倍增加。就我自己的习惯最小集合通常是插件需要读配置和输入数据READ_FILE | READ_DIR插件需要写缓存在特定缓存目录上追加WRITE_FILE | MAKE_REG | MAKE_DIR插件需要运行其他程序在/usr/bin等目录上追加EXECUTE注意这里的EXECUTE权限不仅控制直接执行文件还和脚本解析相关。如果脚本解释器在/usr/bin/bash但你给/usr/bin只放了READ_FILE那么执行脚本时同样会被拒。这也是很多初学者第一次跑通示例后兴致勃勃去限制 shell 脚本结果发现脚本无法启动的原因。5. 实战中绕不过的坑与排查技巧5.1 普通用户调用失败的根源prctl 与 no_new_privsLandlock 要求调用进程先设置PR_SET_NO_NEW_PRIVS这本来是 seccomp 的一条规则Landlock 沿用了它。目的是保证这个进程及其子进程不会通过execve获得新的特权否则后续 Landlock 的限制可能被 setuid 程序绕过。实际操作中如果忘记调用prctl(PR_SET_NO_NEW_PRIVS, 1)landlock_restrict_self会直接返回EPERM。这个错误在文档里可能不够显眼但它几乎是我见过的最常见失败原因。还有个隐藏点如果父进程本身运行在一个启用了no_new_privs的环境比如 systemd 的某些服务配置了NoNewPrivilegesyes那就没必要重复设置。重复设置也不会报错所以我的习惯是无脑调用一次。5.2 路径规则与挂载点别跨到另一个文件系统landlock_add_rule的parent_fd指向的路径必须和最终要访问的路径“归属同一个文件系统视图”。这句话翻译成人话就是如果你对一个/tmp目录加规则然后子进程去访问/tmp/landlock_demo/a/b/c.txt只要这些路径都在同一个挂载点下就没事但如果/tmp下面某个子目录是另一个独立挂载例如/tmp/secret单独 mount 了一个 tmpfsLandlock 可能不会自动限制那个新的挂载点。这个问题的根源在于 Landlock 规则绑定的是 inode 和 mount 的层次关系。它检查路径时是根据“相对某个挂载点的路径”来判断是否命中规则的。所以如果你的场景里存在动态挂载要么在挂载之后再补一条规则要么干脆把限制目标的子目录路径都显式加进去。5.3 想解除限制门都没有经常有朋友问我“能不能在子进程里通过执行一个 setuid 程序来绕过 Landlock 限制”答案是不能。原因有两点Landlock 限制会随着execve完整保留即使新程序拥有更高的 uid它的 LSM 钩子链上仍然挂着当前进程的规则集PR_SET_NO_NEW_PRIVS已经保证了 execve 不会增加权限。所以从安全模型上看Landlock 的门一旦关上就只能从“里面”等待进程退出没有捷径可走。这个设计其实让人很安心因为做安全最怕的就是“限制可以被绕过”而这里根本没有预留后门。5.4 与 seccomp 一起使用时先谁后谁Landlock 管路径seccomp 管系统调用两者协同使用时顺序会影响最终效果。我的建议是“先 Landlock再 seccomp”。原因在于Landlock 需要open()、openat()这类系统调用正常执行才能确认路径规则是否生效如果先用 seccomp 把openat直接封掉后续 Landlock 的add_rule会因为无法打开parent_fd而失败。反过来先 Landlock 再 seccomp 则很顺畅Landlock 规则在路径层面收紧了边界seccomp 再以execve作为示意性的双保险拦截即使路径合法也不希望子进程调用的危险系统调用。这里再多说一句seccomp过滤器的错误处理策略建议用SECCOMP_RET_ERRNO | EPERM不要用SECCOMP_RET_KILL否则子进程一旦被误杀排查问题时看不到任何提示只会看到诡异的状态码。5.5 常见问题速查表问题出现原因解决办法landlock_restrict_self返回EPERM没设置PR_SET_NO_NEW_PRIVS调用prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)landlock_create_ruleset返回ENOSYS内核版本低于 5.13或没开启CONFIG_SECURITY_LANDLOCK升级内核到 5.15 并确认 LSM 列表包含 landlockopen(path, O_PATH)报ENOENT目录还不存在先把目录建好再添加规则脚本无法执行没有在解释器路径上声明EXECUTE在/usr/bin等路径的allowed_access中加入LANDLOCK_ACCESS_FS_EXECUTE子进程访问了新挂载的路径规则只覆盖挂载点内的原有路径动态挂载后重新添加规则或采用更精细的路径规划既有写权限但open(O_WRONLY)仍然失败handled_access_fs没有包含WRITE_FILE但也没放行任何路径在目标路径allowed_access中显式添加WRITE_FILE5.6 调试技巧Landlock 本身没有非常直观的日志输出不像 SELinux 有ausearch可以查 AVC 审计记录。我的调试套路就是三步走第一全程用strace跟踪用户态调用strace -e tracelandlock_create_ruleset,landlock_add_rule,landlock_restrict_self,openat ./landlock_demo这样能在第一时间看到系统调用返回的错误码省去自己瞎猜。第二检查内核日志。虽然早期版本的 Landlock 没有专门的审计类但某些情况下 VFS 层会留下audit记录。如果你在dmesg里看到type1400 audit(...)之类的内容多半是 LSM 审计框架捕获的事件。第三也是最实用的在规则放行最小集合的情况下逐步增加权限位每次只加一项观察程序的失败阈值在哪。这个过程很像在做二分查找能快速找到被遗忘的目录或文件对象。6. 应用场景、后续演进与个人感受6.1 浏览器与插件系统的进程隔离我之所以对 Landlock 这么上心很大程度上是看到 Chromium 社区一直在探索“渲染进程无特权化”的路线。浏览器渲染进程要解析的 HTML/JS 大部分来自不可信网络但它们又需要读取本地缓存、字体文件和用户配置。传统做法是把进程放进严格的 seccomp-bpf 沙箱但 seccomp 对路径的控制很弱而 Landlock 正好补上这一步。在插件系统里同样如此。如果你开发的是编辑器、IDE 或者类似“应用商店”的产品需要运行第三方插件完全可以在插件进程启动前用 Landlock 把当前工作区设置为只读把系统目录设置为只读只给插件写一个临时缓存目录的权限。这样即使插件本身有漏洞攻击者能拿到的最多就是那点缓存目录的控制权。6.2 容器之外的轻量级隔离很多人一提到隔离就想到 Docker、Podman但容器本质上是靠内核 namespace cgroup 安全模块共同实现的。如果你的场景不需要完整的文件系统视图隔离只需要“限制某个进程别乱写”Landlock 是远比容器轻量的方案。没有守护进程没有镜像层不需要 root一组系统调用就搞定。比如我后来就把它用在了构建系统里每次执行外部拉下来的安装脚本之前先 fork 一个子进程用 Landlock 把仓库目录设为只读把临时构建目录设为可写。效果上很多恶意的“删库跑路”式命令还没碰到源码目录就被内核拦住了而性能开销几乎可以忽略——因为路径检查只是在 LSM hook 上多几次字符串比较和 inode 比对。6.3 从 5.15 开始的生态Landlock 在 5.13 合并进内核主线5.15 算是稳定性比较好的版本但能限制的还只有文件系统访问。后来内核版本陆续加入了网络访问控制能力比如 TCP bind/connect 限制、Unix socket 限制等ABI 版本也从 1 一路升到 4、5。如果你不锁定在 5.15我建议看看新版内核的 Landlock 能力适用范围会更广。值得留意的是 ABI 兼容问题。Landlock 提供了landlock_create_ruleset时如果传入的handled_access_fs包含当前内核不认识的权限位会返回EOPNOTSUPP或EINVAL。所以在跨内核版本部署时建议先查询 ABI 版本再决定设置哪些权限或者干脆在代码里做降级处理ABI 低就少声明几个不常用的权限位。6.4 根据自己的经验给个小结Landlock 不是万能钥匙它解决的是“文件系统路径可达性”这一个具体问题。如果你的威胁模型涉及网络命名空间隔离、进程间通信审计、资源配额限制那还是得老老实实配合 namespace、cgroup、seccomp 等机制一起用。但我个人非常喜欢它的设计哲学把安全能力下沉到内核路径解析层让普通用户也能安全地使用。跟 seccomp 相比它不需要理解底层系统调用语义跟 SELinux 相比它不需要全局策略库跟容器相比它几乎没有资源开销。在很多只需要“防手滑、防恶意插件乱写”的场景里它可能就是最优雅的那把锁。最后分享一个小习惯我在所有使用 Landlock 的程序里都会在调用landlock_restrict_self之后立刻close(ruleset_fd)并且把所有与规则相关的路径描述符也关闭。这能避免程序后续误用旧描述符绕过自己的意图还能让内核尽早释放相关资源。这个动作放不放进代码里都不会影响正常运行但长期做安全加固的人应该都懂能关的权限早点关永远不亏。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进