目录

Linux-23 权限与授权的演进史:从 9 位权限到 MAC

10 篇讲了权限的操作,22 篇讲了凭证模型。这一篇把它们串成一条五十年的时间线 —— 每一层机制都是为了补上前一层的缺陷而出现的。

先看五个问题:

  1. 为什么权限只有 9 位(rwx × 3)?为什么不是更细的模型?
  2. SUID 是什么时候被发明的?它欠下的安全债有多大?
  3. ACL 明明更强大,为什么是「后来补的」而且用得很少
  4. sudo 为什么是一个用户态的 SUID 程序,而不是内核功能?
  5. 既然有了 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_unixpam_ldappam_u2fpam_google_authenticator
account 账号是否可用(未过期、允许此时/此地登录) pam_unixpam_timepam_accesspam_nologin
password 修改密码时的策略 pam_pwquality(复杂度)、pam_pwhistory(不能重复用旧密码)
session 会话的建立与销毁 pam_limits(ulimit)、pam_systemd(创建 user slice)、pam_mkhomedirpam_motdpam_loginuid(审计)

这个划分本身就解释了一个 13 篇的坑/etc/security/limits.confpam_limits.sosession 阶段生效 —— 而 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 avcsealertaudit2allow aa-statusaa-complainaa-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 竞态accessopen 前文件被换成软链接)、忘记清附加组、把可逃逸的程序设为 SUIDvim/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-arsync-Atar--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.confpam_limits.sosession 阶段生效,而 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 全都是)的共同特征是「资源属主可自行决定谁能访问」,三个无法修补的缺陷:

  1. 属主可任意授权 → 管理员无法强制策略。公司规定「客户数据不得外传」,但属主 chmod 777cp/tmp 在 DAC 里完全合法
  2. 进程继承用户的全部权限一个漏洞 = 全部数据。你的浏览器和你的 SSH 私钥、财务表格在 DAC 眼里是同一个 uid,浏览器被 RCE 就等于全部沦陷。这是最致命的:权限的粒度是「用户」,而威胁的粒度是「程序」
  3. 特洛伊木马问题 → 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 不带 -atar 不带 --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 在这套机制里到底怎么实现的。