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 的四件套
每个进程持有四个 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 程序的特权身份 |
| saved | seteuid 的合法目标集合 | 提权后降回用户 |
| 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 是用户名」——用户名只是
/etc/passwd里的映射。删掉用户后,文件仍保留数字 UID,ls -l显示为数字。 - 「root 的 UID 一定是 0」——内核只认 0。把某个普通用户的 UID 改成 0,他就成了 root;
/etc/passwd里写root但 UID 不是 0 的账户没有特权。 - 「有效 UID 就是真实 UID」——只在没有 setuid 位时成立。setuid 程序里两者不同,这正是提权的来源。
- 「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 那一刻发生了什么
文件权限位里有三个特殊位:
| 位 | 八进制 | 对可执行文件的效果 |
|---|---|---|
| setuid | 04000 | exec 后有效 UID = 文件属主 |
| setgid | 02000 | exec 后有效 GID = 文件属组 |
| sticky | 01000 | 对目录:只有属主能删自己的文件(/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):
- 若文件带 setuid 位且文件系统没有以
nosuid挂载,euid = 文件属主,suid = 文件属主; ruid保持不变;- 若文件带 setgid 位,
egid/sgid同理; - 若进程有
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 脚本也能提权」——Linux 忽略
#!脚本上的 setuid 位。脚本由解释器执行,解释器用调用者的凭证运行。需要提权就用编译过的二进制或sudo规则。 - 「
chmod u+s一定生效」——nosuid挂载点(常见于/tmp、容器 rootfs、NFS)会忽略 setuid 位。 - 「root 什么都能做」——root 绕过的是 DAC 权限检查,不是所有检查。它仍受 capability、LSM(SELinux/AppArmor)、只读挂载、
no_new_privs限制;执行文件仍需要x位。 - 「
getuid()==0才能判断是否以 root 运行」——setuid 程序里getuid()是调用者,geteuid()才是有效身份。判断特权要看geteuid()。 - 「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 程序里很常见:只在真正需要特权的几行代码里提权,其余时间保持用户身份。但它比「一次性降权」更难审计,能用后者就不要用前者。
若进程以非特权身份运行(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 下,其余全部以调用者身份运行。
顺序不能错:
- 校验调用者与输入:用
getuid()确定真实用户,拒绝不合法参数;路径用绝对路径,打开时加O_NOFOLLOW。 - 清环境:
clearenv()后重建最小环境,或用secure_getenv()读取(setuid 时它返回NULL)。 - 降附加组:
setgroups(0, NULL)清空,或initgroups(user, gid)按目标用户重建。 - 降 GID:
setresgid(g, g, g)。 - 降 UID:
setresuid(u, u, u)。 - 验证:
setuid(0)必须失败(EPERM),geteuid()必须等于目标 UID。 - 加固:
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) 也被验证过会失败。
- 先降 UID 再降 GID——降 UID 后失去
CAP_SETGID,setgroups/setresgid返回EPERM,附加组还留着 root 组。 - 只调
seteuid不调setresuid——保存 UID 还是 0,程序被利用后仍可通过seteuid(0)回到 root。 - 忘了附加组——
setgroups没清,进程仍属于shadow、docker、sudo等组,权限比预期大。 - 降权后还信任环境变量——
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)限制容器能力的基础。
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/gid | kuid_t/kgid_t | 真实 UID/GID | getuid()/getgid() |
euid/egid | kuid_t/kgid_t | 有效 UID/GID | geteuid()/getegid() |
suid/sgid | kuid_t/kgid_t | 保存 UID/GID | /proc/status 第三列 |
fsuid/fsgid | kuid_t/kgid_t | 文件系统 UID/GID | setfsuid() |
cap_inheritable | kernel_cap_t | 可继承集合 I | CapInh |
cap_permitted | kernel_cap_t | 许可集合 P | CapPrm |
cap_effective | kernel_cap_t | 有效集合 E | CapEff |
cap_bset | kernel_cap_t | 边界集合 B | CapBnd |
cap_ambient | kernel_cap_t | 环境集合 A | CapAmb |
securebits | unsigned | SECBIT_* 标志 | 无直接用户接口 |
user_ns | struct user_namespace * | 所属用户命名空间 | /proc/pid/ns/user |
group_info | struct group_info * | 附加组数组 | getgroups() |
security | void * | 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 日志区分。
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 被设为新 e | EPERM |
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(getuid())什么也不做」——它会改有效 UID(如果当前 euid 不是 ruid),并可能同步 fsuid。对 setuid 程序来说,这是降权的第一步。 - 「
seteuid(0)在降权后仍能成功」——只有保存 UID 还是 0 时才行。setresuid(u,u,u)之后保存 UID 也是u,seteuid(0)返回EPERM。 - 「
setresuid(-1, -1, -1)是合法空操作」——是空操作,但仍会走prepare_creds()/commit_creds(),在 seccomp 严格过滤或 LSM 审计下会留下记录。热路径上不要用它「探测」。 - 「
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 位 | 文件系统非 nosuid | euid = 文件属主,suid = 文件属主 | 若进程有 CAP_SETUID,按 SECBIT_* 规则可能保留 |
| setgid 位 | 非 nosuid | egid = 文件属组,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_NOROOT | UID 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 里 |
0 | NULL | 没找到(不是错误) |
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」的来源。
三条推论:
- 只有
+p的 file capability 不会自动生效:P'有了,但E'为空(F(effective)=0),程序必须自己capset把位搬进 effective。 - bounding 是硬上限:
F(permitted) & B,所以PR_CAPBSET_DROP掉的位,即使文件有也拿不到。 - 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 对谁有效。
- 「
setcap设置的 capability 对所有用户生效」——生效,但受文件系统nosuid、no_new_privs、bounding 集合限制;容器里挂载的nosuid会让它失效。 - 「
CapBnd可以通过capset增加」——不能。bounding 只能通过PR_CAPBSET_DROP减少,且需要CAP_SETPCAP。 - 「ambient 可以随便提升」——必须先在 permitted 与 inheritable 里,且
PR_CAP_AMBIENT_RAISE在SECBIT_NO_CAP_AMBIENT_RAISE下失败。 - 「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 -pie | canary、编译期检查、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 十二个陷阱:现象、原因、修复
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | chmod u+s 后提权失败 | /tmp、容器 overlay、NFS 以 nosuid 挂载 | findmnt -T,换目录或改挂载 |
| 2 | setuid 脚本无效 | Linux 忽略脚本的 setuid 位 | 用编译二进制或 sudo |
| 3 | setuid(0) 返回 EPERM | saved UID 已被降掉 | 用 setresuid 保留退路,或重新设计 |
| 4 | 降权后仍能读 shadow | 只改了 euid,saved 还是 0 | setresuid(u,u,u) 三步全降 |
| 5 | 降权后 setgroups 失败 | 先降了 UID,丢了 CAP_SETGID | 顺序:groups → gid → uid |
| 6 | getpwnam_r 返回 ERANGE 被当成错误 | 缓冲区不足是「重试」信号 | 倍增缓冲区循环 |
| 7 | 容器里 id 是 root,宿主机文件却写不了 | 用户命名空间映射,宿主机 UID 不是 0 | 看 /proc/pid/uid_map,调映射或卷权限 |
| 8 | setcap 后程序仍无权限 | 文件系统 nosuid 或 no_new_privs | 换挂载、检查 NoNewPrivs |
| 9 | CapBnd 少了位,setcap 也补不回 | bounding 只能减不能增 | 从父进程/容器配置恢复 |
| 10 | ambient 提升失败 | 位不在 permitted 或 inheritable | 先 capset 补 P/I,再 raise |
| 11 | secure_getenv("HOME") 返回 NULL | AT_SECURE 下 glibc 只对部分变量返回 NULL? | 实际是 secure_getenv 对所有变量在 secure 模式返回 NULL;用 getpwuid 取家目录 |
| 12 | setuid 程序崩溃产生 core,泄露密码 | core 以 root 写入且含内存 | RLIMIT_CORE=0 + PR_SET_DUMPABLE=0 |
| 13 | popen 在 setuid 程序里执行了错误命令 | PATH/IFS 被污染 | 禁用 system/popen,绝对路径 execve |
| 14 | initgroups 在容器里卡住 | NSS 后端(LDAP/SSSD)网络超时 | 配置 nsswitch 只用 files,或设超时 |
getcap/ls -l确认特权来源(setuid 还是 file caps);- 读
main开头 50 行:有没有clearenv、chdir、PR_SET_DUMPABLE; - 搜
system/popen/exec*lp/execlp:有没有相对路径; - 搜
setuid/seteuid/setresuid:降权是否三步、是否验证setuid(0)失败; - 搜
open:是否O_NOFOLLOW、是否检查fstat属主; - 看
atexit/析构函数:是否在 root 下运行; - 用
strace -f -e trace=%process,%file跑一遍正常路径,确认没有意外 exec。
2.9.14 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出四个 UID 各决定什么 | 能区分 real/effective/saved/fs | 2.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=+ep | 2.9.6 |
| 解释 setuid 脚本为何无效 | 能说出内核忽略脚本的 setuid 位 | 2.9.3 |
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把信号变成事件循环能处理的事件。