Linux-22 凭证模型:内核为什么只认数字,四套 uid 从哪来
10 篇讲了 uid/gid 的操作,这一篇讲它们为什么长成这样 —— 特别是那个让无数人困惑的问题:为什么一个进程需要四套 uid?
先看五个问题:
- 为什么需要
ruid/euid/suid/fsuid四套 uid?两套不够吗? setuid()的语义为什么这么怪 —— root 调用和非 root 调用的行为完全不同?capabilities把 root 拆成了 40 多个能力,为什么没能取代 SUID?- 为什么
setuid()的返回值必须检查?历史上有什么著名漏洞? - 容器里的 root 和宿主机的 root 是同一个吗?
1. 凭证是进程的属性
1.1 内核里的 cred
10 篇说过「内核只认数字」。在内核里这些数字装在一个叫 cred 的结构体里,它属于进程,不属于用户:
// include/linux/cred.h(简化)
struct cred {
kuid_t uid; // real uid —— 我本来是谁
kgid_t gid; // real gid
kuid_t suid; // saved uid —— 我曾经合法拥有过谁的身份
kgid_t sgid;
kuid_t euid; // effective uid —— ✅ 权限检查【实际用】的就是它
kgid_t egid;
kuid_t fsuid; // fs uid —— 文件系统访问专用
kgid_t fsgid;
kernel_cap_t cap_inheritable; // ┐
kernel_cap_t cap_permitted; // ├─ capabilities 的五个集合(第 5 章)
kernel_cap_t cap_effective; // │
kernel_cap_t cap_bset; // │
kernel_cap_t cap_ambient; // ┘
struct user_namespace *user_ns; // ✅ 属于哪个 user namespace(第 6 章)
struct group_info *group_info; // 附加组列表
...
};
cred 是 RCU 保护的不可变对象:变更凭证时内核会创建一份新的 cred 然后原子替换指针,而不是就地修改 —— 这样其他 CPU 上正在做权限检查的代码不会看到「改了一半」的凭证。
1.2 权限检查的实际代码
// fs/namei.c —— 「文件权限检查」的核心(03/10 篇讲的三选一规则)
static int acl_permission_check(struct mnt_idmap *idmap, struct inode *inode, int mask)
{
unsigned int mode = inode->i_mode;
if (likely(uid_eq(current_fsuid(), i_uid_into_vfsuid(...))))
// ^^^^^^^^^^^^^^^^ 注意:用的是 fsuid,不是 euid!
mode >>= 6; // ① 属主匹配 -> 用 user 那 3 位,结束
else {
if (in_group_p(...))
mode >>= 3; // ② 属组匹配 -> 用 group 那 3 位
} // ③ 否则用 other 那 3 位
if ((mask & ~mode & (MAY_READ|MAY_WRITE|MAY_EXEC)) == 0)
return 0;
return -EACCES;
}
// root 的「特殊通道」在现代内核里是这样实现的:
int generic_permission(...)
{
...
if (capable(CAP_DAC_OVERRIDE)) // ✅ 检查的是 capability,不是硬编码 uid == 0
return 0;
...
}
两个重要细节:
① 文件权限检查用的是 fsuid,不是 euid(见 2.4)
② 「root 无视权限」在现代内核里是通过 CAP_DAC_OVERRIDE 实现的,
而不是硬编码的 uid == 0 判断 —— 这是 capabilities 改造的结果(第 5 章)
uid==0 的进程只是【默认拥有全部 capability】而已
2. 为什么需要四套 uid
2.1 一套 uid 的世界
假设进程只有一个 uid,看看会发生什么:
场景:passwd 程序(SUID root,10 篇 5.1)
① 用户 lhx(uid=1000)执行 /usr/bin/passwd
② SUID 生效,进程的 uid 变成 0
③ 程序需要写 /etc/shadow -> ✅ 可以,因为 uid=0
④ 但程序还需要知道「是谁在改密码」 -> ❌ 原来的 1000 已经丢了!
第一个问题:需要记住「我本来是谁」。 于是有了两套:
ruid(real uid) = 我本来是谁(1000)—— 用于审计与身份判断
euid(effective uid) = 我现在有什么权限(0)—— ✅ 权限检查用这个
2.2 两套还不够:临时降权的需求
场景:一个 SUID root 程序,大部分时间不需要特权
安全最佳实践是「用完就放弃特权」(最小权限原则)
① 启动时 euid=0,做需要特权的事(绑定 80 端口、读密钥文件)
② 降权 euid=1000 -> 之后的漏洞不会造成 root 级损害
③ 但后面又需要一次特权操作(比如日志轮转后重新打开文件)
④ 想把 euid 改回 0 -> ❌ 现在 euid=1000,凭什么让你改回 0?
核心矛盾:如果允许「euid=1000 的进程把 euid 改成 0」,那任何普通程序都能提权。但 SUID 程序确实需要这个能力。
解法:第三套 uid —— saved uid。
suid(saved set-user-ID)= 我「曾经合法拥有过」的身份
规则:非特权进程可以把 euid 改成 ruid 或 suid 中的任意一个,但不能改成别的值。
① exec SUID root 程序:ruid=1000, euid=0, suid=0
(内核把 euid 的值也存进 suid,作为「你合法拥有过 0」的凭据)
② 临时降权 seteuid(1000):ruid=1000, euid=1000, suid=0
^^^^^^ 保留着 0
③ 需要时升回 seteuid(0):✅ 允许,因为 suid==0
④ 彻底放弃 setresuid(1000,1000,1000):suid 也变成 1000
此后【永远无法】再变回 0 ← 这才是真正的「放弃特权」
这就是开篇第一问的核心:三套 uid 分别承担「身份记录」「权限判定」「可恢复的凭据」三个不同职责,缺一不可。
2.3 状态转换
exec 一个 SUID root 程序(文件属主 root + SUID 位)
+---------------------------------------------+
| ruid=1000 euid=0 suid=0 | <- 完全特权
+---------------------------------------------+
| seteuid(1000) 临时降权
v
+---------------------------------------------+
| ruid=1000 euid=1000 suid=0 | <- 暂时无权,但【可以回去】
+---------------------------------------------+
| seteuid(0) 升回 ---------------------+
| | (可反复来回)
| <------------------------------------+
|
| setresuid(1000,1000,1000) 永久放弃
v
+---------------------------------------------+
| ruid=1000 euid=1000 suid=1000 | <- ✅ 不可逆
+---------------------------------------------+
# 从用户态观察四套 uid
grep -E '^(Uid|Gid|Groups)' /proc/self/status
# Uid: 1000 1000 1000 1000
# ^^^^ real ^^^^ effective ^^^^ saved ^^^^ fs
# Gid: 1000 1000 1000 1000
# Groups: 1000 27 999
# 看一个正在运行的 SUID 程序(sudo 会话)
sudo cat /proc/$(pgrep -n sudo)/status 2>/dev/null | grep -E '^(Uid|Gid)'
# Uid: 1000 0 0 0
# ^^^^ ruid 保留着调用者 ^^ euid/suid/fsuid 都是 0
2.4 第四套:fsuid 的历史来源
fsuid 是 Linux 特有的(不在 POSIX 里),它的存在纯属一个历史需求:
1990 年代用户态实现的 NFS 服务器:
它需要「以客户端用户的身份」访问文件(让内核按那个用户的权限检查),
但【不能真的把 euid 改成那个用户】——
因为 euid 还控制着「谁能给我发信号」「谁能 ptrace 我」。
如果 nfsd 把 euid 改成 1000 来访问文件,那么本地的 uid 1000 用户
就能给 nfsd 发信号甚至 ptrace 它(euid 相同即可)-> 严重漏洞
解法:把「文件访问用的身份」单独拆出来 = fsuid
setfsuid(1000); // 只影响文件权限检查,不影响信号/ptrace
// 打开客户端请求的文件
setfsuid(0); // 改回来
今天 fsuid 基本没人显式用了(现代 NFS 在内核里实现),但它始终跟随 euid 变化(setuid/seteuid 都会同步修改它),所以你几乎感觉不到它的存在。
这是一个典型的「历史需求留下的永久接口」 —— 18 篇讲的 ABI 承诺意味着它永远不会被删除。
3. setuid 家族的语义地狱
3.1 开篇第二问:setuid 为什么不对称
int setuid(uid_t uid);
这个函数的行为取决于调用者是否特权 —— 这是 Unix 里最反直觉的接口之一:
| 调用者 | setuid(x) 的效果 |
|---|---|
euid == 0(或有 CAP_SETUID) |
ruid、euid、suid 全部设为 x ← 不可逆的完全切换 |
| 非特权 | 只改 euid,且 x 必须等于 ruid 或 suid 之一;ruid 与 suid 不变 |
// 场景 A:特权进程(euid=0),初始 ruid=0 euid=0 suid=0
setuid(1000);
// 结果:ruid=1000 euid=1000 suid=1000 ← 全变了,【永远回不去 root】
// 场景 B:SUID root 程序降权后,初始 ruid=1000 euid=0 suid=0
seteuid(1000); // ruid=1000 euid=1000 suid=0
setuid(0); // ✅ 允许(因为 suid==0),结果 ruid=1000 euid=0 suid=0
// 只改了 euid
为什么设计成这样? 因为历史上 setuid 是唯一的接口,它必须同时满足两个矛盾的需求:
需求 A(守护进程):我是 root,要【永久】变成 nobody 再也回不来
需求 B(SUID 程序):我是 SUID root,要【临时】切换并且能切回来
于是靠「调用者是否特权」区分这两种语义 —— 一个接口两种行为。
这是典型的「接口过载」,也是无数安全漏洞的来源。
3.2 后来的补救:更明确的接口
int seteuid(uid_t euid); // 只改 euid
int setreuid(uid_t ruid, uid_t euid); // 改两个(-1 表示不变)
int setresuid(uid_t ruid, uid_t euid, uid_t suid); // ✅ 三个都显式指定
int getresuid(uid_t *ruid, uid_t *euid, uid_t *suid); // 读取三个值
推荐一律用 setresuid():没有任何隐含行为,三个值全部显式写出,读代码的人一眼知道结果。
3.3 顺序陷阱:必须先 setgid
// ❌ 错误顺序
setuid(1000); // 先放弃了 root
setgid(1000); // ❌ 失败!非特权进程不能随意改 gid
// 结果:uid 变了但 gid 还是 0 -> 仍然拥有 root 组的权限
// ✅ 正确顺序
setgid(1000); // 先改组(此时还是 root,有权限)
setgroups(0, NULL); // 清空附加组(见第 4 章)
setuid(1000); // 最后改用户
记忆方法:先做需要权限的事(改组、清附加组),最后才放弃权限本身(改用户)。
3.4 开篇第四问:不检查返回值的历史漏洞
setuid() 可能失败,而失败时进程仍然是 root:
// 💀 经典漏洞模式
setuid(getuid()); // 以为降权了
system("/bin/sh"); // 如果 setuid 失败,这是一个 root shell!
setuid 会失败的真实原因:
① RLIMIT_NPROC 限制(最著名的一类)
目标用户的进程数已达上限 -> setuid 返回 EAGAIN
攻击者可以【故意】把自己的进程数打满,让 SUID 程序的降权失败
→ 2000 年代有多个 CVE 是这个模式(sendmail、Apache suEXEC 等)
② 缺少 CAP_SETUID(capabilities 模型下,即使 uid==0 也可能被剥夺了这个能力)
③ user namespace 里 uid 未映射(第 6 章)-> EINVAL
④ LSM(SELinux/AppArmor)策略拒绝
// ✅ 正确写法:检查返回值 + 二次验证
if (setresuid(uid, uid, uid) != 0) {
syslog(LOG_ERR, "降权失败: %m");
_exit(1); // ✅ 必须退出,绝不能继续以 root 运行
}
uid_t r, e, s;
getresuid(&r, &e, &s);
if (r != uid || e != uid || s != uid) _exit(1); // ✅ 确认三个都变了
if (setuid(0) == 0) _exit(1); // ✅ 确认真的回不去了
# 复现 RLIMIT_NPROC 导致失败的条件
ulimit -u 5 # 把进程数限制设得很低,然后跑满
# 此时以该用户身份 setuid 会返回 EAGAIN
Go 里的处理:
// ⚠️ 不要自己调 syscall.Setuid —— 它在多线程程序里语义有问题
// (POSIX 的 setuid 只作用于调用线程,而 Go 的 goroutine 会在线程间迁移;
// Go 1.16+ 会用信号让所有线程一起改,但仍然容易出错)
// ✅ 正确做法:在 exec 时指定身份(21 篇的 SysProcAttr)
cmd := exec.Command("/usr/local/bin/worker")
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{
Uid: 1000, Gid: 1000,
Groups: []uint32{}, // ✅ 显式清空附加组
},
}
// 内核在 fork 后的子进程里(此时单线程)完成降权,然后 exec —— 安全且明确
// ✅ 或者根本不在程序里降权,交给 systemd(10 篇 8 章):User=myapp
4. 组凭证与 setgroups
4.1 忘记 setgroups 的漏洞
组也有四套(gid/egid/sgid/fsgid),外加一个附加组列表。而降权代码最常见的严重错误就是忘了清空附加组:
// ❌ 看起来完整的降权
setgid(1000);
setuid(1000);
// 💀 但【附加组没有清空】!
# 为什么这是严重问题
ls -l /dev/sda /etc/shadow
# brw-rw---- 1 root disk 8, 0 ... /dev/sda <- disk 组可读写整个磁盘
# -rw-r----- 1 root shadow 1654 ... /etc/shadow <- shadow 组可读密码哈希
# root 运行时的附加组可能包含 disk(6)、shadow(42)、docker(999)…
# 降权后 euid 是 1000,但【仍然在 disk 组里】
# -> dd if=/dev/sda 读走整个磁盘,完全绕过文件权限
# -> 在 docker 组里 = 等于 root(10 篇 7.5 讲过)
# 检查一个进程的附加组
grep Groups /proc/$(pgrep -n myapp)/status
# Groups: 1000 <- ✅ 只有主组,正确
# Groups: 0 6 42 1000 <- ❌ 残留了 root/disk/shadow
// ✅ 完整正确的降权序列
if (setgroups(0, NULL) != 0) { perror("setgroups"); _exit(1); }
// ^^^^^^^^^^^^^^^^^ 必须在放弃 root 【之前】做(它需要 CAP_SETGID)
if (setresgid(gid, gid, gid) != 0) { perror("setresgid"); _exit(1); }
if (setresuid(uid, uid, uid) != 0) { perror("setresuid"); _exit(1); }
// 或者按目标用户【应有】的组列表设置(更常见的需求)
struct passwd *pw = getpwnam("myapp");
if (initgroups(pw->pw_name, pw->pw_gid) != 0) _exit(1); // 读 /etc/group
if (setresgid(pw->pw_gid, pw->pw_gid, pw->pw_gid) != 0) _exit(1);
if (setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) != 0) _exit(1);
4.2 降权检查清单
[ ] setgroups(0, NULL) 或 initgroups() —— 在放弃 root 之前
[ ] setresgid() 三个值都设
[ ] setresuid() 三个值都设
[ ] 顺序:组 -> 附加组 -> 用户
[ ] 每一步都检查返回值
[ ] 事后用 getresuid/getgroups 二次验证
[ ] 关闭不该继承的 fd(21 篇 2.2)
[ ] 清理危险环境变量(LD_PRELOAD、LD_LIBRARY_PATH、PATH、IFS)
5. capabilities:把 root 拆开
5.1 动机:SUID 太粗
问题:ping 需要发送 ICMP 原始包 -> 只需要一个能力:CAP_NET_RAW
传统做法:给 ping 加 SUID root
后果:ping 运行时拥有【全部】root 权限 ——
它能读 /etc/shadow、改任何文件、加载内核模块……
一旦 ping 有漏洞(历史上确实有过),就是完整的 root 提权
核心矛盾:程序只需要一个特定能力,却被给了全部特权。
capabilities 的思路:把 root 的权限拆成独立的、可单独授予的能力。
grep Cap /proc/self/status
# CapInh: 0000000000000000 Inheritable
# CapPrm: 0000000000000000 Permitted
# CapEff: 0000000000000000 Effective
# CapBnd: 000001ffffffffff Bounding
# CapAmb: 0000000000000000 Ambient
capsh --decode=000001ffffffffff | head -5 # 解码成可读的名字
grep -c '^#define CAP_' /usr/include/linux/capability.h # 40+ 个
5.2 重要的几个
| capability | 允许做什么 | 典型使用者 |
|---|---|---|
CAP_NET_BIND_SERVICE |
绑定 < 1024 的端口 | nginx、Go 服务(10 篇 8 章用过) |
CAP_NET_RAW |
原始 socket(ICMP、抓包) | ping、tcpdump |
CAP_NET_ADMIN |
配置网络(接口、路由、防火墙) | ip、iptables |
CAP_DAC_OVERRIDE |
无视文件读写权限(「root 能读所有文件」的来源) | 备份工具 |
CAP_CHOWN / CAP_FOWNER |
改任意属主 / 无视「必须是属主」的检查 | — |
CAP_SETUID / CAP_SETGID |
任意改变 uid/gid | login、su |
CAP_KILL |
给任意进程发信号 | — |
CAP_SYS_CHROOT |
chroot() |
容器运行时 |
CAP_SYS_PTRACE |
ptrace 任意进程 | gdb、strace |
CAP_SYS_TIME |
改系统时间 | chrony |
CAP_SYS_MODULE |
加载内核模块(≈ 完全 root) | modprobe |
CAP_SYS_ADMIN |
一大堆杂项(mount、setns、quota、swapon…)≈ 半个 root | 容器运行时 |
CAP_BPF / CAP_PERFMON |
eBPF / perf(5.8+ 从 CAP_SYS_ADMIN 拆出来的) |
观测工具(38 篇) |
CAP_SYS_NICE |
提高优先级、设实时调度 | 低延迟应用 |
CAP_IPC_LOCK |
mlock 锁定内存 |
数据库 |
5.3 五个集合:为什么这么复杂
| 集合 | 含义 |
|---|---|
Permitted(P) |
我「可以拥有」的能力上界 —— 能从这里往 Effective 加 |
Effective(E) |
当前实际生效(内核检查的就是它) |
Inheritable(I) |
exec 时可传给子进程(需配合文件的 Inheritable) |
Bounding(B) |
绝对上限,一旦移除任何方式都无法再获得(不可逆) |
Ambient(A) |
Linux 4.3+ 新增,能跨 exec 保留而不需要文件 capability |
为什么需要五个? 因为 exec 时的传递规则必须同时满足几个矛盾的需求:
需求 1:SUID root 程序要能获得全部能力 -> 需要「文件能授予能力」
需求 2:普通程序 exec 后不能凭空获得能力 -> 需要「默认清空」
需求 3:容器要能限制「无论如何都不能有的能力」 -> 需要 Bounding(不可逆上限)
需求 4:wrapper 脚本要能把能力传给子进程 -> 需要 Inheritable
需求 5:但 Inheritable 必须配合文件标记,而【脚本文件不能带 capability】
-> 于是 2015 年才加了 Ambient 来补这个洞
exec 时的计算规则(简化):
P' = (Bounding & 文件_P) | (文件_I & I) | A
E' = 文件_E ? P' : A
I' = I
A' = (文件有 caps || no_new_privs) ? 0 : A
→ 这个公式的复杂度本身就说明了问题:capabilities 的可用性很差
5.4 实操:给 Go 服务加 CAP_NET_BIND_SERVICE
# 场景:Go 服务要监听 80 端口,但不想用 root 跑(10 篇 8 章提过)
# ── 方案一:文件 capability ──
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/myapp
# ^^^ e=effective p=permitted
getcap /usr/local/bin/myapp
# /usr/local/bin/myapp cap_net_bind_service=ep
sudo -u myapp /usr/local/bin/myapp # ✅ 普通用户也能绑 80 端口
sudo setcap -r /usr/local/bin/myapp # 移除
# ⚠️ 三个坑
# ① 文件 capability 存在【扩展属性】里,普通 cp 会丢失(04 篇讲过要 -a 才保留 xattr)
getfattr -n security.capability /usr/local/bin/myapp
cp /usr/local/bin/myapp /tmp/ && getcap /tmp/myapp # 空的!
# ② 文件系统必须支持 xattr
# ③ 有 file capability 的程序被视为「特权程序」:
# LD_PRELOAD/LD_LIBRARY_PATH 被忽略、ptrace 受限、core dump 默认关闭
# ── 方案二:systemd(推荐,不改文件属性)──
# [Service]
# AmbientCapabilities=CAP_NET_BIND_SERVICE
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE ← ✅ 同时限制上界
# NoNewPrivileges=yes
systemctl show myapp -p AmbientCapabilities -p CapabilityBoundingSet
# ── 方案三:干脆不需要低端口 ──
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80 # ✅ 最简单:降低门槛
# 或者服务听 8080,前面挂 nginx 反代
# 观察一个进程的能力
sudo capsh --decode=$(grep CapEff /proc/$(pgrep -n myapp)/status | awk '{print $2}')
sudo pscap | head # libcap-ng-utils 包
5.5 开篇第三问:为什么没取代 SUID
capabilities 在 1999 年(内核 2.2)就引入了,二十多年后 SUID 依然普遍存在。五个原因:
① 生态惯性(18 篇讲的规律)
SUID 是 POSIX 的一部分,所有 Unix 都支持;capabilities 是 Linux 特有的。
跨平台软件不能依赖它。
② 五个集合的规则太复杂
上面那个 exec 传递公式,绝大多数开发者理解不了也不想理解。
相比之下 chmod u+s 只需要一个动作。
③ Ambient 集合来得太晚(2015,内核 4.3)
在此之前「让普通用户执行 wrapper 脚本并把能力传下去」几乎做不到 ——
因为 Inheritable 必须配合文件 capability,而【脚本文件不能带 capability】。
这个缺陷让 capabilities 在很多场景根本用不了。
④ 有些 capability 本身就 ≈ root(拆分在这些能力上是名义上的)
CAP_SYS_ADMIN 垃圾桶:mount、setns、swapon、quota… 几十种操作
CAP_SYS_MODULE 能加载内核模块 = 完全控制内核
CAP_DAC_OVERRIDE 能读写任何文件 = 能改 /etc/shadow 和 /etc/sudoers
CAP_SYS_PTRACE 能 ptrace 任何进程 = 能往 root 进程注入代码
⑤ 文件 capability 的运维负担
存在 xattr 里 -> cp/tar/rsync 默认丢失、容器镜像层可能不保留、
包管理器升级后要重设 -> 实践中很容易「悄悄失效」
今天的实际格局:
容器 / systemd 场景 -> ✅ capabilities 用得很好(由运行时统一管理,不依赖文件 xattr)
传统 SUID 程序 -> 仍是 passwd/sudo/su/mount 的实现方式
新写的服务 -> 用 systemd 的 AmbientCapabilities,或干脆避免需要特权
# Docker 默认丢弃了大部分危险 capability(比 root 少很多)
docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE myimage # ✅ 最小权限
docker run --rm --privileged myimage # ❌ 全部 capability + 设备访问 = 等于宿主 root
6. user namespace:重新定义 root
6.1 开篇第五问:容器里的 root
没有 user namespace 时:容器里的 root 就是宿主机的 root。
# 10 篇 1.1 验证过的现象
docker run --rm -v /tmp/x:/x alpine touch /x/f
ls -l /tmp/x/f
# -rw-r--r-- 1 root root 0 ... /tmp/x/f <- 宿主机上属主是【真正的 root】
# 因为容器里的 uid 0 和宿主机的 uid 0 是同一个数字,而内核只认数字
user namespace 引入了 uid 映射:
宿主机 uid 空间 容器内 uid 空间(新 namespace)
+-------------------+ +----------------------+
| 100000 | <-----> | 0 (容器里的 root) |
| 100001 | <-----> | 1 |
| ... | | ... |
| 165535 | <-----> | 65535 |
+-------------------+ +----------------------+
容器里的进程认为自己是 uid 0(在该 ns 内有全部 capability),
但它在宿主机上实际是 uid 100000 —— 一个毫无特权的普通用户
# 手动创建一个 user namespace 验证
unshare --user --map-root-user bash
id
# uid=0(root) gid=0(root) groups=0(root) <- "我是 root"
cat /proc/self/uid_map
# 0 1000 1
# ^^^^^^ 容器内 uid ^^^^^^ 宿主机 uid ^^^ 映射长度
# 意思是:这个 namespace 里的 uid 0 = 宿主机的 uid 1000
touch /etc/newfile
# touch: cannot touch '/etc/newfile': Permission denied
# ^^ 因为在宿主机上你还是 uid 1000 —— 这个 root 是"局部的"
capsh --print | grep Current # 但在这个 ns 内确实有全部 capability
exit
cat /etc/subuid /etc/subgid # rootless 容器用的 uid 区间分配
# lhx:100000:65536
6.2 rootless 容器与安全影响
# Podman 的 rootless 模式(不需要 root 就能跑容器)
podman run --rm -it alpine sh # 容器里是 root,宿主机上是你自己
podman unshare cat /proc/self/uid_map
# ✅ 安全收益:即使容器逃逸,攻击者拿到的只是一个普通用户的权限
# ⚠️ 代价:
# - 默认不能绑定 < 1024 端口(除非调 ip_unprivileged_port_start)
# - 需要 fuse-overlayfs / slirp4netns(性能略低)
# - 宿主机上看到的文件属主是 100000+ 这类奇怪的 uid
⚠️ user namespace 本身也扩大了内核攻击面:
在 ns 内,非特权用户获得了几乎全部 capability,
于是能触达大量原本只有 root 能走的内核代码路径(mount、网络配置、设备操作)
-> 历史上有多个 CVE 是「利用 user namespace 提权」
# 有些发行版因此限制它
sysctl kernel.unprivileged_userns_clone # Debian/Ubuntu(1 = 允许)
sysctl user.max_user_namespaces # 上限(0 = 禁用)
# RHEL 7 曾默认禁用,后来因为 rootless 容器的需求而开放
7. 三道额外的防线
capabilities 之后,内核又加了三层机制与凭证模型配合:
7.1 no_new_privs:禁止提权
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
// 效果:本进程及其【所有后代】永远无法通过 exec 获得新特权
// -> SUID/SGID 位被忽略、文件 capability 被忽略
// -> 这个标志【不可撤销】
# 就是 systemd 的 NoNewPrivileges=yes(10 篇 8 章的加固项)
grep NoNewPrivs /proc/$(pgrep -n myapp)/status # NoNewPrivs: 1
docker run --security-opt no-new-privileges myimage
# ✅ 最简单有效的加固之一:即使容器里有 SUID 程序,也无法用它提权
7.2 seccomp:限制系统调用
# 凭证模型控制「你能对什么做操作」,seccomp 控制「你能调用哪些系统调用」
grep Seccomp /proc/self/status # 0=关闭 1=strict 2=filter
systemd-analyze syscall-filter @system-service | head # 看这个集合包含什么
docker run --rm alpine grep Seccomp /proc/self/status # Seccomp: 2(Docker 默认有 profile)
7.3 LSM:在 DAC 之上加 MAC
DAC(自主访问控制)= 本篇讲的这一整套
特征:资源的【属主】可自行决定谁能访问(chmod/chown 是行使这个权力)
缺陷:进程一旦以某身份运行,就拥有该身份的【全部】权限
—— 被攻破的 web 服务器可以读走该用户的所有文件
MAC(强制访问控制)= SELinux / AppArmor
特征:策略由【管理员】统一制定,进程和资源都打标签,
即使 uid 匹配,标签不允许也不能访问
价值:把「nginx 只能读 /var/www、只能写 /var/log/nginx」固化成策略
cat /sys/kernel/security/lsm # 当前启用的 LSM
# lockdown,capability,landlock,yama,apparmor
getenforce # SELinux:Enforcing/Permissive/Disabled
ls -Z /var/www/html/index.html # 文件的安全上下文
ps -eZ | grep nginx # 进程的上下文
sudo ausearch -m avc -ts recent # ✅ 查看被 SELinux 拒绝的操作(排查必用)
sudo aa-status # AppArmor
sudo aa-complain /usr/sbin/nginx # 改成只告警不阻止(调试用)
这就是 23 篇的主线:从 DAC 的缺陷出发,看权限模型如何一路演进到 MAC。
8. 面试题
Q:为什么一个进程需要 ruid、euid、suid 三套 uid(外加 fsuid)?
三套各承担一个不可替代的职责:
ruid(real):记住「我本来是谁」。SUID 程序把 euid 变成 0 后,仍然需要知道是谁在调用它(审计、权限判断)euid(effective):权限检查实际使用的值suid(saved):「我曾经合法拥有过的身份」的凭据。它解决的是「临时降权后还能升回来」这个需求 —— 规则是非特权进程可以把 euid 改成 ruid 或 suid 之一,但不能改成别的值。所以 SUID 程序seteuid(1000)降权后,因为suid还是 0,可以seteuid(0)升回;而setresuid(1000,1000,1000)把 suid 也改掉后就永久不可逆了 —— 这才是真正的「放弃特权」
fsuid 是 Linux 特有的历史产物:1990 年代用户态 NFS 服务需要「以客户端用户身份访问文件」,但不能真改 euid(因为 euid 还控制着「谁能给我发信号、谁能 ptrace 我」,改了会被本地同 uid 用户攻击)。所以把「文件访问用的身份」单独拆出来。今天它始终跟随 euid 变化,几乎感觉不到。
Q:setuid() 的语义为什么不对称?应该用什么替代?
因为历史上它是唯一的接口,必须同时满足两个矛盾需求:守护进程要「永久变成 nobody 再也回不来」,SUID 程序要「临时切换并能切回」。于是靠「调用者是否特权」区分:
- euid == 0 时:
setuid(x)把 ruid、euid、suid 全部设为 x(不可逆) - 非特权时:只改 euid,且 x 必须等于 ruid 或 suid 之一
这是典型的「接口过载」。应该一律用 setresuid(r, e, s) —— 三个值全部显式指定,没有任何隐含行为。
还有两个配套要点:顺序必须是「改组 → 清附加组 → 改用户」(反了的话 setgid 会因为已经没有特权而失败,结果 uid 变了但 gid 还是 0);以及每一步都要检查返回值。
Q:为什么 setuid() 的返回值必须检查?
因为它会失败,而失败时进程仍然是 root。经典漏洞模式是 setuid(getuid()); system("/bin/sh"); —— 如果 setuid 失败,那就是一个 root shell。
最著名的失败原因是 RLIMIT_NPROC:目标用户的进程数已达上限时 setuid 返回 EAGAIN,而攻击者可以故意把自己的进程数打满来触发它。2000 年代有多个 CVE 是这个模式(sendmail、Apache suEXEC)。其他原因:缺少 CAP_SETUID、user namespace 里 uid 未映射、LSM 策略拒绝。
正确写法是检查返回值 + 二次验证(getresuid 确认三个值都变了、setuid(0) 确认真的回不去了),失败就 _exit(1)。
Go 里不要自己调 syscall.Setuid(POSIX 语义只作用于调用线程,而 goroutine 会在线程间迁移),应该用 exec.Command + SysProcAttr.Credential(在 fork 后的单线程状态下降权),或者干脆交给 systemd 的 User=。
Q:降权时忘记 setgroups() 会有什么后果?
附加组不会被自动清空 —— 进程降权后 euid 是普通用户,但仍然在 root 原有的附加组里。
后果很严重:如果附加组包含 disk(/dev/sda 是 brw-rw---- root disk),进程可以 dd 出整个磁盘,完全绕过文件权限;如果包含 shadow,能读密码哈希;如果包含 docker,等于 root(10 篇 7.5 讲过)。
正确做法是 setgroups(0, NULL) 必须在放弃 root 之前调用(它本身需要 CAP_SETGID),或者用 initgroups() 设置目标用户应有的组列表。验证方式是 grep Groups /proc/<pid>/status —— 应该只看到主组。
Q:capabilities 解决了什么问题?为什么二十多年了还没取代 SUID?
它解决的是 SUID 粒度太粗:ping 只需要 CAP_NET_RAW,但 SUID root 给了它全部特权,一个漏洞就是完整提权。capabilities 把 root 拆成 40 多个可单独授予的能力。
没能取代 SUID 的五个原因:
- 生态惯性 —— SUID 是 POSIX,capabilities 是 Linux 特有,跨平台软件不能依赖
- 五个集合的规则太复杂 —— Permitted/Effective/Inheritable/Bounding/Ambient 加上 exec 时的传递公式,绝大多数开发者不愿理解;而
chmod u+s只需要一个动作 - Ambient 来得太晚(2015) —— 在此之前「让 wrapper 脚本把能力传给子进程」几乎做不到,因为 Inheritable 必须配合文件 capability,而脚本文件不能带 capability
- 有些能力本身就 ≈ root ——
CAP_SYS_ADMIN是垃圾桶(mount/setns/swapon/quota…)、CAP_SYS_MODULE能加载内核模块、CAP_DAC_OVERRIDE能改/etc/sudoers、CAP_SYS_PTRACE能往 root 进程注入代码 - 文件 capability 存在 xattr 里 ——
cp/tar/rsync默认丢失、包升级后要重设,实践中容易悄悄失效
今天的格局:容器和 systemd 场景用得很好(由运行时统一管理,不依赖文件 xattr),传统 SUID 仍是 passwd/sudo/mount 的实现方式。新服务推荐 systemd 的 AmbientCapabilities + CapabilityBoundingSet,或者用 ip_unprivileged_port_start 这类办法避免需要特权。
Q:容器里的 root 和宿主机的 root 是同一个吗?
取决于有没有开 user namespace。
没开时是同一个 —— docker run -v /tmp/x:/x alpine touch /x/f 创建的文件在宿主机上属主是真正的 root,因为「内核只认数字」,容器里的 uid 0 和宿主的 uid 0 是同一个数字。这就是为什么容器逃逸等于 root 沦陷。
user namespace 引入了 uid 映射:容器内的 uid 0 映射到宿主机的 uid 100000(一个毫无特权的普通用户)。容器里的进程认为自己是 root 且在该 namespace 内拥有全部 capability,但它对宿主机资源的任何操作都按 uid 100000 检查。可以用 unshare --user --map-root-user bash 手动验证:id 显示 root,但 touch /etc/newfile 仍然 Permission denied,cat /proc/self/uid_map 能看到映射关系。
这是 rootless 容器(Podman、rootless Docker)的基础。代价是不能绑低端口、需要 fuse-overlayfs/slirp4netns(性能略低)、宿主机上文件属主显示为奇怪的高位 uid。
⚠️ 但 user namespace 本身也扩大了内核攻击面 —— 非特权用户在 ns 内获得几乎全部 capability,能触达大量原本只有 root 能走的内核路径,历史上有多个 CVE 是利用它提权。所以有些发行版会用 kernel.unprivileged_userns_clone 或 user.max_user_namespaces 限制它。
Q:NoNewPrivileges 是什么?为什么说它是最简单有效的加固?
它对应内核的 prctl(PR_SET_NO_NEW_PRIVS):本进程及其所有后代永远无法通过 exec 获得新特权 —— SUID/SGID 位被忽略、文件 capability 被忽略,而且这个标志不可撤销。
有效性在于它切断了提权的主要通路:即使容器镜像或文件系统里存在 SUID 程序(passwd、sudo、mount),被攻破的服务也无法利用它们提权。而成本几乎为零 —— 只要服务本身不依赖 SUID 程序,加上这一行就完事。
对应配置:systemd 的 NoNewPrivileges=yes、Docker 的 --security-opt no-new-privileges。验证:grep NoNewPrivs /proc/<pid>/status 应为 1。
它与 seccomp(限制能调用哪些系统调用)和 LSM(在 DAC 之上加强制访问控制)构成三道独立的防线 —— 凭证模型管「你是谁、能对什么做操作」,seccomp 管「你能用哪些系统调用」,MAC 管「策略允许你访问哪些标签的资源」。
小结
- 凭证是进程的属性,装在 RCU 保护的
cred里;变更时创建新对象原子替换,而非就地修改 - 三套 uid 各有不可替代的职责:
ruid记身份、euid判权限、suid是「可恢复凭据」(临时降权后能升回,改掉它才是永久放弃) fsuid是用户态 NFS 留下的历史接口 —— 因为 ABI 承诺,它永远不会被删除setuid()的不对称是「接口过载」的后果(一个接口两种语义);一律用setresuid()- 降权的顺序、返回值检查、
setgroups(0, NULL)三件都不能少 —— 忘了清附加组等于把disk/shadow/docker组的权限带了下来 setuid会因RLIMIT_NPROC失败,而攻击者可以主动触发它 —— 这是一类真实的 CVE 模式- capabilities 把 root 拆成 40 多个能力,但没能取代 SUID:生态惯性、五集合过于复杂、Ambient 来得太晚、部分能力本身≈root、xattr 易丢失
- user namespace 通过 uid 映射重新定义 root —— rootless 容器的基础,但它本身也扩大了内核攻击面
NoNewPrivileges是性价比最高的加固;它与 seccomp、LSM 构成凭证模型之外的三道防线
下一篇讲 权限与授权的演进史 —— 把 10 篇的操作和本篇的模型串成一条完整的时间线:9 位权限的空间约束 → SUID 的发明与它的安全债 → ACL 为什么是后来补的 → sudo 的设计权衡 → PAM 的可插拔架构 → SELinux 为什么要在 DAC 之上再加一层 MAC。
xingliuhua