目录

Linux-22 凭证模型:内核为什么只认数字,四套 uid 从哪来

10 篇讲了 uid/gid 的操作,这一篇讲它们为什么长成这样 —— 特别是那个让无数人困惑的问题:为什么一个进程需要四套 uid?

先看五个问题:

  1. 为什么需要 ruid/euid/suid/fsuid 四套 uid?两套不够吗?
  2. setuid() 的语义为什么这么怪 —— root 调用和非 root 调用的行为完全不同
  3. capabilities 把 root 拆成了 40 多个能力,为什么没能取代 SUID
  4. 为什么 setuid() 的返回值必须检查?历史上有什么著名漏洞?
  5. 容器里的 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 的历史来源

fsuidLinux 特有的(不在 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、抓包) pingtcpdump
CAP_NET_ADMIN 配置网络(接口、路由、防火墙) ipiptables
CAP_DAC_OVERRIDE 无视文件读写权限(「root 能读所有文件」的来源) 备份工具
CAP_CHOWN / CAP_FOWNER 改任意属主 / 无视「必须是属主」的检查
CAP_SETUID / CAP_SETGID 任意改变 uid/gid loginsu
CAP_KILL 给任意进程发信号
CAP_SYS_CHROOT chroot() 容器运行时
CAP_SYS_PTRACE ptrace 任意进程 gdbstrace
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/sdabrw-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 的五个原因

  1. 生态惯性 —— SUID 是 POSIX,capabilities 是 Linux 特有,跨平台软件不能依赖
  2. 五个集合的规则太复杂 —— Permitted/Effective/Inheritable/Bounding/Ambient 加上 exec 时的传递公式,绝大多数开发者不愿理解;而 chmod u+s 只需要一个动作
  3. Ambient 来得太晚(2015) —— 在此之前「让 wrapper 脚本把能力传给子进程」几乎做不到,因为 Inheritable 必须配合文件 capability,而脚本文件不能带 capability
  4. 有些能力本身就 ≈ root —— CAP_SYS_ADMIN 是垃圾桶(mount/setns/swapon/quota…)、CAP_SYS_MODULE 能加载内核模块、CAP_DAC_OVERRIDE 能改 /etc/sudoersCAP_SYS_PTRACE 能往 root 进程注入代码
  5. 文件 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_cloneuser.max_user_namespaces 限制它。

Q:NoNewPrivileges 是什么?为什么说它是最简单有效的加固?

它对应内核的 prctl(PR_SET_NO_NEW_PRIVS)本进程及其所有后代永远无法通过 exec 获得新特权 —— SUID/SGID 位被忽略、文件 capability 被忽略,而且这个标志不可撤销

有效性在于它切断了提权的主要通路:即使容器镜像或文件系统里存在 SUID 程序(passwdsudomount),被攻破的服务也无法利用它们提权。而成本几乎为零 —— 只要服务本身不依赖 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。