传统 UNIX 的权限模型里,特权判定只有一条:UID 等于 0 就是 root,拥有全部权力;不等于 0,就什么特权都没有。这个二元设计的代价是权限粒度太粗:一个只想绑 80 端口的 Web 服务被要求以 root 运行,一次漏洞被利用就是整机沦陷;ping 需要构造原始套接字的特权,于是历史上被 setuid root——一个几百行的小工具常年持有完整 root 权力,2010 年它真的因此出过提权漏洞(CVE-2010-2526,iputils 的 setuid ping 缓冲区溢出)。Linux Capabilities(能力机制)就是对这个二元模型的手术:把「root 的权力」拆成 38 项独立的能力位(CAP_NET_BIND_SERVICE、CAP_NET_RAW、CAP_SYS_ADMIN……),进程与可执行文件都可以只持有需要的几项。这一篇讲透它的集合运算规则、文件能力的用法、容器场景的取舍,以及三个让工程师栽跟头的实战坑。

一、从 UID 0 到 38 项能力:设计动机与清单
内核里「root 的特权」从来不是一笔糊涂账,而是散布在几十个检查点:绑 1024 以下端口查一次、chroot 查一次、加载内核模块查一次、改动网络接口查一次……2.2 内核(1999)引入 capabilities 的做法是给这些检查点编号:每个特权操作对应一个能力位,进程持有的能力位集合决定它能过哪些检查。38 个能力位里,后端工程师最该认识的有这些(/usr/include/linux/capability.h 有完整清单):CAP_NET_BIND_SERVICE(绑 1024 以下端口)、CAP_NET_RAW(原始套接字,ping/tcpdump 的根基)、CAP_SYS_ADMIN(mount、改 namespace、大量杂项——俗称「能力界的 root」,安全审计的头号目标)、CAP_DAC_OVERRIDE(绕过文件读写执行权限检查)、CAP_CHOWN/CAP_FOWNER(改任意文件属主/无视属主限制)、CAP_SYS_PTRACE(ptrace 其他进程)、CAP_KILL(给任意进程发信号)、CAP_SYS_TIME(改系统时钟)、CAP_NET_ADMIN(改网络配置:路由、防火墙、接口)。一个典型 Web 服务的最小能力集往往只有 NET_BIND_SERVICE 一项——比完整的 root 权力小了 37 项,攻击面天差地别。
先在机器上直观感受一下这些位的存在:
$ capsh --print # 查看当前 shell 的能力集合
Current: cap_chown,cap_dac_override,...,cap_net_raw,...=ep
Bounding set = cap_chown,cap_dac_override,...
$ capsh --decode=000001ffffffffff # 解码十六进制串
0x000001ffffffffff=cap_chown,cap_dac_override,cap_dac_read_search,...
capsh --print 输出的 Current 是当前进程实际生效的能力集合,Bounding set 是这棵进程树的「天花板」(后面详述)。普通用户的 Current 里通常只有 cap_net_raw=ep 一项——这就是为什么普通用户能跑 ping(ping 探测需要 ICMP 原始套接字),却不能 mount 文件系统。
二、五个集合:能力的继承与运算规则
capabilities 不是单个开关,而是进程上的五个集合,理解它们的运算规则是用好这套机制的前提。Permitted(允许集):进程最多能拥有哪些能力,是其余集合的来源上限。Effective(生效集):内核检查点真正查的集合——某个特权操作能不能过,看的是 effective 里有没有对应位。Inheritable(可继承集):execve 之后哪些能力位允许被「传递」给新程序(对新程序而言成为其 permitted 的候选)。Bounding(边界集):进程及其所有后代的 permitted 集合的天花板,任何途径(包括 setuid root)都取回已被边界集剔除的能力——这是「降权后无法再升权」的单向阀。Ambient(环境集,4.3 内核新增):非特权程序也能保留的能力位,解决「普通用户身份 + 需要少量能力」的组合场景。execve 时新进程的能力按一条固定公式计算,非特权程序是:pP' = (X & fI) | (fP & fE)(新 permitted 来自文件的可继承与 permitted 位);特权(setuid-root 或文件带能力)程序是:pP' = (X & fI) | fP、pE' = (fX ? pP' : fP)——日常只需要记住结论:程序要真正用上某项能力,它必须在 effective 里;要跨 exec 保留,要靠 inheritable + 文件能力(或 ambient)。这套规则画成图更清楚:
flowchart TD
A["父进程五集合<br/>P/I/E/B(Ambient)"] -->|fork| B["子进程:原样继承五集合"]
B -->|execve 新程序| C{"可执行文件的文件能力<br/>getcap 查到的 xattr"}
C -- "无能力文件 + 非 setuid" --> D["effective 清零(drop 掉特权)<br/>成为普通进程"]
C -- "带文件能力" --> E["新 permitted = (边界集 ∩ 可继承集 ∩ 文件I) ∪ 文件P<br/>文件有 +e 位则对应能力进 effective"]
C -- "setuid-root" --> F["permitted = 边界集全量<br/>(被剔过的能力永远回不来)"]
E --> G["内核检查点查 effective<br/>bind 80 端口 → 查 CAP_NET_BIND_SERVICE"]
工程上最常用的两个动作都能从图上找到依据。给二进制加能力(绑 80 端口的例子):setcap cap_net_bind_service=+ep ./webserver——+ep 的意思是把这项能力加入文件的 permitted 与 effective 位集合,程序被任何用户执行都能生效;验证用 getcap ./webserver,本质是文件扩展属性 security.capability。给容器/进程减能力(安全加固):systemd 单元里配 CapabilityBoundingSet=CAP_NET_BIND_SERVICE,docker 里 --cap-drop=ALL --cap-add=NET_BIND_SERVICE——drop ALL 把边界集清到只剩指定项,此后即使进程被攻破、攻击者也无法 execve 出一个「setuid 回 root」的逃逸路径,因为边界集是单向阀。
三、ping 的进化史:文件能力的实战样本
ping 是理解 capabilities 的最佳教材,因为它完整走过了「特权工具」的三种实现形态。第一阶段:setuid root——ls -l /bin/ping 显示 -rwsr-xr-x root root,任何用户执行都以 root 身份运行,只为了那一个 CAP_NET_RAW;这在 2010 年之前是标准做法,代价是 ping 的一切漏洞都直接等于 root 提权。第二阶段:文件能力——发行版开始改用 setcap cap_net_raw=ep /usr/bin/ping(getcap /usr/bin/ping 可以验证),ping 不再是 setuid,只持有 NET_RAW 一项;同一时期 Arch、Fedora 等还尝试过第三条路:把 ping 装进 iputils 的非特权模式(ping_group_range sysctl,把 ICMP 的 unprivileged 端口范围放宽给普通组,内核代为发送 ICMP echo 而不建原始套接字)——Debian/Ubuntu 至今采用文件能力方案。第三阶段:ambient 能力——4.3 内核补齐了「普通用户 + 非特权文件也要保留能力」的缺口,此前的规则里,非 root 用户执行的程序(除非文件带能力)execve 后 effective 会被清空,脚本场景(shell 调用带能力的工具)经常断链;ambient 让进程可以在 exec 前把少量能力放进环境集,普通程序也能保留。
动手实验一遍最直观(在容器或非关键环境尝试):
$ id -u; capsh --print | grep Current # 普通用户,几乎没有能力
$ cp /usr/bin/python3.11 /tmp/pymon # 复制一份解释器
$ setcap cap_net_bind_service=+ep /tmp/pymon
$ getcap /tmp/pymon
/tmp/pymon cap_net_bind_service=ep
$ /tmp/pymon -c 'import socket; s=socket.socket(); s.bind(("0.0.0.0",80)); print("ok")'
ok # 普通用户绑上了 80 端口
$ /tmp/pymon -c 'import os; print(os.getuid())'
1000 # UID 还是 1000,不是 root
这就是「细粒度特权」的直接演示:进程持有 NET_BIND_SERVICE 一项能力,但 UID 是普通用户——它能绑 80 端口,却读不了 /etc/shadow、改不了系统时间、kill 不了别人的进程。对比 setuid root 方案,能力面的缩减是 38:1。
四、三个高频坑:动态库、归档丢属性、setuid 交互
capabilities 的实战事故集中在三个坑。坑一:setcap 之后动态库搜索路径失效。内核把「带文件能力的程序」视同 setuid 程序处理(安全等价:两者都是「提权执行」),而 setuid 程序的 LD_LIBRARY_PATH、LD_PRELOAD 会被动态链接器安全机制忽略——这是刻意的防注入设计(否则任何人都能 LD_PRELOAD 一个假 libc 窃取特权)。现象是:程序直接跑没事,一旦 setcap 就报 error while loading shared libraries,因为脚本里写的 LD_LIBRARY_PATH=/opt/app/lib 不再生效。解法有三个:用静态链接;把库路径写进 /etc/ld.so.conf.d/ 并 ldconfig(系统级可信路径不受影响);或用 rpath(编译期 -Wl,-rpath,/opt/app/lib)把路径焊死在二进制里。坑二:归档/传输丢 xattr。文件能力存在扩展属性里,而 tar 默认不保留、rsync 需要 --xattrs 参数、scp/某些 CI 的 artifact 机制干脆不支持——「本地测试好好的,发布到服务器就不能绑端口了」十有八九是这个。解法:部署流水线里归档后统一 setcap(写进 Dockerfile 或部署脚本),别依赖打包工具传属性;排查用 getcap <binary>,没有输出就是属性丢了。坑三:setuid-root 与能力的交互规则。setuid-root 的程序 execve 后 permitted 被拉满(受边界集约束),但很多现代程序(如 docker、systemd)会主动 prctl(PR_CAPBSET_DROP) 清边界集或 capset 清 effective——理解「setuid root ≠ 全能」很重要:一个被 --cap-drop=ALL 加固过的容器里,即使进程是 UID 0,也绑不了 80 端口以外的「被剔除能力」对应的操作。反过来,边界集是单向阀的特性也让它成为纵深防御的关键层:哪怕应用被 RCE,攻击者拿到的也只是边界集内那几项能力,无法通过 setuid 提权回到全量 root。
五、容器场景:--cap-drop 的经济学
容器是 capabilities 用武之地最集中的应用场景。Docker/K8s 默认给容器一组「默认白名单能力」(Docker 默认约 14 项,含 NET_BIND_SERVICE、NET_RAW、CHOWN 等,剔除了 SYS_ADMIN、SYS_PTRACE 等高危项),但这套默认值仍比多数应用的需求大——安全基线(CIS Docker Benchmark 等)的建议是 --cap-drop=ALL 后按需 --cap-add。给一个典型 Web 应用的账:默认 14 项能力,实际需要 1~2 项(NET_BIND_SERVICE,若配 80 端口),削减幅度超过 85%。对 K8s 的 Pod spec 来说是:
securityContext:
runAsNonRoot: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
注意与 runAsNonRoot: true 的配合:后者把容器进程的 UID 钉死为非 0,配合最小能力集,进程既不是 root、又只有绑端口这一项特权——这是容器安全的「金标准」配置。还有两个容器特有的细节:其一,无特权端口解法近年流行改用 net.ipv4.ip_unprivileged_port_start=0(在容器 netns 内设置,Linux 4.11+ 支持非特权设置),把「1024 以下端口」的检查直接关掉,容器进程连 NET_BIND_SERVICE 都不需要——本质是把「按能力放行」换成「按网络命名空间放行」,安全边界从进程级退到容器级,取舍要自己评估。其二,CAP_NET_RAW 常被遗忘但危害不小:持有它的进程可以构造任意 IP 包、监听全部流量(原始套接字可以收本机网络上的包),在共享网络的多租户环境里相当于旁路监听权——cap-drop ALL 时它也在被剔除之列,这正是基线要求 drop ALL 的原因之一。
小结
Capabilities 把「UID 0 的全部权力」拆成 38 项可独立授予、继承、剔除的能力位,进程与可执行文件都能持有子集。五个集合(permitted/effective/inheritable/bounding/ambient)的运算规则决定了能力的生死与传递:内核检查点只看 effective,跨 exec 的传递靠文件能力或 ambient,bounding 是「降权后无法复权」的单向阀,是容器与 systemd 加固的基石。文件能力的实战三坑——setcap 后 LD_LIBRARY_PATH 失效(动态链接器把带能力程序视同 setuid)、归档传输丢 xattr(部署脚本补 setcap)、setuid 与能力的边界集交互——每一个都能在生产环境折腾一整天,本质都是「提权执行」的安全语义在起作用。容器场景给出可直接落地的结论:--cap-drop=ALL 后按需 add,配合 runAsNonRoot 组成金标准;或者评估 ip_unprivileged_port_start=0 换取更简洁的端口方案。权限体系的演进方向从来都是「把大权限切成小权限、把人治变成清单」——capabilities 是这条路在 Linux 内核里走出的二十五年,值得每个后端工程师把它从「听说过」变成「用得上」。
读者留言
COMMENTS 暂无还没有留言,来说第一句?