暗色模式

Linux capabilities 能力机制:从 setuid 特权到 setcap 最小授权

技术教程
2026-09-10
2
0
本文要点
  • setuid 的「全有或全无」问题:被攻破的程序直接等于 root。现代发行版已经在用更细的**文件能力**(capabilities)替代它——实测本机 /usr/bin/ping 是普通 755 权限 + cap_net_raw=ep 能力位,而 /bin/su 仍是 -rwsr-xr-x 的 setuid 程序
  • 能力 = 内核里的细粒度权限位:绑定 1024 以下端口只需要 cap_net_bind_service(第 10 位,0x400)这一个能力,不需要整个 root
  • 文件能力存放在可执行文件的扩展属性里:setcap 授予、getcap 查看、setcap -r 摘除、setcap -v 校验
  • 实测闭环:普通用户 nobody 直接跑 python3 绑定 80 端口报 PermissionError;给二进制 setcap cap_net_bind_service=ep 后立即成功,且该进程 CapEff 只多出 0000000000000400 一个能力——真正的「最小授权」
  • 踩坑:cp 等拷贝默认不保留文件能力(xattr 被剥离),备份、搬运、发布程序后要记得重新 setcapsetcap -v 可以离线校验能力是否就位

Linux capabilities 能力机制:从 setuid 特权到 setcap 最小授权

Unix 权限模型里流传最久的「祖传做法」是这样的:某个程序需要一点点特殊权限(比如 ping 要发 ICMP 报文、Web 服务要绑 1024 以下端口),就给可执行文件加 setuid 位——运行时整个进程变成 root。后果你也猜到了:程序的一个漏洞,就直接等于 root 被攻破,权限粒度是「全有或全无」。

Linux 从 2.2 起就提供了另一套机制:capabilities(能力位)。内核把 root 的特权拆成 40 来个独立小能力,可以精确到「这个程序只能绑低端口,别的什么都干不了」。但真正普及到普通软件包,是近几年发行版陆续把 ping 这类程序从 setuid 迁移到文件能力之后的事。

本文在一台 Ubuntu 22.04 云服务器(KVM,内核 5.15.0-30-generic)上真实走一遍:先看系统里 setuid 与文件能力并存的现状,再做一次「给普通用户最小授权绑 80 端口」的完整实验,最后踩一个备份时能力丢失的坑。全部命令实测执行,截图即真实输出。

现状:su 还是 setuid,ping 已经换了赛道

先看本机两个典型程序的文件属性与能力位:

ls -l /usr/bin/ping /bin/su && getcap /usr/bin/ping

同为提权程序:su 仍是 setuid,ping 已改用文件能力

输出里两行关键信息:

  • /bin/su 的权限是 -rwsr-xr-x——s 位就是 setuid:任何用户一执行它,进程就获得 root 身份;
  • /usr/bin/ping 只是普通的 -rwxr-xr-x,没有 s 位,但它带着一行 cap_net_raw=ep——这就是文件能力(file capabilities)。

ping 只需要「发原始 ICMP 报文」这一件事,对应能力 cap_net_raw(第 13 位)。Ubuntu/Debian 打包时把它写进了 ping 二进制的扩展属性 security.capability,于是:

谁执行 ping,谁就只获得 cap_net_raw 这一个能力,而不是整个 root。

顺带看看这台机器上还有哪些程序在用文件能力(getcap -r 递归扫描目录):

getcap -r /usr/bin 2>/dev/null | head -n 6
/usr/bin/ping cap_net_raw=ep
/usr/bin/mtr-packet cap_net_raw=ep

mtr-packet(mtr 追踪路由的底层进程)同样只要 cap_net_raw,同样放弃了 setuid。这正是发行版近年的迁移方向:能用单个能力解决的,就不再给整个 root。而 su 这种「切换用户身份」的程序语义上就是要获得任意用户权限,只能保留 setuid——它也因此一直是重点加固对象。

能力从哪来:运行中的进程看 Cap*

进程真正持有的能力记录在 /proc/self/status 里,按五个集合分开(实测 root shell):

grep Cap /proc/self/status
CapInh:    0000000000000000
CapPrm:    000001ffffffffff
CapEff:    000001ffffffffff
CapBnd:    000001ffffffffff
CapAmb:    0000000000000000

十六进制 000001ffffffffff 的 41 个位全为 1,表示这台机器上内核定义的全部能力。五个集合各管一段,简化版如下:

集合作用
CapInh(Inheritable)可继承能力:exec 新程序时可传给子进程的候选
CapPrm(Permitted)许可能力:进程当前「有资格用」的能力上限
CapEff(Effective)有效能力:内核做权限检查时真正看的那组位
CapBnd(Bounding)边界集合:exec 之后能力能保留的上限,只减不增
CapAmb(Ambient)环境能力:让非 root 进程 exec 后也能保住能力(systemd 服务常用)

root 的进程默认五个集合基本全满;普通进程则几乎全空——除非它执行了一个带文件能力的程序。exec 时内核会计算新集合,规则一句话概括:执行带 ep 文件能力的程序,新进程的 CapPrm/CapEff 就恰好是文件上写的那几个能力(还要与 CapBnd 取交集)。文件能力有 p(permitted)和 e(effective)两个字段,=ep 就是两者都置位,进程一启动就直接生效。

文件能力怎么读写:setcap 三兄弟

文件能力的存取命令都在 libcap2-bin 包里(Ubuntu 默认已装)。getcap 查看,setcap 授予/修改,setcap -r 摘除。先在 /tmp 里造一个实验副本(下一节的绑定实验还要用到它,复制到 /tmp 而不是动系统二进制):

cp -f /usr/bin/python3.10 /tmp/py80

然后就能随便折腾了:

setcap cap_net_bind_service=ep /tmp/py80 && getcap /tmp/py80
/tmp/py80 cap_net_bind_service=ep

setcap -v 则是「校验」而不是「设置」:它只检查文件上现有的能力是否等于指定值,不写入,很适合部署脚本里做断言:

setcap -v cap_net_bind_service=ep /tmp/py80
/tmp/py80: OK

主实验:给普通用户最小授权,绑定 80 端口

Linux 约定 1024 以下端口只有 root(准确说是持有 cap_net_bind_service 的进程)能绑定。我们把 python3 复制一份到 /tmp/py80,用系统自带的最小权限用户 nobody 去绑定 80 端口——没有能力位时,结果如预期:

cp -f /usr/bin/python3.10 /tmp/py80 && runuser -u nobody -- /tmp/py80 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",80)); print("bind 80 OK")'

无能力位:nobody 绑定 80 端口被拒

注意:nobody 用的是你自己复制的 /tmp/py80,根本没碰系统里的 /usr/bin/python3.10,所以接下来给它挂能力位是安全的,演示完删掉即可:

setcap cap_net_bind_service=ep /tmp/py80 && getcap /tmp/py80 && runuser -u nobody -- /tmp/py80 -c 'import socket,os; eff=[l.split()[1] for l in open("/proc/self/status") if l.startswith("CapEff")][0]; print("CapEff:",eff); s=socket.socket(); s.bind(("127.0.0.1",80)); print("bind 80 OK")'

setcap 授予后:CapEff 只多 0x400 一个能力,绑定成功

这段输出是整篇文章的核心,值得拆开看:

  1. getcap 确认能力已写到文件上:cap_net_bind_service=ep
  2. 进程自己读 /proc/self/statusCapEff 变成了 0000000000000400——整个 41 位能力空间里只有第 10 位被置上0x400 正好是 1 << 10,也就是 cap_net_bind_service
  3. 绑定成功。

如果换成 setuid 方案,这个 nobody 进程会拥有全部 root 能力;而文件能力方案下它只拿得到绑端口这一项。程序真的被攻破,攻击者能干的事也就只有绑端口。用 capsh 可以把十六进制位反解回能力名,自证 0x400 是什么:

capsh --decode=0000000000000400
0x0000000000000400=cap_net_bind_service

常见的几个能力位速查:

能力位号十六进制典型用途
cap_chown00x1修改文件属主
cap_net_bind_service100x400绑定 <1024 端口
cap_net_raw130x2000原始 socket / ICMP(ping)
cap_sys_admin210x200000挂载、命名空间等管理操作(接近 root)
cap_sys_chroot180x40000调用 chroot

完整 41 个能力的含义见文末的 capabilities(7) 手册

踩坑:cp 不保留文件能力

文件能力存放在扩展属性里,普通拷贝工具默认会把它丢掉。实测把系统里带能力的 ping 复制一份:

cp -f /usr/bin/ping /tmp/ping2 && getcap /tmp/ping2; ls -l /tmp/ping2
---
-rwxr-xr-x 1 root root 76672 Sep  9 21:09 /tmp/ping2

getcap 一行输出都没有——复制品的能力位是空的,只剩下普通 755 权限。这带来两个实际后果:

  • 备份/迁移后要重设:从备份恢复、scp 到新机器、打进镜像再解出来的程序,能力都会丢,表现为「以前能绑 80 现在突然 Permission denied」,重跑一遍 setcap 即可(所以部署脚本里记得带 setcap -v 校验);
  • 反过来也要警惕:如果你把带能力位的文件(比如带 ep 的 root 属主二进制)复制到 /tmp 这类任何人可写/可执行的目录,等于免费送别人一个提权工具——复制完及时清理,或干脆只对专用目录里的副本挂能力。

收尾与延伸

清理实验产物(文章里所有 setcap 都只动过 /tmp 下的副本,系统程序无一被改动):

rm -f /tmp/py80 /tmp/ping2

能力机制在今天的生态里无处不在,举两个最常见的延伸场景:

  • Docker:容器默认以 root 运行但只带少量能力,docker run --cap-drop ALL --cap-add NET_BIND_SERVICE ... 就是在做同一件事——把容器能力砍到只剩绑端口,见 Docker 运行时能力说明
  • systemd 服务:给服务单元写 AmbientCapabilities=CAP_NET_BIND_SERVICE,可以让非 root 服务进程不依赖任何文件能力就持有它,适合不方便改二进制的场景。

一句话总结:capabilities 把「root 权限」拆成了可枚举、可授予、可审计的最小单元。给程序授权时先想想它到底需要哪几个能力位,而不是无脑 setuid 或 sudo——这既是安全习惯,也是现代 Linux 运维的标配姿势。

发表评论

暂无评论,快来抢沙发吧!