本文要点
- 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 被剥离),备份、搬运、发布程序后要记得重新setcap;setcap -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
输出里两行关键信息:
/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=epmtr-packet(mtr 追踪路由的底层进程)同样只要 cap_net_raw,同样放弃了 setuid。这正是发行版近年的迁移方向:能用单个能力解决的,就不再给整个 root。而 su 这种「切换用户身份」的程序语义上就是要获得任意用户权限,只能保留 setuid——它也因此一直是重点加固对象。
能力从哪来:运行中的进程看 Cap*
进程真正持有的能力记录在 /proc/self/status 里,按五个集合分开(实测 root shell):
grep Cap /proc/self/statusCapInh: 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=epsetcap -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 用的是你自己复制的 /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")'
这段输出是整篇文章的核心,值得拆开看:
getcap确认能力已写到文件上:cap_net_bind_service=ep;- 进程自己读
/proc/self/status,CapEff变成了0000000000000400——整个 41 位能力空间里只有第 10 位被置上。0x400正好是1 << 10,也就是cap_net_bind_service; - 绑定成功。
如果换成 setuid 方案,这个 nobody 进程会拥有全部 root 能力;而文件能力方案下它只拿得到绑端口这一项。程序真的被攻破,攻击者能干的事也就只有绑端口。用 capsh 可以把十六进制位反解回能力名,自证 0x400 是什么:
capsh --decode=00000000000004000x0000000000000400=cap_net_bind_service常见的几个能力位速查:
| 能力 | 位号 | 十六进制 | 典型用途 |
|---|---|---|---|
cap_chown | 0 | 0x1 | 修改文件属主 |
cap_net_bind_service | 10 | 0x400 | 绑定 <1024 端口 |
cap_net_raw | 13 | 0x2000 | 原始 socket / ICMP(ping) |
cap_sys_admin | 21 | 0x200000 | 挂载、命名空间等管理操作(接近 root) |
cap_sys_chroot | 18 | 0x40000 | 调用 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/ping2getcap 一行输出都没有——复制品的能力位是空的,只剩下普通 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 运维的标配姿势。
评论 (0)
暂无评论,快来抢沙发吧!