☰
Linux权限模型:UID/EUID/SUID与SGID的进程身份和文件权限位
2026/11/3 12:06:12 网站建设 项目流程

很多人第一次被这套身份体系绊倒,都是在写一个需要读取受保护配置的小工具时:程序明明用chmod 4755加上了 SUID 位,ls -l里也出现了那个显眼的s,但程序里打印出来的geteuid()依旧是自己,配置文件照样打不开。反过来也有更吓人的情况——程序莫名其妙能删掉别人建的文件,一查才发现是 SGID 目录的组继承在起作用。UID、GID、EUID、EGID、SUID、SGID 这六个名字,看着像同一家族里的一串缩写,实际上分属两个完全不同的层面:进程的身份槽位和文件 inode 上的权限位。分不清这两层,排查方向就会一路跑偏。这篇内容把两层的语义、转换规则、内核判定顺序、以及实际写代码时哪些坑必踩,一次讲透。适合已经会用ls -l、chmod,但被“为什么我提权了又掉回来了”“为什么这个目录下新建文件属组不对”这类问题卡住的同学,也适合想把权限模型彻底补齐的运维和开发。

1. 六个名字先摆到台面上认清

1.1 为什么要把身份拆成好几个

Unix 的权限模型本质上只有三组九位:属主、属组、其他人,各三位读写执行。这个模型极其简单,简单到很多人以为权限就是“我是谁,能不能动这个东西”。但只要系统里出现“普通用户需要完成一件只有特权身份才能完成的事”,这个二分模型立刻就不够用了。

最典型的例子是改密码。密码哈希存在一个普通用户连读都不许读的文件里,可用户又必须能改自己的密码。如果直接把那个文件开成所有人可写,整套认证体系当场报废;如果让密码程序一直以 root 跑,那用户输入的每一个字节都处在最高权限环境里,任何一处越界写入都是灾难。系统的解法是:让这个程序在执行的那一瞬间,临时把“当前生效的身份”换成文件属主的身份,程序跑完退出,身份跟着进程一起消失,对调用者本人没有任何残留影响。

要做到“临时借、用完还”,就必须把“你是谁”和“你现在以谁的身份在干活”拆成两个变量分别存。前者是记账用的,后者是判定用的。再进一步,为了支持“借了之后还能还回来”,还得有第三个变量存“借之前是什么身份”。三个槽位一摆出来,UID、EUID、SUID(进程意义上的)就都出现了,GID 那一组是完全对称的镜像。理解了这个动机,后面所有参数和调用规则都是它的自然推论,而不是需要死记硬背的清单。

1.2 一张表把术语撞车问题解决掉

这套术语最坑的地方不是概念难,而是同一个缩写在不同语境下指两样东西。SUID 既可以是进程里的 saved set-user-ID,也可以是文件 inode 上的 set-user-ID 权限位。看文档时如果不注意上下文,很容易把“设置 saved ID”和“设置文件权限位”搞混。先把这层窗户纸捅破:

缩写进程语境文件语境
UIDreal user ID,启动进程的那个用户inode 里的属主 uid
EUIDeffective user ID,当前权限判定的依据无此概念
SUIDsaved set-user-ID,切换前的 EUID 备份set-user-ID 权限位,八进制 4000
GIDreal group IDinode 里的属组 gid
EGIDeffective group ID无此概念
SGIDsaved set-group-ID,切换前的 EGID 备份set-group-ID 权限位,八进制 2000;对目录另有语义

还有一个容易漏掉的角色:Linux 特有的fsuid(filesystem UID)。内核在做文件访问检查时,用的其实是 fsuid 而不是 euid。平时seteuid()会同步把 fsuid 改掉,所以绝大多数情况下两者一致,你感觉不到它的存在;但一旦代码里出现setfsuid(),就会出现“euid 是 root、fsuid 不是”这种诡异状态,排查起来非常费劲。/proc/PID/status里的Uid:一行其实是四个数,最后一个就是 fsuid:

grep -E '^(Uid|Gid):' /proc/self/status # Uid: 1000 1000 1000 1000 # Gid: 1000 1000 1000 1000

四个数依次是 real、effective、saved、fs。平时四个值一样,看着像废话;等哪天它们不一致了,这一行就是最快的现场证据。

1.3 三条随手可用的查看命令

排查权限问题,第一件事永远是“把当前状态打出来”,而不是靠猜。日常最顺手的三个:

id # 当前 shell 的 uid/gid 和附加组 id -u; id -g; id -G # 分开取,脚本里常用 ps -o pid,user,euser,group,egroup,comm -p $$

ps的euser/egroup列非常有用,它直接显示进程的 effective 身份。当你不确定某个守护进程到底以谁的身份在跑,这一列比看配置文件可靠得多。另外一个常被忽略的点:ls -l显示的是文件的属主属组,跟进程身份毫无关系。很多新手对着ls -l的输出研究“为什么我的程序权限不对”,方向从一开始就错了。

2. UID 与 GID:进程和文件两侧的身份证

2.1 进程出生时就带上的那套身份

一个进程被fork出来时,内核会把父进程的 real/effective/saved 三组身份原样复制过去。execve执行新程序时,这组身份通常保持不变——这是默认行为,也是为什么 SUID 位必须由内核在execve内部特殊处理才能生效。换句话说,身份是进程的属性,不是程序的属性。程序文件只是躺在磁盘上的一个静态对象,它不“拥有”任何身份。

用户的 uid 存在/etc/passwd第三个字段,gid 在第四个字段;用户属于哪些附加组写在/etc/group里。这里有个细节值得强调:进程的附加组列表(supplementary groups)在权限判定中同样参与匹配。一个用户主组是staff,同时被加进docker、devteam,那么他对属组为devteam且组可读的文件就有读权限。id -G输出的就是这个完整列表,而id -g只给主组。看到“组权限明明给了却不生效”,八成是附加组没刷新——用户被usermod -aG加进新组后,已经登录的会话不会自动感知,必须重新登录或者newgrp一次。

2.2 文件侧记的只有两个数字

inode 里跟权限相关的核心信息就三样:属主 uid、属组 gid、十二个权限位(九个常规位加三个特殊位)。用stat能看到全部:

stat -c '%n uid=%u gid=%g mode=%a (%A)' /usr/bin/passwd # /usr/bin/passwd uid=0 gid=0 mode=4755 (-rwsr-xr-x)

注意mode=4755这个四位八进制:最高位 4 就是 SUID。三位数写法755和四位数写法4755的区别,就在于是否显式带上了特殊位。chmod +s这种写法看着方便,实际上同时给 SUID 和 SGID 都置了位(对普通文件),很容易在不需要的地方留下一个 SUID。我个人的习惯是永远写四位数字,宁可多打一个 0。

2.3 权限判定的真实次序

内核判定“这个进程能不能读这个文件”,走的是一条固定顺序,且一旦命中就停止:

  1. 进程的 EUID 是 0(也就是 root 或具备相应能力)→ 直接放行,不看任何权限位。唯一例外是执行权限:root 要执行一个文件,文件至少得有一个 x 位,否则还是EACCES。
  2. EUID 等于文件的属主 uid → 只看属主那三位,组位和其他位一律忽略。
  3. 进程的 EGID 或任一附加组等于文件属组 gid → 只看组那三位,其他位忽略。
  4. 以上都不匹配 → 只看其他位。

第 2、3 步的“只看”两个字是重点。曾经有个经典困惑:某文件权限rw-r-----,属主是 A,属组是 G,进程以 B 身份运行但 B 在 G 组里,结果写不进去——因为组位只有 r,没有 w。这不算 bug,是设计如此。很多人以为“属主和组权限会叠加”,实际上不会。

3. EUID 与 EGID:真正决定能不能动的那个

3.1 一个可以五分钟做完的实验

抽象讲半天不如亲手看一眼。准备一个最小程序,把几个身份全打出来:

/* ident.c —— 打印进程的六个身份槽位 */ #define _GNU_SOURCE #include <stdio.h> #include <unistd.h> #include <sys/types.h> int main(void) { uid_t r, e, s; gid_t gr, ge, gs; if (getresuid(&r, &e, &s) != 0) { perror("getresuid"); return 1; } if (getresgid(&gr, &ge, &gs) != 0) { perror("getresgid"); return 1; } printf("uid real=%u effective=%u saved=%u\n", (unsigned)r, (unsigned)e, (unsigned)s); printf("gid real=%u effective=%u saved=%u\n", (unsigned)gr, (unsigned)ge, (unsigned)gs); return 0; }

编译完先原样跑一次,四个数字全是自己的 uid。然后加上 SUID 位再跑:

gcc -Wall -O2 -o ident ident.c sudo chown root:root ident sudo chmod 4755 ident ./ident # uid real=1000 effective=0 saved=0 # gid real=1000 effective=1000 saved=1000

real还是 1000,因为启动它的确实是这个普通用户;effective变成 0,因为文件属主是 root,SUID 位生效了。这一行输出就是整套机制的全部真相:记账身份没变,判定身份被换了。此时这个进程去读只有 root 能读的文件,内核看的是 effective=0,直接放行;但要去查“这个操作是谁发起的”,看的是 real=1000。

顺带说一句 GID:上面的例子里 gid 三兄弟一个都没变,因为只设了 SUID。想同时看到 EGID 变化,把chmod换成6755(SUID+SGID),saved和effective的 gid 就会跟着变成文件的属组。

3.2 三个槽位之间怎么来回切

这一块是全篇最容易出错的地方,因为 Linux 的setuid()家族在不同权限下有完全不同的行为,而且有些操作是不可逆的。核心规则如下:

调用进程当前有 CAP_SETUID(典型是 root)进程无特权
setuid(x)同时设置 real、effective、saved 三个槽位为 x,不可逆只设 effective,且 x 必须等于 real 或 saved,否则 EPERM
seteuid(x)只设 effective 为 x同上,只设 effective
setreuid(r, e)设 real 和 effective,传 -1 表示不动可把 real 与 effective 互换,受限制
setresuid(r, e, s)三个槽位分别设,传 -1 表示不动只能设为 real、effective、saved 三者之一

表格里“不可逆”三个字必须画重点。很多人在 root 程序里想“先降权做点事,做完再提回来”,写成了:

setuid(getuid()); /* 想临时降权 */ /* ... 做点普通用户该做的事 ... */ setuid(0); /* 想提回来 —— 这里会失败 */

第二次调用会返回 -1 并置EPERM,因为三个槽位已经全部变成普通用户了,saved 槽位也没了,进程从内核视角看就是一个彻头彻尾的普通进程,再没有任何“回去”的凭据。正确写法是保留 saved:

uid_t saved = geteuid(); /* 先把特权身份记下来 */ if (seteuid(getuid()) != 0) { perror("drop"); return 1; } /* ... 以调用者身份干活 ... */ if (seteuid(saved) != 0) { perror("restore"); return 1; }

seteuid只改 effective,saved 槽位原封不动,所以随时能切回去。这个模式在需要短暂使用特权的程序里是标准做法,我个人的原则是:特权窗口越短越好,能不用就不用。

3.3 saved 槽位存在的唯一理由

单独看,saved set-user-ID 这个槽位有点多余——为什么不直接记在用户态变量里?因为用户态变量会被程序自己改,也可能被缓冲区溢出之类的漏洞覆盖,内核对它毫无信任可言。把“回程票”放在内核维护的进程结构里,用户态代码只能通过受限的系统调用去影响它,安全性完全不在一个量级。

更实际的原因是:如果只靠 real 和 effective 两个槽位,一个 SUID 程序在降权之后就没有合法途径回到特权状态了,除非它重新 exec 自己一次。而重新 exec 意味着重新走一遍加载、初始化、参数解析,成本高且容易出岔子。saved 槽位的设计,就是用一小块内核状态换来“可逆降权”这个能力。理解了这一点,再看setresuid(uid, uid, -1)这种写法(改 real 和 effective,保留 saved)就顺理成章了。

4. SUID 与 SGID:文件上的那三个特殊位

4.1 SUID 位生效的硬性条件

SUID 位不是万能开关,它只在execve这个时间点上起作用,而且必须同时满足一串条件,缺一个就静默失效。这是排查时最浪费时间的地方,因为没有报错,只有“没生效”。

  • 文件必须是可执行的普通文件,且当前进程有执行权限。
  • 文件所在文件系统必须以允许 suid的方式挂载。带nosuid选项挂载的分区上,所有 SUID 位一律被忽略,常见于/tmp、/home的独立分区以及各种内存文件系统。
  • 文件必须是二进制可执行文件。以#!开头的脚本,Linux 内核会明确忽略 SUID 位,这一点后面单独讲。
  • 进程不能处于no_new_privs状态。容器环境里这个标志很常见,它会直接让所有提权机制失效。
  • 文件所在的文件系统如果是用user选项挂载的可移动介质,同样受限制。

验证挂载选项的命令:

findmnt -T /path/to/your/binary -o TARGET,FSTYPE,OPTIONS

输出里的OPTIONS如果含nosuid,那不管你怎么chmod 4755都不会有用。

4.2 SGID 位在文件和目录上是两套语义

SUID 只有一种含义:执行时把 EUID 换成文件属主。SGID 就分裂了:

  • 在普通文件上:执行时把 EGID 换成文件属组。用途比 SUID 窄得多,典型场景是需要访问某个组专属资源的工具程序。
  • 在目录上:完全不涉及提权。它表示“在这个目录里新建的文件和子目录,属组自动继承目录的属组,而不是创建者的主组”。而且新建的子目录会自动带上 SGID 位,让继承链一路延续下去。

目录 SGID 这个特性,是多人协作目录的基石。没有它,一个团队共享目录会变成灾难:A 建的文件属组是 A 的主组,B 建的是 B 的主组,最后目录里躺着一堆不同属组的文件,谁也说不清谁能改。有了它,所有新文件统一归属团队组,配合chmod 2775就能做出一个干净的协作区。

4.3 协作目录的完整实操

假设要给一个开发小组建一个共享区,组名叫devteam(虚构名称,实际按自己环境替换):

sudo groupadd devteam sudo usermod -aG devteam alice sudo usermod -aG devteam bob sudo mkdir -p /srv/shared/proj sudo chown root:devteam /srv/shared/proj sudo chmod 2775 /srv/shared/proj

2775拆开看:2 是 SGID,后面 775 是属主和属组可读写执行、其他人可读可执行。设置完之后验证:

ls -ld /srv/shared/proj # drwxrwsr-x 2 root devteam 4096 ... /srv/shared/proj # ^ 属组位的 x 变成了 s

那个小写的s就是 SGID 生效的标志。如果显示成大写的S,说明属组位没有 x 权限,此时 SGID 处于“置了位但无效”的状态,是个典型的配置失误。

接着以组成员身份新建文件,观察属性:

mkdir -p /srv/shared/proj/sub touch /srv/shared/proj/a.txt ls -l /srv/shared/proj # -rw-r--r-- 1 alice devteam ... a.txt # drwxrwsr-x 2 alice devteam ... sub

a.txt的属组是devteam而不是alice的主组,sub目录也自动带上了 SGID。这就是继承链在起作用。

这里必须提醒一个高频踩坑点:SGID 只管属组,不管权限位。上面a.txt的权限是644,也就是组只能读不能写。原因是创建进程的 umask 是022,touch请求的666被掩掉写位之后剩下644。协作目录里大家要互相改文件,必须让 umask 变成002(在/etc/profile或组级配置里设),或者用默认 ACL 兜底:

sudo setfacl -d -m g:devteam:rwx /srv/shared/proj

默认 ACL 的好处是它对新文件和对新目录同时生效,而且不受 umask 干扰,比依赖每个人自己改 umask 靠谱得多。我现在的习惯是:协作目录一律 SGID + 默认 ACL 双保险,umask 靠个人自觉这事儿基本不可靠。

5. 动手写一个受控的特权程序

5.1 需求与设计的取舍

模拟一个很常见的需求:某服务需要从/var/lib/mockapp/secret.conf读一行配置,这个文件权限600、属主 root,普通用户读不了。完整的解法有两条路:一是给服务配一个系统服务单元,以 root 身份启动;二是做一个带 SUID 位的小工具,只做“读这一行”这一件事。前者权限过大,后者权限可控但需要小心编写。

这里选第二条,但要严格遵守“最小特权窗口”原则:特权只用于open那一下,文件描述符拿到之后立刻降权,后续所有输出、格式化、写日志都在调用者身份下完成。这样做的好处是,即使后续代码有漏洞,攻击面也被限制在一个已打开的描述符上,而不是整个进程。

5.2 完整代码与逐段说明

/* reader.c —— 只读取固定路径配置的一行内容 */ #define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <fcntl.h> #include <string.h> #include <errno.h> #include <sys/types.h> #include <sys/prctl.h> #define CONF_PATH "/var/lib/mockapp/secret.conf" int main(void) { uid_t ruid, euid, suid; char buf[512]; ssize_t n; int fd; /* 1. 开跑第一件事:禁止本进程再获得新特权 */ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { perror("prctl"); return 1; } if (getresuid(&ruid, &euid, &suid) != 0) { perror("getresuid"); return 1; } /* 2. 路径写死,绝不拼接 argv 或环境变量 */ fd = open(CONF_PATH, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); if (fd < 0) { fprintf(stderr, "open %s: %s\n", CONF_PATH, strerror(errno)); return 1; } /* 3. 拿到 fd 立刻丢掉特权,后面的活全用调用者身份干 */ if (seteuid(ruid) != 0) { perror("seteuid"); close(fd); return 1; } n = read(fd, buf, sizeof(buf) - 1); close(fd); if (n < 0) { perror("read"); return 1; } buf[n] = '\0'; /* 4. 极简输出,不调用 shell,不做格式化拼接 */ fputs(buf, stdout); return 0; }

几个决定值得逐个说清楚。

PR_SET_NO_NEW_PRIVS放在最前面。它的作用是让这个进程及其所有后代永远无法再通过execve获得新特权。即使后面代码被注入,也没法exec一个 SUID shell 把权限放大。代价是这个标志不可撤销,所以必须放在“我已经确定不需要再 exec 任何提权程序”之后。对这个程序来说完全没问题。

O_NOFOLLOW防的是符号链接攻击。如果攻击者能控制路径上的某一环(比如/var/lib/mockapp目录可写),他可以把secret.conf换成一个指向别处的软链,让特权进程去打开任意文件。加了O_NOFOLLOW,最后一段是软链就直接失败。更彻底的做法是用openat2配合RESOLVE_NO_SYMLINKS和目录 fd 锚定,但对这个场景O_NOFOLLOW已经够用。

O_CLOEXEC防止描述符泄漏到子进程。虽然这个程序不 fork,但养成习惯比事后补要好。

降权时机是整段代码的灵魂。open返回的 fd 已经完成了权限检查,之后对这个 fd 的read不再受身份影响。所以完全可以在降权之后再读——这一步把“持有特权的时长”压缩到了两次系统调用之间。如果反过来写成“先读完再降权”,特权窗口就覆盖了整个read,虽然功能一样,但风险面大了一截。

5.3 部署、赋权与验证

# 准备受保护的文件(属主 root,仅属主可读) sudo mkdir -p /var/lib/mockapp echo "token=mock-value-123" | sudo tee /var/lib/mockapp/secret.conf >/dev/null sudo chown root:root /var/lib/mockapp/secret.conf sudo chmod 600 /var/lib/mockapp/secret.conf # 编译并以 root 身份安装 gcc -Wall -Wextra -O2 -o reader reader.c sudo install -o root -g root -m 4755 reader /usr/local/bin/mock-reader

install命令比cp再chmod更适合这个场景:它能在一次操作里同时指定属主、属组和权限模式,顺手把 SUID 位设好。用cp的话,普通cp会按 umask 建新文件,SUID 位直接丢掉;cp -p虽然保留权限,但保留的是源文件的(可能是 755),还得再chmod一次,多一步就多一次出错机会。

验证分三层:

# 第一层:文件属性对不对 ls -l /usr/local/bin/mock-reader # -rwsr-xr-x 1 root root ... /usr/local/bin/mock-reader # 第二层:普通用户直接读原文件,应该失败 cat /var/lib/mockapp/secret.conf # cat: /var/lib/mockapp/secret.conf: Permission denied # 第三层:通过工具读,应该成功 mock-reader # token=mock-value-123

如果第三层失败,先别改代码,回到第 6 章的排查表走一遍。九成的情况是挂载选项或者权限位被后续操作覆盖了。

5.4 用 strace 看一眼内核到底做了什么

理论讲再多,不如直接看系统调用序列。用strace过滤出关键调用:

strace -f -e trace=execve,openat,setresuid,setuid,write ./mock-reader 2>&1 | head -20

你会看到execve之后内核把进程的 effective uid 改成了 0,然后是我们的openat,接着一次setresuid把 effective 降回普通用户,最后才是write。这一串调用的顺序就是“特权窗口”的可视化。把这个序列截图存下来,以后向别人解释 SUID 到底做了什么,比讲十分钟原理管用。

6. 常见问题与排查实录

6.1 SUID 不生效的速查表

这是我这几年被问得最多的一类问题,几乎全部落在这张表里:

现象大概率原因验证命令处理
geteuid()还是自己权限位没设上ls -l看是否有ssudo chmod 4755 file
同上分区以nosuid挂载findmnt -T path -o OPTIONS换到/usr/local/bin
同上文件在内存盘或可移动介质上findmnt -T path -o FSTYPE挪到常规磁盘分区
同上进程带了no_new_privsgrep NoNewPrivs /proc/PID/status检查容器或服务单元配置
脚本怎么设都没反应内核忽略#!脚本的 SUID换二进制程序验证写 C 程序或用其他提权路径
文件被覆盖后位丢了编辑器/编译输出重建了 inodels -l时间戳每次部署后重新赋权
备份还原后失效归档工具未保留特殊位tar -tvf看权限列用tar -p或install
大写的S有s位但没有x位ls -lchmod u+x补上执行位

6.2 脚本为什么不能带 SUID

这条值得单独展开,因为网上流传的错误说法太多了。#!/bin/sh这样的脚本,内核在binfmt_script处理阶段会主动忽略SUID 和 SGID 位。原因很实际:脚本真正的解释器路径是由脚本第一行指定的,如果 SUID 生效,攻击者可以构造一个软链指向任意解释器,或者利用解释器自身的参数注入来获得特权执行,内核根本没法安全地保证“执行的是你看到的那个脚本”。

相对的绕过方式是给解释器本身设 SUID,那是另一个层面的问题,而且现代发行版几乎不会再这么干。所以看到“给 shell 脚本加 SUID 就能提权”的教程,直接跳过就好,那是基于某些老旧系统的误传。真正需要这个能力时,写一个几十行的 C 程序,用execvp去调脚本,把 SUID 设在 C 程序上——即便如此也要极其小心参数传递,因为execvp后面的脚本依然以调用者身份运行,你只是获得了“以特权身份启动一个解释器”的能力,这本身就危险。

6.3 排查的几个手法与教训

第一个教训:先用id和/proc/self/status确认真实状态,再动代码。我见过太多次花两小时改代码、最后发现是文件在/tmp上而/tmp是nosuid。状态确认这一步只花十秒钟,却能砍掉大半的无效排查。

第二个手法:临时把getresuid和getresgid的返回值打进日志,包括每一次seteuid前后的值。身份切换出问题时,日志里能看到确切的跳变点,比在调试器里单步快得多。生产环境不方便打日志的话,/proc/PID/status的Uid:行随时可读,用外部进程观察也行。

第三个是我自己踩过的坑:用chmod g+s给一个目录设 SGID,设完发现新建文件属组还是不对。原因是那个目录的属组根本不是目标组,chmod g+s只置位不改属组,所以继承的是“目录当前的属组”——一个毫无意义的组。正确顺序永远是先chown :group再chmod。顺序反了不会报错,只会让你对着正确的结果怀疑人生。

7. 安全边界:这几个位是双刃剑

7.1 定期审计是必须做的事

SUID 程序是整台机器上权限最高的东西之一,任何一处代码缺陷都可能被放大成完整的提权路径。审计的第一步是把家底摸清楚:

# 列出所有带 SUID 或 SGID 的文件 find / -xdev \( -perm -4000 -o -perm -2000 \) -type f -printf '%M %u:%g %p\n' 2>/dev/null # 只看 SUID find / -xdev -perm -4000 -type f 2>/dev/null

-xdev别漏,否则会顺着挂载点爬进网络盘或者容器镜像,跑上半小时还没结束。-perm -4000里的减号表示“这些位必须全部置上”,跟-perm /4000(任意一位置上)是两回事,写错了结果会差很多。把输出存成基线,隔一段时间 diff 一次,新增的 SUID 文件就是重点审查对象。很多入侵排查就是从这一条命令开始的。

7.2 写特权程序的几条红线

  • 绝不调用 shell。system()、popen()、execl("/bin/sh", ...)一律不要出现在特权路径上。环境变量污染、路径劫持、参数注入,任何一个都能让你前面所有的校验白做。
  • 绝不信任环境变量。PATH、IFS、LD_PRELOAD、LD_LIBRARY_PATH、TMPDIR全部不可信。要么在程序入口处把它们清空,要么干脆只依赖绝对路径。
  • 绝不信任参数。命令行参数是用户完全可控的输入。路径类参数要做白名单校验,数字类参数要做范围校验,文件名里带..和 nul 字节的要拒绝。
  • 打开文件一律带O_NOFOLLOW和O_CLOEXEC。前者防软链,后者防描述符泄漏。
  • 临时文件不要用固定路径。用mkstemp在不可预测的目录下创建,并立刻unlink,避免符号链接竞争。
  • 特权窗口尽量短。拿到需要的资源后立刻seteuid降权,不要“反正还有几步就结束了一起降”。
  • 退出前不需要手动恢复特权。进程结束,身份跟着没了,别画蛇添足地seteuid(0)。

7.3 一张加固对照表

风险点具体表现加固手段
路径注入用argv[1]拼成路径去open路径写死或用白名单校验
软链跟随攻击者把目标换成软链O_NOFOLLOW,必要时openat2
环境变量污染依赖PATH找子程序只用绝对路径,入口处clearenv
描述符泄漏特权 fd 被子进程继承O_CLOEXEC,或用close_range
特权窗口过长整个main都在 root 下跑资源到手立刻seteuid降权
权限放大程序能exec出 SUID shellPR_SET_NO_NEW_PRIVS
竞态条件检查和使用之间文件被替换用 fd 操作代替路径操作,先open再fstat

写这类程序的心态要摆正:不是在“实现功能”,而是在“设计一个对外暴露的最小攻击面”。功能写错顶多是程序不能用,权限写错可能是整台机器被人拿走。

8. 我个人的几条经验

最后说几条纯经验层面的东西,都是文档里不会写、但实际排障时最省时间的。

一个是永远用四位八进制写 chmod。chmod 2755和chmod 755差的不只是样式,前者明确表达了“我要 SGID”,后者是“我不关心特殊位”。半年后回来 review 的人(可能就是你自己)一眼就能看出意图,不用去stat反查。

另一个是SGID 目录和 umask 必须配套考虑。只设 SGID 不设 umask 或默认 ACL,协作目录里成员之间照样互相改不了文件,看起来像 SGID 没生效,实际上是权限位不够。这两个东西一个是“属组归属”,一个是“属组权限”,缺一个都跑不通,但报错时不会告诉你缺的是哪个。

还有一个提醒:升级或重装任何带 SUID 位的程序之后,重新核一遍权限位。包管理器通常会把位设对,但手工覆盖安装、从源码编译后make install、或者从别处拷过来的二进制,经常悄悄丢掉这个位。上面那条find命令加上比较,十分钟能覆盖全机器,性价比极高。

至于 EUID 和 saved 槽位那部分,真正记住的方法只有一个:自己动手写一次“临时降权再回来”的代码,故意用setuid写错一次,看到EPERM,再用seteuid改对。那个报错比任何文章都让人印象深刻。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询