Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.9 进程凭证、用户、组与能力

本章回答

真实、有效、保存、文件系统 UID 各决定什么?同样是 uid=1000,为什么一个进程能读 /etc/shadow、另一个不能?setuid 程序在 exec 的那一刻发生了什么?capability 又如何把 root 的特权拆成可单独授予的位?

读完你会

读懂 /proc/self/status 里的四组 UID/GID 与五组 capability;写出 setuid-root 程序的正确降权顺序,并验证降权后回不去;用 setcap/capsh 授予单一特权而不是整个 root;解释为什么 setuid 脚本在 Linux 上无效。

2.8 把 exec 当成「换程序」的边界。但边界两侧还有一个问题没回答:换完程序之后,这个进程是谁? 内核做权限检查时,不看你的程序名,也不看启动它的用户,只看进程凭证(credentials)里那几个整数。

Unix 用 UID/GID 表示「你是谁」,用有效 UID 表示「此刻你能做什么」。这两者分离,才有了 setuid 程序:/usr/bin/passwd 的属主是 root、带 setuid 位,普通用户执行它时,真实 UID 仍是自己,有效 UID 变成 0,于是能写 /etc/shadow。这个机制简洁,但也把「一段代码在特权下运行」的风险集中到了程序作者身上。

Linux 后来加了 capability:把 root 的特权拆成几十个独立的位,进程可以只持有「绑定低端口」这一项,而不是全部。于是「不要用 root 跑服务」从口号变成了可操作的配置。

这一章的主线是「权限是凭证的函数,不是用户名的函数」。用户名只是 /etc/passwd 里的一行;内核只认 UID、GID 与 capability 集合。理解这一点,你才能解释 sudo 为什么要清环境、容器里的 root 为什么常常「不完全是 root」、以及为什么给二进制 setcap 比 chmod u+s 更安全。

2.9.1 UID 与 GID 的四件套

定义 2.9.1 · 进程的四个 UID

每个进程持有四个 UID(GID 同理):

  • 真实 UID(real UID):启动进程的用户。决定谁能给它发信号、/proc/{pid} 归谁所有。
  • 有效 UID(effective UID):内核做文件权限、capability 检查时用的身份。通常等于真实 UID,setuid 程序除外。
  • 保存 UID(saved set-user-ID):exec 时的有效 UID 快照。它让程序能在「提权」与「降权」之间来回切换。
  • 文件系统 UID(filesystem UID,Linux 专有):只用于文件访问检查。NFS 服务器常用它临时以另一个身份读写文件,而不改变有效 UID。
                  fork / exec                     setresuid(u, u, u)
  real UID   1000 ───────────────────────────►  1000   我是谁:信号、/proc 归属
  effective  1000 ──── exec setuid-root ─────►     0   我能做什么:权限检查
  saved      1000 ───────────────────────────►     0   我还能变回谁
  fs UID     1000 ───────────────────────────►  1000   文件访问用谁(Linux)
                    │
                    └─ exec /usr/bin/passwd:ruid=1000, euid=0, suid=0
                       程序可以先 seteuid(0) 干活,再 seteuid(1000) 回到用户

读图:exec 一个 setuid-root 程序时,真实 UID 不变,有效 UID 与保存 UID 变成文件属主。这三者构成一个「权限三角」:有效 UID 是当前身份,保存 UID 是退路。setuid() 在特权状态下会同时改这三个,之后就回不去了;seteuid() 只改有效 UID,保留保存 UID 作为退路。这个区别是 setuid 程序设计的核心。

四件套的用途可以用一句话概括:

UID谁在用典型场景
real内核(信号权限)、/proc 归属、审计kill 能否发给某进程
effective文件权限、capability、access 之外的检查setuid 程序的特权身份
savedseteuid 的合法目标集合提权后降回用户
fs仅文件访问(Linux)NFS、setfsuid

/proc/self/status 把这些值直接列出来:

Uid:	1000	1000	1000	1000
Gid:	1000	1000	1000	1000

四列依次是 real、effective、saved、fs。GID 同理。id 命令只显示 real/effective 与附加组,看不到 saved——这是排查 setuid 问题时容易漏掉的信息。

关于 UID 的四个误解
  1. 「UID 是用户名」——用户名只是 /etc/passwd 里的映射。删掉用户后,文件仍保留数字 UID,ls -l 显示为数字。
  2. 「root 的 UID 一定是 0」——内核只认 0。把某个普通用户的 UID 改成 0,他就成了 root;/etc/passwd 里写 root 但 UID 不是 0 的账户没有特权。
  3. 「有效 UID 就是真实 UID」——只在没有 setuid 位时成立。setuid 程序里两者不同,这正是提权的来源。
  4. 「GID 只有主组一个」——进程还有附加组(supplementary groups),权限检查时主组与附加组一起参与。newgrp、sg 改的是有效 GID。

2.9.2 用户数据库:/etc/passwd、/etc/shadow、/etc/group

三个文件的字段顺序必须记牢,排查登录问题时天天用:

# /etc/passwd —— 7 个字段,冒号分隔
alice:x:1000:1000:Alice Wang:/home/alice:/bin/bash
 │    │  │    │      │            │           └─ 登录 shell
 │    │  │    │      │            └─ 家目录
 │    │  │    │      └─ GECOS(注释)
 │    │  │    └─ 主组 GID
 │    │  └─ UID
 │    └─ 密码占位符:x 表示在 shadow 里
 └─ 用户名

# /etc/shadow —— 9 个字段
alice:$6$...:19800:0:99999:7:::
      │       │    │   │    │ └─ 过期相关
      │       │    │   │    └─ 到期前警告天数
      │       │    │   └─ 最长使用天数
      │       │    └─ 最短使用天数
      │       └─ 最后一次修改:自 1970-01-01 起的天数
      └─ 密码哈希(* 或 ! 表示禁止登录)

# /etc/group —— 4 个字段
sudo:x:27:alice,bob
 │   │  │   └─ 附加组成员(逗号分隔)
 │   │  └─ GID
 │   └─ 密码占位符
 └─ 组名

/etc/shadow 只有 root 可读(0400 或 0000 + shadow 组)。这就是为什么 passwd 必须是 setuid root:普通用户需要改自己的密码,但没有权限写 shadow。

编程接口有可重入与不可重入两套:

#include <pwd.h>
#include <grp.h>

struct passwd *getpwnam(const char *name);      /* 静态缓冲区,不可重入 */
int getpwnam_r(const char *name, struct passwd *pwd,
               char *buf, size_t buflen, struct passwd **result);

struct group *getgrnam(const char *name);
int getgrnam_r(const char *name, struct group *grp,
               char *buf, size_t buflen, struct group **result);

getpwnam 返回指向静态缓冲区的指针,下一次调用会覆盖它;多线程程序必须用 _r 版本。_r 版本的返回值语义也容易记错:返回 0 不代表找到,要看 *result 是否为 NULL。

struct passwd pwd, *result = NULL;
char buf[4096];
int rc = getpwnam_r("alice", &pwd, buf, sizeof buf, &result);
if (rc != 0) {
    errno = rc;                 /* _r 函数用返回值表示错误,不设置 errno */
    perror("getpwnam_r");
} else if (result == NULL) {
    printf("no such user\n");
} else {
    printf("uid=%ld gid=%ld home=%s shell=%s\n",
           (long)pwd.pw_uid, (long)pwd.pw_gid, pwd.pw_dir, pwd.pw_shell);
}

用户查询不一定读本地文件:glibc 的 NSS(Name Service Switch)按 /etc/nsswitch.conf 的 passwd: 行决定顺序,可能是 files systemd、files ldap、files sss。容器里 /etc/passwd 常常是镜像里的一份静态文件,和宿主机的用户数据库无关——这解释了「容器里 id 显示 uid=0 但用户名是 root」以及「挂载宿主机文件后显示数字 UID」。

2.9.3 setuid 程序:exec 那一刻发生了什么

文件权限位里有三个特殊位:

位八进制对可执行文件的效果
setuid04000exec 后有效 UID = 文件属主
setgid02000exec 后有效 GID = 文件属组
sticky01000对目录:只有属主能删自己的文件(/tmp)
sudo chown root:root ./show_creds
sudo chmod 4755 ./show_creds      # rwsr-xr-x
ls -l show_creds
# -rwsr-xr-x 1 root root ... show_creds

chmod 4755 的 s 出现在属主执行位上,表示 setuid;如果是大写 S,说明 setuid 位置了但执行位没置——这种文件不能被 exec,setuid 也不生效。

exec 时的凭证变换规则(Linux):

  1. 若文件带 setuid 位且文件系统没有以 nosuid 挂载,euid = 文件属主,suid = 文件属主;
  2. ruid 保持不变;
  3. 若文件带 setgid 位,egid/sgid 同理;
  4. 若进程有 CAP_SETUID,规则更复杂(可以保留或设置);普通用户没有,按上面三条走。

下面这个程序把四个 UID、附加组与五组 capability 全部打印出来:

/* show_creds.c:打印进程凭证与 capability(Linux) */
#include <errno.h>
#include <grp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

static void print_status_line(const char *key)
{
    FILE *fp = fopen("/proc/self/status", "r");
    if (fp == NULL)
        return;
    char line[512];
    size_t klen = strlen(key);
    while (fgets(line, sizeof line, fp) != NULL) {
        if (strncmp(line, key, klen) == 0) {
            fputs(line, stdout);       /* 原样打印,含 Uid:/Gid:/Cap* 行 */
            break;
        }
    }
    fclose(fp);
}

int main(void)
{
    printf("real      uid=%ld gid=%ld\n", (long)getuid(), (long)getgid());
    printf("effective uid=%ld gid=%ld\n", (long)geteuid(), (long)getegid());

    /* 四件套(含 saved 与 fs)只能从 /proc 看到 */
    print_status_line("Uid:");
    print_status_line("Gid:");

    /* 附加组 */
    int n = getgroups(0, NULL);
    if (n == -1)
        die("getgroups");
    gid_t *groups = calloc((size_t)n + 1, sizeof *groups);
    if (groups == NULL)
        die("calloc");
    if (getgroups(n, groups) == -1)
        die("getgroups");
    printf("supplementary groups:");
    for (int i = 0; i < n; i++)
        printf(" %ld", (long)groups[i]);
    printf("\n");
    free(groups);

    /* capability 五组集合 */
    print_status_line("CapInh:");
    print_status_line("CapPrm:");
    print_status_line("CapEff:");
    print_status_line("CapBnd:");
    print_status_line("CapAmb:");

    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g show_creds.c -o show_creds
./show_creds

普通用户运行:

real      uid=1000 gid=1000
effective uid=1000 gid=1000
Uid:	1000	1000	1000	1000
Gid:	1000	1000	1000	1000
supplementary groups: 1000 27
CapInh:	0000000000000000
CapPrm:	0000000000000000
CapEff:	0000000000000000
CapBnd:	000001ffffffffff
CapAmb:	0000000000000000

setuid root 后运行:

real      uid=1000 gid=1000
effective uid=0 gid=1000
Uid:	1000	0	0	0
Gid:	1000	1000	1000	1000
supplementary groups: 1000 27
CapInh:	0000000000000000
CapPrm:	000001ffffffffff
CapEff:	000001ffffffffff
CapBnd:	000001ffffffffff
CapAmb:	0000000000000000

两处关键差异:Uid: 行是 1000 0 0 0(real 仍是 1000,effective/saved/fs 是 0);CapEff 从 0 变成全 1。root 的特权在 Linux 上就是「capability 全集」,UID 0 只是获得这套集合的默认方式。

关于 setuid 的五个误解
  1. 「setuid 脚本也能提权」——Linux 忽略 #! 脚本上的 setuid 位。脚本由解释器执行,解释器用调用者的凭证运行。需要提权就用编译过的二进制或 sudo 规则。
  2. 「chmod u+s 一定生效」——nosuid 挂载点(常见于 /tmp、容器 rootfs、NFS)会忽略 setuid 位。
  3. 「root 什么都能做」——root 绕过的是 DAC 权限检查,不是所有检查。它仍受 capability、LSM(SELinux/AppArmor)、只读挂载、no_new_privs 限制;执行文件仍需要 x 位。
  4. 「getuid()==0 才能判断是否以 root 运行」——setuid 程序里 getuid() 是调用者,geteuid() 才是有效身份。判断特权要看 geteuid()。
  5. 「setuid 程序的 $PATH 无所谓」——setuid 程序的 PATH、IFS、TZ 等环境变量仍来自调用者,system()/popen() 里用相对命令名等于把 root 交给攻击者。

2.9.4 修改凭证:setuid 家族的规则

六个函数,名字相近,语义差别很大:

调用改哪些非特权进程能做什么
setuid(u)特权:三个一起;非特权:只改 effective非特权要求 u 等于 real 或 saved
seteuid(u)只改 effective只能设成 real 或 saved
setreuid(r, e)real、effective可以交换;设成 -1 表示不变
setresuid(r, e, s)三个分别设每个都只能是 real/effective/saved 之一
setfsuid(u)只改 fs UID只能设成 real/effective/saved/fs 之一
setuid(0)(在已降权进程里)三个失败,EPERM

关键规则:特权进程(有效 UID 为 0 或有 CAP_SETUID)可以设任意值;非特权进程的 setuid 只改有效 UID,且 u 必须是 real 或 saved 之一。特权进程一旦用 setuid(u) 把三个都设成非 0,保存 UID 也没了,就永久失去回到 root 的能力——这正是降权想要的。

/* 非特权进程:在 real 与 saved 之间切换 */
uid_t ruid = getuid();
uid_t euid = geteuid();
if (seteuid(ruid) == -1) {          /* 暂时以用户身份运行 */
    perror("seteuid");
}
/* ... 不需要特权的代码 ... */
if (seteuid(euid) == -1) {          /* 回到特权身份 */
    perror("seteuid");
}

这种「来回切换」的模式在需要长期运行的 setuid 程序里很常见:只在真正需要特权的几行代码里提权,其余时间保持用户身份。但它比「一次性降权」更难审计,能用后者就不要用前者。

定理 2.9.1 · 降权的不可逆条件

若进程以非特权身份运行(euid != 0 且无 CAP_SETUID),且通过 setresuid(u, u, u) 把 real、effective、saved 全部设为非 0 的 u,则此后调用 setuid(0) 必然失败并返回 EPERM。证明:非特权进程的 setuid(0) 要求 0 等于当前 real 或 saved,而两者都已被设为 u != 0。

2.9.5 setuid 程序的编写纪律

一个 setuid-root 程序从 exec 成功那一刻起,就以 root 身份运行。攻击面包括:参数、环境变量、当前目录、打开的 fd、配置文件、PATH、信号处理器。正确做法是尽早降权:只把需要特权的那一小段留在 root 下,其余全部以调用者身份运行。

顺序不能错:

  1. 校验调用者与输入:用 getuid() 确定真实用户,拒绝不合法参数;路径用绝对路径,打开时加 O_NOFOLLOW。
  2. 清环境:clearenv() 后重建最小环境,或用 secure_getenv() 读取(setuid 时它返回 NULL)。
  3. 降附加组:setgroups(0, NULL) 清空,或 initgroups(user, gid) 按目标用户重建。
  4. 降 GID:setresgid(g, g, g)。
  5. 降 UID:setresuid(u, u, u)。
  6. 验证:setuid(0) 必须失败(EPERM),geteuid() 必须等于目标 UID。
  7. 加固:prctl(PR_SET_NO_NEW_PRIVS, 1),禁止后续 exec 再获得新特权。

先降 GID 再降 UID:降 UID 之后就没有 CAP_SETGID 了,setgroups/setresgid 会失败。

/* drop_privs.c:setuid-root 程序的最小降权骨架(Linux) */
#include <errno.h>
#include <fcntl.h>
#include <grp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/types.h>
#include <unistd.h>

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

static void show_uid_line(void)
{
    FILE *fp = fopen("/proc/self/status", "r");
    if (fp == NULL)
        return;
    char line[256];
    while (fgets(line, sizeof line, fp) != NULL) {
        if (strncmp(line, "Uid:", 4) == 0) {
            fputs(line, stdout);
            break;
        }
    }
    fclose(fp);
}

int main(void)
{
    if (geteuid() != 0) {
        fprintf(stderr, "需要 setuid root(当前 euid=%ld)\n", (long)geteuid());
        return EXIT_FAILURE;
    }

    uid_t target_uid = getuid();       /* 调用者的真实身份 */
    gid_t target_gid = getgid();
    if (target_uid == 0) {
        fprintf(stderr, "请不要以 root 身份测试降权\n");
        return EXIT_FAILURE;
    }

    /* 特权动作:只有这一小段需要 root */
    int fd = open("/etc/shadow", O_RDONLY | O_CLOEXEC);
    printf("before drop: open /etc/shadow -> %s\n",
           fd >= 0 ? "ok" : strerror(errno));
    if (fd >= 0)
        close(fd);

    /* 1. 附加组:先清空(需要 CAP_SETGID) */
    if (setgroups(0, NULL) == -1)
        die("setgroups");

    /* 2. GID:三个一起降 */
    if (setresgid(target_gid, target_gid, target_gid) == -1)
        die("setresgid");

    /* 3. UID:三个一起降,此后无法回到 0 */
    if (setresuid(target_uid, target_uid, target_uid) == -1)
        die("setresuid");

    /* 4. 验证:不能回到 root */
    errno = 0;
    if (setuid(0) == 0 || errno != EPERM) {
        fprintf(stderr, "降权失败:仍可回到 root\n");
        return EXIT_FAILURE;
    }

    show_uid_line();

    /* 降权后再读 shadow 应当失败 */
    fd = open("/etc/shadow", O_RDONLY | O_CLOEXEC);
    printf("after  drop: open /etc/shadow -> %s\n",
           fd >= 0 ? "ok(不应该)" : strerror(errno));
    if (fd >= 0)
        close(fd);

    /* 5. 禁止后续 exec 获得新特权(setuid 二进制、file caps 都失效) */
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1)
        die("prctl");

    execlp("id", "id", (char *)NULL);
    die("execlp");                      /* exec 成功则不会到这里 */
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g drop_privs.c -o drop_privs
sudo chown root:root drop_privs && sudo chmod 4755 drop_privs
./drop_privs                       # 以普通用户执行
before drop: open /etc/shadow -> ok
Uid:	1000	1000	1000	1000
after  drop: open /etc/shadow -> Permission denied
uid=1000(alice) gid=1000(alice) groups=1000(alice)

before 与 after 两行是同一份代码、同一个进程,差别只在凭证。Uid: 四个 1000 说明三个 UID 都降了,setuid(0) 也被验证过会失败。

关于降权的四个坑
  1. 先降 UID 再降 GID——降 UID 后失去 CAP_SETGID,setgroups/setresgid 返回 EPERM,附加组还留着 root 组。
  2. 只调 seteuid 不调 setresuid——保存 UID 还是 0,程序被利用后仍可通过 seteuid(0) 回到 root。
  3. 忘了附加组——setgroups 没清,进程仍属于 shadow、docker、sudo 等组,权限比预期大。
  4. 降权后还信任环境变量——PATH、IFS、TZ、LOCPATH 都来自调用者。降权不能修复被污染的环境,必须 clearenv() 或 secure_getenv()。

2.9.6 capability:把 root 拆成位

capability 把 root 的特权分成约 40 个独立的位(Linux 6.8 有 41 个)。每个进程有五组集合:

                  exec(普通程序)              exec(setuid root / file caps)
  Permitted  P  ── 保留 ───────────────────►  按规则重算:P' = (I & F) | (F & B) | A
  Effective  E  ── 清空 ───────────────────►  E' = P'(若文件有 setuid 或 file effective 位)
  Inheritable I ─ 保留 ───────────────────►  I' = I
  Bounding   B  ── 保留 ───────────────────►  B' = B(只能减,不能增)
  Ambient    A  ── 清空(非特权)──────────►  A' = (I & P) 若可继承

读图:Bounding 是上限,从父进程继承后只能通过 PR_CAPBSET_DROP 减少;Permitted 是可用池,Effective 是当前真正生效的子集;Inheritable 只有在文件也声明了 inheritable 位时才参与;Ambient 是 Linux 4.3 加的,让非特权程序也能把 capability 带过 exec。

/proc/self/status 的十六进制就是这些集合。第 0 位是 CAP_CHOWN,第 1 位 CAP_DAC_OVERRIDE,第 2 位 CAP_DAC_READ_SEARCH,第 6 位 CAP_SETGID,第 7 位 CAP_SETUID,第 12 位 CAP_NET_ADMIN,第 13 位 CAP_NET_RAW,第 21 位 CAP_SYS_ADMIN。

常用工具:

# 给二进制授予「绑定 80 端口」的能力,而不是整个 root
sudo setcap cap_net_bind_service=+ep /usr/local/bin/myserver
getcap /usr/local/bin/myserver
# /usr/local/bin/myserver cap_net_bind_service=ep

# 查看当前 shell 的 capability
capsh --print | head -5

# 在受限 capability 集合里运行命令
capsh --drop=cap_net_raw -- -c 'ping -c1 127.0.0.1'

+ep 表示把该 capability 同时放进 Permitted 与 Effective。文件 capability 与 setuid 位有交互:如果文件有 file capability 且带 +e,exec 后 Effective 直接置位;如果只有 +p,程序需要自己 capset 提升。

在程序里操作 capability 需要用 capget/capset 系统调用(glibc 没有包装,用 syscall());libcap 提供了更友好的封装。下面的程序演示「以 root 启动,但主动放弃 CAP_NET_RAW」:

/* cap_drop.c:从 bounding 与 effective 集合里移除 CAP_NET_RAW(Linux) */
#include <errno.h>
#include <linux/capability.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/socket.h>
#include <sys/syscall.h>
#include <unistd.h>

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

static int try_raw_socket(void)
{
    int fd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (fd == -1)
        return -errno;
    close(fd);
    return 0;
}

int main(void)
{
    int rc = try_raw_socket();
    printf("before: raw socket -> %s\n", rc == 0 ? "ok" : strerror(-rc));

    /* 1. 从 bounding 集合移除:此后 exec 也不能再获得 */
    if (prctl(PR_CAPBSET_DROP, CAP_NET_RAW, 0, 0, 0) == -1)
        die("prctl(PR_CAPBSET_DROP)");

    /* 2. 从 permitted/effective 里移除 */
    struct __user_cap_header_struct hdr;
    struct __user_cap_data_struct data[2];
    memset(&hdr, 0, sizeof hdr);
    memset(data, 0, sizeof data);
    hdr.version = _LINUX_CAPABILITY_VERSION_3;
    hdr.pid = 0;                       /* 0 表示当前进程 */
    if (syscall(SYS_capget, &hdr, data) == -1)
        die("capget");

    unsigned int idx = CAP_TO_INDEX(CAP_NET_RAW);
    unsigned int mask = CAP_TO_MASK(CAP_NET_RAW);
    data[idx].effective &= ~mask;
    data[idx].permitted &= ~mask;

    if (syscall(SYS_capset, &hdr, data) == -1)
        die("capset");

    rc = try_raw_socket();
    printf("after : raw socket -> %s\n", rc == 0 ? "ok(不应该)" : strerror(-rc));
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g cap_drop.c -o cap_drop
sudo ./cap_drop
# before: raw socket -> ok
# after : raw socket -> Operation not permitted

PR_CAPBSET_DROP 需要 CAP_SETPCAP;以 root 启动时默认持有。移除后即使再 exec 一个 setuid-root 程序,bounding 集合也不会让它重新获得该 capability——这是容器运行时(runc、CRI-O)限制容器能力的基础。

`no_new_privs` 与 seccomp

prctl(PR_SET_NO_NEW_PRIVS, 1) 让后续 execve 不再授予任何新特权:setuid 位、setgid 位、file capability 全部失效。它是单向的,一旦设置无法撤销,且会被子进程继承。seccomp 过滤器要求进程先设置 no_new_privs(或有 CAP_SYS_ADMIN),否则 seccomp(SECCOMP_SET_MODE_FILTER, ...) 返回 EACCES。容器默认的 seccomp profile 就是在这套机制上工作的(第 5 篇展开)。

2.9.7 凭证的内核表示:struct cred 与提交路径

这一节回答:内核把「你是谁」存在哪里,setresuid() 修改凭证时为什么不是直接改 task_struct,capable() 与 ns_capable() 的区别在哪。

struct cred 的字段

内核用 struct cred 表示一份不可变的凭证快照,task_struct 里有两个指针:real_cred(真实凭证,用于信号与审计)与 cred(有效凭证,用于权限检查)。关键字段:

字段类型含义对应用户态
uid/gidkuid_t/kgid_t真实 UID/GIDgetuid()/getgid()
euid/egidkuid_t/kgid_t有效 UID/GIDgeteuid()/getegid()
suid/sgidkuid_t/kgid_t保存 UID/GID/proc/status 第三列
fsuid/fsgidkuid_t/kgid_t文件系统 UID/GIDsetfsuid()
cap_inheritablekernel_cap_t可继承集合 ICapInh
cap_permittedkernel_cap_t许可集合 PCapPrm
cap_effectivekernel_cap_t有效集合 ECapEff
cap_bsetkernel_cap_t边界集合 BCapBnd
cap_ambientkernel_cap_t环境集合 ACapAmb
securebitsunsignedSECBIT_* 标志无直接用户接口
user_nsstruct user_namespace *所属用户命名空间/proc/pid/ns/user
group_infostruct group_info *附加组数组getgroups()
securityvoid *LSM 私有数据SELinux/AppArmor 标签

kuid_t 不是 uid_t:它是内核内部的 UID 表示,带用户命名空间信息。同一个 kuid_t 在不同命名空间里 from_kuid() 出来的数字可能不同——这是容器里「uid 0 在宿主机上是 100000」的底层机制。

修改凭证:copy-on-write 的 cred

凭证被设计成不可变:任何修改都走「复制—改副本—原子提交」三步:

setresuid(ruid, euid, suid)
  └─ __sys_setresuid()
       ├─ prepare_creds()        复制当前 cred,引用计数 +1
       ├─ 权限检查                ns_capable_setid(CAP_SETUID) 或值必须是现有 r/e/s 之一
       ├─ 修改 new->uid/euid/suid(以及 fsuid,视规则)
       └─ commit_creds(new)      rcu_assign_pointer(current->cred, new),旧 cred 延迟释放

prepare_creds() 用 kmem_cache_alloc(cred_jar) 分配,commit_creds() 用 RCU 替换指针。读路径(current_cred())不需要锁,因为凭证一旦提交就不再修改。这带来两个工程后果:

  • 信号处理器里读 geteuid() 是安全的(读的是当前 cred),但改凭证不是 async-signal-safe;
  • 多线程进程里,setresuid() 只改调用线程的 cred;Linux 的 cred 是 per-thread 的(虽然 POSIX 要求进程级凭证,glibc 通过 setxid 机制向所有线程广播,见 nptl 的 __nptl_setxid)。

capable() 与 ns_capable()

权限检查的入口是 capable(cap),它等价于 ns_capable(&init_user_ns, cap):检查当前进程的有效 capability 是否包含该位,且该位在目标用户命名空间里有效。ns_capable(ns, cap) 用于「在某个命名空间里是否有权限」,容器运行时的检查几乎都用它:

  • 容器里的 root 持有 CAP_SYS_ADMIN,但只在自己的用户命名空间里有效;对宿主机命名空间调用 ns_capable(&init_user_ns, CAP_SYS_ADMIN) 返回 false。
  • file_ns_capable(file, ns, cap) 检查「打开这个文件的进程是否有权限」,用于 /proc 等伪文件。

LSM 在 capability 检查之后插入钩子:security_capable() 先走 commoncap 的 cap_capable(),再走 SELinux/AppArmor。所以「root 被拒绝」可能来自 capability 不足、LSM 拒绝、只读挂载、seccomp,这四层的报错都是 EPERM/EACCES,要靠 dmesg/audit 日志区分。

`/proc/{pid}/status` 的 Cap 行怎么读

CapEff: 000001ffffffffff 是十六进制位图,第 0 位是 CAP_CHOWN。用 capsh --decode=000001ffffffffff 可以把位图翻成名字列表。CapBnd 是边界集合,容器里通常被裁剪;CapAmb 非 0 说明进程用了 ambient capability。

2.9.8 setuid 家族的完整规则矩阵

这一节回答:特权与非特权进程调用 setuid/seteuid/setreuid/setresuid 时,各自能改哪些字段、失败条件是什么;setfsuid 为什么存在;exec 时凭证如何被文件 capability 与 no_new_privs 改写。

规则矩阵

设调用前凭证为 (r, e, s),实参为 (R, E, S)(-1 表示不变),当前进程是否有 CAP_SETUID 决定走哪一列:

调用有 CAP_SETUID无 CAP_SETUID失败 errno
setuid(u)r=e=s=u,fsuid=u若 u==r 或 u==s:只改 e=u;否则失败EPERM
seteuid(u)e=u,fsuid=u若 u==r 或 u==s:e=u;否则失败EPERM
setreuid(r,e)任意设置 r、e;s 可能被更新每个实参必须是当前 r/e/s 之一;若 r 被设置,或 e 被设成不等于旧 r 的值,s 被设为新 eEPERM
setresuid(r,e,s)任意设置三个每个实参必须是当前 r/e/s 之一EPERM
setfsuid(u)任意必须是当前 r/e/s/fsuid 之一返回旧 fsuid(无错误返回)
setgroups(n, list)任意需要 CAP_SETGID;无权限时失败EPERM
setgid/setegid/setregid/setresgid同 UID,换成 CAP_SETGID同 UID 规则EPERM

两个容易忽略的细节:

  • setreuid 会动保存 UID。POSIX 规定:如果 r 被设置,或 e 被设成不等于旧 r 的值,则 s 被设为新 e。这会让「先 setreuid(uid, 0) 提权、再 setreuid(0, uid) 降权」的旧代码在第二次调用后永久失去 root(s 已被改成 uid)。用 setresuid 显式控制三个值更安全。
  • setfsuid 没有错误返回。它返回旧的 fsuid;失败时返回的还是旧值,调用者无法区分「成功设成同一个值」与「失败」。正确用法是调用后再 setfsuid(-1) 读一次(传 -1 不改变),比较是否等于目标。这也是 NFS 服务器代码里常见的写法。
关于 `setuid` 规则的四个误解
  1. 「非特权进程调用 setuid(getuid()) 什么也不做」——它会改有效 UID(如果当前 euid 不是 ruid),并可能同步 fsuid。对 setuid 程序来说,这是降权的第一步。
  2. 「seteuid(0) 在降权后仍能成功」——只有保存 UID 还是 0 时才行。setresuid(u,u,u) 之后保存 UID 也是 u,seteuid(0) 返回 EPERM。
  3. 「setresuid(-1, -1, -1) 是合法空操作」——是空操作,但仍会走 prepare_creds()/commit_creds(),在 seccomp 严格过滤或 LSM 审计下会留下记录。热路径上不要用它「探测」。
  4. 「setgroups 只影响附加组,不影响权限」——附加组参与文件权限检查(组读/组执行位),也参与 newgrp、sg、docker 组等特权判定。降权时必须清空或重建。

程序:实测非特权进程的 setresuid 合法集合

下面的程序不要求 setuid,直接以普通用户运行;它逐个尝试「设为 real」「设为 saved」「设为 0」,把规则矩阵变成可观测的返回值。

/* cred_rules.c:非特权进程中 setresuid 的合法目标集合 */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>

static void show_ids(const char *tag)
{
    uid_t r, e, s;
    gid_t gr, ge, gs;
    if (getresuid(&r, &e, &s) == -1 || getresgid(&gr, &ge, &gs) == -1) {
        perror("getresuid/getresgid");
        exit(EXIT_FAILURE);
    }
    printf("%s: ruid=%ld euid=%ld suid=%ld rgid=%ld egid=%ld sgid=%ld\n",
           tag, (long)r, (long)e, (long)s, (long)gr, (long)ge, (long)gs);
}

int main(void)
{
    uid_t r, e, s;
    if (getresuid(&r, &e, &s) == -1) {
        perror("getresuid");
        return EXIT_FAILURE;
    }

    show_ids("start ");

    /* 1. 设成真实 UID:任何进程都允许 */
    errno = 0;
    if (setresuid(r, r, s) == -1)
        printf("setresuid(r,r,s): %s\n", strerror(errno));
    else
        printf("setresuid(r,r,s): ok\n");
    show_ids("after1");

    /* 2. 设成保存 UID:允许,但这里 s 通常等于 r,等于没变 */
    errno = 0;
    if (setresuid(r, s, s) == -1)
        printf("setresuid(r,s,s): %s\n", strerror(errno));
    else
        printf("setresuid(r,s,s): ok\n");

    /* 3. 设成 0:只有本来就有 0 在 r/e/s 里才允许 */
    errno = 0;
    if (setresuid(0, 0, 0) == -1)
        printf("setresuid(0,0,0): %s (expected for non-root)\n", strerror(errno));
    else
        printf("setresuid(0,0,0): ok (进程本来就有 root 权限)\n");

    show_ids("end   ");
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g cred_rules.c -o cred_rules
./cred_rules
start : ruid=1000 euid=1000 suid=1000 rgid=1000 egid=1000 sgid=1000
setresuid(r,r,s): ok
setresuid(r,s,s): ok
setresuid(0,0,0): Operation not permitted (expected for non-root)
end   : ruid=1000 euid=1000 suid=1000 rgid=1000 egid=1000 sgid=1000

若以 root 运行,第三行会变成 ok,end 行全是 0。把程序 chmod 4755、属主设为 root 后再以普通用户运行,start 行会变成 ruid=1000 euid=0 suid=0,第三行仍然 ok——因为此时 s=0,setresuid(0,0,0) 是合法目标。

exec 时凭证如何被改写

execve() 的凭证变换由文件属性与进程状态共同决定:

文件属性进程条件exec 后 euid/egid其它
无 setuid/setgid、无 file caps任意不变普通 exec
setuid 位文件系统非 nosuideuid = 文件属主,suid = 文件属主若进程有 CAP_SETUID,按 SECBIT_* 规则可能保留
setgid 位非 nosuidegid = 文件属组,sgid 同理附加组不变
file capability +e非 nosuid、文件系统支持 xattr不变E' = P'(effective 直接置位)
file capability +p同上不变P' 增加,E' 需程序自己 capset
任意no_new_privs = 1不变setuid/setgid/file caps 全部忽略

AT_SECURE 是这套规则的「用户态可见信号」:当 exec 使 euid 与 ruid 不同,或授予了 file capability 时,内核在 auxv 里设置 AT_SECURE=1。glibc 收到后进入安全模式:

  • 忽略 LD_PRELOAD、LD_LIBRARY_PATH、LD_AUDIT;
  • secure_getenv() 对这些变量返回 NULL;
  • getenv("IFS") 等仍可读,但 system()/popen() 的危险不变。

程序自己可以用 getauxval(AT_SECURE) 判断是否处于 setuid 场景,从而决定是否信任环境变量。

SECBIT_*:root 特权的细粒度开关

prctl(PR_SET_SECUREBITS, bits) 控制内核对 root 的特殊处理:

位含义
SECBIT_NOROOTUID 0 不再自动获得全部 capability(需要 file caps 或显式提升)
SECBIT_NOROOT_LOCKED上者不可撤销
SECBIT_NO_SETUID_FIXUP改变 UID 时不再自动调整 capability
SECBIT_NO_SETUID_FIXUP_LOCKED上者不可撤销
SECBIT_KEEP_CAPS从 root 降到非 root 时保留 permitted
SECBIT_KEEP_CAPS_LOCKED上者不可撤销
SECBIT_NO_CAP_AMBIENT_RAISE禁止提升 ambient
SECBIT_NO_CAP_AMBIENT_RAISE_LOCKED上者不可撤销

SECBIT_KEEP_CAPS 是「切换 UID 但保留 capability」的标准做法:先 prctl(PR_SET_SECUREBITS, SECBIT_KEEP_CAPS),再 setresuid(nobody, nobody, nobody),cap_permitted 不被清空,随后可以 capset 把需要的位放进 effective。systemd 的 User= + AmbientCapabilities= 内部就用这套机制。

2.9.9 用户数据库的解析、NSS 与 _r 函数的正确用法

这一节回答:getpwnam_r 的 ERANGE 循环怎么写,getgrouplist 与 initgroups 的区别,NSS 如何让「用户」不再只是 /etc/passwd。

_r 函数的三态返回

getpwnam_r(name, &pwd, buf, buflen, &result) 有三种结果,必须分开处理:

返回值*result含义
0非 NULL找到,数据在 pwd 与 buf 里
0NULL没找到(不是错误)
ERANGE未定义缓冲区太小,需要更大的 buf
其它错误码未定义EIO、ENOMEM、NSS 后端错误

ERANGE 不是「失败」,而是「重试」。正确写法是从一个合理大小开始,按需翻倍,直到成功或超过上限:

/* getpwnam_r 的 ERANGE 重试骨架 */
static int lookup_user(const char *name, struct passwd *out, char **buf_out)
{
    size_t cap = 1024;
    for (;;) {
        char *buf = malloc(cap);
        if (buf == NULL)
            return ENOMEM;
        struct passwd *result = NULL;
        int rc = getpwnam_r(name, out, buf, cap, &result);
        if (rc == 0 && result != NULL) {
            *buf_out = buf;          /* 调用者负责 free */
            return 0;
        }
        free(buf);
        if (rc == 0)
            return ENOENT;           /* 没找到 */
        if (rc != ERANGE)
            return rc;               /* 真正的错误 */
        if (cap >= (1u << 20))
            return ERANGE;           /* 防御:1 MiB 还不够,放弃 */
        cap *= 2;
    }
}

注意 out 结构体里的 pw_name/pw_dir/pw_shell 指针指向 buf,所以 buf 必须和 out 活得一样久;返回后立刻 strdup 再 free(buf) 是常见做法。

getgrouplist 与 initgroups

  • getgrouplist(user, group, groups, &ngroups):只查询,把用户的所有组(主组 + /etc/group 里的附加组)填进数组;不改变进程状态。缓冲区不够时返回 -1,并把 ngroups 设为所需数量,需要重试。
  • initgroups(user, group):查询并设置,等价于 setgroups() 把当前进程的附加组设为该用户的组列表;需要 CAP_SETGID。setuid 程序降权时用它把附加组重建为目标用户的组,而不是简单清空。
/* initgroups 降权片段:需要 euid=0 */
if (initgroups(target_user, target_gid) == -1)
    die("initgroups");
if (setresgid(target_gid, target_gid, target_gid) == -1)
    die("setresgid");
if (setresuid(target_uid, target_uid, target_uid) == -1)
    die("setresuid");

顺序仍然是「附加组 → GID → UID」。initgroups 内部会读 NSS,可能触发网络查询(LDAP/SSSD),在 chroot 或容器里失败时要决定是「清空附加组继续」还是「拒绝启动」——安全默认是后者。

NSS:/etc/passwd 只是其中一个后端

glibc 的 NSS 按 /etc/nsswitch.conf 的 passwd:、group: 行决定查询顺序:

passwd: files systemd
group:  files systemd

files 读 /etc/passwd,systemd 通过 systemd-userdbd 提供动态用户(systemd-homed、DynamicUser=),sss/ldap 走网络。getent passwd alice 走的就是完整 NSS 链,而 grep alice /etc/passwd 只查本地文件——排查「id 能查到但 grep 查不到」时,先看 nsswitch.conf。

容器里的用户数据库通常是镜像自带的静态 /etc/passwd,与宿主机无关。挂载宿主机的 /etc/passwd 或共享 UID 时,容器内外的数字 UID 相同但用户名映射可能不同;ls -l 显示数字 UID 就是「当前 NSS 链里没有这个 UID 的名字」。

用户命名空间里的 UID 映射

/proc/{pid}/uid_map 描述「命名空间内 UID → 父命名空间 UID」的映射:

         0       1000          1

表示命名空间内的 UID 0 映射到父命名空间的 UID 1000,长度为 1。unshare -Ur 会写一个单行映射,让当前用户在新命名空间里成为 root;但这份 root 只在命名空间内有效,宿主机上仍是原 UID。这解释了:

  • 容器里 id 显示 uid=0(root),宿主机 ps 看到的是 100000;
  • 容器里的 root 对挂载的宿主机文件仍受宿主机 UID 检查;
  • CAP_SYS_ADMIN 在容器内有效,对宿主机命名空间无效(ns_capable(&init_user_ns, ...) 为 false)。

程序:_r 函数的两段式重试

/* nss_lookup.c:getpwnam_r 的 ERANGE 重试 + getgrouplist 的两段式分配 */
#include <errno.h>
#include <grp.h>
#include <pwd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static int lookup_user(const char *name, struct passwd *pw, char **bufp)
{
    size_t cap = 1024;
    for (;;) {
        char *buf = malloc(cap);
        if (buf == NULL)
            return ENOMEM;
        struct passwd *res = NULL;
        int rc = getpwnam_r(name, pw, buf, cap, &res);
        if (rc == 0 && res != NULL) {
            *bufp = buf;
            return 0;
        }
        free(buf);
        if (rc == 0)
            return ENOENT;
        if (rc != ERANGE)
            return rc;
        if (cap >= (1u << 20))
            return ERANGE;
        cap *= 2;
    }
}

static void print_groups(const char *user, gid_t gid)
{
    int n = 16;
    gid_t *groups = NULL;
    for (;;) {
        gid_t *tmp = realloc(groups, (size_t)n * sizeof *tmp);
        if (tmp == NULL) {
            free(groups);
            perror("realloc");
            return;
        }
        groups = tmp;
        int want = n;
        int ret = getgrouplist(user, gid, (void *)groups, &want);
        if (ret != -1) {
            n = ret;
            break;
        }
        n = want;                 /* 缓冲区不够:按所需数量重来 */
        if (n > 65536) {
            free(groups);
            fprintf(stderr, "too many groups\n");
            return;
        }
    }

    /* 注:Linux 原型是 gid_t *,macOS 是 int *;这里用 void * 兼容两种 libc */
    printf("groups(%d):", n);
    for (int i = 0; i < n; i++) {
        char buf[1024];
        struct group gr, *gres = NULL;
        if (getgrgid_r(groups[i], &gr, buf, sizeof buf, &gres) == 0 && gres != NULL)
            printf(" %s(%ld)", gr.gr_name, (long)groups[i]);
        else
            printf(" %ld", (long)groups[i]);
    }
    printf("\n");
    free(groups);
}

int main(int argc, char **argv)
{
    char name[256];
    if (argc > 1) {
        snprintf(name, sizeof name, "%s", argv[1]);
    } else {
        struct passwd *self = getpwuid(getuid());
        if (self == NULL) {
            fprintf(stderr, "getpwuid(%ld) failed\n", (long)getuid());
            return EXIT_FAILURE;
        }
        snprintf(name, sizeof name, "%s", self->pw_name);
    }

    struct passwd pw;
    char *buf = NULL;
    int rc = lookup_user(name, &pw, &buf);
    if (rc == ENOENT) {
        printf("%s: no such user\n", name);
        return EXIT_SUCCESS;
    }
    if (rc != 0) {
        fprintf(stderr, "getpwnam_r(%s): %s\n", name, strerror(rc));
        return EXIT_FAILURE;
    }

    printf("user=%s uid=%ld gid=%ld home=%s shell=%s\n",
           pw.pw_name, (long)pw.pw_uid, (long)pw.pw_gid,
           pw.pw_dir, pw.pw_shell);
    print_groups(name, pw.pw_gid);
    free(buf);
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g nss_lookup.c -o nss_lookup
./nss_lookup alice
getent passwd alice
user=alice uid=1000 gid=1000 home=/home/alice shell=/bin/bash
groups(3): alice(1000) sudo(27) docker(999)
alice:x:1000:1000:Alice Wang:/home/alice:/bin/bash

groups(3) 的三项来自 getgrouplist:主组 1000 加 /etc/group 里的两个附加组。如果 getpwnam_r 第一次的 1024 字节缓冲区不够(长 GECOS、NIS 后端),会走 ERANGE 分支翻倍;把 buf 的大小改成 16 字节再跑,能稳定复现几次重试。getent 的输出与程序一致,说明两者走的是同一条 NSS 链。

2.9.10 capability 的五组集合、capset() 与 ambient

这一节回答:exec 时五组集合的精确变换公式,capset() 能改哪些集合,ambient capability 解决什么问题,file capability 的 xattr 长什么样。

exec 的变换公式

以 P/E/I/B/A 表示进程的 permitted/effective/inheritable/bounding/ambient,F 表示文件的 permitted/inheritable/effective 位:

P'(ambient)     = (文件是特权文件) ? 0 : P(ambient)
P'(permitted)   = (I & F(inheritable)) | (F(permitted) & B) | P'(ambient)
P'(effective)   = F(effective) ? P'(permitted) : P'(ambient)
P'(inheritable) = I
P'(bounding)    = B

「特权文件」指带 setuid/setgid 位或 file capability 的文件。若进程的 real 或 effective UID 为 0,内核会把 F(inheritable)/F(permitted) 当作全 1(受 SECBIT_NOROOT 控制),这就是「root 执行普通程序仍有全部 capability」的来源。

三条推论:

  1. 只有 +p 的 file capability 不会自动生效:P' 有了,但 E' 为空(F(effective)=0),程序必须自己 capset 把位搬进 effective。
  2. bounding 是硬上限:F(permitted) & B,所以 PR_CAPBSET_DROP 掉的位,即使文件有也拿不到。
  3. ambient 能让非特权程序跨 exec 保留 capability:A 非空时,P' 与 E' 都包含它;但 ambient 的位必须同时在 P 与 I 里,且不能被 SECBIT_NO_CAP_AMBIENT_RAISE 禁止。

capset() 能改什么

capset() 接收 (effective, inheritable, permitted) 三个集合,内核(commoncap 的 cap_capset)检查:

  • 新 effective 必须是新 permitted 的子集;
  • 新 permitted 不能超出「旧 permitted ∪ 旧 inheritable」,除非有 CAP_SETPCAP;
  • 新 inheritable 不能超出「旧 inheritable ∪ 旧 permitted」,除非有 CAP_SETPCAP;
  • 不能触碰 bounding(那是 PR_CAPBSET_DROP 的事)。

capget()/capset() 的数据结构是版本化的:

struct __user_cap_header_struct {
    __u32 version;   /* _LINUX_CAPABILITY_VERSION_3 */
    int pid;         /* 0 = 当前进程;>0 = 指定进程(需要权限) */
};
struct __user_cap_data_struct {
    __u32 effective;
    __u32 permitted;
    __u32 inheritable;
};
/* 64 位 capability 需要两个元素:data[0] 是 cap 0–31,data[1] 是 32–63 */

CAP_TO_INDEX(cap) 算下标(cap >> 5),CAP_TO_MASK(cap) 算位(1u << (cap & 31))。Linux 6.8 有 41 个 capability(CAP_CHECKPOINT_RESTORE=40),所以 data[1] 只用到低 9 位。

ambient capability 的规则

prctl(PR_CAP_AMBIENT, ...) 有四个子命令:

子命令作用条件
PR_CAP_AMBIENT_IS_SET查询某位是否在 ambient无
PR_CAP_AMBIENT_RAISE把某位加入 ambient该位必须在 P 与 I 中,且 SECBIT_NO_CAP_AMBIENT_RAISE 未置
PR_CAP_AMBIENT_LOWER从 ambient 移除无
PR_CAP_AMBIENT_CLEAR_ALL清空 ambient无

典型用法:一个以 root 启动的服务,想在下拉 UID 后仍保留 CAP_NET_BIND_SERVICE 绑定 80 端口:

/* 把 CAP_NET_BIND_SERVICE 搬进 inheritable + permitted,再提升 ambient */
cap_value_t cap = CAP_NET_BIND_SERVICE;
cap_t caps = cap_get_proc();                 /* libcap */
cap_set_flag(caps, CAP_INHERITABLE, 1, &cap, CAP_SET);
cap_set_flag(caps, CAP_PERMITTED,   1, &cap, CAP_SET);
cap_set_proc(caps);
prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, CAP_NET_BIND_SERVICE, 0, 0);
cap_free(caps);

/* 之后 setresuid 到非 root,exec 新程序,ambient 仍在 */

不用 libcap 时,用 capget/capset 手工改 data[0].inheritable/permitted,再 prctl。注意 capset 不检查 ambient 的一致性;提升 ambient 时内核才检查「必须在 P 与 I 中」。

file capability 的 xattr 格式

setcap 把 capability 写进文件的 security.capability 扩展属性,格式是:

struct vfs_cap_data {
    __u32 magic_etc;          /* 低 24 位:revision;0x000001:effective 标志 */
    struct {
        __u32 permitted;
        __u32 inheritable;
    } data[2];                /* data[0] 是 cap 0–31,data[1] 是 32–63 */
};
/* revision 3(Linux 4.14+,用户命名空间)多一个 rootid 字段 */

getcap 读的就是这个 xattr。magic_etc 的 revision 常见值:0x02000000(VFS_CAP_REVISION_2)、0x03000000(REVISION_3)。+e 对应 VFS_CAP_FLAGS_EFFECTIVE(0x000001)。rootid 是「这个文件属于哪个用户命名空间的 root」,在容器里决定 capability 对谁有效。

关于 capability 的四个误解
  1. 「setcap 设置的 capability 对所有用户生效」——生效,但受文件系统 nosuid、no_new_privs、bounding 集合限制;容器里挂载的 nosuid 会让它失效。
  2. 「CapBnd 可以通过 capset 增加」——不能。bounding 只能通过 PR_CAPBSET_DROP 减少,且需要 CAP_SETPCAP。
  3. 「ambient 可以随便提升」——必须先在 permitted 与 inheritable 里,且 PR_CAP_AMBIENT_RAISE 在 SECBIT_NO_CAP_AMBIENT_RAISE 下失败。
  4. 「root 永远有全部 capability」——SECBIT_NOROOT、PR_CAPBSET_DROP、容器 runtime 的默认裁剪都会让 root 只剩子集。

程序:解码一个二进制的 file capability

/* file_caps.c:读取并解码 security.capability xattr(Linux) */
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/xattr.h>
#include <unistd.h>

#define VFS_CAP_REVISION_MASK 0xff000000u
#define VFS_CAP_REVISION_2    0x02000000u
#define VFS_CAP_REVISION_3    0x03000000u
#define VFS_CAP_FLAGS_EFFECTIVE 0x000001u
#define CAP_LAST 40

struct cap_data { uint32_t permitted, inheritable; };

static const char *cap_name(int bit)
{
    switch (bit) {
    case 0:  return "CAP_CHOWN";
    case 1:  return "CAP_DAC_OVERRIDE";
    case 2:  return "CAP_DAC_READ_SEARCH";
    case 6:  return "CAP_SETGID";
    case 7:  return "CAP_SETUID";
    case 8:  return "CAP_SETPCAP";
    case 10: return "CAP_NET_BIND_SERVICE";
    case 12: return "CAP_NET_ADMIN";
    case 13: return "CAP_NET_RAW";
    case 19: return "CAP_SYS_PTRACE";
    case 21: return "CAP_SYS_ADMIN";
    case 23: return "CAP_SYS_NICE";
    case 25: return "CAP_SYS_TIME";
    case 27: return "CAP_MKNOD";
    case 31: return "CAP_SETFCAP";
    case 38: return "CAP_PERFMON";
    case 39: return "CAP_BPF";
    case 40: return "CAP_CHECKPOINT_RESTORE";
    default: return "CAP_?";
    }
}

static void dump_set(const char *label, uint32_t lo, uint32_t hi)
{
    printf("%s:", label);
    int any = 0;
    for (int bit = 0; bit <= CAP_LAST; bit++) {
        uint32_t word = bit < 32 ? lo : hi;
        if (word & (1u << (bit & 31))) {
            printf(" %s(%d)", cap_name(bit), bit);
            any = 1;
        }
    }
    if (!any)
        printf(" (empty)");
    printf("\n");
}

int main(int argc, char **argv)
{
    if (argc != 2) {
        fprintf(stderr, "usage: %s <file>\n", argv[0]);
        return EXIT_FAILURE;
    }

    unsigned char buf[64];
    ssize_t n = getxattr(argv[1], "security.capability", buf, sizeof buf);
    if (n == -1) {
        if (errno == ENODATA) {   /* Linux 没有 ENOATTR;那是 macOS/BSD */
            printf("%s: no file capability\n", argv[1]);
            return EXIT_SUCCESS;
        }
        perror("getxattr");
        return EXIT_FAILURE;
    }
    if (n < 12) {
        fprintf(stderr, "short xattr (%zd bytes)\n", n);
        return EXIT_FAILURE;
    }

    uint32_t magic;
    struct cap_data d[2];
    memcpy(&magic, buf, 4);
    memcpy(&d[0], buf + 4, 8);
    memcpy(&d[1], buf + 12, 8);

    uint32_t rev = magic & VFS_CAP_REVISION_MASK;
    printf("magic=0x%08x revision=%u effective_flag=%u\n",
           magic, rev >> 24, (magic & VFS_CAP_FLAGS_EFFECTIVE) ? 1 : 0);
    if (rev != VFS_CAP_REVISION_2 && rev != VFS_CAP_REVISION_3) {
        printf("unknown revision, raw bytes:");
        for (ssize_t i = 0; i < n; i++)
            printf(" %02x", buf[i]);
        printf("\n");
        return EXIT_SUCCESS;
    }

    dump_set("permitted ", d[0].permitted, d[1].permitted);
    dump_set("inheritable", d[0].inheritable, d[1].inheritable);

    if (rev == VFS_CAP_REVISION_3) {
        uint32_t rootid = 0;
        if (n >= 20)
            memcpy(&rootid, buf + 20, 4);
        printf("rootid=%u (file capability valid in that user namespace)\n", rootid);
    }

    struct stat st;
    if (stat(argv[1], &st) == 0)
        printf("mode=%04o setuid=%d setgid=%d\n", st.st_mode & 07777,
               (st.st_mode & S_ISUID) ? 1 : 0, (st.st_mode & S_ISGID) ? 1 : 0);
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g file_caps.c -o file_caps
sudo setcap cap_net_bind_service=ep /usr/bin/python3.12   # 示例,勿在生产机上做
./file_caps /usr/bin/python3.12
sudo setcap -r /usr/bin/python3.12

不要在生产机上对系统解释器 setcap:它会让任何 Python 脚本获得绑定低端口的能力,扩大攻击面。测试请复制一个自己的二进制。

预期输出:

magic=0x02000001 revision=2 effective_flag=1
permitted : CAP_NET_BIND_SERVICE(10)
inheritable: (empty)
mode=0755 setuid=0 setgid=0

magic 的低位 1 表示 effective 标志,所以 +ep 里的 e 是文件属性;permitted 只有第 10 位。若看到 revision=3 且 rootid=100000,说明这个文件是在用户命名空间里设置的,capability 只对该命名空间的 root 有效。

2.9.11 setuid 程序的攻击面与加固清单

这一节回答:一个 setuid-root 程序在真正开始干活之前,必须假设哪些东西已被攻击者控制;每一类攻击面对应的加固动作是什么。

攻击面清单

攻击面攻击方式加固
环境变量LD_PRELOAD、LD_LIBRARY_PATH、LD_AUDIT、GCONV_PATH、LOCPATH、TZ、MALLOC_*glibc 在 AT_SECURE 下忽略前三个;其余用 clearenv() + secure_getenv()
PATH把恶意程序放进 PATH,等程序 system("ls")用绝对路径;清空 PATH 后设成 /usr/bin:/bin
当前目录相对路径打开配置文件/临时文件chdir("/");open 用绝对路径 + O_NOFOLLOW
fd 0/1/2关闭或指向攻击者控制的文件/管道启动时 open("/dev/null") 补齐 0/1/2,检查 fstat
umask影响创建文件的权限显式 umask(022) 或 fchmod
core dump崩溃时把内存(含密码)写到磁盘setrlimit(RLIMIT_CORE, 0)、prctl(PR_SET_DUMPABLE, 0)
ptrace附加到 setuid 进程读内存PR_SET_DUMPABLE=0 后非 root 无法 attach
信号处理器在 root 身份下运行,处理函数里做危险操作处理器只设 volatile sig_atomic_t 标志
TOCTOU检查路径后被替换成符号链接open(O_NOFOLLOW) + fstat + 对 fd 操作
配置文件属主/权限不对,被替换fstat 检查属主为 root、非组/他人可写
子进程system/popen 继承 root,shell 注入禁止使用;必须执行就用 execve 绝对路径 + 清环境
退出路径atexit/析构函数在 root 下运行降权后再注册;或 _exit
缓冲区溢出/格式串栈/堆溢出改写返回地址;%n 写内存;strcpy/sprintf 无界用 snprintf/strlcpy;编译 -fstack-protector-strong -D_FORTIFY_SOURCE=3;禁用 %n(-Wformat-security);不要用 gets

最小加固骨架

/* 启动阶段的加固顺序(在 main 开头,做任何业务之前) */
static void harden_startup(void)
{
    /* 1. 关掉 core dump 与 ptrace 可达性 */
    struct rlimit rl = { 0, 0 };
    setrlimit(RLIMIT_CORE, &rl);
    prctl(PR_SET_DUMPABLE, 0, 0, 0, 0);

    /* 2. 补齐 0/1/2,防止攻击者用关闭的 fd 制造意外 */
    for (int fd = 0; fd < 3; fd++) {
        if (fcntl(fd, F_GETFD) == -1) {
            int nfd = open("/dev/null", O_RDWR);
            if (nfd != fd) { /* 极端情况:dup2 后关闭 */ }
        }
    }

    /* 3. 固定工作目录与 umask */
    if (chdir("/") == -1) { /* 记录并退出 */ }
    umask(022);

    /* 4. 清环境,只保留白名单 */
    clearenv();
    setenv("PATH", "/usr/bin:/bin", 1);
    setenv("LANG", "C", 1);
}

PR_SET_DUMPABLE=0 还有一个副作用:/proc/{pid} 的属主变成 root,普通用户读不到该进程的 maps、environ、fd;这既是加固,也会让排障变难。调试 setuid 程序时通常临时设回 1。

缓冲区溢出与格式串:setuid 程序的「内存入口」

setuid 程序的输入全部不可信,而 C 的字符串函数默认不检查长度:

char buf[64];
strcpy(buf, argv[1]);          /* 攻击者给 1000 字节 → 覆盖返回地址 */
printf(argv[1]);               /* 攻击者给 "%n" → 往栈上写值 */

加固分三层:

层手段作用
源码snprintf、strlcpy/strscpy、fgets、显式长度消除无界写
编译-fstack-protector-strong、-D_FORTIFY_SOURCE=3、-Wformat-security、-fPIE -piecanary、编译期检查、ASLR
运行no_new_privs、seccomp、只读重定位(RELRO)缩小被利用后的能力

%n 是格式串写内存的入口:printf(user_input) 遇到 %n 会把「已输出字符数」写到对应指针参数指向的地址。永远写 printf("%s", user_input),不要直接把用户输入当格式串。

no_new_privs:一次性的「不再提权」承诺

prctl(PR_SET_NO_NEW_PRIVS, 1) 之后:

  • execve 忽略 setuid/setgid 位与 file capability;
  • 该标志被 fork/exec 继承,不能撤销;
  • seccomp 过滤器可以无 CAP_SYS_ADMIN 安装。

对 setuid 程序来说,降权完成后设置它,可以保证「即使后面 exec 了一个 setuid 二进制,也不会重新获得特权」。代价是:任何依赖 setuid 的子命令(sudo、passwd、ping 的旧实现)都会失效。systemd 的 NoNewPrivileges=yes、容器 runtime 的默认配置都建立在这个标志上。

一个反例:system() 在 setuid 程序里

/* 错误示范:不要在生产代码里这样写 */
setuid(0);
system("cp /etc/shadow /tmp/leak");   /* PATH 被污染 -> 执行攻击者的 cp */

即使程序自己没写 system,链接的库、atexit 处理器、NSS 模块都可能调用 popen。加固的正确方向不是「审所有库」,而是把特权代码缩到最小:一个只做「打开 fd 并降权」的小程序,把 fd 传给无特权的 worker(类似 sudo 的 exec 模型),比一个长期以 root 运行的大程序安全得多。

2.9.12 两个观测实验

实验一:file capability 的 xattr 与 getcap

cp /bin/true /tmp/capdemo
sudo setcap cap_net_raw,cap_net_bind_service=+ep /tmp/capdemo
getcap /tmp/capdemo
getfattr -d -m - -e hex /tmp/capdemo 2>/dev/null | grep security.capability
./file_caps /tmp/capdemo

预期输出:

/tmp/capdemo cap_net_bind_service,cap_net_raw=ep
security.capability=0x0100000200000000000000000000000000000000
magic=0x02000001 revision=2 effective_flag=1
permitted : CAP_NET_BIND_SERVICE(10) CAP_NET_RAW(13)
inheritable: (empty)
mode=0755 setuid=0 setgid=0

怎么读:xattr 的前 4 字节 01000002 按小端是 0x02000001,低 24 位 revision=2、最低位 effective=1;随后 8 字节是 permitted(0x00000400 与 0x00002000 分别对应第 10、13 位),再 8 字节是 inheritable(空)。getcap 的输出是解码后的可读形式。

实验二:用户命名空间里的 root 与 capability

unshare -Ur bash -c '
  id
  cat /proc/self/uid_map
  capsh --print | sed -n "1,4p"
  nsenter --target $$ --user -- cat /proc/self/status | grep -E "Uid|CapEff"
'

预期输出(宿主机 UID 1000):

uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
         0       1000          1
Current: cap_chown,cap_dac_override,...,cap_checkpoint_restore
Bounding set =cap_chown,...

怎么读:uid_map 的 0 1000 1 说明命名空间内 UID 0 映射到宿主机 1000;capsh 显示在新命名空间里拥有全部 capability,但这些 capability 只对该命名空间有效。在宿主机上 ps -o uid 看同一个进程仍是 1000。容器安全的核心之一就是「不要把这个命名空间的 root 映射到宿主机 root」。

2.9.13 十二个陷阱:现象、原因、修复

#现象原因修复
1chmod u+s 后提权失败/tmp、容器 overlay、NFS 以 nosuid 挂载findmnt -T,换目录或改挂载
2setuid 脚本无效Linux 忽略脚本的 setuid 位用编译二进制或 sudo
3setuid(0) 返回 EPERMsaved UID 已被降掉用 setresuid 保留退路,或重新设计
4降权后仍能读 shadow只改了 euid,saved 还是 0setresuid(u,u,u) 三步全降
5降权后 setgroups 失败先降了 UID,丢了 CAP_SETGID顺序:groups → gid → uid
6getpwnam_r 返回 ERANGE 被当成错误缓冲区不足是「重试」信号倍增缓冲区循环
7容器里 id 是 root,宿主机文件却写不了用户命名空间映射,宿主机 UID 不是 0看 /proc/pid/uid_map,调映射或卷权限
8setcap 后程序仍无权限文件系统 nosuid 或 no_new_privs换挂载、检查 NoNewPrivs
9CapBnd 少了位,setcap 也补不回bounding 只能减不能增从父进程/容器配置恢复
10ambient 提升失败位不在 permitted 或 inheritable先 capset 补 P/I,再 raise
11secure_getenv("HOME") 返回 NULLAT_SECURE 下 glibc 只对部分变量返回 NULL?实际是 secure_getenv 对所有变量在 secure 模式返回 NULL;用 getpwuid 取家目录
12setuid 程序崩溃产生 core,泄露密码core 以 root 写入且含内存RLIMIT_CORE=0 + PR_SET_DUMPABLE=0
13popen 在 setuid 程序里执行了错误命令PATH/IFS 被污染禁用 system/popen,绝对路径 execve
14initgroups 在容器里卡住NSS 后端(LDAP/SSSD)网络超时配置 nsswitch 只用 files,或设超时
setuid 程序的审计顺序
  1. getcap/ls -l 确认特权来源(setuid 还是 file caps);
  2. 读 main 开头 50 行:有没有 clearenv、chdir、PR_SET_DUMPABLE;
  3. 搜 system/popen/exec*lp/execlp:有没有相对路径;
  4. 搜 setuid/seteuid/setresuid:降权是否三步、是否验证 setuid(0) 失败;
  5. 搜 open:是否 O_NOFOLLOW、是否检查 fstat 属主;
  6. 看 atexit/析构函数:是否在 root 下运行;
  7. 用 strace -f -e trace=%process,%file 跑一遍正常路径,确认没有意外 exec。

2.9.14 本章出口检查

检查项达标线没达标去哪
说出四个 UID 各决定什么能区分 real/effective/saved/fs2.9.1
读懂 /etc/passwd/shadow/group能说出每个字段与权限2.9.2
解释 setuid exec 的凭证变化能说出 ruid 不变、euid/suid 变2.9.3
写出正确降权顺序组 → GID → UID → 验证2.9.5
解释 capability 五组集合能说出 bounding 只能减2.9.6
用 setcap 替代 setuid能给出 cap_net_bind_service=+ep2.9.6
解释 setuid 脚本为何无效能说出内核忽略脚本的 setuid 位2.9.3
即时自测 · 2.9

Q1 一个 setuid-root 程序启动后 Uid: 行是 1000 0 0 0。此时它调用 seteuid(1000) 会成功吗?之后还能回到 0 吗?为什么?

Q2 为什么 passwd 必须是 setuid root?login 的二进制通常也带 setuid 位,它需要的又是什么特权?

Q3 一个程序用 chmod 4755 设置了 setuid,放在 /tmp 下却提权失败。列出两种可能原因。

Q4 capsh --drop=cap_net_raw -- -c 'ping 127.0.0.1' 为什么会让 ping 失败?ping 需要什么 capability?

Q5 降权代码写成 setresuid(u,u,u); setgroups(0,NULL); setresgid(g,g,g);,会发生什么?

答案

A1 能成功。seteuid(1000) 只改有效 UID,保存 UID 仍是 0,所以之后 seteuid(0) 可以回到 root。这正是「只降 euid」不足以安全降权的原因;要不可逆,必须 setresuid(u,u,u)。

A2 passwd 要写 /etc/shadow 里自己的密码哈希,普通用户对该文件没有写权限,所以它 setuid root 后以 root 写 shadow,写完立即降回用户。login 的 setuid 位主要用于切换凭证:校验通过后 setuid 到目标用户、chown 控制终端、建立会话,这些都需要 root;读 shadow 在现代系统上多由 PAM 的 unix_chkpwd 辅助程序完成,并不要求 login 自己长期持有 root。两者的共同点是「以 root 启动」,差别在需要 root 的具体操作。

A3 ① /tmp 常以 nosuid 挂载,setuid 位被忽略;② 文件系统是 nosuid 的容器 overlay/NFS;③ 程序被 no_new_privs 或 LSM 限制;④ 文件属主不是 root(chown 没做对)。

A4 现代 ping 用 SOCK_DGRAM ICMP(ping_group_range 允许普通用户),不需要 CAP_NET_RAW;capsh --drop=cap_net_raw 本身不一定会让它失败。若 ping 用 SOCK_RAW,则需要 CAP_NET_RAW,被 drop 后 socket() 返回 EPERM。题目里失败的原因通常是 drop 掉了 ping 实际需要的能力(老版本用 raw socket),或者 capsh 的 bounding 集合限制让 setuid 的 ping 也无法取回。

A5 先 setresuid(u,u,u) 会把 real/effective/saved UID 全部降掉,进程随即失去 CAP_SETGID。后面的 setgroups 与 setresgid 都以 EPERM 失败,结果是 UID 降了、附加组与 GID 仍是 root 的——降权不完整,进程还属于 shadow、docker 等组。正确顺序是 setgroups → setresgid → setresuid,每步检查返回值。

母本对应

母本章节本书落点程度
K第 8 章 用户和组2.9.2完整(passwd/shadow/group、NSS)
K第 9 章 进程凭证2.9.1–2.9.5完整(四件套、setuid、降权)
K第 39 章 能力(capabilities)2.9.6完整(五组集合、file caps、bounding)
C第 8 章 8.2 进程2.9.1、2.9.3背景(进程抽象与用户态)
G第 13 章 Linux 账号管理与 ACL 权限设置2.9.2、2.9.6背景(id、usermod、setfacl)
O——未用:本章不涉及调度与内存

下一站:2.10 信号 进入异步世界:信号从产生到递送经过哪些状态,为什么处理器里只能调用 async-signal-safe 函数,SA_RESTART 如何影响慢系统调用,以及怎样用 self-pipe 与 signalfd 把信号变成事件循环能处理的事件。