Linux-23 权限与授权的演进史:从 9 位权限到 MAC
10 篇讲了权限的操作,22 篇讲了凭证模型。这一篇把它们串成一条五十年的时间线 —— 每一层机制都是为了补上前一层的缺陷而出现的。
先看五个问题:
- 为什么权限只有 9 位(rwx × 3)?为什么不是更细的模型?
- SUID 是什么时候被发明的?它欠下的安全债有多大?
- ACL 明明更强大,为什么是「后来补的」而且用得很少?
- sudo 为什么是一个用户态的 SUID 程序,而不是内核功能?
- 既然有了 DAC + capabilities,为什么还需要 SELinux 这样的 MAC?
1. 时间线总览
1971 Unix v1:文件权限位出现(最初只有 owner/other,7 位)
|
1973 SUID 被发明(Dennis Ritchie 申请了专利)
| 动机:让 passwd 这类程序能临时拥有特权
| 债务:把「普通用户可触发的代码」与「root 权限」绑在一起
|
1975 Unix v6:9 位权限 + 组的概念定型(owner/group/other × rwx)
|
1979 3BSD:saved uid 出现(22 篇 2.2 讲的临时降权需求)
|
1983 4.2BSD:【附加组】(一个用户可属于多个组)
| 此前一个用户只能在一个组里,靠 newgrp 切换
|
1980s Sticky 位被用于目录(补上「/tmp 里能删别人文件」的漏洞)
| SUNY/CU-Boulder 的 sudo 出现(用户态的授权层)
|
1995 Sun 发布 PAM(认证的可插拔化)
|
1997 POSIX.1e(ACL 标准草案)【被撤回】
| -> 各系统各自实现,导致今天的 ACL 兼容性混乱
|
1999 Linux 2.2:capabilities(把 root 拆成 40 多个能力,22 篇第 5 章)
|
2000 SELinux 发布(NSA),2003 年进入 Linux 2.6 主线
| 动机:DAC 无法表达「强制的、管理员定义的」策略
|
2002 Linux 2.6:LSM 框架(让 SELinux/AppArmor/Smack 等能共存)
2002 Linux 2.5.46:POSIX ACL 进入主线(虽然标准已撤回)
|
2008 AppArmor 进入主线(路径模型,比 SELinux 简单)
|
2012 seccomp-bpf(限制系统调用,与权限正交的一层)
2013 user namespace 完成(22 篇第 6 章:重新定义 root)
2015 Ambient capabilities(补上 capabilities 用不了的场景)
|
2021 Landlock 进入 5.13(无需 root 的自我沙箱)
这条时间线的模式很清晰:每一层都在补上一层的缺陷,而且从不删除旧机制(ABI 承诺,18 篇)。所以今天的 Linux 权限系统是五十年的地层叠加。
2. 起点:9 位权限的空间约束
2.1 开篇第一问:为什么只有 9 位
因为 1970 年代的 inode 里放不下更多东西。
Unix v6(1975)的 inode 结构:总共只有 32 字节
+--------------------------------------------------+
| i_mode 2 字节 <- 文件类型 + 权限位【全在这 16 位里】|
| i_nlink 1 字节 |
| i_uid 1 字节 <- 只有 8 位!最多 256 个用户 |
| i_gid 1 字节 <- 最多 256 个组 |
| i_size 3 字节 |
| i_addr 16 字节 <- 数据块指针 |
| i_atime 4 字节 |
| i_mtime 4 字节 |
+--------------------------------------------------+
i_mode 的 16 位怎么分配?
4 位:文件类型(普通/目录/字符设备/块设备…)
3 位:SUID、SGID、Sticky
9 位:权限 <- 就剩这么多了
在这个预算下,「三方 × 三种操作」是能表达的最大信息量:
为什么是 owner / group / other 三方?
- owner:文件的创建者必须有控制权(最基本)
- other:需要表达「所有其他人」(公开与私有的区分)
- group:中间态 —— 「一小群协作者」。这是最小的可用协作单位
为什么是 read / write / execute 三种操作?
- 这是文件操作的最小完备集合
- execute 在目录上被复用为「通行权」(03 篇 5.1)—— 一位表达两种语义,
正是位预算紧张的直接证据
2.2 这个模型的表达力边界
✅ 能表达:
「我可读写,我的组只读,其他人不可见」 -> 640
「所有人可执行,只有我能改」 -> 755
「团队共享,其他人看不到」 -> 770
❌ 不能表达:
「alice 可读写,bob 只读,carol 不可见」 -> 只有一个 group 位置!
「这个目录下新建的文件默认给 team 组可写」 -> 需要 SGID + umask 配合(10 篇 3.2)
「即使是属主也不能删除」 -> 需要 chattr +i(10 篇 9.4)
「只允许在工作时间访问」 -> 完全无法表达
「这个程序只能读 /var/www」 -> 需要 MAC(第 8 章)
2.3 但它的优点不容忽视
这个模型能活五十年,不只是因为惯性:
① 检查开销极小 —— 22 篇 1.2 那段代码只有几次比较和位移,
而路径解析要对每一级目录都做一次检查(20 篇 4.1),性能是硬约束
② 语义可预测 —— ls -l 一眼看懂,不需要工具查询
③ 存储成本为零 —— 就在 inode 里,不需要额外的数据结构
④ 与「一切皆文件」完美契合 —— 设备、管道、socket 全都能复用同一套检查
对比 ACL:一个文件的 ACL 可能有几十条规则,检查时要遍历(还要处理 mask、默认继承),而且存在扩展属性里(额外的磁盘读取)。这就是为什么 9 位权限至今仍是快路径,ACL 只在需要时才启用。
3. SUID:1973 年的发明与它的安全债
3.1 它解决的问题
10 篇 5.1 讲过 passwd 的场景。SUID 的发明(Dennis Ritchie,1973,还申请了专利)是一个真正的突破:它让「受控的提权」成为可能 —— 普通用户可以执行一段以更高权限运行的、由管理员预先审查过的代码。
没有 SUID 的世界,「用户改自己的密码」只有三个选择:
① 给所有用户 /etc/shadow 的写权限 -> 灾难
② 只有 root 能改密码,用户找管理员 -> 不可用
③ 做一个常驻的特权守护进程,用户通过 IPC 请求它 -> 复杂,且当时没有成熟的 IPC
SUID 是唯一务实的答案。
3.2 它欠下的债
SUID 的本质是:把「普通用户可以触发的代码」和「root 权限」绑在了一起。 于是那个程序的每一行代码都成了安全边界。
一个 SUID root 程序需要正确处理【所有】来自用户的输入:
命令行参数、环境变量、当前目录、文件描述符、信号、资源限制、
ulimit、locale、terminal 设置、时区……
任何一处疏漏就是完整的 root 提权。
三十年的漏洞史可以归成几类:
| 漏洞类型 | 机制 | 例子 |
|---|---|---|
| 缓冲区溢出 | 参数/环境变量过长覆盖返回地址 | 大量早期 SUID 程序 |
| 环境变量注入 | LD_PRELOAD/LD_LIBRARY_PATH/IFS/PATH 被利用 |
早期动态链接器;IFS 攻击 shell 脚本 |
| 不检查降权返回值 | RLIMIT_NPROC 触发 setuid 失败(22 篇 3.4) |
sendmail、Apache suEXEC |
| 文件路径竞态(TOCTOU) | access() 检查后、open() 之前文件被换成软链接 |
大量「先检查再打开」的代码 |
| 忘记清附加组 | 保留了 disk/shadow 组(22 篇 4.1) |
多个守护进程 |
| 可逃逸的程序被 SUID | 程序能执行任意命令(vim/find/awk) |
10 篇 7.5 的 GTFOBins 清单 |
| 信号处理竞态 | 在处理函数里做非可重入操作 | — |
3.3 缓解措施的演进(每一条都是一次「补债」)
① 动态链接器忽略危险环境变量
检测到 SUID 时(AT_SECURE),忽略 LD_PRELOAD、LD_LIBRARY_PATH 等
-> 22 篇 5.4 提到的「有 file capability 的程序也被视为特权程序」是同一机制
② 内核【完全忽略】脚本的 SUID 位(10 篇 5.1 提过)
因为脚本的 SUID 存在无法修补的竞态:
内核读 #! 行 -> 启动解释器 -> 解释器打开脚本文件
这中间脚本文件可能被换掉(TOCTOU)
-> 这就是为什么需要提权的脚本只能通过 sudo
③ nosuid 挂载选项(10 篇 5.1)
用户可写的分区上让 SUID 位全部失效
④ capabilities(22 篇第 5 章)
从「给全部特权」变成「给一个能力」—— 但没能取代 SUID(22 篇 5.5)
⑤ no_new_privs(22 篇 7.1)
从进程侧彻底关闭这条通路
⑥ 减少 SUID 程序的数量(发行版的持续努力)
# 看看现在系统上还剩多少 SUID 程序(对比十年前少了很多)
find / -perm -4000 -type f 2>/dev/null | sort
# /usr/bin/passwd /usr/bin/sudo /usr/bin/su /usr/bin/mount /usr/bin/umount
# /usr/bin/newgrp /usr/bin/chsh /usr/bin/chfn /usr/bin/gpasswd ...
find / -perm -4000 -type f 2>/dev/null | wc -l # 通常 10~20 个
# 一些原本 SUID 的程序已经改用 capabilities 了
getcap /usr/bin/ping 2>/dev/null
# /usr/bin/ping cap_net_raw=ep <- ✅ 不再需要 SUID root
ls -l /usr/bin/ping # 现代发行版上通常没有 s 位了
# 审计:与基线对比(10 篇 5.1 提过)
find / -perm -4000 -type f 2>/dev/null | sort > /tmp/suid.now
diff /etc/suid.baseline /tmp/suid.now
4. 组的扩展:从单组到附加组
最初一个用户只能属于一个组 —— 因为 cred 里就一个 gid 字段(22 篇 1.1)。想访问别的组的文件必须主动切换:
newgrp developers # 10 篇 3.1 讲过它:开一个新 shell,主组变成 developers
# 这在当年不是「便利功能」,而是【唯一】的多组访问方式
1983 年 4.2BSD 引入附加组(supplementary groups),一个进程可以同时属于多个组。
为什么最初不这么设计?
① cred 结构体大小 —— 一个变长的组列表意味着额外的内存分配和管理
② 权限检查开销 —— 从「比较一个 gid」变成「遍历一个列表」
(in_group_p() 要扫描整个 group_info,22 篇 1.2 的代码里就是它)
③ 上限问题 —— 至今仍有 NGROUPS_MAX 限制
sysctl kernel.ngroups_max # 65536(现代内核)
# ⚠️ 但 NFSv3 协议的 AUTH_SYS 只能传【16 个组】—— 超过的会被静默丢弃!
# 这是「本地能访问、NFS 上访问被拒」的一类经典原因
id -G | tr ' ' '\n' | wc -l
这个演进留下的痕迹:10 篇讲的「主组 vs 附加组」的区别(主组决定新建文件的属组、附加组只用于权限判断)正是这段历史的结果 —— 主组是原生字段,附加组是后加的列表。
5. ACL:一个被撤回的标准
5.1 开篇第三问:为什么是「后来补的」且用得少
需求早就存在(10 篇 6.1 的例子:alice 读写、bob 只读、审计组只读、其他人不可见),9 位权限根本表达不了。但 ACL 的历史很坎坷:
1990 年代:各家 Unix 各自实现 ACL(Solaris、AIX、HP-UX、IRIX 互不兼容)
|
1997:IEEE POSIX.1e 草案试图统一 —— 【但最终被撤回,从未成为正式标准】
|
v
后果:各系统只能基于那份【草案】实现,细节各有差异
- Linux/Solaris:POSIX.1e draft 17 风格
- NFSv4/Windows:完全不同的 ACL 模型(更细粒度、有继承规则、有 deny 条目)
- macOS:NFSv4 风格
-> 跨平台的 ACL 迁移至今仍然麻烦
Linux 直到 2002 年(2.5.46)才把 POSIX ACL 合并进主线,比需求出现晚了十几年。
5.2 mask 的设计妥协
10 篇 6.3 讲过 mask 的坑(chmod 会改 mask,导致 ACL 条目被降级)。这个坑不是 bug,而是为了向后兼容而做的妥协:
问题:老程序只知道 9 位权限。当一个文件有了 ACL,
老程序执行 chmod 640 时,它以为自己设置了「组只读」——
但如果 ACL 里有 user:bob:rw 这样的条目,chmod 完全不知道它的存在。
如果 chmod 不影响 ACL:老程序以为收紧了权限,实际上 bob 仍能写 -> 安全假设被破坏
如果 chmod 清空 ACL: 管理员精心配置的 ACL 被一个 chmod 抹掉 -> 不可接受
POSIX.1e 的妥协:引入 mask 作为「所有命名条目 + 属组」的权限【上限】,
并让 chmod 的 group 位【映射到 mask】。
-> 于是 chmod 640 会把 mask 设成 r--,所有 ACL 条目被降级为只读
-> 老程序的「收紧权限」意图得以保留,ACL 条目本身也没丢
# 验证这个机制
setfacl -m u:bob:rw report.xlsx
getfacl report.xlsx | grep -E 'bob|mask'
# user:bob:rw-
# mask::rw-
chmod 640 report.xlsx # 老程序的动作
getfacl report.xlsx | grep -E 'bob|mask'
# user:bob:rw- #effective:r-- <- ✅ 条目还在,但被 mask 降级
# mask::r--
setfacl -m m::rw report.xlsx # 显式恢复 mask
5.3 为什么实际用得少
① 标准被撤回 -> 跨系统行为不一致,工具支持参差
② mask 语义反直觉(上面那个坑)
③ 备份/迁移容易丢失(03/12 篇讲过):
cp 默认不带 ACL(要 -a 或 --preserve=all)
rsync 要 -A(-a 不含!)
tar 要 --acls
docker 镜像层、云存储同步、CI 制品往往直接丢弃
④ ls -l 只显示一个 + 号,看不出实际权限 -> 必须 getfacl,运维心智负担大
⑤ 大多数场景可以用「多建一个组」绕过 —— 而组是所有工具都支持的
实践建议:能用组解决的就用组,ACL 留给「必须给单个用户特殊权限」且「有工具保证 ACL 不丢」的场景(如 setfacl 写进配置管理)。
6. sudo:用户态的授权层
6.1 开篇第四问:为什么不做进内核
因为「谁能以什么身份执行什么命令」是策略(policy),不是机制(mechanism) —— 这正是 18 篇第 4 章讲的分离原则。
内核提供的机制已经够了:
- setuid/setgid(改变身份)
- capabilities(细粒度特权)
- 文件权限(控制谁能执行 sudo 本身)
而「策略」的需求极其多样:
- alice 能在 web 服务器上重启 nginx,但在数据库服务器上不能
- deploy 账号只能执行特定命令,且不需要密码
- 运维组能查所有日志,但不能改配置
- 某些命令需要二次确认 / 需要双人授权 / 需要记录完整会话
- 授权可能来自 LDAP、可能有时间窗口、可能要求 MFA
如果把这些做进内核:
✗ 内核要解析一门配置语言(sudoers DSL)—— 违反内核的最小化原则
✗ 每次策略变化都要改内核
✗ 无法适配企业的各种身份系统
✗ 一个解析 bug 就是内核级漏洞
所以 sudo 的形态是必然的:一个 SUID root 的用户态程序,读一份配置文件决定策略,用内核提供的 setuid 机制执行。
6.2 sudo 相对 su 的三个改进
su 的问题(10 篇 7.7 的对照表):
① 需要【目标用户的密码】—— 用 root 就必须共享 root 密码
② 全权或无权 —— 无法只授权某个命令
③ 审计弱 —— 日志里只有「某人 su 成了 root」,之后做了什么无从追溯
sudo 的解法:
① 用【调用者自己的密码】—— root 密码可以不存在(现代发行版就是这样)
② 命令级授权 —— sudoers 里可以精确到路径和参数
③ 每条命令都记日志(谁、在哪个终端、在哪个目录、执行了什么)
6.3 sudoers DSL 的表达力与危险
它是一门真正的小语言:用户/组/主机/命令/运行身份的多维授权,
加上别名(User_Alias/Cmnd_Alias/Host_Alias)、通配符、否定、Defaults 选项。
表达力换来的代价:
✗ 语法错误会让 sudo 完全拒绝工作(所以必须用 visudo,10 篇 7.2)
✗ 通配符的语义容易出错(`/bin/systemctl restart *` 允许重启任何服务)
✗ 「授权一个命令」经常等于「授权 root shell」(10 篇 7.5 的 GTFOBins)
✗ sudo 本身是 SUID root 程序 -> 它的漏洞就是 root 提权
(近年仍有严重 CVE,如 2021 年的堆溢出 CVE-2021-3156)
6.4 现代替代与演进
# ── doas(OpenBSD,2015):极简替代 ──
# 设计动机:sudo 太复杂(几万行代码 + 一门 DSL)= 攻击面太大
cat /etc/doas.conf
# permit nopass :wheel as root cmd systemctl args restart myapp
# permit persist lhx as root
# 只有几百行配置语法,代码量是 sudo 的几十分之一
# 取舍:放弃了 sudoers 的表达力,换取可审计的简单性
# ── polkit(桌面与系统服务的授权)──
# 思路完全不同:不是「以 root 执行命令」,而是「向特权守护进程请求某个动作」
pkaction | head -5 # 系统里定义了哪些「动作」
pkcheck --action-id org.freedesktop.systemd1.manage-units --process $$
# systemctl 的非 root 操作走的就是它(策略用 JS 规则文件描述)
# ✅ 优势:不需要 SUID 程序,权限检查在守护进程里做,攻击面小得多
# ── 企业方案:集中授权 + 短期凭证 ──
# Teleport / Boundary / HashiCorp Vault SSH:
# 授权来自中心策略,凭证短期有效(16 篇 3.4 的 SSH 证书思路)
# 会话可录制回放(sudo 也有 log_input/log_output,10 篇 7.6)
7. PAM:认证的可插拔化
7.1 它解决的问题
在 PAM 之前,每个需要认证的程序都自己实现认证逻辑:
login、sshd、su、sudo、cron、ftpd、imapd… 每个都自己:
读 /etc/shadow -> crypt() 比对密码 -> 检查账号是否过期
于是想支持一种【新的认证方式】(LDAP、Kerberos、双因素、指纹、smartcard),
就必须【逐个修改所有程序】—— 而且它们的行为可能不一致
(某个程序忘了检查账号过期,某个程序的密码策略不同)
Sun 在 1995 年提出 PAM(Pluggable Authentication Modules),思路是 18 篇讲的「机制与策略分离」的又一次应用:程序只调用统一 API,具体怎么认证由配置决定。
程序(sshd)
| 调用 pam_authenticate()
v
libpam
| 读 /etc/pam.d/sshd
v
按配置依次调用模块:
pam_unix.so (查 /etc/shadow)
pam_ldap.so (查 LDAP)
pam_google_authenticator.so (TOTP 双因素)
pam_faillock.so (失败次数锁定)
pam_limits.so (设置 ulimit —— 19 篇 7.2 讲的那个)
7.2 四个管理组
cat /etc/pam.d/sshd | grep -vE '^#|^$' | head -12
| 管理组 | 负责什么 | 典型模块 |
|---|---|---|
auth |
验证身份(你是你声称的人吗) | pam_unix、pam_ldap、pam_u2f、pam_google_authenticator |
account |
账号是否可用(未过期、允许此时/此地登录) | pam_unix、pam_time、pam_access、pam_nologin |
password |
修改密码时的策略 | pam_pwquality(复杂度)、pam_pwhistory(不能重复用旧密码) |
session |
会话的建立与销毁 | pam_limits(ulimit)、pam_systemd(创建 user slice)、pam_mkhomedir、pam_motd、pam_loginuid(审计) |
这个划分本身就解释了一个 13 篇的坑:/etc/security/limits.conf 由 pam_limits.so 在 session 阶段生效 —— 而 systemd 启动的服务不经过 PAM(没有登录会话),所以它对服务无效,必须用 unit 里的 LimitNOFILE=。
7.3 控制流的设计
required 必须成功;失败【继续执行后面的模块】,但最终结果是失败
(为什么继续?为了不泄漏「是哪一步失败的」—— 防止攻击者探测)
requisite 必须成功;失败【立即返回】,不再执行后续
sufficient 成功则【立即返回成功】(前面没有 required 失败的话)
optional 结果通常被忽略(除非它是栈里唯一的模块)
include 引入另一个配置文件的同类型规则
substack 类似 include,但 done/die 的作用域局限在子栈内
[key=value...] 精细控制每种返回值的行为(现代写法)
# required 与 requisite 的区别有实际安全含义
# auth required pam_unix.so <- 密码错了也继续跑后面的模块,最后统一报"认证失败"
# auth requisite pam_nologin.so <- /etc/nologin 存在就立即拒绝(没必要再验密码)
# 一个真实的加固例子:给 SSH 加 TOTP 双因素
# /etc/pam.d/sshd
# auth required pam_google_authenticator.so nullok
# 然后 sshd_config 里:
# KbdInteractiveAuthentication yes
# AuthenticationMethods publickey,keyboard-interactive
# ^^ 16 篇 8.2 讲的:要求「公钥 + 双因素」两者都过
PAM 的代价:配置栈的语义相当微妙(控制标志 × 模块顺序 × 返回值),改错了会导致无法登录,而且和 sudoers 一样属于「改坏了就锁死自己」的配置。
# ⚠️ 改 PAM 前的保命措施
sudo cp -a /etc/pam.d /root/pam.d.bak
# 保留一个已登录的 root 会话不要退出,在【新终端】测试
# 单用户模式可以绕过大部分 PAM 配置(19 篇 8.2 的救援手段)
7.4 PAM 与 NSS 的分工
这两个经常被混淆(15 篇 5.1 提过 NSS):
NSS(Name Service Switch,/etc/nsswitch.conf)
回答「这个用户/组/主机名是什么」—— 【信息查询】
passwd: files ldap -> getpwnam() 去哪找用户信息
hosts: files dns -> getaddrinfo() 去哪解析域名
PAM(/etc/pam.d/)
回答「这个用户能不能登录」—— 【认证与会话】
两者独立:
一个 LDAP 用户能被 NSS 查到(id lhx 有输出),
但如果 PAM 没配 pam_ldap,他仍然无法登录(认证失败)
-> 这是企业环境里「用户存在但登不进去」的典型原因
8. DAC 的根本缺陷与 MAC
8.1 开篇第五问:DAC 的三个无法修补的问题
DAC(Discretionary Access Control,自主访问控制)= 前面讲的所有内容(权限位、ACL、capabilities、sudo)。它的共同特征是:资源的属主可以自行决定谁能访问。
缺陷 ①:属主可以任意授权 -> 管理员无法强制策略
公司规定「客户数据不得外传」,但文件属主可以 chmod 777
或者把文件 cp 到 /tmp —— DAC 模型里这完全合法
缺陷 ②:进程继承用户的【全部】权限 -> 一个漏洞 = 全部数据
你的浏览器和你的 SSH 私钥、财务表格、源码仓库,
在 DAC 眼里是同一个 uid,权限完全相同。
浏览器被 RCE = 攻击者能读走你的一切
(这是 DAC 最致命的问题:权限的粒度是「用户」,而威胁的粒度是「程序」)
缺陷 ③:无法防御可信程序的误用(特洛伊木马问题)
经典场景:alice 运行了 bob 写的一个工具。
那个工具以 alice 的身份运行,于是能读走 alice 的所有文件并发到外网。
DAC 无法区分「alice 想做的事」和「程序替 alice 做的事」
8.2 MAC 的思路
MAC(Mandatory Access Control,强制访问控制):策略由管理员统一制定,进程和资源都被打上标签,即使 uid 匹配,标签不允许也不能访问 —— 而且属主无权修改策略。
DAC: 「谁(uid)能访问什么(文件权限)」 属主可改
MAC: 「哪个【程序】能访问哪类【资源】」 只有安全管理员能改
# SELinux 的 Type Enforcement 模型
ls -Z /var/www/html/index.html
# unconfined_u:object_r:httpd_sys_content_t:s0 index.html
# ^^^^^^^^^^^^^^^^^^^^ 文件的【类型标签】
ps -eZ | grep nginx
# system_u:system_r:httpd_t:s0 1234 ? nginx
# ^^^^^^^ 进程的【域标签】
# 策略规则的形式(简化):
# allow httpd_t httpd_sys_content_t : file { read getattr open };
# -> httpd_t 域的进程可以读 httpd_sys_content_t 类型的文件
# -> 反过来说:nginx 【不能】读 user_home_t(用户家目录)的文件,
# 即使它以 root 运行、即使那个文件是 777!
这解决了 DAC 的三个缺陷:
✅ 缺陷①:策略在 /etc/selinux/ 里,属主 chmod 777 也没用(标签才是决定性的)
✅ 缺陷②:权限粒度变成了「程序」—— nginx 只能碰 web 内容,
即使被 RCE 也读不到 /root/.ssh 或数据库文件
✅ 缺陷③:程序运行在自己的域里,能力由策略限定,不再自动继承用户的全部权限
8.3 SELinux vs AppArmor
| SELinux(NSA,2000) | AppArmor(2008 进主线) | |
|---|---|---|
| 标识资源的方式 | 标签(存在文件的 xattr 里) | 路径(配置文件里写路径通配) |
| 优点 | 精确(跟着 inode 走,mv 不改变标签)、表达力极强 |
易读易写(配置就是路径列表) |
| 缺点 | 极难理解与调试、标签会丢失(cp/解压/恢复备份后要 restorecon) |
路径可被绕过(硬链接、bind mount)、粒度较粗 |
| 默认发行版 | RHEL / Fedora / CentOS | Ubuntu / SUSE / Debian |
| 典型排查 | ausearch -m avc、sealert、audit2allow |
aa-status、aa-complain、aa-logprof |
# ── SELinux 的日常排查(RHEL 系必备)──
getenforce # Enforcing / Permissive / Disabled
sudo ausearch -m avc -ts recent # ✅ 看被拒绝的操作
sudo sealert -a /var/log/audit/audit.log # 人类可读的分析与修复建议
sudo audit2allow -a -M mypolicy # 从审计日志【生成】策略模块
sudo semodule -i mypolicy.pp # 安装(⚠️ 生成的策略可能过宽,要审查)
# 常见的「明明权限是对的却访问被拒」
sudo restorecon -Rv /var/www/html # ✅ 恢复默认标签(最高频的修复动作)
sudo semanage fcontext -a -t httpd_sys_content_t "/data/web(/.*)?" # 自定义标签规则
sudo restorecon -Rv /data/web
sudo setsebool -P httpd_can_network_connect on # ✅ 布尔开关(比写策略简单)
getsebool -a | grep httpd | head
# 临时排除 SELinux 因素(排查时用,不要留在生产)
sudo setenforce 0 # 改成 Permissive(只记日志不阻止)
# 确认是 SELinux 的问题后,正确做法是修标签或开布尔值,而不是永久关闭
# ── AppArmor(Debian 系)──
sudo aa-status
sudo aa-complain /usr/sbin/nginx # 只告警不阻止(调试)
sudo aa-logprof # ✅ 交互式地根据日志生成规则
sudo cat /etc/apparmor.d/usr.sbin.nginx | head -20
8.4 为什么 SELinux 难用(但仍在演进)
① 概念负担重:用户/角色/类型/级别四元组标签、几千条策略规则、
上下文转换规则(type_transition)、布尔开关……
② 错误信息不直观:应用只看到 EACCES,看不出是 SELinux 拒绝的
-> 必须去查 audit 日志(这是「明明 chmod 777 了还是拒绝」的经典困惑)
③ 标签容易丢:cp(不带 -a)、tar(不带 --selinux)、恢复备份、
容器挂载卷 -> 都可能导致标签错误
④ 排查工具链需要专门学习
但它在演进:
- 发行版提供了完善的默认策略(大多数场景不需要写策略)
- 布尔开关(setsebool)覆盖了常见的定制需求
- 容器运行时自动为容器分配 SELinux 上下文(Docker/Podman 的 --security-opt label)
- Landlock(2021,5.13+)提供了一条完全不同的路:应用【自己】限制自己
# Landlock:无需 root、无需系统级策略,进程自我沙箱
# 与 MAC 的关键区别:由【应用开发者】声明「我只需要访问这些路径」
# Go 里可以用 landlock-lsm/go-landlock:
# landlock.V3.BestEffort().RestrictPaths(
# landlock.RODirs("/usr/share"), landlock.RWDirs("/var/lib/myapp"))
# ✅ 思路上更接近 seccomp(自我约束)而不是 SELinux(外部强制)
# 这与 10 篇 8 章的 systemd 沙箱选项是同一个方向:让服务主动缩小自己的能力
9. 今天的分层格局
┌─────────────────────────────────────────────────────────────┐
│ 应用自我约束 seccomp / Landlock / systemd 沙箱选项 │ ← 最内层
├─────────────────────────────────────────────────────────────┤
│ MAC(强制访问控制) SELinux / AppArmor(标签或路径 + 强制策略) │
├─────────────────────────────────────────────────────────────┤
│ 特权细分 capabilities(40+ 能力)+ no_new_privs │
├─────────────────────────────────────────────────────────────┤
│ 授权层(用户态) sudo / doas / polkit(策略) │
├─────────────────────────────────────────────────────────────┤
│ 认证层(用户态) PAM(可插拔认证)+ NSS(身份信息查询) │
├─────────────────────────────────────────────────────────────┤
│ DAC 扩展 ACL(细粒度)+ SUID/SGID/Sticky │
├─────────────────────────────────────────────────────────────┤
│ DAC 基础 9 位权限 + uid/gid 凭证模型 │ ← 最底层
└─────────────────────────────────────────────────────────────┘
↑ 1971 年至今,从未删除任何一层
每一层解决的问题:
| 层 | 补上了什么缺陷 |
|---|---|
| 9 位权限 | 最基础的隔离(在 32 字节 inode 的预算内) |
| SUID | 受控提权(让 passwd 这类需求可实现) |
| Sticky | 补上「/tmp 里能删别人文件」的漏洞 |
| 附加组 | 一个用户属于多个组 |
| ACL | ugo 三方不够用时的细粒度授权 |
| PAM | 认证方式的可插拔(不用改每个程序) |
| sudo | 命令级授权 + 审计(替代共享 root 密码) |
| capabilities | 把 root 拆开(缓解 SUID 的全权问题) |
| no_new_privs | 从进程侧关闭提权通路 |
| MAC | DAC 的三个根本缺陷(属主可任意授权、权限粒度是用户而非程序、特洛伊木马) |
| seccomp | 限制系统调用(与权限正交的一层) |
| user namespace | 重新定义 root(容器与 rootless) |
| Landlock | 应用自我沙箱(不需要 root 与系统策略) |
10. 面试题
Q:为什么 Unix 的文件权限只有 9 位?这个模型的边界在哪?
因为 1970 年代 inode 的字节预算装不下更多。 Unix v6 的 inode 总共只有 32 字节,其中 i_mode 只有 16 位:4 位表示文件类型、3 位给 SUID/SGID/Sticky,剩下 9 位就是全部权限信息(i_uid 甚至只有 8 位,最多 256 个用户)。
在这个预算下,「三方 × 三种操作」是能表达的最大信息量:owner(创建者必须有控制权)、other(区分公开与私有)、group(最小的协作单位);read/write/execute 是文件操作的最小完备集 —— 而 execute 在目录上被复用为「通行权」(03 篇),一位表达两种语义正是位预算紧张的直接证据。
它表达不了「alice 读写、bob 只读、carol 不可见」(只有一个 group 位置)、「即使属主也不能删除」(要 chattr +i)、「这个程序只能读 /var/www」(要 MAC)。
但它能活五十年不只是惯性:检查开销极小(几次比较和位移,而路径解析要对每一级目录都检查一次,20 篇)、语义可预测(ls -l 一眼看懂)、存储成本为零、且与「一切皆文件」完美契合。这就是为什么它至今是快路径,ACL 只在需要时启用。
Q:SUID 解决了什么问题,又欠下了什么债?
它让受控提权成为可能。没有 SUID 的话,「用户改自己的密码」只有三个选择:给所有人 /etc/shadow 写权限(灾难)、只有 root 能改(不可用)、做一个特权守护进程 + IPC(1973 年没有成熟的 IPC)。
债务是:它把「普通用户可触发的代码」和「root 权限」绑在了一起 —— 那个程序的每一行代码都成了安全边界,必须正确处理所有来自用户的输入(参数、环境变量、cwd、fd、信号、rlimit、locale……)。
三十年的漏洞归类:缓冲区溢出、环境变量注入(LD_PRELOAD/IFS)、不检查降权返回值(RLIMIT_NPROC 触发 setuid 失败)、TOCTOU 竞态(access 后 open 前文件被换成软链接)、忘记清附加组、把可逃逸的程序设为 SUID(vim/find/awk)。
缓解措施都是「补债」:动态链接器在 SUID 时忽略危险环境变量、内核完全忽略脚本的 SUID 位(因为「读 shebang → 启动解释器 → 解释器打开文件」这中间有无法修补的竞态)、nosuid 挂载选项、capabilities、no_new_privs,以及发行版持续减少 SUID 程序的数量(现在通常只剩 10~20 个,ping 已经改用 cap_net_raw)。
Q:ACL 明明更强大,为什么用得少?mask 那个坑是怎么来的?
因为它的标准被撤回了。 1990 年代各家 Unix 各自实现 ACL,1997 年 IEEE POSIX.1e 草案试图统一但最终被撤回,从未成为正式标准。于是各系统只能基于那份草案实现,细节各有差异(Linux/Solaris 是 draft 17 风格,NFSv4/Windows 是完全不同的模型),跨平台迁移至今麻烦。Linux 直到 2002 年才把它合并进主线,比需求出现晚了十几年。
mask 不是 bug 而是向后兼容的妥协:老程序只知道 9 位权限,它执行 chmod 640 时完全不知道 ACL 里还有 user:bob:rw 这样的条目。如果 chmod 不影响 ACL,老程序以为收紧了权限但 bob 仍能写(安全假设被破坏);如果 chmod 清空 ACL,管理员的配置被一个命令抹掉(不可接受)。折中方案是引入 mask 作为所有命名条目的权限上限,并把 chmod 的 group 位映射到它 —— 于是 ACL 条目还在但被降级,老程序的意图得以保留。
用得少的其他原因:mask 语义反直觉、备份迁移容易丢失(cp 要 -a、rsync 要 -A、tar 要 --acls,容器镜像和 CI 制品往往直接丢弃)、ls -l 只显示一个 + 号看不出实际权限。实践建议:能用组解决的就用组。
Q:sudo 为什么是用户态的 SUID 程序,而不是内核功能?
因为「谁能以什么身份执行什么命令」是策略而不是机制(18 篇的分离原则)。内核提供的机制已经够了(setuid、capabilities、文件权限),而策略的需求极其多样:按主机区分、按命令区分、免密与否、时间窗口、MFA、来自 LDAP、需要双人授权、会话录制……
如果做进内核:内核要解析一门配置语言(违反最小化原则)、每次策略变化都要改内核、无法适配企业身份系统、一个解析 bug 就是内核级漏洞。
sudo 相对 su 的三个改进:用调用者自己的密码(root 密码可以根本不存在)、命令级授权、每条命令都有审计日志。
代价是 sudoers DSL 的表达力换来了复杂性:语法错误会让 sudo 完全拒绝工作(所以必须 visudo)、通配符容易配错、「授权一个命令」经常等于「授权 root shell」(GTFOBins),而且 sudo 本身是 SUID root 程序,它的漏洞就是 root 提权(2021 年的 CVE-2021-3156 就是堆溢出)。
现代替代反映了两种不同思路:doas 用「放弃表达力换取可审计的简单性」(代码量是 sudo 的几十分之一);polkit 则根本不用 SUID —— 改成「向特权守护进程请求某个动作」,权限检查在守护进程里做,攻击面小得多(systemctl 的非 root 操作走的就是它)。
Q:PAM 解决了什么问题?为什么 limits.conf 对 systemd 服务无效?
PAM 之前,login/sshd/su/sudo/cron 每个程序都自己实现认证(读 shadow、crypt() 比对、检查过期)。想支持一种新认证方式(LDAP、Kerberos、双因素、smartcard)就得逐个修改所有程序,而且它们的行为可能不一致。
PAM(Sun,1995)让程序只调用统一 API,具体怎么认证由 /etc/pam.d/ 的配置决定 —— 又一次「机制与策略分离」。它分四个管理组:auth(验证身份)、account(账号是否可用)、password(改密码的策略)、session(会话的建立与销毁)。
limits.conf 由 pam_limits.so 在 session 阶段生效,而 systemd 启动的服务不经过 PAM(它们不是登录会话,没有 PAM 上下文),所以那个文件对服务完全无效,必须用 unit 里的 LimitNOFILE=。验证要看 cat /proc/<pid>/limits 而不是 ulimit -n。
顺带区分 PAM 与 NSS:NSS(/etc/nsswitch.conf)回答「这个用户/组/主机名是什么」(信息查询),PAM 回答「这个用户能不能登录」(认证)。两者独立 —— 一个 LDAP 用户能被 NSS 查到(id 有输出)但 PAM 没配 pam_ldap 就仍然登不进去,这是企业环境里「用户存在但登录失败」的典型原因。
Q:DAC 有哪三个根本缺陷?MAC 怎么解决?
DAC(权限位、ACL、capabilities、sudo 全都是)的共同特征是「资源属主可自行决定谁能访问」,三个无法修补的缺陷:
- 属主可任意授权 → 管理员无法强制策略。公司规定「客户数据不得外传」,但属主
chmod 777或cp到/tmp在 DAC 里完全合法 - 进程继承用户的全部权限 → 一个漏洞 = 全部数据。你的浏览器和你的 SSH 私钥、财务表格在 DAC 眼里是同一个 uid,浏览器被 RCE 就等于全部沦陷。这是最致命的:权限的粒度是「用户」,而威胁的粒度是「程序」
- 特洛伊木马问题 → alice 运行别人写的工具,那工具以 alice 身份读走她的所有文件。DAC 无法区分「alice 想做的事」和「程序替 alice 做的事」
MAC 的解法是「标签 + 强制策略」:进程和资源都打标签,策略由管理员统一制定且属主无权修改。SELinux 的规则形如 allow httpd_t httpd_sys_content_t : file read —— nginx 进程(httpd_t 域)不能读用户家目录(user_home_t),即使它以 root 运行、即使那个文件是 777。
这正好补上三个缺陷:策略不受 chmod 影响;权限粒度从「用户」变成「程序」;程序运行在自己的域里不再自动继承用户的全部权限。
Q:SELinux 和 AppArmor 有什么区别?SELinux 为什么难用?
核心区别是标识资源的方式:SELinux 用标签(存在文件 xattr 里),AppArmor 用路径(配置里写路径通配)。
SELinux 更精确(标签跟着 inode 走,mv 不改变它)且表达力极强,但标签会丢失(cp 不带 -a、tar 不带 --selinux、恢复备份后都要 restorecon);AppArmor 易读易写,但路径可被绕过(硬链接、bind mount)且粒度较粗。前者是 RHEL 系默认,后者是 Ubuntu/SUSE 默认。
SELinux 难用的四个原因:概念负担重(四元组标签 + 几千条规则 + 上下文转换)、错误信息不直观(应用只看到 EACCES,看不出是 SELinux 拒绝的 —— 这是「明明 chmod 777 了还是被拒」的经典困惑,必须去查 audit 日志)、标签容易丢、排查工具链需要专门学。
日常排查三件套:ausearch -m avc -ts recent 看被拒绝的操作、restorecon -Rv <path> 恢复默认标签(最高频的修复动作)、setsebool -P xxx on 用布尔开关(比写策略简单得多)。排查时可以 setenforce 0 临时排除 SELinux 因素,但确认后应该修标签或开布尔值,而不是永久关闭。
Landlock(2021,5.13+)提供了另一条路:应用自己声明「我只需要访问这些路径」,不需要 root、不需要系统级策略 —— 思路上更接近 seccomp(自我约束)而非 SELinux(外部强制),与 systemd 沙箱选项是同一个方向。
Q:为什么今天的 Linux 权限系统有这么多层?
因为每一层都是为了补上前一层的缺陷而出现的,而且从不删除旧机制(18 篇的 ABI 承诺)。所以它是五十年的地层叠加:
9 位权限(32 字节 inode 的预算)
→ SUID(受控提权)→ 但欠下巨大安全债
→ Sticky(补 /tmp 能删别人文件的漏洞)
→ 附加组(一个用户多个组)
→ ACL(ugo 三方不够用)→ 但标准被撤回,用得少
→ PAM(认证可插拔)+ NSS(身份查询)
→ sudo(命令级授权 + 审计,替代共享 root 密码)
→ capabilities(把 root 拆开)→ 但没能取代 SUID
→ no_new_privs(从进程侧关闭提权通路)
→ MAC(补 DAC 的三个根本缺陷)
→ seccomp(限制系统调用,正交的一层)
→ user namespace(重新定义 root)
→ Landlock(应用自我沙箱)
实践含义:加固一个服务时应该分层选择而不是只用一层。10 篇 8 章那份 Go 服务配置就是这个思路的落地 —— 专用系统账号(DAC)+ setcap/AmbientCapabilities(特权细分)+ NoNewPrivileges(关提权通路)+ ProtectSystem/ReadWritePaths(沙箱)+ SystemCallFilter(seccomp)+ 精确的 sudo 授权。任何单独一层都不够。
小结
- 9 位权限的形状由 32 字节 inode 的预算决定;它至今是快路径,因为检查开销极小且语义可预测
- SUID(1973)让受控提权成为可能,代价是把「用户可触发的代码」与 root 绑在一起 —— 三十年漏洞史与一系列缓解措施(忽略危险环境变量、脚本 SUID 无效、
nosuid、capabilities、no_new_privs)都是在补这笔债 - ACL 的标准(POSIX.1e)在 1997 年被撤回 → 跨系统不一致、
mask是向后兼容的妥协、备份易丢失 → 能用组就用组 - sudo 在用户态是必然的:授权是策略不是机制;
doas用简单性换表达力,polkit用「请求守护进程」摆脱了 SUID - PAM 把认证变成可插拔的栈;它只在登录会话里生效,所以
limits.conf对 systemd 服务无效 - DAC 的致命缺陷是「权限粒度是用户,而威胁粒度是程序」 → MAC 用「标签 + 强制策略」把粒度变成程序
- SELinux 精确但难用(标签易丢、错误不直观),日常靠
ausearch/restorecon/setsebool三件套;Landlock 提供了「应用自我约束」的新方向 - 今天的权限系统是五十年的地层叠加,加固要分层选择
下一篇进入内存部分:虚拟内存:为什么值得付出这么大代价 —— 分段与分页之争、多级页表的空间时间折中、TLB 为什么是性能命门、缺页是 bug 还是特性、以及 21 篇讲的 COW 在这套机制里到底怎么实现的。
xingliuhua