如何使用 Landlock 为 Linux 进程创建沙箱环境——无需 root 权限
以下是一个会自我限制自身权限、然后尝试读取两个文件的程序: 在没有启用Landlock机制的情况下: read /etc/hostname 可以成功执行 read /tmp/secret.txt 也可以成功执行 而在启用了Landlock且只允许访问/etc目录的情况下: read /etc/hostname 可以成功执行 read /tmp/secret.txt 失败(权限被拒绝) 这个程序没有使用root账户,也没有使用任何容器或配置文件,更没有后台守护进程。它只是请求内核剥夺自己对大部分文件系统的访问权限,而内核也确实这么做了。 这就是Landlock机制。自2021年以来,它就已经被集
以下是一个会自我限制自身权限、然后尝试读取两个文件的程序:
在没有启用Landlock机制的情况下:
read /etc/hostname 可以成功执行
read /tmp/secret.txt 也可以成功执行
而在启用了Landlock且只允许访问/etc目录的情况下:
read /etc/hostname 可以成功执行
read /tmp/secret.txt 失败(权限被拒绝)
这个程序没有使用root账户,也没有使用任何容器或配置文件,更没有后台守护进程。它只是请求内核剥夺自己对大部分文件系统的访问权限,而内核也确实这么做了。
目录结构
-
-
-
-
-
-
-
-
-
-
-
-
-
所需准备事项
grep landlock /sys/kernel/security/lsm
ls /usr/include/linux/landlock.h
第一条命令非常重要。Landlock机制可以被编译进内核中,但只有在启动时启用了Linux安全模块,它才会真正开始发挥作用。在这台机器上,相关的配置文件中会包含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挂钩,此时已加载的各个安全模块就可以决定是否允许相应的操作。
SELinux和AppArmor是人们最常听到的两种安全机制,它们都属于管理员工具:需要具有root权限的用户才能编写安全策略,系统会加载这些策略,而程序的行为就会受到这些策略的约束。
然而Landlock却采用了不同的设计。它是第一个允许进程在运行时自行应用安全规则的安全模块,而且无需管理员的干预。程序本身可以申请比当前更严格的访问权限,然后由内核来决定是否批准这些请求。
这种“主动申请更严格权限”的机制正是Landlock的设计核心。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_landlockrestrict_self, fd, 0); }
landlock_create_ruleset用于指定需要管理的访问类型,并返回一个表示规则集的文件描述符。landlock_add_rule用于添加允许访问的路径作为例外情况。landlockrestrict_self则会将这些规则永久地应用到调用该函数的进程上。
在规则集属性中,handled_access_fs这个字段的含义容易被误解。它并不是列出了哪些访问是被允许的,而是说明了这个规则集负责管理哪些类型的访问权限。列表中的任何条目都会被拒绝,除非明确指定了允许访问的路径。例如,如果允许读取某个文件系统目录,那么除了这个目录之外,该程序将无法读取其他任何文件。
一个自我限制的程序
下面是具体的实现代码:
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include
#include
#include
#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_landlockrestrict_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("读取 %s 失败: %s\n", path, strerror(errno));
else
{ printf("成功读取 %s\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("使用Landlock后,可以访问 /etc:\n");
try_read("/etc/hostname");
try_read("/tmp/secret.txt");
return 0;
}
构建并运行它:
gcc -Wall -o sandbox sandbox.c
echo "hunter2" > /tmp/secret.txt
./sandbox
使用 landlock 时,如果允许访问 /etc 目录,会发生以下情况:
read /etc/hostname 可以执行
read /tmp/secret.txt 失败(权限不足)
其中有两个细节需要注意。该规则是通过打开文件的描述符来指定目录的,而不是使用路径字符串;而且这些描述符是用 O_PATH 方式打开的,因此无需具备对目录本身的读取权限就可以访问它。另外,restrict_self 规则会立即对调用它的进程产生效果,且这种设置无法被撤销。
为什么必须设置 no_new_privs
当程序尝试使用 prctl 时,它会停止运行:
landlockrestrict_self -> 操作被禁止
出现这种错误是因为遇到了 EPERM 错误,而这是有意为之的。PR_SET_NO_NEW_PRIVS 告诉内核:这个进程及其后代进程永远不能通过 execve 来获取新的权限。这样一来,被限制在沙箱环境中的进程就无法通过运行具有 setuid 特权的程序来逃脱限制。
如果没有这种限制机制,被限制的进程就可以执行 sudo 命令或任何具有 setuid 特权的程序,从而突破你设置的限制。因此,必须始终首先设置 no_new_privs 这一选项。
规则集相互重叠时,其权限范围永远不会扩大
这一点非常重要。先应用一个允许访问 /etc 目录的规则集,然后再应用另一个允许访问 /tmp 目录的规则集,看看最终能访问哪些目录:
应用第一个规则集后:/etc 可以访问,/tmp 无法访问
应用第二个规则集后:/etc 和 /tmp 都无法访问
第二个规则集并没有增加对 /tmp 的访问权限,反而取消了了对 /etc 的访问权限,最终导致完全无法访问任何目录。
每个 restrict_self 规则都会与之前已经应用的所有规则产生重叠。第一个规则允许访问 /etc 目录,而其他目录都无法被访问;第二个规则允许访问 /tmp 目录,而其他目录依然无法被访问。最终剩下的只有这两个规则的交集,而这个交集是空的。
因此,Landlock 沙箱机制实际上是一种“棘轮结构”:一旦设置了限制,这些限制就永远不能被解除;API 中没有任何功能可以扩大被限制进程的权限范围。如果需要让某个进程能够访问两个目录,就必须将这两个规则合并到一个规则集中,然后再应用这个规则集。这也意味着你不能改变自己的决定。一个在早期就被设置了限制的进程,之后无论是你自己还是其他人都无法再为其增加额外的权限,除非重新启动一个新的进程。
你的孩子会继承什么
这些限制会在程序执行`fork`操作时自动被应用,而不会询问用户是否同意:
父进程:/etc=可访问 /tmp/secret.txt=权限被拒绝
子进程:/etc=可访问 /tmp/secret.txt=权限被拒绝
子进程会完全继承父进程的权限设置,而且没有任何选项可以用来取消这些限制。`execve`函数也是如此,而这正是`no_new_privs`规则的作用所在:新程序一启动就会处于旧程序创建的沙盒环境中。
正因为如此,Landlock对于那些你并非自己编写的程序来说才非常有用。你可以先为自己设置各种限制,然后再执行那些需要被控制的程序,而这些程序会在完全受限制的环境中运行,而它们本身根本不会意识到这些限制的存在。
`exec`函数的陷阱机制
Landlock还会设置一些陷阱。以上面的例子来说,如果只允许访问`/etc`目录,那么尝试执行任何其他文件都会失败:
允许访问的目录:/etc
尝试执行命令:execl(/usr/bin/cat) 失败:权限被拒绝
执行二进制文件首先需要读取它的内容。Landlock的规则系统会检查`/usr/bin-cat`这个文件是否可以被读取,由于没有针对`/usr`目录的明确规定,因此系统会在程序开始之前就拒绝执行该命令。
如果将`/usr`目录添加到允许访问的目录列表中,问题就会得到解决:
允许访问的目录:/etc 和 /usr
从中我们可以得出一个普遍的结论: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, "用法:%s 目录 命令...\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_landlockrestrict_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这类工具是通过在程序周围创建挂载命名空间和用户命名空间来达到这种效果的。它们仅用了大约六十行代码就实现了这一功能,而且完全不需要安装任何额外的组件。
查找程序所需的路径
在任何沙箱环境中,真正困难的部分并不在于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语言库以及区域设置相关文件都是程序运行所必需的,因此包装工具在允许程序访问这些路径之前,会先检查/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版本开始,Linux内核会通过审计子系统来报告权限拒绝事件,所以在查找日志时,请先确认你的内核版本是否支持这种日志记录功能。
您使用的ABI版本
自5.13版本之后,Landlock的功能有了许多扩展,但某些您所了解的功能可能并不适用于您的内核。您可以直接通过以下代码来查询当前内核支持的ABI版本:
int v = syscall(__NR_landlock_create_ruleset, NULL, 0,
LANDLOCK_CREATE_RULESET_VERSION);
当前系统报告的ABI版本号为1,这对应的是5.13版本中的原始配置。该版本提供了十三种文件系统访问权限:
grep -oE "LANDLOCK_ACCESS_FS_[A-Z_]+" /usr/include/linux/landlock.h | sort -u
LANDLOCK_ACCESS_fs_EXECUTE LANDLOCK_ACCESS_FSMAKE_BLOCK
LANDLOCK_ACCESS_FS_MAKE_CHAR LANDLOCK_ACCESS_FSMAKE_DIR
LANDLOCK_ACCESS_FS_make_FIFO LANDLOCK_ACCESS_FS MAKE_REG
LANDLOCK_ACCESS_FSMAKE_SOCK LANDLOCK_ACCESS_FSMAKESYM
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绑定和连接操作的网络规则控制机制,同时还扩展了对设备I/O控制命令的支持。每种新功能都会通过ABI版本的更新来体现,因此如果程序需要使用新的权限功能,就应该先查询当前内核支持的ABI版本,然后再根据实际情况进行相应的调整;如果强行使用内核不支持的权限,调用landlock_create_ruleset函数时会返回EINVAL错误码,而如果不事先检查版本号,这种错误会给调试工作带来很大困扰。
结论
现在,您已经能够让一个进程在自身内部创建沙箱环境,而且无需任何额外的权限设置或配置调整。同时,您也了解了使用Landlock时可能会遇到的几个需要注意的问题:no_new_privs选项必须被设置为true,否则没有任何权限限制会生效;规则集是相互叠加的,而不是简单累积的,因此应该创建一个包含所有必要权限的规则集;子进程会继承父进程的权限设置,这也是Landlock的一个重要特性;此外,如果只允许读取某些文件,那么exec命令也会受到相应的限制,除非程序也被允许访问那些文件。
今后还可以从几个方向继续探索Landlock的功能。例如,可以在handled_access_fs集合中添加LANDLOCK_ACCESS_FS_write_FILE权限,从而创建出能够广泛读取文件但只能向某个特定目录写入文件的程序;对于那些不是自己编写的程序,也可以通过限制其可执行的操作范围,然后再调用execve函数来使其在沙箱环境中运行;另外,研究strace>工具是如何记录这些权限限制信息的,也是了解程序实际需要访问哪些路径的有效方法。
结语
关于Landlock,真正有趣的地方并不在于它能够做什么,而在于它不能表达什么。规则集用于指定文件系统的访问路径,因此权限控制的单位其实是文件系统中的某个位置,而不是某一个具体的文件。例如,你可以规定某个进程只能读取/etc目录下的文件,但无法规定它只能读取用户指定的那一个文件而其他文件都无法访问。
正是这种限制机制,让我开始思考如何构建一种以能力模型为基础的桌面操作系统。在这种系统中,权限是通过指向特定对象的句柄来控制的,而不是通过针对某个位置的规则来实现的。Debian生态系统依然可以在这种系统中正常运行,而Landlock则承担了其中大部分权限控制工作。正是这些它无法实现的功能,使得整个系统的设计变得更加有趣和有价值。
我在thechris.in上撰写关于系统及其奥秘的文章。
相关文章
如何利用“政策即代码”与开放隐私框架来管理由人工智能生成的基础设施【完整手册】
现代模型在近100%的情况下都能生成语法正确的代码。Veracode在2026年发布的报告明确指出:“语法问题已经基本得到解决。” 这听起来像是一个里程碑,但实际上正是这个原因导致了当前存在的问题。 同一份报告测试了上百种模型,发现它们的平均安全通过率仅为56%,“与第一份报告中的55%几乎没有变化”;大约44%的代码生成任务会引入安全隐患。 功能正确性与安全性其实是两个不同的问题,而目前只有其中一个问题接近得到解决。 这个结果既不是个特例,也不是什么新鲜事。2022年,在IEEE安全与隐私会议上,纽约大学坦登分校的一个团队让GitHub Copilot在89种与安全相关的场景中运行,生成了1
阅读全文
如何为您的开发团队制定端点数据丢失预防策略
开发人员的笔记本电脑中存储着比大多数人意识到的更多敏感数据:API密钥、数据库登录凭据、测试环境的配置信息,有时甚至还包括为了“测试”而下载的正式生产环境数据副本。 最终用户要为75%的内部数据丢失事件负责,而这些事件大多属于意外情况,并非恶意行为所致。对于开发团队来说,这种风险主要集中在终端设备上——也就是那些用于编写代码、进行测试以及部署代码的机器上。 以下是制定保护这些终端设备的策略的方法,而且这一策略不会影响团队的正常工作效率。 目录 如何制定终端数据丢失预防策略 步骤1:确定终端设备中敏感数据的存放位置 步骤2:将访问控制作为基础措施来实施 步骤3:强化终端设备的操作系统安全 步骤4
阅读全文
人工智能如何改变恶意软件检测方式:从传统的杀毒技术到下一代防护机制
过去,恶意软件的结构通常非常简单:病毒会附着在某个文件上,而杀毒软件则会将其清除。 但那种情况已经一去不复返了。如今,一次攻击就能窃取你的密码、锁定你的照片、监视你输入的内容,甚至隐藏在你信任的软件之中。 更严重的问题在于恶意软件的数量。根据 AV-TEST研究所的数据,每天都有超过450,000种新的恶意程序出现 。没有任何安全团队能够手动检查这么多文件,因此这项工作不得不交给机器来完成,而杀毒软件的决策机制也随之发生了变化。 在这篇文章中,我们将探讨签名扫描技术的工作原理,以及为什么这种技术会逐渐失效;同时也会介绍机器学习为反病毒防护带来了哪些帮助,行为分析是如何在恶意软件运行过程中将其捕
阅读全文
如何在Flutter开发中运用各种技能:开发者手册
关于人工智能辅助开发,最大的误解之一就是认为使用人工智能就意味着要放弃多年来积累的工程经验。其实并非如此。 你可以将自己所学到的架构模式、犯过的错误、团队遵循的规范,以及作为Flutter工程师所制定的标准,通过特定的技能教给人工智能编码助手。这样一来,你就不必在自身经验与人工智能之间做出选择,而是可以将两者结合起来使用。 然而,几乎每一位Flutter开发者,在第一次在实际项目中使用人工智能编码助手时,都会遇到一些令人沮丧的情况。 比如,当你让助手生成一个个人资料页面时,它虽然能生成能够正常运行的代码,但却没有在`widgets/`文件夹中创建一个结构清晰、可重复使用的`ProfileCar
阅读全文