前言
在 上一篇文章 中,我们从 runc 的 SPEC.md 了解了容器的纵深防御体系,留了一个核心问题没有展开:CapPrm 和 CapEff 既然都是 capability 集合,为什么必须分开?CapBnd 和它们有什么区别?
要真正理解这些问题,必须先搞清楚两个前置概念:fork 和 exec。本文从零开始,用实验驱动的方式,把 Capabilities 的五大属性讲透。
一、先修概念:fork 和 exec
1.1 fork = 复制一个进程
fork() 是 Linux 系统调用的名字,意思是"复制当前进程":
int pid = fork();
if (pid == 0) {
// 我是子进程
printf("子进程: PID=%d\n", getpid());
} else {
// 我是父进程
printf("父进程: 子进程PID=%d\n", pid);
}执行结果:
父进程: 子进程PID=101
子进程: 子进程: PID=101注意:fork 之后,父进程和子进程完全独立,各自运行,互不干扰。
1.2 exec = 换灵魂
execve() 是另一个系统调用,意思是"用新程序替换当前进程的内存空间":
// 当前是 bash 进程
execve("/bin/ls", argv, envp);
// 这行之后,当前进程还是 PID=100,但已经变成 ls 了关键点:进程 PID 不变,运行什么程序变了。
1.3 fork + exec = 跑一个新程序
这是 Linux 最经典的进程创建组合:
shell (PID=100)
fork() → 子进程 (PID=101,是 shell 的克隆)
execve("/bin/runc", ...) → PID=101 的进程变成了 runc
runc 开始运行Docker 容器创建进程的方式本质上就是这样:先 fork 再 exec。
二、Capabilities 的五大属性
Linux 中的 process capabilities 由五个集合组成,可以用 cat /proc/self/status 查看:
CapInh: 0000000000000000 # Inheritable
CapPrm: 000001ffffffffff # Permitted
CapEff: 000001ffffffffff # Effective
CapBnd: 000001ffffffffff # Bounding
CapAmb: 0000000000000000 # Ambient2.1 CapEff(Effective)—— 当前生效的能力
进程当前实际使用的 capabilities。
类比:你手里正在用的工具。
2.2 CapPrm(Permitted)—— 已允许的能力
进程最多能拥有的 capabilities 的上限。
类比:你包里装的工具。手里可以不用,但包里得有。
2.3 CapBnd(Bounding)—— 全局上限
系统级限制,规定了一个进程这辈子最多能拥有哪些 capabilities。
类比:你的最高学历。可以低,不能高;丢了就回不来。
2.4 CapInh(Inheritable)—— fork 时继承
fork 产生的子进程能从父进程继承哪些 capabilities。
类比:遗传基因,子进程自动获得。
2.5 CapAmb(Ambient)—— exec 后保留
exec(程序替换)后仍能保留的 capabilities(仅对非 root 程序有效)。
类比:随身携带的证件,exec 后依然有效。
三、CapPrm 和 CapEff 为什么必须分开?
这是 Capabilities 设计最精妙的地方——最小权限原则(Principle of Least Privilege)。
3.1 场景一:Web 服务器
nginx 进程需要绑定 80 端口(cap_net_bind_service),但它 99% 的时间在处理普通 HTTP 请求,完全不需要这个能力。
启动时:
CapPrm = cap_net_bind_service, cap_setuid, cap_sys_admin ...
CapEff = cap_net_bind_service ← 绑定 80 端口时短暂需要
bind() 完成后:
CapEff = 空 ← 用完了就丢
处理 10000 个 HTTP 请求:
CapEff = 空 ← 正常工作不需要任何特权
遇到 CGI 脚本需要执行:
CapEff = cap_setuid ← 临时开启
执行完成
CapEff = 空 ← 再丢
... 如此循环 ...好处:如果黑客攻破 nginx 进程,只能拿到 CapEff 那一刻生效的能力。如果 CapEff 全程等于 CapPrm = 全开,黑客立刻拥有所有 capability。
3.2 场景二:setuid 程序降权
sudo 程序的运行过程:
如果 CapEff 和 CapPrm 绑在一起,降权后想再临时提权就非常麻烦。
3.3 场景三:fork 子进程继承
父进程:
CapInh = {cap_net_bind}
CapPrm = {cap_net_bind}
CapEff = {cap_net_bind}
fork() 后:
子进程自动继承:
CapInh = {cap_net_bind}
CapPrm = {cap_net_bind}(子进程从父进程继承 CapPrm)
CapEff = {cap_net_bind}
子进程立刻丢弃 CapEff:
CapPrm = {cap_net_bind}(还在包里,没丢)
CapEff = 空
→ 如果子进程被攻击者利用,攻击者拿到的是 CapEff = 空
而不是 CapPrm = {cap_net_bind}3.4 一句话总结
CapPrm 是"弹药库",CapEff 是"手里的枪"。弹药库可以被偷,但枪不在手里就开不了火。分开是为了:任何时候都尽量让 CapEff = 空或最小集,被攻击了也只丢最小权限。
四、CapBnd 和 CapPrm 的区别
这是最容易混淆的一对概念:
| CapBnd | CapPrm | |
|---|---|---|
| 作用域 | 系统级,整个进程树的全局限制 | 进程级,单个进程自己的限制 |
| 能不能恢复 | 不能,丢弃就是丢了,exec 后也回不来 | 能,capset() 可以重新开启 |
| 初始值 | 内核 cap_last_cap 决定(系统所有 capability 的全集) | 来自父进程 fork/exec 时的继承 |
4.1 形象比喻
CapBnd = 你的"最高学历"(博士)
→ 你这辈子最多拿到博士学位,不可能再高了
→ 即使丢了,以后也不可能再拿回来(除非重启)
CapPrm = 你包里的"证件"(工程师证 + 教师证 + ...)
→ 可以随时丢弃,也可以随时考新的补回来4.2 CapBnd 的真正价值:阻止提权
CapBnd 是防止已经拥有 root 权限的进程提权的最后防线。
Docker 容器逃逸场景:
宿主机 runc 进程:
CapBnd = 全开(40 个 capability 都有)
CapPrm = 全开
CapEff = 全开
容器创建时,runc 会裁剪容器的 CapBnd(去掉 cap_sys_admin 等):
容器内进程:
CapBnd = {去掉 CAP_SYS_ADMIN} ← 内核强制限制!
CapPrm = {去掉 CAP_SYS_ADMIN}
CapEff = {去掉 CAP_SYS_ADMIN}
→ 即使容器内进程通过漏洞提权成功,也无法执行 mount()/unshare()/chroot()
因为这些操作需要 CAP_SYS_ADMIN,而 CapBnd 里没有CapBnd 是铁门,CapPrm 是木门。
4.3 操作对比
CapBnd 只能丢弃,不能恢复:
capsh --drop=cap_sys_admin --
capsh --add=cap_sys_admin --printCapPrm 可以丢弃和恢复:
capsh --drop=cap_net_bind_service --
capsh --add=cap_net_bind_service --五、CapInh 和 CapAmb 的区别
| CapInh | CapAmb | |
|---|---|---|
| 生效时机 | fork 时继承 | exec 时保留 |
| 用途 | 控制子进程能拿到哪些 capabilities | 控制 exec 后还能不能用 |
| 常见场景 | 需要 fork 出带权限的子进程 | 需要 exec 后保持某些权限 |
5.1 CapInh 的工作原理
父进程:
CapInh = {cap_net_bind_service}
fork()
↓ 子进程继承
子进程 CapInh = {cap_net_bind_service}(自动有)
子进程 CapPrm = {cap_net_bind_service}(从 CapInh 继承)5.2 CapAmb 的工作原理
普通程序 exec 后会丢失所有 capabilities:
普通进程(无 root):
execve("/bin/ls", ...)
→ 丢失所有 capabilities(因为 ls 不是 setuid 程序)CapAmb 允许 exec 后保留一部分:
普通进程:
CapPrm = {cap_net_bind_service}
CapEff = {cap_net_bind_service}
CapAmb = {cap_net_bind_service} ← 标记了"exec 后保留"
execve("/bin/myprogram", ...)
→ myprogram 仍然有 cap_net_bind_service5.3 CapAmb 的三个限制
- 程序不能是 setuid/setgid 程序
CapAmb ≤ CapPrm(不能保留没在包里有的能力)CapAmb ≤ CapInh(不能保留没被标记继承的能力)
六、CapInh 和 CapAmb 的实际案例
6.1 CapInh 案例:Nginx fork worker 进程
nginx master 进程(root):
CapInh = {cap_net_bind_service} ← 设置了需要继承的能力
fork()
↓ 子进程继承
nginx worker 进程:
CapPrm = {cap_net_bind_service}(从 CapInh 继承)
CapEff = {cap_net_bind_service}6.2 CapAmb 案例:普通用户运行网络服务
普通用户进程:
CapAmb = {cap_net_bind_service}
execve("/usr/sbin/my-server", ...)
→ my-server 仍然可以绑定 80 端口(即使不是 root)七、完整初始化过程
进程启动(exec):
CapBnd = 内核 cap_last_cap 决定的最大集合(通常 40 个全开)
↓
CapPrm = 继承自父进程
↓
CapEff = 继承自父进程
↓
CapInh = 继承自父进程(可被程序显式修改)
↓
CapAmb = 继承自父进程(可被程序显式修改)八、查看和操作 Capabilities 的常用命令
8.1 查看当前进程的 capabilities
cat /proc/self/status | grep Cap输出示例:
CapInh: 0000000000000000
CapPrm: 000001ffffffffff
CapEff: 000001ffffffffff
CapBnd: 000001ffffffffff
CapAmb: 00000000000000008.2 解码十六进制为可读列表
sudo apt install libcap-ng-utils
capsh --decode=000001ffffffffff输出示例:
0x000001ffffffffff=cap_chown,cap_dac_override,cap_dac_read_search,
cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,
cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,
cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,
cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,
cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,
cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap ...8.3 capsh 工具详解
capsh --print
capsh --drop=cap_sys_admin --print
capsh --add=cap_net_bind_service --print
capsh --caps="cap_net_bind_service+ep" -- -c "..."8.4 查看任意进程的 capabilities
cat /proc/1234/status | grep Cap
capsh --decode=$(cat /proc/1234/status | grep CapEff | awk '{print $2}')8.5 getpcaps 工具(查看其他进程)
sudo apt install libcap-ng-utils
getpcaps 1234九、实验演示
9.1 实验一:观察 root 和普通用户的 capabilities 差异
sudo cat /proc/$/status | grep Cap
su - testuser -c 'cat /proc/self/status | grep Cap'对比:root 的 CapEff 通常是全开(000001ffffffffff),普通用户的 CapEff 通常只有部分。
9.2 实验二:CapBnd 丢弃后无法恢复
capsh --drop=cap_sys_admin --
capsh --add=cap_sys_admin --print9.3 实验三:fork 后 CapInh 继承
在有 root 权限的环境中:
// fork_test.c
#include <stdio.h>
#include <unistd.h>
#include <sys/capability.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("子进程: CapEff=%016llx\n", (unsigned long long)cap_get_flag(cap_get_proc(), CAP_EFFECTIVE));
_exit(0);
} else {
wait(NULL);
}
return 0;
}9.4 实验四:exec 后 CapAmb 保留
普通用户(无 root)执行:
sudo setcap cap_net_bind_service=ep /usr/sbin/python3
su - testuser -c "capsh --caps='cap_net_bind_service=ep' --addamb=cap_net_bind_service -- -c 'getpcaps \$\$'"9.5 实验五:观察 Docker 容器的 CapBnd
docker run --rm ubuntu cat /proc/self/status | grep Cap
cat /proc/self/status | grep Cap容器的 CapBnd 通常比宿主机少很多(比如没有 CAP_SYS_ADMIN)。
十、runc 中 Capabilities 的应用
在 runc 创建容器时,会根据 config.json 中的 linux.capabilities 字段设置容器进程的 capabilities:
// libcontainer/capabilities/capabilities.go
func (c *linuxCap) ApplyCaps() error {
// 1. 丢弃所有 capabilities
for _, cap := range capability.List() {
cap.Set(cap.Effective, false)
cap.Set(cap.Permitted, false)
cap.Set(cap.Inheritable, false)
}
// 2. 只开启 config.json 中声明的 capabilities
for _, cap := range c.bounds {
cap.Set(cap.Permitted, true)
cap.Set(cap.Effective, true)
}
return nil
}容器的 CapBnd 也会被裁剪,去掉容器不需要的高危 capabilities(如 CAP_SYS_ADMIN、CAP_SYS_MODULE 等),防止容器内进程通过这些 capability 实现逃逸。
十一、Seccomp 详解:Capabilities 的补充
11.1 什么是 Seccomp
Seccomp(Secure Computing Mode)是 Linux 内核的系统调用过滤器,用来限制进程能调用哪些系统调用(syscall)。
每个程序都通过系统调用和内核交互:
用户程序 ──read()──→ 内核
用户程序 ──write()──→ 内核
用户程序 ──mount()──→ 内核 ← 高危!
用户程序 ──unshare()──→ 内核 ← 高危!Seccomp 的作用是在 syscall 到达内核处理函数之前就拦截它:
没有 Seccomp:
程序 ──mount──→ ✅ 通过 ← 危险!
有 Seccomp(只允许 read/write/exit):
程序 ──mount──→ ❌ 拒绝(返回 -1,errno = EPERM)11.2 Seccomp 的两种模式
Mode 1(严格模式,内核 2.6.12 引入)
只能完全允许或完全禁止,不灵活:
echo 1 > /proc/self/seccompMode 2(过滤模式,内核 3.5 引入)
可以基于规则过滤 syscall,主流用法:
允许:read, write, exit, sigreturn
禁止:mount, unshare, mknod, chroot, ...11.3 Capabilities 和 Seccomp 的关系
这两个确实有功能交叉,但设计目的不同:
| Capabilities | Seccomp | |
|---|---|---|
| 控制粒度 | 单个 capability | 单个系统调用 |
| 层次 | Linux 用户态权限模型 | 内核强制执行 |
| 关系 | "你有没有这个证" | "你有没有调用这个 syscall 的权利" |
关键区别:
Capabilities 检查在内核 syscall 处理函数内部(有证才放行)
Seccomp 过滤在 syscall 到达处理函数之前就拦截了两者叠加是双重保险:
攻击者通过漏洞获取了 CAP_SYS_ADMIN:
只有 Capabilities:
mount() ──→ 有 CAP_SYS_ADMIN ──→ 成功 ⚠️ 容器逃逸!
叠加 Seccomp:
mount() ──→ Seccomp 拦截 ──→ EPERM ❌
即使有 CAP_SYS_ADMIN 也白搭形象比喻:
Capabilities = 保安(检查你有没有通行证)
Seccomp = 门上锁(直接物理上禁止开这扇门)11.4 查看和配置 Seccomp
查看进程的 Seccomp 状态:
cat /proc/self/status | grep Seccomp启用 Seccomp(Mode 2):
#include <linux/seccomp.h>
#include <sys/prctl.h>
// 启用 Seccomp 过滤模式
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);用 libseccomp 编写规则:
#include <seccomp.h>
int main() {
// 创建过滤规则
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);
// 默认允许所有 syscall
// 添加规则:禁止 mount
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM),
SCMP_SYS(mount), 0);
// 禁止 unshare
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM),
SCMP_SYS(unshare), 0);
// 加载规则
seccomp_load(ctx);
// 现在 mount() 会被拒绝
mount("none", "/tmp", "tmpfs", 0, NULL); // 返回 EPERM
}验证 Seccomp 拦截:
strace mount -t tmpfs none /tmp 2>&1runc 中的 Seccomp 配置(config.json):
{
"linux": {
"seccomp": {
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["mount", "umount2", "unshare", "chroot", "mknod"],
"action": "SCMP_ACT_ERRNO"
}
]
}
}
}十二、如何给进程配置 Capability
12.1 查看进程的 Capability
cat /proc/self/status | grep Cap
getpcaps <pid>12.2 给可执行文件设置 Capability(永久)
使用 setcap 命令,给二进制文件设置:
sudo setcap cap_net_bind_service=ep /usr/bin/python3
sudo setcap cap_net_raw=ep /bin/ping
sudo setcap cap_net_bind_service,cap_net_admin=ep /usr/sbin/myapp
getcap /usr/bin/python3
sudo setcap -r /usr/bin/python312.3 给运行中的进程调整 Capability(临时)
使用 capsh 命令:
capsh --drop=cap_sys_admin --print
capsh --caps="cap_net_bind_service+ep" -- -c "python3 -m http.server 80"
capsh --add=cap_net_bind_service --12.4 在代码中设置 Capability(libcap)
#include <sys/capability.h>
int main() {
// 获取当前进程的 capability
cap_t caps = cap_get_proc();
// 清除所有 capabilities
cap_clear(caps);
// 添加 cap_net_bind_service 到 Effective 和 Permitted
cap_value_t vals[] = {CAP_NET_BIND_SERVICE};
cap_set_flag(caps, CAP_EFFECTIVE, 1, vals, CAP_SET);
cap_set_flag(caps, CAP_PERMITTED, 1, vals, CAP_SET);
// 应用到当前进程
cap_set_proc(caps);
// 现在可以绑定 80 端口了
bind(sock, ...);
cap_free(caps);
return 0;
}编译:
gcc -o myapp myapp.c -lcap12.5 在 Docker 中设置 Capability
docker run --rm --cap-add=SYS_ADMIN ubuntu bash
docker run --rm --cap-add=NET_ADMIN ubuntu bash
docker run --rm --cap-drop=SYS_ADMIN ubuntu bash
docker run --rm \
--cap-add=SYS_ADMIN \
--cap-add=NET_ADMIN \
--cap-drop=ALL \
ubuntu bash
docker run --rm --privileged ubuntu bash12.6 在 runc config.json 中设置
{
"linux": {
"capabilities": {
"bounding": ["CAP_NET_BIND_SERVICE", "CAP_CHOWN"],
"effective": ["CAP_NET_BIND_SERVICE", "CAP_CHOWN"],
"permitted": ["CAP_NET_BIND_SERVICE", "CAP_CHOWN"],
"inheritable": ["CAP_NET_BIND_SERVICE"],
"ambient": ["CAP_NET_BIND_SERVICE"]
}
}
}12.7 在 Kubernetes 中设置
apiVersion: v1
kind: Pod
metadata:
name: mypod
spec:
containers:
- name: mycontainer
image: ubuntu
securityContext:
capabilities:
add:
- SYS_ADMIN
- NET_ADMIN
drop:
- ALL12.8 一图总结 Capability 配置方式
永久配置(文件级别):
setcap cap_xxx+ep /path/to/binary
↓
文件有 extended attributes (xattr)
↓
exec() 时内核自动加载到进程
临时配置(进程级别):
capsh / cap_set_proc()
↓
只影响当前进程和子进程
↓
进程退出就没了
容器内配置:
docker run --cap-add=xxx
↓
改写容器的 CapBnd/CapPrm
↓
runc 通过 libcontainer 应用到容器进程十三、总结
| 属性 | 含义 | 作用域 | 能否恢复 |
|---|---|---|---|
| CapEff | 当前生效的能力 | 进程级 | 随时 capset() |
| CapPrm | 已允许的能力(上限) | 进程级 | 随时 capset() |
| CapBnd | 全局上限(Bounding) | 系统级 | 不能 |
| CapInh | fork 时继承 | 进程级 | 随时修改 |
| CapAmb | exec 后保留 | 进程级 | 随时 capset() |
核心设计思想:最小权限原则。CapEff 应该始终保持最小集合,用完就丢;CapBnd 是防止提权的最后防线。
本文是 runc 源码解读系列的第六篇,重点剖析了 Linux Capabilities 的设计哲学与实验验证。