无需 Root 权限:使用 Landlock 沙箱化 Linux 进程指南
下面是一个程序先限制自身,随后尝试读取两个文件的结果:
未使用 landlock:
读取 /etc/hostname 成功
读取 /tmp/secret.txt 成功
启用 landlock,允许访问 /etc:
读取 /etc/hostname 成功
读取 /tmp/secret.txt 失败(权限被拒绝)
不需要 root 权限、不需要容器、不需要配置文件、不需要守护进程。程序请求内核剥夺自身对大部分文件系统的访问权限,而内核顺从地照办了。
这就是 Landlock,自 2021 年起就存在于内核中,却鲜为人知。本文从零开始构建这个程序,运行它,并带读者了解初学者首次接触时最容易遭遇的四个意外。
目录
你需要什么
若要跟随操作,你需要内核版本 5.13 或更新,标准头文件,以及 C 编译器。不需要其他任何东西,尤其不需要 root 权限。
grep landlock /sys/kernel/security/lsm
ls /usr/include/linux/landlock.h
第一条命令很关键。Landlock 可以编译进内核但仍未激活,因为 Linux Security Modules 必须在启动时启用。在这台机器上,该文件内容为 lockdown,capability,landlock,yama,apparmor。
如果你的内核缺少 landlock,请在内核命令行现有参数的最前面添加 lsm=landlock,,然后重启系统。Ubuntu 从 22.04 版本起已默认启用该模块,当前的 Fedora 和 Arch 内核也包含它,但上述 grep 检查才是验证你本机是否启用的唯一标准。
下文所有操作均在 Ubuntu 22.04.5 系统上,基于内核 5.15.0-190-generic,使用 gcc 11.4 编译,且全程以普通用户身份运行,未使用任何 sudo 权限。
Landlock 的位置
Linux 安全模块(LSM)是一个框架,而非具体的安全策略。内核在关键决策点——如打开文件、创建进程或映射可执行内存之前——调用 LSM 钩子,已加载的安全模块有权在此处做出允许或拒绝的决定。
人们最熟悉的是 SELinux 和 AppArmor,二者均为管理员工具:由拥有 root 权限的用户编写策略,系统加载后,程序便运行在该策略定义的约束之中。
Landlock 颠覆了这一逻辑。它是首个允许进程在运行时、无需特权即可自我施加的 LSM。你无需说服管理员授予你的程序特定策略,而是由程序主动申请减少其当前拥有的权限,内核随之收紧限制。
“申请减少权限”正是其核心设计。Landlock 只能移除访问权限,不存在任何调用能授予你原本未有的权限,这也正是它能安全暴露给非特权进程的原因。
三个系统调用,无需外部库
Landlock 仅包含三个系统调用,且 glibc 并未对其做任何封装,因此需通过 syscall() 直接调用:
static int create_ruleset(const struct landlock_ruleset_attr *attr)
{ return syscall(__NR_landlock_create_ruleset, attr, sizeof(*attr), 0); }
static int add_rule(int fd, const struct landlock_path_beneath_attr *pb)
{ return syscall(__NR_landlock_add_rule, fd, LANDLOCK_RULE_PATH_BENEATH, pb, 0); }
static int restrict_self(int fd)
{ return syscall(__NR_landlock_restrict_self, fd, 0); }
landlock_create_ruleset 声明你要管控的访问类型,并返回一个代表该规则集的文件描述符。landlock_add_rule 以“允许保留的目录”形式添加例外规则。最后,landlock_restrict_self 将整套规则永久应用于调用该函数进程。
ruleset 属性中的 handled_access_fs 字段是大家最容易理解反的地方。它列的不是你允许什么,而是这个 ruleset 要管控哪些访问类型——列表里的每一项,除了你显式添加的路径之外,在其他所有地方都会被拒绝。一旦声明了读取权限,整个文件系统的读取都会被禁止,直到你补上相应的规则。
一个限制自身的程序
完整代码如下:
#define _GNU_SOURCE
#include <linux/landlock.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#define READ_RIGHTS (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR)
static int create_ruleset(const struct landlock_ruleset_attr *attr)
{ return syscall(__NR_landlock_create_ruleset, attr, sizeof(*attr), 0); }
static int add_rule(int fd, const struct landlock_path_beneath_attr *pb)
{ return syscall(__NR_landlock_add_rule, fd, LANDLOCK_RULE_PATH_BENEATH, pb, 0); }
static int restrict_self(int fd)
{ return syscall(__NR_landlock_restrict_self, fd, 0); }
static int allow_read(int ruleset_fd, const char *path)
{
struct landlock_path_beneath_attr pb = { .allowed_access = READ_RIGHTS };
int rc;
pb.parent_fd = open(path, O_PATH | O_CLOEXEC);
if (pb.parent_fd < 0) { perror(path); return -1; }
rc = add_rule(ruleset_fd, &pb);
close(pb.parent_fd);
return rc;
}
static void try_read(const char *path)
{
int fd = open(path, O_RDONLY);
if (fd < 0)
printf(" read %-24s FAILED (%s)\n", path, strerror(errno));
else
{ printf(" read %-24s ok\n", path); close(fd); }
}
int main(void)
{
struct landlock_ruleset_attr attr = { .handled_access_fs = READ_RIGHTS };
int ruleset_fd = create_ruleset(&attr);
if (ruleset_fd < 0) { perror("landlock_create_ruleset"); return 1; }
if (allow_read(ruleset_fd, "/etc") < 0) return 1;
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("prctl"); return 1; }
if (restrict_self(ruleset_fd)) { perror("landlock_restrict_self"); return 1; }
close(ruleset_fd);
printf("with landlock, /etc allowed:\n");
try_read("/etc/hostname");
try_read("/tmp/secret.txt");
return 0;
}
编译并运行:
gcc -Wall -o sandbox sandbox.c
echo "hunter2" > /tmp/secret.txt
./sandbox
with landlock, /etc allowed:
read /etc/hostname ok
read /tmp/secret.txt FAILED (Permission denied)
这段输出里有两个关键细节。规则通过打开的文件描述符而非路径字符串来引用目录,打开时使用 O_PATH 标志,这样你可以获得句柄而无需对目录本身具备读取权限。另外,restrict_self 对调用进程立即生效,且无法撤销。
为什么 no_new_privs 是强制要求
移除 prctl 调用后,程序将无法正常运行:
landlock_restrict_self -> Operation not permitted
这是 EPERM 错误,且是故意为之。PR_SET_NO_NEW_PRIVS 告知内核,该进程及其子进程永远无法通过 execve 提升权限,从而阻止沙箱化的进程通过运行 setuid 二进制文件来逃逸。
如果没有这一保证,受限进程可以执行 sudo 或任何 setuid 程序,从而突破刚施加的限制。Landlock 宁可完全不生效,也不提供一个存在此漏洞的沙箱。因此,每次都要先设置 no_new_privs。
规则集是求交集,永远不会放宽
这是必须理解的核心特性。先应用一个允许 /etc 的规则集,再应用一个允许 /tmp 的规则集,看看最终能访问什么:
after first ruleset: /etc=ok /tmp=Permission denied
after second ruleset: /etc=Permission denied /tmp=Permission denied
第二个规则集并没有增加 /tmp 的权限,而是撤销了 /etc 的权限,最终结果是什么都访问不了。
每次 restrict_self 都会与已生效的所有规则求交集。第一个规则集允许 /etc 并拒绝其他;第二个允许 /tmp 并拒绝其他。存活下来的是两者的交集,而它是空的。
Landlock 沙箱是个棘轮机构。每个应用只能收紧权限,绝不能放宽,API 里没有任何操作能让受限进程扩大其可执行范围。如果进程需要访问两个目录,必须在应用规则集之前将两条规则都加入同一个规则集。
这也意味着一旦设定就不能反悔。一个早期就自我限制的长驻进程,无法由自身或他人赋予更多权限,除非启动新进程。
子进程继承什么
限制会随 fork 自动传递,无需请求:
父进程: /etc=允许 /tmp/secret.txt=拒绝权限
fork出的子进程: /etc=允许 /tmp/secret.txt=拒绝权限
子进程会完全继承父进程的 Landlock 域,没有退出的标志位。这在 execve 过程中同样适用,正是 no_new_privs 的要点所在:新程序启动时,就已经处于旧程序构建的沙箱之内。
这正是 Landlock 对封装非自研程序的价值所在。先限制自身,然后执行要封装的程序,它会在你的限制内运行,甚至不知道这些限制的存在。
exec 的陷阱
这里也埋着陷阱。取上述程序,仅保留 /etc 的访问权限,尝试执行任意程序:
允许: /etc
execl(/usr/bin/cat) 失败: 拒绝权限
执行二进制文件需要读取它。此规则集处理的是 LANDLOCK_ACCESS_FS_READ_FILE,内核会检查是否允许读取 /usr/bin/cat,发现没有覆盖 /usr 的规则,于是在程序启动前就拒绝了。
在同一个规则集中添加 /usr,就能正常工作:
允许: /etc 和 /usr
devils-dell
通用经验是,Landlock 沙箱必须包含进程触及的一切,而这个集合比你想象的要大。包括你的二进制文件、其解释器、加载的所有共享库,以及启动时读取的任何配置。用 ldd 检查二进制文件是开始列出清单的好方法。
封装非自研程序
限制在 exec 过程中的继承性,让 Landlock 的价值超越了自研代码。先限制自身,然后执行你想封装的任何程序,它会在沙箱内运行,无需配合,甚至毫不知情。
上面那个程序还需要补充一点才能成为实用的包装器:在读取权限之外加上 LANDLOCK_ACCESS_FS_EXECUTE,放行任何二进制都需要的系统目录,然后再放行用户指定的工作目录:
#define RIGHTS (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | \
LANDLOCK_ACCESS_FS_EXECUTE)
static int add_path(int ruleset_fd, const char *path)
{
struct landlock_path_beneath_attr pb = { .allowed_access = RIGHTS };
int rc;
pb.parent_fd = open(path, O_PATH | O_CLOEXEC);
if (pb.parent_fd < 0)
return -1;
rc = syscall(__NR_landlock_add_rule, ruleset_fd,
LANDLOCK_RULE_PATH_BENEATH, &pb, 0);
close(pb.parent_fd);
return rc;
}
int main(int argc, char **argv)
{
struct landlock_ruleset_attr attr = { .handled_access_fs = RIGHTS };
const char *base[] = { "/usr", "/lib", "/lib64", "/bin", "/etc" };
int fd, i;
if (argc < 3) { fprintf(stderr, "usage: %s DIR CMD...\n", argv[0]); return 2; }
fd = syscall(__NR_landlock_create_ruleset, &attr, sizeof(attr), 0);
if (fd < 0) { perror("create_ruleset"); return 1; }
for (i = 0; i < (int)(sizeof(base) / sizeof(*base)); i++)
add_path(fd, base[i]);
if (add_path(fd, argv[1]) < 0) { perror(argv[1]); return 1; }
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("prctl"); return 1; }
if (syscall(__NR_landlock_restrict_self, fd, 0)) { perror("restrict_self"); return 1; }
close(fd);
execvp(argv[2], &argv[2]);
fprintf(stderr, "%s: %s\n", argv[2], strerror(errno));
return 1;
}
循环里故意忽略 add_path 的返回值,这样同一个二进制在缺少 /lib64 或 /bin、或它们被符号链接到别处的系统上也能正常运行。而用户指定的目录会检查返回值,因为路径打错应该立刻报错,而不是等到后面出现莫名其妙的拒绝访问。
现在 cat 和 ls 只能看到工作目录,其他一律不行:
mkdir -p /tmp/work && echo "project data" > /tmp/work/notes.txt
./llrun /tmp/work cat /tmp/work/notes.txt
./llrun /tmp/work cat /tmp/secret.txt
./llrun /tmp/work ls /home
project data
cat: /tmp/secret.txt: Permission denied
ls: cannot open directory '/home': Permission denied
两个程序都没有被修改、重编译或索取同意。bwrap 等工具通过在程序周围构建挂载和用户命名空间来实现隔离。而这种方法只需向一个 LSM 申请更少的权限,代码约六十行,且无需安装任何东西。
找出程序所需的路径
任何沙箱难点都不在 API,而在文件列表上。程序打开的文件远比你想象的多,遗漏一个路径就会导致运行深处某个环节失败。
有两个工具可以帮你生成这个列表。ldd 列出共享库,这些库必须可读,否则程序无法启动:
ldd /usr/bin/cat
linux-vdso.so.1 (0x00007fff85f39000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4f61bb5000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4f61e0a000)
strace 则记录其余所有操作。先不加限制地运行程序,收集它打开的内容:
strace -e trace=openat cat /etc/hostname 2>&1 | grep -oE '"/[^"]+"' | sort -u
"/etc/hostname"
"/etc/ld.so.cache"
"/lib/x86_64-linux-gnu/libc.so.6"
"/usr/lib/locale/locale-archive"
一个仅打印单个文件的程序需要四条路径,其中只有一条是你指定的文件。链接器缓存、C 库和语言环境档案都是必需的,这就是为什么 wrapper 在允许你自定义的路径之前,会先允许访问 /usr、/lib 和 /etc。
一旦加上限制,strace 还能准确告诉你拒绝操作的原因:
strace -f -e trace=openat ./llrun /tmp/work cat /tmp/secret.txt 2>&1 | grep EACCES
openat(AT_FDCWD, "/tmp/secret.txt", O_RDONLY) = -1 EACCES (Permission denied)
这就是调试循环:运行程序,读取 EACCES 日志,判断该路径是否应加入规则集,或者程序根本不该去访问它,然后重复上述步骤。在当前内核上,Landlock 本身不记录日志,因此 strace 充当调试器。6.15 及更高版本的内核会通过审计子系统报告拒绝事件,所以在寻找不存在的日志前,请先检查内核版本。
确定你的 ABI 版本
自 5.13 以来,Landlock 不断演进,你在文档中看到的特性可能并不存在于你的内核中。可以直接询问内核:
int v = syscall(__NR_landlock_create_ruleset, NULL, 0,
LANDLOCK_CREATE_RULESET_VERSION);
这台机器返回的值为 1,这是 5.13 版本引入的初始版本,提供 13 种文件系统访问权限:
grep -oE "LANDLOCK_ACCESS_FS_[A-Z_]+" /usr/include/linux/landlock.h | sort -u
LANDLOCK_ACCESS_FS_EXECUTE LANDLOCK_ACCESS_FS_MAKE_BLOCK
LANDLOCK_ACCESS_FS_MAKE_CHAR LANDLOCK_ACCESS_FS_MAKE_DIR
LANDLOCK_ACCESS_FS_MAKE_FIFO LANDLOCK_ACCESS_FS_MAKE_REG
LANDLOCK_ACCESS_FS_MAKE_SOCK LANDLOCK_ACCESS_FS_MAKE_SYM
LANDLOCK_ACCESS_FS_READ_DIR LANDLOCK_ACCESS_FS_READ_FILE
LANDLOCK_ACCESS_FS_REMOVE_DIR LANDLOCK_ACCESS_FS_REMOVE_FILE
LANDLOCK_ACCESS_FS_WRITE_FILE
后续版本增加了文件重父、截断、涵盖 TCP 绑定和连接的规则,以及对设备 ioctl 的控制。这些功能都伴随着新的 ABI 版本,因此程序若想使用新权限,应查询当前版本并优雅降级,而非直接假设可用。如果传入一个运行中内核不认识的权限标志,landlock_create_ruleset 会因 EINVAL 失败。在调试时,如果不先检查版本,这个错误原因往往难以定位。
结论
你现在可以从进程内部对其自身进行沙箱化,无需特权,也无需配置。同时你已了解到四个常见的“意外”: no_new_privs 必须最先设置,否则后续限制不会生效。规则集是求交集而非累积,因此最好在单个规则集中包含所有需要的权限。子进程会继承规则,这是一个特性。此外,读取限制会破坏 exec 执行,除非二进制文件的路径也被允许。
接下来可以尝试几个方向:将 LANDLOCK_ACCESS_FS_WRITE_FILE 添加到 handled_access_fs 中,创建一个可广泛读取但仅向一个特定目录写入的程序;或者通过限制自身权限,然后调用 execve,为未编写的程序包裹沙箱;还可以研究 strace 如何报告拒绝访问,这是快速列出真实程序实际所需路径列表的有效方法。
结语
关于 Landlock,有趣的问题不在于它能做什么,而在于它无法表达什么。由于规则集基于路径,权限的基本单位是文件系统中的一个位置,而非某特定文件。你可以允许该进程读取 /etc 下的文件,却无法表达“允许该进程只读取用户刚选择的那个特定文件,且不能读其他文件”。
过去一段时间我一直在填这个空白:构建一个基于 capability 的桌面操作系统,权限以对象句柄的形式授予,而不是关于某个路径的规则,同时底层依然兼容 Debian 生态。大量工作由 Landlock 完成,而它止步之处正是这个设计真正有意思的地方。
我在 thechris.in 上写关于系统及其奥秘的文章。