原文: How to Sandbox a Linux Process with Landlock, No Root Required

下面这个程序先限制自身,然后尝试读取两个文件:

without landlock:
  read /etc/hostname            ok
  read /tmp/secret.txt          ok
with landlock, /etc allowed:
  read /etc/hostname            ok
  read /tmp/secret.txt          FAILED (Permission denied)

这里没有 root 权限,没有容器,没有配置文件,也没有守护进程。这个程序请求内核收回它自身对文件系统大部分区域的访问权限,而内核照做了。

这就是 Landlock。它自 2021 年起便已存在于内核中,却没有引起大多数人的注意。本文将从零开始构建这个程序并运行它,然后介绍四个初次使用 Landlock 时常让人意外的地方。

目录

  • 所需条件
  • Landlock 所处的位置
  • 三个系统调用,不需要库
  • 一个限制自身的程序
  • 为什么必须使用 no_new_privs
  • 规则集取交集,绝不会扩大权限
  • 子进程会继承什么
  • exec 陷阱
  • 封装一个并非由你编写的程序
  • 找出程序需要访问的路径
  • 确定你拥有的 ABI 版本
  • 结论
  • 后记

所需条件

要跟着本文操作,你需要 5.13 或更高版本的内核、标准头文件以及一个 C 编译器。除此之外不需要任何东西,尤其不需要 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 安全模块(Linux Security Modules,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 将整个规则集永久应用于调用进程。

规则集属性中的 handled_access_fs 字段是人们经常理解反的部分。它列出的不是你要允许的操作,而是这个规则集负责处理的访问类型。凡是列入其中的访问类型,除了你明确添加的路径外,其他地方一律拒绝。只要把读取访问纳入处理范围,你就会失去对整个文件系统的读取权限,直到重新通过规则添加允许访问的路径。

一个限制自身的程序

下面是完整程序:

#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 的访问权限,让你最终什么也无法访问。

Landlock 沙箱分三个阶段逐步收紧:没有规则集时,五个目录全部可读;应用仅允许访问 /etc 的规则集后,只有 /etc 可读;再应用允许访问 /tmp 的第二个规则集后,由于两个规则集取交集且交集为空,所有目录都不可读

每次调用 restrict_self,都会与此前已应用的所有规则取交集。第一个规则集允许访问 /etc 并拒绝其余路径;第二个规则集允许访问 /tmp 并拒绝其余路径。最终保留下来的是两者重叠的部分,而这个交集为空。

因此,Landlock 沙箱就像一个棘轮。每次应用都只能进一步收紧限制,绝不能放宽,而且整个 API 中不存在任何能够扩大受限进程访问权限的操作。如果你需要一个进程访问两个目录,就必须在应用规则集之前,把两条规则都放入同一个规则集。

这也意味着你不能反悔。一个长时间运行的进程如果很早就限制了自身,那么此后无论是它自己还是其他任何主体,都无法再向它授予更多权限,除非启动一个新进程。

子进程会继承什么

限制会自动随 fork 继承:

parent:       /etc=ok   /tmp/secret.txt=Permission denied
forked child: /etc=ok   /tmp/secret.txt=Permission denied

子进程会完整继承父进程的 Landlock 域,而且没有任何标志可以选择退出。跨越 execve 时也是如此,而这正是 no_new_privs 的意义所在:新程序一开始就已经处于旧程序建立的沙箱中。

这使得 Landlock 可用于封装并非由你编写的程序。先限制自身,再执行你想要约束的程序,它便会在你的限制范围内运行,根本不需要知道这些限制的存在。

exec 陷阱

这也设置了一个陷阱。沿用上面的程序,只允许访问 /etc,然后尝试执行任何程序:

allowed: /etc
execl(/usr/bin/cat) failed: Permission denied

执行二进制文件需要读取它。这个规则集处理 LANDLOCK_ACCESS_FS_READ_FILE,因此内核会检查是否允许读取 /usr/bin/cat。由于找不到涵盖 /usr 的规则,内核会在程序真正启动之前拒绝执行。

把 /usr 添加到同一个规则集后,它就能正常工作:

allowed: /etc and /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 减少权限便实现了同样的结果,代码大约只有 60 行,而且无需安装任何东西。

找出程序需要访问的路径

任何沙箱最困难的部分都不是 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 开始的内核会通过审计子系统报告拒绝事件,所以在寻找一个并不存在的日志之前,请先检查你的内核版本。

确定你拥有的 ABI 版本

Landlock 自 5.13 版本以来一直在发展,而你读到的功能可能并不存在于自己的内核中。直接询问内核即可:

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 版本升级,因此,需要使用较新权限的程序应该查询 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 撰写有关系统及其奥秘的文章。