4 权限:capability、seccomp、landlock、user namespace
同一个 openat,为什么以 uid 1000 跑和以 root 跑结果不同?root 的「全能」到底被拆成了哪些位?seccomp 能拦系统调用却看不见路径,landlock 能看路径却拦不住已打开的文件,二者各自漏掉什么?user namespace 里的 root 为什么不是宿主 root?这些机制必须以什么顺序安装,才能让后一步不被前一步拦住?
画出进程权限的六元组;解释 capability 五集合与 exec 变换;写出一个 arch 检查正确、默认拒绝的 seccomp 白名单;按 ABI 表选择 landlock 权限并判断 6.8 上能用到哪一版;写出正确的 uid_map/gid_map;按依赖顺序组合 userns、mount、降权、no_new_privs、landlock 与 seccomp;用 /proc/self/status 证明每一层真的生效。
第 3 章把 agent 关进进程、文件、网络、资源四层边界。那四层回答「能碰到什么」;这一章回答「以谁的身份碰、碰的时候内核允许哪些动作」。两者不能互相替代:一个进程可以在允许的目录里,以过宽的身份把目录里的东西读走;也可以在极小的 uid 下,调用一个内核漏洞把边界打穿。
权限不是一条 chmod 或一句「以 nobody 运行」。Linux 在每一次系统调用里查的是一组彼此独立的状态:uid/gid、capability 位、no_new_privs、seccomp 过滤器、landlock 域、LSM 标签。它们各自挡不同的事故,装错顺序还会互相打架。agent 的权限工程,核心不是「把权限调到最小」这句口号,而是知道每一步降的是哪一组位、失败时 errno 是什么、以及为什么必须在这个时刻降。
这一章按「身份 → capability → 系统调用 → 路径 → 命名空间 → 组合」推进。每一节都给出可观测的证据;最后一节把这些机制压成一个能编译的 C 程序和一个能在 macOS 上跑的检查器。读完之后,你应该能看着一段沙箱启动代码,指出它漏了哪一层、顺序错在哪、以及攻击者会从哪个 errno 开始试探。
4.1 权限模型:UID/GID、capability 与最小权限
先看一个最小实验。同一段 C 代码,以普通用户和 root 分别打开 /etc/shadow:
id -u
# 1000
cat /etc/shadow
# cat: /etc/shadow: Permission denied
sudo cat /etc/shadow | head -1
# root:*:19832:0:99999:7:::
差别不在代码,而在内核检查时读到的凭证。普通用户的 openat 在 DAC 检查处失败,返回 EACCES;root 的 openat 命中 CAP_DAC_OVERRIDE,跳过读权限检查。权限是内核在系统调用入口读的一组状态,不是程序的意图。
4.1.1 UID 与 GID:四个 UID 与一个组集合
/proc/self/status 的 Uid: 一行有四个数:
Uid: 1000 1000 1000 1000
依次是 real、effective、saved set-user-ID、filesystem UID。它们的分工经常被混为一谈:
| 字段 | 作用 | 谁在什么时候改它 |
|---|---|---|
| real UID | 「你是谁」;信号权限、/proc 归属等少数检查用它 | setuid 系列;登录时由内核设置 |
| effective UID | 大多数权限检查用它;文件访问、kill、ptrace | seteuid;exec setuid 文件时 |
| saved UID | 允许在 effective 与 real 之间来回切换的锚点 | setuid 家族按规则更新 |
| fs UID | 文件系统访问专用;Linux 用它做 DAC 检查 | setfsuid |
setuid(2) 的规则可以压成一句:特权进程(有 CAP_SETUID)可以设置任意值;非特权进程只能在 real、effective、saved 三个值之间切换。setresuid(ru, eu, su) 是更明确的版本,传 -1 表示「保持不动」。很多提权漏洞来自程序只改了 effective UID,却忘了 saved UID 还留着 0,攻击者随后用 setuid(0) 切回去。
组也是同理。Groups: 一行列出 supplementary groups;setgroups(2) 修改它,需要 CAP_SETGID。DAC 检查会同时看 fs UID 的属主位、fs GID 与 supplementary groups 的属组位、以及 other 位。一个常见的 agent 配置错误是只改了主组,忘了清 supplementary groups:进程仍然属于 docker、sudo 或某个数据组,文件权限看起来收紧了,实际没有。
uid 0 不是魔法。 内核并不会在 uid == 0 时无条件放行,而是把「root 的传统权力」拆成 capability 位,在相应检查处查位。uid 0 的特殊之处只在于:登录/exec 时内核默认给它一整套 permitted/effective capability(受 securebits 控制)。所以「以 root 运行但 capability 被清空」的进程,读 /etc/shadow 一样会失败;反过来,「非 root 但持有 CAP_DAC_OVERRIDE」的进程可以读任何文件。
4.1.2 capability:把 root 拆成 41 个位
Linux 6.8 有 41 个 capability,编号 0–40,最后一个是 CAP_CHECKPOINT_RESTORE。每个位对应内核里的一类检查:
| capability | 放行什么 | agent 场景里的风险 |
|---|---|---|
CAP_DAC_OVERRIDE | 绕过文件读/写/执行权限 | 读走工作区外的密钥、源码 |
CAP_DAC_READ_SEARCH | 绕过目录搜索与读权限 | 遍历本不可见的目录 |
CAP_SYS_ADMIN | mount、namespace、大量管理操作 | 挂载宿主根、改边界 |
CAP_SYS_PTRACE | 跟踪、读写其他进程内存 | 注入 supervisor、偷密钥 |
CAP_NET_ADMIN | 改网卡、路由、防火墙 | 打开 egress、劫持流量 |
CAP_NET_BIND_SERVICE | 绑定小于 1024 的端口 | 相对低危,但仍可冒充服务 |
CAP_SETPCAP | 改 capability 边界、securebits | 阻止后续降权 |
CAP_SETFCAP | 给文件设置 file capability | 把权力写进磁盘,跨 exec 保留 |
CAP_SYS_CHROOT | chroot | 配合 mount 做路径逃逸 |
CAP_SYS_MODULE | 加载内核模块 | 直接接管内核 |
这张表说明什么:capability 不是「root 的别名」,而是一组可以逐位裁剪的检查开关。最小权限的第一层,就是把这张表里与工具无关的位全部关掉,而不是只改 uid。
对 agent 来说,最危险的不是「工具需要某个 capability」,而是运行器继承了整个服务账号的 capability。一个只读搜索工具和一个能改系统配置的运维工具跑在同一个进程里,前者就自动获得了后者的全部权力。第 2 章的「工具契约」如果只写 auth_scope 而不落到进程凭证,就只是一句注释。
进程在某一时刻的权限可写成
其中:
- 是 uid/gid 四元组与 supplementary groups;
- 是 capability 五集合(permitted、effective、inheritable、bounding、ambient),见 定义 4.2.1;
- 是
no_new_privs标志; - 是 seccomp 模式与过滤器树;
- 是 landlock 域与 LSM 标签。
称权限 不宽于 (记 ),当且仅当 能做的文件操作包含于 、 的每个集合都是 对应集合的子集、、且 与 允许的动作包含于 与 。最小权限就是:在满足工具契约的前提下,求一个 意义下极小的 。
这个定义故意把「策略」与「机制」分开。auth_scope: "repo:read" 是策略;把它变成「以 uid 1001 运行、只允许读 /workspace/repo、只允许 openat/read/write/exit_group」才是机制。前者可以被模型绕过,后者不能。
4.1.3 最小权限不是「一个低权限账号」
很多团队的做法是:建一个 agent 用户,把服务 sudo -u agent 跑起来,然后认为权限问题解决了。这只解决了「不要以 root 跑」,没有解决三件事:
- 同一账号下的横向移动。 所有工具共享
agent的文件权限;一个被注入的网页抓取工具可以读同一账号下的所有仓库、缓存与凭据。 - capability 与 uid 解耦。 如果二进制带 file capability,或者进程从父进程继承了 ambient cap,uid 1000 仍然可以
mount、ptrace或绑低端口。 - 内核攻击面不变。 uid 1000 仍能调用几百个系统调用;一个内核漏洞不会因为你不是 root 就消失。
所以最小权限至少是三层同时收:身份(uid/gid/groups)、位(capability)、动作(seccomp/landlock)。第 3 章的 mount namespace 与只读 bind mount 是第四层,它限制「看得见什么」;这一章的后三节处理「以什么身份、能做什么」。
- 「root 什么都能做」——seccomp 可以杀掉
mount,landlock 可以拒绝路径,LSM 可以拒绝标签,只读挂载可以拒绝写。root 只是「默认拿到全部 capability」,不是「绕过所有检查」。 - 「uid 1000 一定比 root 安全」——如果这个 uid 持有
CAP_DAC_OVERRIDE、带 file capability,或位于一个把宿主 uid 映射进来的 user namespace,它可能比受限的 root 更能干。 - 「
chmod 000能防住 root」——CAP_DAC_OVERRIDE直接跳过权限位;要防 root,得先把它降成非 root 并清 capability。 - 「提示词里写『不要读 /etc/shadow』就是权限控制」——那是策略建议,不是内核检查。模型可以无视,注入可以覆盖。
- 「容器里的 root 就是宿主 root」——只有在没有 user namespace 映射、且 capability 未裁剪时才近似成立;一旦 userns 把 ns 内 0 映射到宿主 100000,ns 内 root 对宿主资源没有 root 权力。
4.2 capability:五集合、文件能力与降权顺序
capability 的难点不在「有哪些位」,而在「这五个集合分别在哪一刻被谁读写」。把五集合当成五个布尔开关,几乎必然写出「drop 了却没生效」的代码。
4.2.1 五集合与 exec 变换
进程的 capability 由五个集合组成:permitted 是进程允许持有的上限;effective 是内核当前检查的位;inheritable 是希望跨 exec 保留的位;bounding 是进程及后代永远不能获得的位;ambient 是非特权 exec 后仍保留的位。内核在 capset 时要求 ;raise ambient 时要求该位同时位于 与 。 只约束 exec 时从文件获得的 permitted,不约束进程已经持有的位; 在 exec 特权文件时被清空。
| 集合 | 含义 | 谁能改 | 跨 exec 的行为 |
|---|---|---|---|
| Permitted () | 进程允许持有的能力上限;不在其中的位永远不能变成 effective | capset;exec 按公式重算 | 按 exec 公式重算 |
| Effective () | 内核当前检查的位;capable() 看它 | capset;exec 按公式重算 | 按公式重算 |
| Inheritable () | 希望跨 exec 保留给新程序的位 | capset | 原样保留 |
| Bounding () | 进程及后代永远不能获得的位;限制 file permitted | 只能 PR_CAPBSET_DROP 减少 | 原样保留 |
| Ambient () | 非特权程序 exec 后仍保留的位 | PR_CAP_AMBIENT_* | 特权文件 exec 时清空 |
exec 那一刻,内核按下面的公式重算( 表示文件的 capability 位, 表示文件 effective 位):
P'(ambient) = (文件是特权文件) ? 0 : P(ambient)
P'(permitted) = (I & F(inheritable)) | (F(permitted) & B) | P'(ambient)
P'(effective) = F(effective) ? P'(permitted) : P'(ambient)
P'(inheritable) = I
P'(bounding) = B
三条推论决定了大部分加固代码的形状:
+p不会自动生效。setcap cap_net_bind_service+p只让新进程的 permitted 有这一位;effective 仍为空,程序必须自己capset把它搬进 effective。只有+e才会在 exec 后直接进 effective。- bounding 是硬上限。
F(permitted) & B意味着:bounding 里没有的位,文件 capability 再大也给不了。PR_CAPBSET_DROP是单向的,drop 掉的位不能通过任何 API 加回来。 - ambient 是「非特权 exec 的保留通道」。 它必须同时在 与 中才能 raise;遇到 setuid/file-capability 文件时清空,所以不能用来绕过特权 exec 的规则。
如果进程的 real 或 effective UID 是 0,内核会把文件的 inheritable/permitted 当作全 1,除非 SECBIT_NOROOT 置位。这就是「root 执行普通程序仍有全部 capability」的来源,也是为什么降权后必须同时清 capability,而不能只改 uid。
4.2.2 读与写:capget / capset
不用 libcap 时,读写 capability 靠两个系统调用。数据结构是版本化的,64 位 capability 需要两个 __u32:
#define _GNU_SOURCE
#include <linux/capability.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
struct __user_cap_header_struct hdr = {
.version = _LINUX_CAPABILITY_VERSION_3,
.pid = 0, /* 0 = 当前进程 */
};
struct __user_cap_data_struct data[2]; /* data[0]: cap 0-31, data[1]: 32-63 */
memset(data, 0, sizeof data);
if (syscall(SYS_capget, &hdr, data) != 0)
perror("capget");
/* 清空 effective/permitted/inheritable;bounding 不在这里改 */
struct __user_cap_data_struct zero[2] = {{0}};
if (syscall(SYS_capset, &hdr, zero) != 0)
perror("capset");
capset 只接收 effective、inheritable、permitted 三个集合。内核对新值的检查是:
- 新 effective 必须是新 permitted 的子集;
- 新 permitted 必须是旧 permitted 的子集(即使是 root,也不能用
capset凭空加位); - 调用者没有
CAP_SETPCAP时,新 inheritable 不能超出「旧 inheritable ∪ 旧 permitted」; - 自 Linux 2.6.25 起,新 inheritable 始终不能超出「旧 inheritable ∪ bounding」;
- bounding 不能通过
capset修改。
这段规则给出了一个关键结论:capset 只能降 permitted,不能升。即使进程有 CAP_SETPCAP,它也只能把 bounding 里的位加到 inheritable,不能把 permitted 加到旧 permitted 之外;要获得新的 permitted,只能 exec 一个带 file capability 或 setuid 的文件。这也解释了为什么 capset 清零之后,当前进程无法靠任何用户态调用把权限拿回来。降权方向永远比升权容易。
4.2.3 bounding:先 drop 再 exec
PR_CAPBSET_DROP 是唯一能缩小 bounding 的接口,每次调用需要 CAP_SETPCAP。一个正确的清空循环长这样:
#include <sys/prctl.h>
/* CAP_SETPCAP 最后 drop:每次 PR_CAPBSET_DROP 都要检查它在不在 effective */
for (int cap = 0; cap <= CAP_LAST_CAP; cap++) {
if (cap == CAP_SETPCAP)
continue;
if (prctl(PR_CAPBSET_DROP, cap, 0, 0, 0) != 0 && errno != EINVAL)
die("PR_CAPBSET_DROP");
}
if (prctl(PR_CAPBSET_DROP, CAP_SETPCAP, 0, 0, 0) != 0)
die("PR_CAPBSET_DROP(CAP_SETPCAP)");
三个细节:
EINVAL表示cap不是合法编号(例如超过内核的CAP_LAST_CAP),不是「已经 drop 过」。重复 drop 一个合法但已不在集合里的位通常返回 0;容器 runtime 已经裁过时,循环仍然会正常走完。CAP_SETPCAP必须最后 drop。 它不是「设置文件 capability」的那个位(那是CAP_SETFCAP);它管的是进程 capability 边界与 securebits。先 drop 它,后面的循环全部EPERM。- drop bounding 不等于清空当前进程的 P/E/I/A。 它只影响「以后 exec 能拿到的上限」。当前进程已经持有的位仍然有效,直到你用
capset清掉。
为什么强调「先 drop bounding 再 exec」?因为 exec 是授权点:setuid 位、file capability、ambient 都在这一刻被内核换算成新的 permitted/effective。如果你先 exec 再 drop,新程序的动态链接器、构造函数、main 之前的代码已经以更高权限跑过了;攻击者只需要在其中任意一个环节插入代码,就赶在你的 drop 之前完成动作。bounding 必须在 exec 之前就位,file capability 才会被它截断。
设进程 在 exec 前满足:capability 、、、。则 及其所有后代在任何一次 exec 之后, 都不会出现在 permitted 或 effective 中。
对后代数量做归纳。fork/clone 不改变 capability 集合,只复制; 中不含 ,故子进程也不含。exec 时按公式
由 ,第二项不含 ;由 ,第一项不含 ;由 且 ambient 只能从 提升, 也不含 。故 不含 ,effective 是 permitted 的子集,也不含 。两次 exec 之间,capset 也不能增加 permitted(新 permitted 必须是旧 permitted 的子集),所以后代无法用 CAP_SETPCAP 绕过。对每一次后续 exec 重复同一论证。∎
这个定理的工程含义比它看起来更硬:只 drop bounding 不够。如果当前进程的 permitted 里还有 CAP_SYS_ADMIN,它会一直持有到 capset 清空为止;fork 出来的子进程也会继承。bounding 只保证「exec 不能再加回来」,不保证「现在没有」。完整的降权必须同时做:drop bounding、清 ambient、清 inheritable、capset 清 permitted/effective。
CAP_SETPCAP 是唯一能改 bounding 与 securebits 的位。它自己也在 bounding 里,所以存在一个「自举」问题:你要用它在循环里 drop 别的位,就不能先 drop 它;而 drop 它之后,任何进一步的边界收缩都不可能。工程上的顺序固定为「其它位 → CAP_SETPCAP」,并在 drop 之前把还需要做的 PR_SET_SECUREBITS 做完。
另一个常见混淆:CAP_SETPCAP 不是 setcap(8) 需要的位。给文件写 security.capability 扩展属性需要的是 CAP_SETFCAP;两者只差一个字母,权限模型里却是完全不同的两件事。
4.2.4 文件能力与 ambient
file capability 把「某个二进制需要某位」写进 inode 的 security.capability 扩展属性,而不是让整个进程以 root 跑。典型的只读工具只需要一个位:
sudo setcap cap_net_bind_service=+ep /usr/local/bin/agent-net
getcap /usr/local/bin/agent-net
# /usr/local/bin/agent-net cap_net_bind_service=ep
+e 是 effective,+p 是 permitted,+i 是 inheritable。三者可以组合:+ep 最常见,+p 需要程序自己 capset 提效,+i 主要给「再 exec 一个子程序」的场景。
file capability 受四个条件约束:
- bounding:文件 permitted 与进程 bounding 取交集;bounding 没有的位给不了。
no_new_privs:置位后 exec 忽略 setuid/setgid 与 file capability。- 挂载选项:
nosuid挂载会让 file capability 与 setuid 一起失效;容器常以nosuid挂载宿主目录。 - user namespace 的 rootid:revision 3 的 xattr 带一个 rootid,表示「这个文件属于哪个 userns 的 root」。容器里映射后的 root 与文件 rootid 不匹配时,capability 不生效。
ambient 解决的是另一个问题:一个非特权程序 exec 之后,如何保留已经持有的某位。它的规则很窄:
/* 必须同时在 permitted 与 inheritable 里,且没有 SECBIT_NO_CAP_AMBIENT_RAISE */
prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, CAP_NET_BIND_SERVICE, 0, 0);
ambient 在 exec 特权文件时被清空;PR_CAP_AMBIENT_CLEAR_ALL 可以一次清空。对 agent 沙箱来说,ambient 通常是「不要有」:它是跨 exec 保留权力的通道,除非某个工具确实需要绑低端口,否则应在 exec 前清空。注意 no_new_privs 不会自动清 ambient;它只阻止 exec 获得新特权,不阻止保留已有特权。
securebits 是这层的细粒度开关,和 capability 一起决定 exec 的行为:
| securebit | 作用 | 什么时候用 |
|---|---|---|
SECBIT_NOROOT | uid 0 或 setuid-root 程序 exec 时不再获得全部 permitted | 必须以 root 启动、但不想让子程序继承 root 权力 |
SECBIT_NOROOT_LOCKED | 锁定上一条,不可撤销 | 同上,且防止后续代码改回 |
SECBIT_NO_SETUID_FIXUP | setuid 变化时不动 capability | 需要精细控制 setuid 与 cap 的交互 |
SECBIT_KEEP_CAPS | setuid 到非零 uid 时保留 permitted | 先 setuid 再清 cap 的两阶段降权 |
SECBIT_KEEP_CAPS_LOCKED | 锁定上一条 | 两阶段降权,防止中途被改 |
PR_SET_SECUREBITS 需要 CAP_SETPCAP,所以它必须排在 drop CAP_SETPCAP 之前。对多数 agent 运行器,no_new_privs 已经足够替代 SECBIT_NOROOT;securebits 主要出现在「必须先以 root 做 mount、再降到非 root」的启动器里。
- 「
CapBnd可以用capset加回来」——不能。bounding 只能通过PR_CAPBSET_DROP减少,且需要CAP_SETPCAP。 - 「
setcap设置的位对所有进程生效」——只对该文件的 exec 生效,且受 bounding、no_new_privs、nosuid、userns rootid 约束。 - 「
CAP_SETPCAP就是setcap用的位」——setcap用的是CAP_SETFCAP;CAP_SETPCAP管进程边界与 securebits。 - 「drop 了 bounding 就等于降权完成」——当前进程的 P/E/I/A 还在;fork 也会继承。必须继续
capset清空,见 定理 4.2.1。 - 「ambient 可以随便 raise」——必须先在 permitted 与 inheritable 里,且
SECBIT_NO_CAP_AMBIENT_RAISE未置位。
4.3 seccomp-bpf:过滤器、返回值与 user notification
capability 回答「哪些特权操作允许」,seccomp 回答「哪些系统调用允许」。两者正交:一个进程可以没有 CAP_SYS_ADMIN,却仍然调用 mount 并得到 EPERM;也可以有 CAP_SYS_ADMIN,但 seccomp 在系统调用入口就把它杀掉,根本走不到 capability 检查。
seccomp 有三种模式,/proc/self/status 的 Seccomp: 字段用 0/1/2 表示:
| 值 | 模式 | 行为 |
|---|---|---|
| 0 | disabled | 不过滤 |
| 1 | strict | 只允许 read、write、_exit、sigreturn;其它调用杀进程 |
| 2 | filter | 由 BPF 程序决定每个系统调用的动作 |
生产里几乎只用 2。strict 模式太窄,连 exit_group 都不允许,只适合纯计算。filter 模式从 Linux 3.5 起可用;seccomp() 系统调用从 3.17 起把 prctl(PR_SET_SECCOMP) 的功能并了进来。
4.3.1 一个过滤器由什么组成
BPF 程序是「经典 BPF」:32 位累加器、只读的 seccomp_data、没有循环、每条指令 8 字节。输入结构固定:
struct seccomp_data {
int nr; /* 系统调用号 */
__u32 arch; /* AUDIT_ARCH_* */
__u64 instruction_pointer; /* 触发调用的用户态地址 */
__u64 args[6]; /* 六个参数 */
};
程序由 struct sock_filter 数组与 struct sock_fprog 描述:
struct sock_filter {
__u16 code; /* BPF_LD | BPF_W | BPF_ABS 等 */
__u8 jt; /* 条件为真时跳过几条 */
__u8 jf; /* 条件为假时跳过几条 */
__u32 k; /* 立即数或偏移 */
};
struct sock_fprog {
unsigned short len; /* 指令条数,最多 BPF_MAXINSNS = 4096 */
struct sock_filter *filter;
};
安装调用的原型与常用标志:
int syscall(SYS_seccomp, unsigned int operation, unsigned int flags, void *args);
syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER,
SECCOMP_FILTER_FLAG_TSYNC, &prog);
glibc 不提供 seccomp() 包装,必须走 syscall(2);libseccomp 提供的是另一套生成器,不是这个系统调用的包装。SECCOMP_FILTER_FLAG_TSYNC 的语义在 4.3.4 单独说。
过滤器里能做的事很有限:读 nr、arch、args[i],做相等/大于/位运算比较,返回一个 32 位动作。它看不到路径字符串,也看不到指针指向的内容;只能看到指针的数值。这是 seccomp 与 landlock 的分界线,也是很多「seccomp 沙箱」看起来严、实际能被绕过的原因。
4.3.2 返回值:动作、数据与优先级
返回值高 16 位是动作、低 16 位是数据:SECCOMP_RET_ACTION_FULL = 0xffff0000,SECCOMP_RET_DATA = 0x0000ffff。
| 动作 | 数值 | 行为 | 可观测性 |
|---|---|---|---|
SECCOMP_RET_KILL_PROCESS | 0x80000000 | 杀掉整个线程组,不执行调用 | 父进程看到 SIGSYS;没有 errno |
SECCOMP_RET_KILL_THREAD | 0x00000000 | 只杀当前线程 | 同上;多线程进程可能残留损坏状态 |
SECCOMP_RET_TRAP | 0x00030000 | 给线程发 SIGSYS,调用不执行 | siginfo.si_errno 带 data |
SECCOMP_RET_ERRNO | 0x00050000 | 调用不执行,返回 -data | errno = data,最常用 |
SECCOMP_RET_USER_NOTIF | 0x7fc00000 | 交给 supervisor 决定 | 需要 listener fd |
SECCOMP_RET_TRACE | 0x7ff00000 | 通知 ptrace tracer | tracer 可改调用号;不适合做安全边界 |
SECCOMP_RET_LOG | 0x7ffc0000 | 记录后执行 | 审计用,不阻断 |
SECCOMP_RET_ALLOW | 0x7fff0000 | 执行 | 无 |
ERRNO 的 data 必须在 1–4095 之间;0 会被当成「成功」还是「无 errno」在不同路径上有歧义,不要用。TRAP 的 data 进 si_errno,可以用来区分策略编号。TRACE 的 data 通过 PTRACE_GETEVENTMSG 给 tracer。
多个过滤器叠加时,内核全部执行,取优先级最高的动作,而不是数值最小:
KILL_PROCESS > KILL_THREAD > TRAP > ERRNO > USER_NOTIF > TRACE > LOG > ALLOW
注意 KILL_PROCESS 的数值是 0x80000000,按无符号比较它并不是最小;按有符号比较它是负数。不要自己写「取最小值」的逻辑,内核有固定的优先级表。新装的过滤器先执行,但所有过滤器都会跑完,即使前面已经返回 KILL。
4.3.3 arch 检查与 x32:白名单的第一道门
一个只比较 nr 的过滤器,在 x86-64 上至少有两个绕过面:
- i386 ABI。 32 位进程用
AUDIT_ARCH_I386,系统调用号与 x86-64 不同。你按 x86-64 的号写白名单,可能刚好放行 i386 下另一个调用。 - x32 ABI。 x32 与 x86-64 共用
AUDIT_ARCH_X86_64,区别在系统调用号带__X32_SYSCALL_BIT(0x40000000)。如果白名单只匹配nr == __NR_write,x32 的0x40000001不会命中,通常会落到默认拒绝;但如果你的默认动作是ALLOW(黑名单),x32 位就能绕过黑名单。
正确的做法是两道门都装:
/* 0 */ BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
(uint32_t)offsetof(struct seccomp_data, arch)),
/* 1 */ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0, 9),
/* 2 */ BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
(uint32_t)offsetof(struct seccomp_data, nr)),
/* 3 */ BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x40000000U, 7, 0),
第 1 条在 arch 不匹配时跳到 KILL_PROCESS;第 3 条把带 x32 位的号直接杀掉。arm64 上没有 x32,这一条不会命中,但保留它不会有害。完整的 12 条指令与跳转偏移在 experiments/ch04/harden.c,注释里标了每个目标下标。
这段代码还说明一个常被忽略的事实:seccomp_data.nr 是 32 位有符号整数。在 x86-64 上,x32 位让它可以大于 0x40000000;用 BPF_JGE 而不是位测试,可以避免重新加载 nr,也避免把符号位搞错。
4.3.4 白名单、黑名单与 TSYNC
白名单(默认 ERRNO 或 KILL,只放行列出的调用)更稳,因为它不依赖「把所有危险调用都列全」。新内核加了 openat2、clone3、io_uring_setup、pidfd_open,黑名单不会自动更新;白名单会让新调用直接失败,逼你在测试里发现。
代价是白名单很脆:glibc、Rust 运行时、Go runtime、DNS 解析器都可能在冷路径上调用你没想到的 syscall。一个实用的折中是分阶段:
- 第一版用
SECCOMP_RET_ERRNO | ENOSYS作为默认动作,跑完整回归与真实任务,收集ENOSYS的调用号; - 把确认需要的调用加入白名单;
- 稳定后再把默认动作升级为
KILL_PROCESS,并把ERRNO保留给「可诊断的拒绝」。
用 ENOSYS 而不是 EPERM 做默认,有一个额外好处:很多程序把 ENOSYS 当作「内核不支持」并回退到旧接口;这会暴露更多调用路径,也会让日志更像真实的能力探测。
白名单假设「没有列出的都不需要」,所以每漏一个调用都会在运行时暴露;黑名单假设「没有列出的都安全」,所以每漏一个危险调用都会在攻击时暴露。前者的失败发生在测试里,后者的失败发生在事故里。Agent 的工具边界应该选前者,并为冷路径留一条可观测的 ENOSYS 通道。
TSYNC 解决多线程安装问题。seccomp 过滤器树是每线程的;seccomp() 默认只装到调用线程。一个 Go 或 Rust 程序在 main 之前已经创建了线程,只装当前线程等于没装。SECCOMP_FILTER_FLAG_TSYNC 把新过滤器同步到同线程组的所有线程:
long rc = syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER,
SECCOMP_FILTER_FLAG_TSYNC, &prog);
if (rc > 0)
fprintf(stderr, "sync failed on tid %ld\n", rc); /* 不是 -1 */
else if (rc != 0)
perror("seccomp");
失败时内核返回第一个无法同步的线程 ID(正数),不是 -1;用 rc == -1 判断会漏掉这类失败。无法同步的原因通常是:另一个线程处于 strict 模式,或者它自己装了不同的过滤器,导致过滤器树分叉。SECCOMP_FILTER_FLAG_TSYNC_ESRCH(5.7+)让它改为返回 ESRCH,便于统一错误处理。
还有两个组合限制:SECCOMP_FILTER_FLAG_NEW_LISTENER 与 TSYNC 同时使用时,必须带 TSYNC_ESRCH,否则 EINVAL;同一个线程只能有一个 listener 过滤器(EBUSY)。
TSYNC 同步的是过滤器树,不是「策略文本」。两个线程各自装过不同的过滤器后,树已经分叉,再同步会失败。正确的工程做法是:在创建任何线程之前装好唯一一份策略;如果运行时已经多线程,就在一个刚 fork 出来的单线程子进程里装,再 exec 目标程序。fork 继承过滤器,exec 保留过滤器,这两条保证了「装一次,后代都有」。
4.3.5 user notification:把决定交给 supervisor
SECCOMP_RET_USER_NOTIF(Linux 5.0+)把系统调用挂起,交给一个用户态 supervisor。安装过滤器时加 SECCOMP_FILTER_FLAG_NEW_LISTENER,seccomp() 返回一个 listener fd;supervisor 用 ioctl 收通知、回响应:
| ioctl | 作用 |
|---|---|
SECCOMP_IOCTL_NOTIF_RECV | 阻塞收一个 seccomp_notif(含 pid、syscall nr、args) |
SECCOMP_IOCTL_NOTIF_SEND | 回 seccomp_notif_resp(返回值或 errno) |
SECCOMP_IOCTL_NOTIF_ID_VALID | 检查通知是否还有效(进程可能已退出) |
SECCOMP_IOCTL_NOTIF_ADDFD | 把一个已打开的 fd 注入 tracee(5.9+) |
supervisor 可以做 seccomp 单独做不到的事:看路径、查策略、打开文件、注入 fd。代价是它自己成了一个必须可信的组件,而且引入了一个经典的时间差问题。
SECCOMP_USER_NOTIF_FLAG_CONTINUE 让被拦的系统调用继续执行。问题在于:supervisor 读参数时看到的是 tracee 内存里的指针;它批准之后、内核真正执行之前,tracee 可以改写那块内存。路径检查与真正 openat 之间有一条 TOCTOU 缝:
supervisor: 读 path = "/tmp/allowed/a" → 批准 CONTINUE
tracee: 把同一块内存改成 "/etc/shadow"(或把 a 换成指向它的符号链接)
kernel: 执行 openat,打开 /etc/shadow
这不是理论问题。seccomp_unotify(2) 明确警告:对带指针参数的系统调用,不要用 CONTINUE 做「检查后再放行」,除非你能证明参数不会被改。正确的替代是 ADDFD:supervisor 自己在可信上下文里打开文件,把 fd 注入 tracee,tracee 只拿到一个已经打开的句柄,路径不再参与后续解析。另一个替代是让 tracee 用 openat2 的 RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS 自己约束路径,但前提是 tracee 的解析逻辑可信——在 agent 场景里通常不成立。
某代码 agent 的 runner 用 seccomp user notification 拦截 openat:supervisor 读到路径在 /workspace 下就回 CONTINUE,否则回 EACCES。测试时一切正常。
攻击者让 agent 生成一段代码:先创建 /workspace/a,再在另一个线程里反复把 /workspace/a 换成指向 /etc/shadow 的符号链接。supervisor 检查时看到的是 a,CONTINUE 之后内核解析到的却是链接目标。strace -f -e trace=openat 只能看到最终路径,看不到 supervisor 的检查与执行之间的时间差;证据来自 supervisor 日志里的 path=/workspace/a 与 tracee 实际读到的内容不一致。
修复有两条:一是把 CONTINUE 换成 ADDFD,由 supervisor 用 openat2(..., RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS) 打开后注入 fd;二是干脆不用 user-notif 做路径策略,改用 landlock,把路径检查放回内核的同一个解析过程里。两条都比「在用户态看一眼字符串再放行」可靠。
4.4 landlock:ABI、路径与网络规则
seccomp 看不见路径;landlock 就是补这一块的。它是一个可堆叠的 LSM:任何进程(包括非特权进程)都可以给自己加一层「路径/端口允许列表」,加完之后不能撤销,子进程继承。它不替代 DAC,也不替代 seccomp;它在 DAC 之外再叠一层「即使你是 root,也只能碰这些路径」。
4.4.1 ABI 表:先确认内核支持到哪一版
landlock 的能力是按 ABI 版本递增的。写策略前先用 landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) 取版本,再按版本裁剪权限位;直接把新版本的位塞给旧内核,create_ruleset 会返回 EINVAL。
| ABI | 内核 | 新增能力 |
|---|---|---|
| 1 | 5.13 | 文件系统基础权限:execute、read/write file、read dir、remove、make |
| 2 | 5.19 | LANDLOCK_ACCESS_FS_REFER:跨目录 rename/link |
| 3 | 6.2 | LANDLOCK_ACCESS_FS_TRUNCATE:truncate、O_TRUNC、creat |
| 4 | 6.7 | 网络:LANDLOCK_ACCESS_NET_BIND_TCP、LANDLOCK_ACCESS_NET_CONNECT_TCP |
| 5 | 6.10 | LANDLOCK_ACCESS_FS_IOCTL_DEV:字符/块设备的 ioctl |
| 6 | 6.12 | IPC scope:LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET、LANDLOCK_SCOPE_SIGNAL |
| 7 | 6.15 | 拒绝访问的 audit 日志与 LANDLOCK_RESTRICT_SELF_LOG_* 标志 |
| 8 | 7.0 | LANDLOCK_RESTRICT_SELF_TSYNC:一次限制进程内所有线程 |
| 9 | 7.1 | LANDLOCK_ACCESS_FS_RESOLVE_UNIX:限制路径型 UNIX socket 的解析 |
这张表说明什么:网络不是 ABI 6 才支持的。ABI 4(Linux 6.7)已经有 TCP bind/connect 规则;ABI 6 加的是 IPC scope(抽象 UNIX socket 与信号),不是网络。把这两件事混在一起,会在 6.8 上错误地以为「landlock 不能限网络」而放弃一整层防护。
本书基线是 Ubuntu 24.04 / Linux 6.8,对应 ABI 4:文件系统到 TRUNCATE,网络到 TCP bind/connect,没有 IOCTL_DEV、没有 IPC scope、没有多线程 TSYNC。ABI 5–9 只需知道「更新内核会多出这些位」,策略代码按版本探测即可。
4.4.2 三个系统调用与两个结构体
landlock 的 API 只有三个调用:
long sys_landlock_create_ruleset(const struct landlock_ruleset_attr *attr,
size_t size, __u32 flags);
long sys_landlock_add_rule(int ruleset_fd, enum landlock_rule_type type,
const void *rule_attr, __u32 flags);
long sys_landlock_restrict_self(int ruleset_fd, __u32 flags);
size 参数是为了向后兼容:旧程序传旧结构体的大小,内核只读它认识的前缀;新内核加字段不会破坏旧程序。flags 传 LANDLOCK_CREATE_RULESET_VERSION 时,attr 必须为 NULL、size 为 0,返回值是 ABI 版本。
规则集属性在 6.8 上是:
struct landlock_ruleset_attr {
__u64 handled_access_fs; /* 本规则集要管的文件权限 */
__u64 handled_access_net; /* ABI 4 起:要管的网络权限 */
};
handled_access_fs 是允许列表的边界:只有列在这里的权限会被拒绝;没列的权限不受这个规则集影响。这是 landlock 最容易被误读的地方——它不是「默认拒绝所有文件访问」,而是「默认拒绝我声明要管、但没有规则放行的那些访问」。
路径规则的属性是 packed 的,parent_fd 是 32 位:
struct landlock_path_beneath_attr {
__u64 allowed_access; /* 允许的动作,必须是 handled_access_fs 的子集 */
__s32 parent_fd; /* O_PATH 打开的目录/文件 */
} __attribute__((packed));
网络规则用端口而不是地址:struct landlock_net_port_attr 只有 allowed_access 与 port 两个字段,端口按主机字节序。
一个最小的「只读 /usr」规则集:
__u64 fs = LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_REMOVE_DIR |
LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_MAKE_DIR |
LANDLOCK_ACCESS_FS_MAKE_SYM | LANDLOCK_ACCESS_FS_MAKE_FIFO |
LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_BLOCK |
LANDLOCK_ACCESS_FS_MAKE_CHAR;
/* 6.8 = ABI 4:REFER 与 TRUNCATE 都可用 */
fs |= LANDLOCK_ACCESS_FS_REFER | LANDLOCK_ACCESS_FS_TRUNCATE;
struct landlock_ruleset_attr attr = { .handled_access_fs = fs };
int rfd = syscall(__NR_landlock_create_ruleset, &attr, sizeof attr, 0);
int usr = open("/usr", O_PATH | O_CLOEXEC);
struct landlock_path_beneath_attr pb = {
.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR |
LANDLOCK_ACCESS_FS_EXECUTE,
.parent_fd = usr,
};
syscall(__NR_landlock_add_rule, rfd, LANDLOCK_RULE_PATH_BENEATH, &pb, 0);
/* 需要 no_new_privs 或 CAP_SYS_ADMIN;见下 */
syscall(__NR_landlock_restrict_self, rfd, 0);
这段代码只放行 /usr 的读与执行;/etc、/home、/tmp 都没有规则,因此在 handled_access_fs 覆盖的权限上会被拒绝。/usr 的写、删、创建也被拒绝,因为规则只给了读/执行。
4.4.3 语义:允许列表、继承与不可撤销
landlock 的语义可以压成六条:
- 允许列表。
handled_access_fs没覆盖的权限不受限;覆盖了但没有规则命中的权限被拒绝。 - 路径继承。 规则作用于
parent_fd指向的目录及其后代;子进程继承整个域。 - 不可撤销。
restrict_self成功后没有「移除」接口;只能再叠加更窄的域。多个域取交集。 - 先打开的文件不受限。 在 enforce 之前打开的 fd 保留原有访问;
TRUNCATE与IOCTL_DEV的权限在open时绑定到 fd 上,之后用这个 fd 做ftruncate/ioctl不再按新域检查。这意味着「先 open 再 sandbox」可以绕过路径策略。 REFER默认拒绝。 即使handled_access_fs没写REFER,跨目录 rename/link 默认也被拒绝;要放行必须显式加规则。这是唯一一个「没 handled 也默认拒绝」的权限。- 不是全文件系统沙箱。
chdir、stat、flock、chmod、chown、setxattr、utime、fcntl、access都不在 landlock 的范围内。它能挡「读/写/创建/删除/执行」,挡不住「看元数据/改元数据」。
第 6 条对 agent 很关键:landlock 可以阻止读取 /etc/shadow,但不能阻止 stat 它、不能阻止 chmod 它(如果 DAC 允许)。不要把 landlock 当成「文件系统全能沙箱」;元数据操作要靠 mount namespace、只读挂载、DAC 与 seccomp 组合。
与 bind mount / OverlayFS 的关系也值得单独记:
- bind mount 会传播规则。 landlock 规则绑定在文件层次上;源路径与 bind 目标共享同一批 inode,规则在两边都生效。把允许目录 bind 到别处不会洗白它。
- OverlayFS 不传播。 lower、upper、merged 是三个独立的层次;对 lower 加的规则不会自动作用于 merged。要限制 merged 路径,必须对 merged 路径本身加规则。
4.4.4 网络与 IPC scope
ABI 4 的网络规则只按端口匹配,不按 IP 地址。LANDLOCK_ACCESS_NET_BIND_TCP 管 bind 本地端口,LANDLOCK_ACCESS_NET_CONNECT_TCP 管 connect 远端端口。它不能表达「只允许连 10.0.0.5:443」;要按地址限制,仍然得用 netns + 防火墙 + egress proxy(第 3.4 节)。
struct landlock_net_port_attr net = {
.allowed_access = LANDLOCK_ACCESS_NET_CONNECT_TCP,
.port = 443,
};
syscall(__NR_landlock_add_rule, rfd, LANDLOCK_RULE_NET_PORT, &net, 0);
ABI 6 的 scope 不是「规则」,而是「域之间的隔离」:LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET 阻止连接到域外创建的抽象 UNIX socket,LANDLOCK_SCOPE_SIGNAL 阻止向域外进程发信号。它们没有例外规则,不能通过 add_rule 放行某个外部对象。对 agent 来说,这能挡住「沙箱内的工具向宿主 supervisor 发信号」或「连到宿主已有的抽象 socket」;但它要求内核 6.12+,6.8 上没有。
4.4.5 为什么 landlock 必须在 seccomp 之前
landlock_restrict_self 自己是一个系统调用,landlock_add_rule 也是。如果 seccomp 先装,白名单里又没放行它们,它们会返回 EPERM——沙箱看起来装好了,实际只装了 seccomp 一层。这是 4.7 节第一个失败案例的同构问题。
landlock 还要求调用线程满足下面之一:
- 已设置
no_new_privs; - 在该 user namespace 里有
CAP_SYS_ADMIN。
非特权 agent 走第一条。no_new_privs 必须在 restrict_self 之前设置;它本身不可撤销,设置之后 exec 不再获得 setuid/file capability。顺序因此固定为「capability 降完 → no_new_privs → landlock → seccomp」。
多线程还有一个坑:ABI 8 之前,restrict_self 只限制调用线程及其后代,不会自动限制同进程的其它线程。Agent runner 如果先起了一个线程池再装 landlock,池里的线程仍在域外。正确做法是在创建线程之前装,或者在单线程子进程里装完再 exec;ABI 8 的 LANDLOCK_RESTRICT_SELF_TSYNC 才能一次覆盖所有线程。
设线程 在时刻 进入 landlock 域 。则对任意 , 及其后代所处的域 满足: 允许的访问集合是 允许集合的子集。特别地,不存在从 回到更宽域的 API。
landlock 只有一个收紧入口 landlock_restrict_self:它把新规则集与当前域合并,合并语义是「所有层都允许才允许」,即交集。没有任何系统调用删除或替换已有域。fork/clone 复制域引用,exec 保留域;因此后代继承的是 或更窄的合并结果。对时间归纳即得单调性。∎
这个定理有一个重要前提:fd 不受域约束。在 之前打开的文件描述符可以继续读写,甚至传给域外的进程。单调性说的是「域允许的路径访问集合」,不是「进程实际能做的所有 I/O」。所以正确的 sandbox 顺序是「先建域,再打开需要的文件」,而不是反过来。
Ubuntu 24.04 的 6.8 内核给到 ABI 4。写策略时按下面三步走:
landlock_create_ruleset(NULL, 0, VERSION)取 ABI;返回ENOSYS表示内核没编译 landlock,返回EOPNOTSUPP表示启动时被禁用。- 按 ABI 裁剪
handled_access_fs/handled_access_net;不要假设TRUNCATE、IOCTL_DEV、scope 一定存在。 - 如果 landlock 不可用,记录一条明确的降级事件,并让 seccomp/挂载层继续工作;不要把「landlock 失败」当成「沙箱成功」。
ABI 7–9 的能力(audit 日志、多线程 TSYNC、RESOLVE_UNIX)在 6.8 上都不存在,策略代码不应该引用这些常量而不做版本判断。
- 「landlock 到 ABI 6 才支持网络」——ABI 4(6.7)已经支持 TCP bind/connect;ABI 6 是 IPC scope,不是网络。
- 「landlock 能限制
stat/chdir/chmod」——不能。它覆盖读/写/创建/删除/执行等路径操作,元数据操作不在范围内。 - 「landlock 可以撤销或替换」——不能。只能叠加更窄的域;要恢复只能重新 exec 一个未受限的进程(如果父进程还有那个能力)。
- 「landlock 是黑名单」——它是允许列表:
handled_access_fs声明要管的权限,没有规则放行的都被拒绝;没声明的权限不受影响。 - 「bind mount 能绕过 landlock」——bind mount 会传播规则;OverlayFS 的 lower/upper/merged 是独立层次,规则不会自动传播,需要分别加。
4.5 user namespace:映射、root in ns 与陷阱
前面四节都在「同一个身份空间」里降权。user namespace 换了一个思路:不降权,而是换一个身份空间,让进程在里面是 root,在外面仍然是普通用户。它解决的是「非特权用户如何做需要 root 的隔离动作」——mount namespace、pid namespace、部分网络配置,都要求调用者在拥有这些 namespace 的 user namespace 里有 CAP_SYS_ADMIN。
创建 userns 只需要一个标志:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
if (unshare(CLONE_NEWUSER) != 0) {
perror("unshare(CLONE_NEWUSER)");
return 1;
}
/* 此刻:在新 userns 里拥有全部 capability;在父 userns 里什么也没多 */
创建之后,进程在新 userns 里是「全 capability 的 root」,但这些 capability 只对由这个 userns 拥有的资源有效。它不能因此读宿主上别人的文件,也不能挂载宿主块设备;它只是获得了「在这个 ns 里当 root」的资格。
4.5.1 uid_map / gid_map:一次写死的一行映射
新 userns 刚创建时,里面的 ID 还没有对应到父 ns。映射写在 /proc/PID/uid_map 与 /proc/PID/gid_map,每行三个数:
<ns 内的 ID> <父 ns 的 ID> <长度>
例如 0 1000 1 表示「ns 内 0 → 父 ns 1000,长度 1」。进程在 ns 内 id 看到 uid=0,在宿主上仍是 1000。它不能 chown 宿主上属于别人的文件,也不能读只有 root 能读的文件。
三个硬规则:
- 只能写一次。 写入成功后,映射不可修改;要换映射只能销毁 namespace 重建。写第二次返回
EPERM。 - 非特权写入只允许单行,且只能映射自己的 euid。 想映射一段范围(例如容器里的 0–65535),需要
newuidmap/newgidmap这类带特权的辅助程序,它们按/etc/subuid、/etc/subgid的授权写多行。 - 写
gid_map前必须先写setgroups。 非特权进程要先往/proc/PID/setgroups写deny,否则内核拒绝gid_map。这是为了防止「用 gid 映射把宿主上某个组认领成自己的」,从而绕过组权限。
下面假设 AppArmor 限制没有生效(Ubuntu 24.04 的默认拦截见 4.5.3):
# 普通用户创建 userns 并把 ns 内 0 映射到自己
unshare --user --map-root-user id
# uid=0(root) gid=0(root) groups=0(root)
cat /proc/self/uid_map
# 0 1000 1
cat /proc/self/gid_map
# 0 1000 1
unshare --map-root-user 内部做的就是「创建 userns → 写一行 0 <euid> 1 → 写 setgroups=deny → 写一行 0 <egid> 1」。如果只写 uid_map 不写 gid_map,id 会显示 gid=65534(nogroup)——未映射的 ID 在父 ns 里显示为 overflow ID。
多行映射的例子(需要 newuidmap 与 /etc/subuid 授权):
grep "$USER" /etc/subuid /etc/subgid
# /etc/subuid:alice:100000:65536
# /etc/subgid:alice:100000:65536
newuidmap $$ 0 100000 65536
newgidmap $$ 0 100000 65536
newuidmap 会核对「请求的范围是否落在 /etc/subuid 给这个用户的区间内」。它通常带 setuid 位或 file capability,是 userns 生态里少数必须保留特权的组件;这也意味着它自身是攻击面,必须保持最小实现并及时打补丁。
user namespace 是身份与 capability 的作用域。它持有一张从本 ns 的 uid/gid 到父 ns 的偏函数 ;只有落在映射范围内的 ID 才有父 ns 对应,未映射的 ID 在父 ns 中表现为 overflow ID(通常是 65534)。进程在本 ns 内的 capability 只对「由本 ns 拥有」的资源生效;跨 ns 操作时,内核用父 ns 的凭证重新检查。映射在 /proc/PID/uid_map、gid_map 中一次写入、不可更改。
这张定义里最重要的是「偏函数」三个字:不是所有 ns 内 ID 都有父 ID;没有映射的 ID 不是「自动变成 root」,而是变成一个谁都不认识的 overflow ID。很多容器文件权限错乱,根源就是映射范围没覆盖镜像里的某个 uid。
4.5.2 root in ns:能做什么、不能做什么
userns 内的 root 有一整套 capability,但它们的作用域被 ns 边界截住:
| 操作 | ns 内 root | 原因 |
|---|---|---|
在本 ns 的 mount namespace 里挂 tmpfs;挂 proc 另需本 ns 拥有的 PID namespace | 可以 | 需要本 userns 的 CAP_SYS_ADMIN |
| 挂载宿主块设备 | 不可以 | 块设备由初始 userns 拥有,跨 ns 检查失败 |
| 读宿主上属于别人的文件 | 不可以 | DAC 仍按父 ns 的 uid/gid 检查 |
ptrace 宿主进程 | 不可以 | 目标不在本 ns 的谱系里 |
| 改宿主网络、路由 | 不可以 | 需要初始 netns 的 CAP_NET_ADMIN |
| bind mount 一个自己能读的宿主路径 | 通常可以 | 只复制已有访问权;受 locked 挂载与传播属性限制 |
| 给自己加新的 capability | 只限本 ns | bounding 与父 ns 仍约束 |
这解释了 rootless 容器为什么能成立:容器里的 root 其实是宿主上的普通 uid 1000(或 100000),它通过 userns 获得「在容器 ns 内当 root」的资格,再通过 mount/pid/net namespace 获得隔离视图。它没有获得宿主 root 的任何权力。
但也别把 userns 当成万能隔离。它有三个明确的局限:
- 它不隔离文件系统与网络。 userns 只换身份作用域;要限制「看得到哪些路径/网卡」,仍然要 mount namespace、netns、landlock、seccomp。
- 它扩大了内核攻击面。 userns 让非特权用户能触达更多内核代码路径;历史上多个提权漏洞(例如
CVE-2022-0185)与 userns 相关。Ubuntu 24.04 默认收紧非特权 userns,正是这个权衡的结果。 - 它不阻止 bind mount 暴露路径。 ns 内 root 可以把一个自己有读权限的宿主目录 bind 到沙箱里;这不会给它新的读权限,但会把「本来只在某个路径可见」的数据搬到工具能看见的位置。文件边界仍要靠 mount namespace 的挂载集合与 landlock。
把 userns 想成一间独立的办公室:你在里面可以当经理(有全部 capability),可以重新布置家具(mount tmpfs)、给员工编号(pid ns)。但你没有这栋楼的大门钥匙(宿主块设备)、不能进别人的办公室(父 ns 的文件)、不能改整栋楼的电路(宿主网络)。帽子只在这间办公室有效;出了门,你还是原来的 uid。
4.5.3 Ubuntu 24.04 的 AppArmor 限制
Ubuntu 24.04 默认启用了一条 AppArmor 限制:非特权用户创建 userns 会被拒绝,unshare(CLONE_NEWUSER) 返回 EPERM。这不是内核不支持,而是发行版在「userns 的可用性」与「内核攻击面」之间选了后者。
sysctl kernel.apparmor_restrict_unprivileged_userns
# kernel.apparmor_restrict_unprivileged_userns = 1
unshare --user --map-root-user id
# unshare: unshare failed: Operation not permitted
三个应对方式,按推荐顺序:
- 给需要 userns 的程序装一个带
userns,规则的 AppArmor profile。 这是发行版推荐的做法:只对受信任的可执行文件放开,而不是全局放开。 - 在测试环境临时关掉限制:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0。这会让整台机器上的非特权 userns 都可用,测试完要改回 1。 - 不用 userns,改用其它隔离层。 如果只需要文件/系统调用限制,landlock + seccomp + mount namespace(由特权启动器创建)可以覆盖大部分场景。
还要看两个上限:
cat /proc/sys/user/max_user_namespaces
# 15000
这个值是「每个用户在每个 userns 里能创建的同类型 namespace 上限」。触顶时 unshare/clone 返回 ENOSPC。Agent runner 如果为每个任务创建一个 userns,必须有回收逻辑;泄漏的 namespace 会一直占着内核对象,直到最后一个引用消失。
4.6 组合顺序与 clone 可变参数
前面五节是零件,这一节是装配图。把 userns、mount、降权、no_new_privs、landlock、seccomp 按错误顺序装,轻则某层静默失效,重则进程在装到一半时被自己的过滤器杀死。
4.6.1 装配图
parent / supervisor
│
├─ ① unshare(CLONE_NEWUSER) ──────────── 新 userns:cap 作用域
├─ ② write uid_map / gid_map ─────────── 身份映射(一次写死)
├─ ③ unshare(CLONE_NEWNS) + mount ────── rootfs / 只读挂载 / proc
├─ ④ setgroups(0) → setresgid → setresuid
├─ ⑤ PR_CAPBSET_DROP → ambient clear → capset(0)
├─ ⑥ PR_SET_NO_NEW_PRIVS = 1
├─ ⑦ landlock_restrict_self
├─ ⑧ seccomp(SECCOMP_SET_MODE_FILTER, TSYNC)
└─ ⑨ execve(target)
│
▼
target 继承:uid/gid、capability、nnp、landlock、seccomp
读图:从上到下,每一步都在缩小下一步能用的权力;箭头不是「建议顺序」,而是依赖关系。③ 需要 ① 给的 CAP_SYS_ADMIN,所以 mount 必须在 drop capability 之前;④ 的 setgroups 需要 CAP_SETGID,所以组必须在 gid 之前清;⑦ 与 ⑧ 都需要 ⑥ 的 no_new_privs(或 CAP_SYS_ADMIN),所以 ⑥ 必须在前;⑧ 必须最后,因为 ⑤–⑦ 自己也要调系统调用,seccomp 装早了会把它们拦掉。⑨ 最后,是因为 exec 是特权授予点:目标程序一旦开始跑,动态链接器和构造函数就已经以新权限执行了。
4.6.2 每一步为什么在这个位置
| 步骤 | 为什么不能提前/推后 | 失败长什么样 |
|---|---|---|
| ① userns | 先有 ns,才有「本 ns 内的 CAP_SYS_ADMIN」去做 mount;在父 ns 里非特权用户没有这个位 | EPERM on unshare(CLONE_NEWNS) |
| ② ID 映射 | 映射没写好,ns 内 root 对父 ns 资源仍是 overflow ID;setuid 目标也可能不存在 | setresuid: EINVAL;文件属主显示 65534 |
| ③ mount | 必须在 drop CAP_SYS_ADMIN 之前;pivot_root/mount 都要它 | EPERM on mount |
| ④ 组与 UID | setgroups 需要 CAP_SETGID,setresuid 需要 CAP_SETUID;先 drop cap 就改不了身份 | EPERM on setgroups/setresuid |
| ⑤ capability | 必须在 exec 之前;exec 会把 file capability/ambient 换算成新权限 | 目标程序带着 CAP_SYS_ADMIN 跑起来 |
⑥ no_new_privs | landlock 与非特权 seccomp 都要求它;它不可撤销,放在降权完成后最安全 | landlock_restrict_self: EPERM;seccomp: EACCES |
| ⑦ landlock | 必须在 seccomp 之前,否则 landlock_* 被自己的白名单拒绝 | EPERM/ENOSYS,但 seccomp 已生效 |
| ⑧ seccomp | 最后装;装完只允许目标程序真正需要的调用 | 后续 prctl/landlock_* 返回 EPERM |
| ⑨ exec | 最后;继承以上全部状态 | 目标拿不到应有的权限,或拿到过多权限 |
这张表说明什么:这不是「最佳实践清单」,而是依赖图的拓扑序。其中 ④ 与 ⑤ 的边界最容易被写错:如果进程以 root 启动,setresuid 到非 root 会默认清空 capability;此时再想 PR_CAPBSET_DROP 就没有 CAP_SETPCAP 了。两种正确写法:
- 父进程先 drop bounding,再让子进程 setuid,最后
capset清零。 bounding 是继承的,父进程 drop 后子进程也受限;子进程 setuid 后即使没有CAP_SETPCAP,也不需要再 drop。 - 子进程用
PR_SET_KEEPCAPS或SECBIT_KEEP_CAPS保留 permitted,setuid 后临时把CAP_SETPCAP提进 effective,完成 bounding drop,再capset清零并清掉 KEEP_CAPS。
两种写法都满足「exec 之前完成」。图里把 ④ 放在 ⑤ 前面,是因为多数 agent runner 从非特权 uid 启动,setresuid 只是把 uid 固定成同一个值,真正的降权在 ⑤;如果你从 root 启动,必须显式处理上面这个先后依赖,不能照抄。
4.6.3 clone 的可变参数:顺序错一位,写坏一片栈
需要「创建子进程并同时建 namespace」时,clone(2) 是最直接的接口。它的原型是可变参数:
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
/* pid_t *parent_tid, void *tls, pid_t *child_tid */);
可变参数的顺序是 parent_tid → tls → child_tid,对应第 5、6、7 个参数。三个指针只在对应标志置位时被内核使用:
| 标志 | 用到的可变参数 | 位置 |
|---|---|---|
CLONE_PARENT_SETTID | parent_tid | 第 5 个参数(第一个可变参数) |
CLONE_SETTLS | tls | 第 6 个参数(第二个可变参数) |
CLONE_CHILD_SETTID | child_tid | 第 7 个参数(第三个可变参数) |
最常见的错误是:只置了 CLONE_CHILD_SETTID,然后写成
/* 错误:只传一个可变参数,它落在 parent_tid 槽;child_tid 槽是垃圾 */
clone(fn, stack, CLONE_CHILD_SETTID | SIGCHLD, arg, &child_tid);
内核只会在 CLONE_PARENT_SETTID 置位时使用第 5 个参数;只置 CLONE_CHILD_SETTID 时,第 5 个参数被忽略,而真正被读取的第 7 个参数是缺失的(栈上的垃圾)。结果是:子进程试图往一个随机地址写自己的 TID,表现为「偶发内存损坏」,而不是一个干脆的错误。
正确写法是把三个位置都补上,不用的传 NULL:
pid_t child_tid = 0;
pid_t pid = clone(child_fn, stack + STACK_SIZE,
CLONE_CHILD_SETTID | SIGCHLD, arg,
NULL, /* parent_tid */
NULL, /* tls */
&child_tid);
如果同时要 CLONE_PARENT_SETTID,就把 &parent_tid 放第 5 个;要 CLONE_SETTLS,就把 TLS 指针放第 6 个。只有 CLONE_CHILD_SETTID 时,第三个可变参数才是 child_tid;只传一个可变参数时,它占据的是第一个可变参数的位置,不是第三个。CLONE_CHILD_CLEARTID 也用第三个位置,常与 CLONE_CHILD_SETTID 配对。
现代代码应优先用 clone3(2),把参数放进结构体,避免可变参数错位:
struct clone_args args = {
.flags = CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID,
.exit_signal = SIGCHLD,
.stack = (uintptr_t)stack,
.stack_size = STACK_SIZE,
};
pid_t pid = syscall(SYS_clone3, &args, sizeof args);
if (pid == 0) {
/* 子进程:像 fork 一样从这里继续 */
}
size 参数让内核知道用户态结构体有多大;新内核加字段不会破坏旧程序。clone3 在 5.3+ 可用,旧内核返回 ENOSYS;它也可能被容器的 seccomp 白名单拦住(很多默认 profile 只放行 clone,不放行 clone3),所以生产代码要准备回退路径。回退时仍然要面对 clone 的可变参数顺序,不能因为「有 clone3」就跳过测试。
在把沙箱启动代码合入之前,逐条确认:
- userns 与映射:
unshare(CLONE_NEWUSER)之后、setuid之前,uid_map/gid_map是否已经写好?gid_map之前是否写了setgroups=deny? - mount 与 cap:mount/pivot_root 是否发生在 drop
CAP_SYS_ADMIN之前?drop 之后是否还有代码试图 mount? - 组与 UID:
setgroups(0, NULL)是否在setresgid之前?setresgid是否在setresuid之前? - capability:bounding 是否在 exec 前 drop?
CAP_SETPCAP是否最后 drop?ambient 是否清空?capset是否把 permitted/effective/inheritable 都清零? - no_new_privs:是否在 landlock 与 seccomp 之前设置?设置后是否还有代码依赖 setuid 子程序?
- landlock:ABI 是否探测?不支持的位是否裁剪?是否在 seccomp 之前安装?多线程是否在装完之后才创建?
- seccomp:是否最后安装?白名单是否只包含目标程序真正需要的调用,并显式覆盖
write/exit_group等退出路径? - 失败处理:每一步的返回值是否检查?失败时是终止并记录,还是继续把
sandboxed=true写进状态?
八条里任意一条答不上来,就不要先接模型;沙箱的失效模式通常是「看起来装好了」,而不是「装的时候报错」。
4.7 失败案例:先 seccomp 后 prctl、cap 未 drop、no_new_privs 遗漏
三个案例都来自同一类错误:把安全步骤当成可以任意排序的初始化代码。它们的共同点是「程序没有崩,日志还显示 sandbox 已启用」,但实际少了一层。下面每个案例给出触发条件、strace/errno 证据与修复。
4.7.1 先装 seccomp,再调 prctl
触发条件。 runner 的初始化顺序写反了:先 install_seccomp()(默认 ERRNO|EPERM,白名单只有 write/exit_group),再 prctl(PR_SET_NO_NEW_PRIVS, 1)。代码认为「seccomp 最危险,先装最安全」。
证据。 strace -f -e trace=prctl,seccomp 看到:
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, {len=12, ...}) = 0
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) = -1 EPERM (Operation not permitted)
prctl 不在白名单里,被自己的过滤器以 EPERM 拒绝。更隐蔽的版本是:代码用 if (prctl(...) == -1) { /* 忽略 */ } 把错误吞掉,然后继续 landlock_restrict_self——如果 NNP 本应由这次 prctl 设置,landlock 也会返回 EPERM;但 seccomp 已经生效,程序看起来「有沙箱」。如果顺序反过来,在没有 no_new_privs、也没有 CAP_SYS_ADMIN 的情况下先装 seccomp,失败会更早:
seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, {len=12, ...}) = -1 EACCES (Permission denied)
注意这两个 errno 不同:seccomp 缺 no_new_privs 是 EACCES,prctl 被过滤器拒绝是 EPERM。把 EACCES 当成「权限不足,重试」是另一个常见错误。
修复。 顺序改为 no_new_privs → landlock → seccomp,并把每一步的返回值当作硬失败:
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
perror("PR_SET_NO_NEW_PRIVS");
return 1;
}
if (landlock_restrict() != 0)
return 1;
if (install_seccomp() != 0)
return 1;
同时把「sandbox 已启用」的状态改成逐步置位的位图,而不是一个布尔值:NNP_OK | LANDLOCK_OK | SECCOMP_OK。只有三位都置上,才允许执行不可信代码;任何一位失败都拒绝启动。这样即使日志被截断,也能从状态里看出缺了哪层。
4.7.2 capability 未 drop:一次 mount 逃逸
触发条件。 runner 以 root 启动,只做了 chroot,没有独立 mount/PID namespace,也没有 drop CAP_SYS_ADMIN。模型生成的代码挂一个新的 proc,再顺着 1/root 读宿主的文件:
mount("proc", "/mnt", "proc", 0, NULL);
open("/mnt/1/root/etc/shadow", O_RDONLY);
chroot 只改路径解析的起点,不是安全边界;有 CAP_SYS_ADMIN 的进程可以 mount。没有 PID namespace 时,/mnt/1/root 指向宿主 PID 1 的根,于是沙箱里能读到宿主 /etc/shadow。容器 runtime 默认丢掉 CAP_SYS_ADMIN,但 --privileged、自定义 --cap-add 或手写的 systemd unit 会把它加回来。
证据。 先看 capability:
capsh --print | grep -E 'Current|Bounding'
# Current: cap_chown,cap_dac_override,...,cap_sys_admin,...
# Bounding set =cap_chown,cap_dac_override,...,cap_sys_admin,...
再用 strace 看 mount 成功:
mount("proc", "/mnt", "proc", 0, NULL) = 0
openat(AT_FDCWD, "/mnt/1/root/etc/shadow", O_RDONLY) = 3
mount 返回 0 就是逃逸成功的证据;如果返回 EPERM,说明 capability 已经被裁掉。注意 strace 本身需要 CAP_SYS_PTRACE 或同 uid 的 ptrace 权限;在已经加固的 runner 里,应该在沙箱外用 supervisor 记录,而不是让沙箱内的进程自己 strace 自己。
修复。 三件事一起做:
PR_CAPBSET_DROP掉CAP_SYS_ADMIN,并capset清空当前 permitted/effective;- seccomp 默认拒绝
mount、umount2、pivot_root、chroot、open_tree、move_mount; - 如果要保留 mount 能力做 rootfs 准备,把它放在父进程里,子进程只继承已经挂好的 rootfs,不再持有
CAP_SYS_ADMIN。
第 3 条是结构性的:准备 rootfs 的进程与执行不可信代码的进程应该是两个进程。前者可以有特权,后者不能;两者之间用已经打开的 fd 或已完成挂载的 mount namespace 传递,而不是把特权一起传下去。
4.7.3 遗漏 no_new_privs:setuid 程序把权限拿回去
触发条件。 runner 已经用 setresuid(1000, 1000, 1000) 降到普通用户,但没有设置 no_new_privs,rootfs 里又存在一个 setuid root 的辅助程序(例如 sudo、passwd、某个遗留的 agent-helper)。模型生成 execve("/usr/bin/agent-helper", ...),程序以 euid 0 跑起来。
证据。 先看 NoNewPrivs:
grep '^NoNewPrivs' /proc/self/status
# NoNewPrivs: 0
再跟踪 exec 与凭证变化:
execve("/usr/bin/agent-helper", ["agent-helper"], ...) = 0
# 在另一个终端读 /proc/<pid>/status
Uid: 1000 0 0 0
Uid: 的第二个字段(effective)从 1000 变成 0,就是提权成功的证据。如果程序是 file capability 而不是 setuid,CapEff 会多出对应的位;getcap /usr/bin/agent-helper 能看到 cap_sys_admin=ep 之类的标记。
修复。 在 exec 之前设置 no_new_privs,并让挂载带 nosuid:
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0)
die("PR_SET_NO_NEW_PRIVS");
no_new_privs 让 exec 忽略 setuid/setgid 位与 file capability;它不是「禁止 exec」,也不是「清空当前权限」。如果进程已经以 root 运行,设置它不会把 root 变成非 root;它只阻止「通过 exec 获得新特权」。所以它必须与「降 uid + 清 cap」一起用,不能单独当沙箱。
MS_NOSUID 是第二道闸:即使 setuid 位还在,挂载选项也会让它失效。容器里挂载宿主目录时默认应带 nosuid,nodev,noexec(按需要);agent 的 workspace 挂载至少应该有 nosuid。
4.7.4 三个案例的共同点
| 案例 | 缺的那一步 | 首错 errno | 修复的关键 |
|---|---|---|---|
| 先 seccomp 后 prctl | 顺序 | EPERM(prctl)或 EACCES(seccomp 安装) | 把顺序写成依赖,不靠注释 |
| cap 未 drop | capability | mount 返回 0(不是错误) | 父进程准备 rootfs,子进程零 cap |
| 缺 no_new_privs | 标志 | Uid: 的 euid 变 0(不是错误) | NNP + nosuid + 清 cap |
这张表说明什么:后两个案例没有 errno。攻击成功时,系统调用返回 0,进程正常跑;只有主动去读 /proc 或做负向测试,才能发现边界没生效。安全工程的难点不是「处理报错」,而是「证明没报错的那条路径真的被挡住了」。
一个「全部成功」的启动日志。 某 runner 启动时打印:
[init] userns: ok
[init] uid_map: ok
[init] mount: ok
[init] setuid: ok
[init] capset: ok
[init] landlock: ok
[init] seccomp: ok
[sandbox] enabled
但 grep '^CapEff' /proc/<pid>/status 显示 00000000a80425fb(Docker 默认的 14 个 capability),NoNewPrivs: 0,Seccomp: 0。原因不是某一步失败,而是每一步都在父进程里执行,exec 之后的目标进程是另一个 pid;日志打印的是父进程的状态,子进程根本没继承。
修复:把每一步的返回值与目标进程的 /proc/<pid>/status 绑定;在 exec 之前由同一个进程完成所有步骤,或者在 exec 之后由 supervisor 读取子进程的 status 做断言。日志里的「ok」必须对应一个可观测的字段,而不是一行 printf。
4.8 观测:/proc/self/status 与自检
权限层最麻烦的地方是「没有报错」。mount 成功、exec 成功、open 成功,都可能意味着边界没生效。观测的目标不是「看日志有没有 ok」,而是从内核暴露的字段证明每一层真的在位。
4.8.1 一张字段表
/proc/<pid>/status 里与本章相关的字段:
| 字段 | 取值 | 说明 |
|---|---|---|
Uid: / Gid: | 四个数 | real、effective、saved、fs;看第二个判断当前有效身份 |
Groups: | 组列表 | supplementary groups;空列表不等于没有主组 |
CapInh: | 十六进制位图 | inheritable |
CapPrm: | 十六进制位图 | permitted;exec 后能力的上限 |
CapEff: | 十六进制位图 | effective;内核当前检查的位 |
CapBnd: | 十六进制位图 | bounding;后代永远拿不到之外的位 |
CapAmb: | 十六进制位图 | ambient;跨非特权 exec 保留的位 |
NoNewPrivs: | 0/1 | 1 表示 exec 不再获得 setuid/file cap |
Seccomp: | 0/1/2 | 0 disabled、1 strict、2 filter |
Seccomp_filters: | 整数 | 已安装的过滤器数量;0 表示没有 |
一条命令能拿到全部:
cat /proc/self/status | grep -E '^(Uid|Gid|Groups|Cap|NoNewPrivs|Seccomp)'
Uid: 1000 1000 1000 1000
Gid: 1000 1000 1000 1000
Groups: 1000 27
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
NoNewPrivs: 1
Seccomp: 2
Seccomp_filters: 1
这段输出(普通用户、已装一层 seccomp)要这样读:
CapEff全 0:当前没有有效 capability;CapBnd仍是 41 位全 1,说明 bounding 没被裁过。这两行必须一起看:CapEff=0不代表后代拿不到能力,CapBnd才是后代的上限。NoNewPrivs: 1:exec 不会再因为 setuid/file cap 提权。Seccomp: 2+Seccomp_filters: 1:filter 模式,装了一层。只看Seccomp: 2不够,还要看过滤器数量;某些路径可能装了两层,层数本身是观测信号。
4.8.2 解码 capability 与 seccomp
capsh 能把位图翻译成名字:
capsh --decode=00000000a80425fb
# 0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,
# cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,
# cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap
capsh --print 适合看当前进程的完整状态(Current、Bounding set、Ambient set、Current IAB);--decode 只翻译一个位图。
getpcaps $$ 更适合看单个进程,输出形如 12345: cap_chown,cap_dac_override,...=ep。=ep 表示 effective + permitted;=p 只有 permitted;=eip 表示 effective、inheritable、permitted 都在。解码时注意末尾的字母,不要只看名字。
seccomp 没有「列出过滤器」的通用命令;Seccomp_filters 只给数量。要看具体指令,需要 ptrace(PTRACE_SECCOMP_GET_FILTER)(4.4+)或 seccomp-tools 这类工具。生产环境更实用的做法是:在测试里用同一份策略源生成过滤器,断言「允许的调用返回 0、拒绝的调用返回预期 errno」,而不是去 dump 二进制。
4.8.3 landlock 没有状态文件
landlock 不提供 /proc 状态字段,也不提供「列出当前域」的接口。自检只能靠负向访问。下面的片段在域内读 /etc/passwd,打印 errno;域外同一段代码能读到内容:
try:
open("/etc/passwd", "rb")
except OSError as e:
print(e.errno, e.strerror) # 13 Permission denied
EACCES 通常来自 DAC 或 landlock;EPERM 通常来自 seccomp 或 capability。要确认是 landlock 而不是 DAC,做一次「DAC 允许但 landlock 拒绝」的对照:用一个 landlock 域外的进程读同一个文件,能读;域内读不到,才是 landlock 的证据。
ABI 也可以直接查:
import ctypes
libc = ctypes.CDLL(None, use_errno=True)
fn = getattr(libc, "landlock_create_ruleset", None)
if fn is not None:
abi = fn(None, 0, 1) # LANDLOCK_CREATE_RULESET_VERSION
else:
abi = libc.syscall(444, None, 0, 1)
print("abi =", abi, "errno =", ctypes.get_errno())
如果 libc 没有导出 landlock_create_ruleset,用 syscall(444, ...)(x86-64/aarch64 的通用号)。返回 -1 且 errno 为 ENOSYS 表示内核没编译 landlock;EOPNOTSUPP 表示启动时被禁用。
dmesg | grep landlock 能看到 landlock: Up and running,但这条日志只说明 LSM 初始化成功,不说明你的进程装了域。进程级证据只能来自负向测试。
4.8.4 用检查器把观测变成断言
experiments/ch04/inspect_linux.py 把上面的字段解析成结构化输出:
python3 experiments/ch04/inspect_linux.py
在 Ubuntu 24.04 上,普通 shell 的输出会显示 CapEff: ... (none)、NoNewPrivs: 0 (unset)、Seccomp: 0 (disabled)。这段是预期样子,不是本机运行结果。本机是 macOS,没有 /proc,检查器会打印跳过原因并以 0 退出:
inspect_linux.py: host is Darwin, not Linux.
/proc/<pid>/status is unavailable here; nothing to inspect.
This checker exits 0 by design so it can live in a cross-platform test matrix.
Run it on Ubuntu 24.04 (Linux 6.8) to see CapEff/Seccomp/NoNewPrivs.
把检查器接进门禁时,不要只看它退出 0;要用 --json 输出断言字段值:NoNewPrivs.set == true、Seccomp.value == 2、CapEff.count == 0。这比「启动日志里有 sandbox enabled」可靠,因为它读的是内核状态,不是程序的自述。
4.9 实验:C 加固程序 + Python 检查器
experiments/ch04/ 把本章的机制压成三个可运行件。它们的分工是:C 程序证明「顺序装对了会怎样」,Python 检查器证明「内核状态能被读到」,shell 脚本负责在 Linux 上把两者串起来。
4.9.1 文件与运行方式
| 文件 | 作用 | 运行平台 |
|---|---|---|
harden.c | no_new_privs → 清 capability → 可选 landlock → seccomp 白名单,然后尝试 open("/etc/passwd") | Linux 6.8 · Ubuntu 24.04 |
verify-linux.sh | 编译、跑两种模式、核对预期输出;macOS 上明确跳过并 exit 1 | Linux;macOS 仅语法检查 |
inspect_linux.py | 解析 /proc/<pid>/status;非 Linux 平台 exit 0 | 跨平台 |
README.md | 验证矩阵与限制 | — |
在 Ubuntu 24.04 上,一条 gcc 命令即可编译:
cc -std=c17 -O2 -Wall -Wextra -Werror \
experiments/ch04/harden.c -o /tmp/harden
/tmp/harden
/tmp/harden --landlock
默认模式的预期输出(普通用户):
uid=1000 euid=1000
NoNewPrivs: 1
CapEff before drop: 0x0000000000000000
CapEff after drop: 0x0000000000000000
bounding: skipped (errno=1; run as root to exercise)
seccomp: filter installed (TSYNC)
openat("/etc/passwd") after seccomp: EPERM (1)
result: OK
--landlock 模式多两行,ABI 4 是 6.8 的值:
landlock: ABI 4, only /usr is readable
openat("/etc/passwd") after landlock: EACCES (13)
这两段是在 Ubuntu 24.04 上应当看到的输出,本机没有 Linux 内核,未运行。本机能验证的是编译与语法:
python3 -m py_compile experiments/ch04/inspect_linux.py
bash -n experiments/ch04/verify-linux.sh
zig cc -target x86_64-linux-gnu -std=c17 -O2 -Wall -Wextra -Werror \
-c experiments/ch04/harden.c -o /tmp/harden.o
zig cc 的交叉编译通过只能说明「头文件与语法在 Linux ABI 下成立」,不能说明系统调用在 6.8 上真的返回预期 errno;后者必须在 Linux 上跑 verify-linux.sh。
4.9.2 C 程序的四个关键点
harden.c 的代码不长,但每一段都对应本章一个失败模式:
say()用SYS_write而不是printf。 seccomp 安装后,stdio 的隐式 flush、locale、fstat都可能被拦;直接write是白名单里唯一保证可用的输出通道。装好 seccomp 之后,程序只再调用write与exit_group,所以过滤器可以小到 12 条指令。- capability 清空顺序。 先
PR_CAPBSET_DROP(CAP_SETPCAP最后),再清 ambient,最后capset清零。普通用户没有CAP_SETPCAP,bounding drop 返回EPERM,程序把它记录成bounding: skipped而不是失败退出;这是有意的——bounding 这一步在普通用户下做不了,但capset清零仍然有效,后续 exec 也不会再获得特权。 - landlock 可选。
--landlock时先landlock_restrict_self,再装 seccomp;规则只放行/usr的读/执行,因此open("/etc/passwd")返回EACCES。如果不加这个参数,同一操作返回 seccomp 的EPERM。两个 errno 的差别就是「哪一层拒绝」的证据。 - seccomp 的 arch 与 x32 检查。 12 条指令里,前 4 条是 arch 与 x32 门;
openat/openat2显式拒绝,write/exit_group放行,其余默认EPERM。跳转偏移在harden.c里按目标下标写死;改指令顺序必须重新算偏移,否则过滤器会跳到错误的分支。
harden.c 把 no_new_privs 放在 capability 之前,是为了让普通用户也能直接装 seccomp(非特权安装要求先有 no_new_privs);no_new_privs 不会清 capability,所以这个提前不影响 4.6 的依赖关系。从 root 启动时,capability 的 drop 仍然是 exec 前的最后一道。
verify-linux.sh 做的是黑盒断言:它不关心 C 代码怎么实现,只看输出行是否精确匹配。这样即使以后把 harden.c 换成 libseccomp 或 Rust 实现,验证脚本仍然有效。
4.9.3 macOS 上的真实输出
本机是 macOS 27.0.1(Darwin 27.0.0)。verify-linux.sh 会明确跳过并非零退出:
bash experiments/ch04/verify-linux.sh
# exit=1
SKIP: harden.c uses Linux-only seccomp/landlock/prctl/capget interfaces.
current host: Darwin 27.0.0
run this on Ubuntu 24.04 (Linux 6.8): bash experiments/ch04/verify-linux.sh
exiting 1 on purpose: a skip must not look like a pass in CI.
inspect_linux.py 则以 0 退出:
python3 experiments/ch04/inspect_linux.py
# exit=0
inspect_linux.py: host is Darwin, not Linux.
/proc/<pid>/status is unavailable here; nothing to inspect.
This checker exits 0 by design so it can live in a cross-platform test matrix.
Run it on Ubuntu 24.04 (Linux 6.8) to see CapEff/Seccomp/NoNewPrivs.
两个脚本的退出码不同,是刻意设计的:Linux-only 的验证不能在 macOS 上假装通过;而检查器是一个可移植的解析器,跳过不是失败。CI 里应该把前者标记为「需要 Linux runner」,而不是把它加入 macOS 的必过列表。
4.9.4 把实验扩成回归
这个实验只有两段输出,但可以沿三个方向扩展:
- 加负向用例。 把
harden.c的probe_open换成mount、ptrace、socket,分别断言EPERM;每个调用对应一条白名单缺失,能直接暴露策略漏洞。 - 加多线程用例。 在装 seccomp 之前起一个线程,验证不加
TSYNC时该线程不受过滤;再加TSYNC验证它被同步。这是 4.3.4 的可执行版本。 - 加 landlock 网络用例。 在 6.8 上监听一个端口,规则只放行另一个端口,断言
bind的EACCES;再把内核换到 6.12,验证 scope 对抽象 UNIX socket 的影响。
扩展时守住一条:每个用例都要有「拒绝时的 errno」与「允许时的成功证据」,不能只断言程序退出码。权限 bug 最常见的形态是「该拒绝的没拒绝,但程序仍然正常退出」。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| 《Linux 系统与内核》第 2 篇 | 2.9 进程凭证、用户、组与能力 | 4.1、4.2 的 UID/GID 与五集合 | 交叉引用 |
| 《Linux 系统与内核》第 3 篇 | 3.3 中断、异常与系统调用 | 4.3 seccomp 在系统调用入口的位置 | 背景 |
| 《Linux 系统与内核》第 3 篇 | 3.4 进程描述与创建 | 4.6 clone/clone3 与 namespace | 交叉引用 |
| 《Linux 系统与内核》第 5 篇 | 5.1 容器原理:隔离、限制与镜像 | 4.6、4.7 的权限组合与逃逸 | 交叉引用 |
| 《Linux 系统与内核》第 5 篇 | 5.2 namespace 深入 | 4.5 user namespace 与 uid_map | 交叉引用 |
| 《Linux 系统与内核》第 5 篇 | 5.5 OCI 运行时与容器生命周期 | 4.6 从 config 到系统调用的顺序 | 背景 |
| man-pages 6.19 | capabilities(7)、seccomp(2)、landlock(7)、user_namespaces(7)、clone(2) | 4.2–4.6 的接口与 errno | 完整 |
| Landlock 内核文档 | ABI 1–9 与 landlock_restrict_self(2) | 4.4 的 ABI 表与语义 | 完整 |
| OCI Runtime Spec 1.3 | process、linux 字段 | 4.6 的 noNewPrivileges/capabilities/seccomp | 背景 |
读这张表要抓住两点:本书对 OS 只做交叉引用,不重复讲 UID 规则、namespace 实现或 clone 的内核路径;对 agent 新增的「组合顺序、失败模式、观测断言」则给到可实现的细节。表中「完整」表示正文覆盖了该主题的接口与边界,读者不需要再翻母本才能写代码。
本章陷阱
| # | 陷阱 | 症状 | 正确做法 | 去哪 |
|---|---|---|---|---|
| 1 | 只改 uid,不清 capability | CapEff 仍非 0;工具仍能 mount/ptrace | capset 清零 + drop bounding + NNP | 4.2 |
| 2 | drop bounding 就以为降权完成 | 当前 P/E/I/A 还在,fork 也继承 | 清 ambient + capset 清 permitted/effective/inheritable | 4.2 |
| 3 | 先 drop CAP_SETPCAP | 后续 PR_CAPBSET_DROP 全部 EPERM | 其它位先 drop,CAP_SETPCAP 最后 | 4.2 |
| 4 | seccomp 只查 nr,不查 arch | i386/x32 绕过黑名单,或白名单误放行 | arch 检查 + 拒绝 nr >= 0x40000000 + 默认拒绝 | 4.3 |
| 5 | 只给当前线程装 seccomp | 线程池里其它线程不受过滤 | TSYNC,或在单线程子进程里装完再 exec | 4.3 |
| 6 | user-notif 用 CONTINUE 检查路径 | 符号链接/内存改写造成 TOCTOU | ADDFD 注入 fd,或改用 landlock | 4.3 |
| 7 | 以为 landlock 到 ABI 6 才有网络 | 6.8 上放弃 TCP 规则 | ABI 4 已有 BIND_TCP/CONNECT_TCP | 4.4 |
| 8 | 把 landlock 当全文件系统沙箱 | stat/chmod/chdir 不受限 | 叠加 mount namespace、只读挂载、DAC、seccomp | 4.4 |
| 9 | 先装 seccomp,再调 prctl/landlock_* | 自己的过滤器返回 EPERM | seccomp 最后装;每步检查返回值 | 4.6、4.7 |
| 10 | uid_map 写两次、漏 setgroups=deny、Ubuntu 24.04 直接 unshare --user | EPERM,或 gid 映射失败 | 一次写死;先 deny;装 AppArmor profile 或临时关限制 | 4.5 |
| 11 | clone 可变参数错位 | child_tid 没写、父进程栈被改写 | parent_tid → tls → child_tid;优先 clone3 | 4.6 |
| 12 | 遗漏 no_new_privs | setuid/file capability 把权限拿回去 | NNP + nosuid 挂载 + 清 capability | 4.7 |
这张表说明什么:十二个陷阱里,只有第 7 个是「版本事实记错」,其余都是顺序、遗漏或观测缺失;它们不会因为换模型、换框架而消失。第 9 条与第 12 条最常见,也最难在 happy path 上发现——两者都需要负向测试才能证明。
本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 能写出权限六元组 | 六个分量齐全,能说明每个分量由谁检查 | 4.1 |
| 能解释 exec 变换 | 写出五集合公式,说明 +p 不自动生效 | 4.2 |
| 能证明 bounding 封闭性 | 说明当前 P/I/A 也必须清,fork 会继承 | 4.2 |
| 能写 seccomp 白名单 | arch + x32 + 默认拒绝 + TSYNC 失败返回值 | 4.3 |
| 能判断 landlock ABI | 6.8 → ABI 4;网络 ABI 4 已有;未 handled 的权限不受限 | 4.4 |
| 能写 uid_map/gid_map | 一次写、非特权单行、setgroups=deny | 4.5 |
| 能按依赖排序 | userns → mount → uid → cap → NNP → landlock → seccomp | 4.6 |
| 能复现三个失败案例 | 给出 errno/证据/修复,不只说「顺序错了」 | 4.7 |
| 能跑实验 | macOS 上 inspect 退 0、verify 退 1;Linux 上 verify 输出 PASS | 4.9 |
Q1 已经 PR_CAPBSET_DROP 掉 CAP_SYS_ADMIN,为什么还要 capset 清空 permitted/effective?
Q2 一个 seccomp 白名单只比较系统调用号,为什么必须同时检查 arch 与 x32 位?
Q3 在 Ubuntu 24.04 上 unshare --user --map-root-user id 返回 EPERM,如何判断是内核不支持还是 AppArmor 限制?
答案
A1 bounding 只约束「以后 exec 能从文件获得什么」,不改变当前进程已经持有的 P/E/I/A;fork 也会把这些集合复制给子进程。必须继续 PR_CAP_AMBIENT_CLEAR_ALL 清 ambient,再用 capset 把 permitted/effective/inheritable 清零,才能保证当前进程与后代都没有这位。
A2 i386 使用不同的 AUDIT_ARCH_I386 与不同的系统调用号;x32 与 x86-64 共用 AUDIT_ARCH_X86_64,但调用号带 0x40000000 位。只按 nr 匹配的白名单可能被 x32 号绕过,黑名单则更危险:未匹配的 x32 调用会落到 ALLOW。正确做法是 arch 不匹配直接 KILL_PROCESS,并拒绝 nr >= 0x40000000。
A3 先看 sysctl kernel.apparmor_restrict_unprivileged_userns:返回 1 且 unshare 是 EPERM,基本就是 AppArmor;再查内核配置(zgrep CONFIG_USER_NS /proc/config.gz 或 /boot/config-$(uname -r))。如果 CONFIG_USER_NS 未开,unshare(CLONE_NEWUSER) 更常见的是 EINVAL;如果开了但被 LSM 拒绝,才是 EPERM。dmesg | grep -i apparmor 能给出拒绝记录。
下一站:5 上下文工程 换一种有限资源:权限管的是「能做什么」,上下文管的是「这一步能看见什么」。两者都要求先算预算,再决定放行。