第 38 章 编写安全的特权程序 (Writing Secure Privileged Programs)
核心结论
-
特权程序的两种形式:以特权 UID (root) 启动的守护进程;或者带 set-user-ID (set-group-ID) 位的程序——exec 后进程 euid 变为文件所有者。最高危的是 set-user-ID-root,因为它直接拿到 root 凭证。
-
最小特权原则:程序应在需要时持有特权,做完即永久丢弃;最危险的是「长期保留 root 然后做普通工作」。第 9 章的 saved set-user-ID 与第 39 章的 capabilities 都是为此设计的。
-
特权变更的正确姿势:临时降权用
seteuid(getuid())+seteuid(orig_euid);永久降权必须改 所有 ID (real/effective/saved),否则setuid(getuid())不会清掉 saved set-user-ID——必须先seteuid(orig_euid)再setuid(getuid()),或用setresuid()/setreuid()。 -
执行其他程序前要丢特权、清 fd、关闭 fd:exec 新程序前必须
setuid(getuid())清掉 saved set-user-ID;不要 exec shell(system()/popen()/execlp()/execvp()都跑/bin/sh);关闭所有特权 fd 或设FD_CLOEXEC。 -
信号与 TOCTOU 竞态:用户可以 SIGTSTP/SIGSTOP 暂停进程,修改环境(权限、符号链接、文件),再 SIGCONT 恢复——检查→使用的窗口被拉长。任何「先 stat 再 open」「先 access 再操作」都是 TOCTOU 漏洞。
-
输入/资源/文件 I/O 的隐患:umask 防公开可写文件;O_EXCL 防止攻击者预占路径;
mkstemp()防止 /tmp 攻击;不要信任PATH、IFS、LD_*、stdin/stdout/stderr;用snprintf/strncpy/strncat防止栈溢出。
|
本章主旨
特权程序(set-UID-root、root 守护进程)一旦被攻破,整个系统的安全就被拿下——所以「安全 = 减少攻击面 + 限制爆炸半径」。作者给出 11 条核心准则:能不写 set-UID 就别写;非 root 特权更安全(用专用 group);只用能完成任务的最小特权;执行外部程序前永久丢弃特权、清 fd;及时擦除敏感数据;用 chroot/capabilities/虚拟化隔离;防信号 TOCTOU;防堆溢出;防 DoS;检查返回值;fail safe(终止或拒绝请求)。读者应理解每条准则背后的「为什么」——大多数是数十年 UNIX 漏洞史的真实教训。 |
一、核心概念
本章围绕 6 个核心概念展开:先辨别何时才真需要特权,再深入「最小特权的具体操作 (ID 变更)」「执行子程序前的清场」「TOCTOU 与信号竞态」「输入与环境信任」「栈溢出防御」「拒绝服务与返回值检查」。
| 概念 | 定义 + 重要性 | 实现提示 |
|---|---|---|
是否真的需要 set-UID? |
写 set-UID 之前先问:能否用普通进程完成?能否把需要特权的部分隔离到一个小型 set-UID helper (如 |
§38.1;如果 root 不是必须,就用专用 group——「running with root opens the gates to possible security compromises」。 |
最小特权 / 临时与永久降权 |
set-UID 程序仅在需要时刻保留特权;用 |
§38.2;记得非特权进程 |
exec 前的清场 (drop privs + close fd) |
执行其他程序前需 (1) |
§38.3;特权 fd (root 读到的 /etc/shadow 等) 一旦泄露,新进程即可旁路所有访问控制。Linux 与多数现代 UNIX 同样忽略脚本的 set-UID 位(脚本会被 exec 成 |
TOCTOU 与信号竞态 |
Time-of-check to time-of-use 漏洞:用户用 SIGSTOP/SIGTSTP 暂停进程,修改权限/符号链接,再 SIGCONT 恢复。「 |
§38.6;用 |
栈溢出防御 |
不要用 |
§38.9; |
DoS + 返回值 + fail safe |
服务器应 throttle (拒绝超载请求)、设 timeout、限 logging、可承受畸形输入;用平衡树或哈希避免 algorithmic-complexity 攻击 (Crosby-Wallach);每个 syscall 都要检查返回值(即使 root 也会遇到 EMFILE/ENOSPC);遇错就 |
§38.10-11;特权进程检查 |
二、详细笔记
38.1 是否真的需要 set-user-ID / set-group-ID?
What:在写 set-UID 程序之前先问:能否用普通进程 + 别的方式完成?若必须,能否「只让程序拥有某个专用 group 的写权限」而非 root 凭证?
Why:root 凭证几乎万能——一旦程序被漏洞攻陷,整个系统的安全地基被掀开;专用 group 凭证限制了爆炸半径(如更新文件用 gid=backup 而不是 uid=root)。
How:
-
能不写就不写:普通进程能否借工具解决?例如,许多 daemon 改用
/run/<name>.sock暴露给用户进程,抛弃 set-UID。 -
将特权隔离到 helper:类似
pt_chown的做法——daemon 负责主流程,需要特权的操作(如改伪终端所有权)单独 exec 一个原子 helper。 -
替代 root 用专用 group:要更新
gid=app拥有的文件,就创建appgroup,让程序 set-GID 到app而不是 set-UID 到 root。 -
库也可走 helper 模式:把需要特权的代码抽到一个 set-UID helper,主程序通过 fork-exec 调用它。
When:每次准备给程序设置 u+s 位之前;问三个问题「这是真的必要吗?能换 group 吗?能外置 helper 吗?」
Example:
// 摘自《The Linux Programming Interface》 第 38 章
// pt_chown 在第 64 章 — 一个最小化的 set-UID-root helper,用于
// 把 slave 伪终端的所有权改成调用进程的 UID/GID,避免整个
// login/SSH 都跑 set-UID-root
38.2 用最小特权运行:临时降权、永久降权
What:seteuid(getuid()) 临时把 euid 降回真实 uid;永久降权必须同时改动 real/effective/saved 三个 ID。
Why:永久降权堵住了「stack-crashing 攻击让 saved set-UID 复活」的路径;最小特权降低攻击面。
How:
降权/升权序列(来自 §38.2):
// 摘自《The Linux Programming Interface》 第 38 章
uid_t orig_euid;
orig_euid = geteuid();
if (seteuid(getuid()) == -1) /* Drop privileges */
errExit("seteuid");
/* Do unprivileged work */
if (seteuid(orig_euid) == -1) /* Reacquire privileges */
errExit("seteuid");
/* Do privileged work */
永久降权的陷阱:当进程的 euid == 0 (set-UID-root) 时,要把它永久降到非 0 看起来简单:
/* Initial UIDs: real=1000 effective=0 saved=0 */
orig_euid = geteuid();
if (seteuid(getuid()) == -1) errExit("seteuid");
/* UIDs changed to: real=1000 effective=1000 saved=0 -- WRONG! */
/* 现在 saved 还是 0 -- 并没有真正永久丢掉 root 能力 */
if (setuid(getuid()) == -1) /* Only changes effective */
errExit("setuid");
/* UIDs unchanged: real=1000 effective=1000 saved=0 */
正确做法是在 setuid(getuid()) 之前 升回 root:
/* Correct permanent drop: */
seteuid(getuid()); /* real=1000 effective=1000 saved=0 */
seteuid(orig_euid); /* real=1000 effective=0 saved=0 */
setuid(getuid()); /* real=1000 effective=1000 saved=1000 */
或者用 setreuid(getuid(), getuid())/setresuid() 一步完成——Linux 上 setreuid() 的特殊语义(ruid 非 -1 时同时改 saved)让一调用即可。
When:每个需要长期运行或会被 fork/exec 的 set-UID 程序都应在 init 后立刻降权,并校验最终状态。
Example:
// 摘自《The Linux Programming Interface》 第 38 章
/* Verify changes actually took effect */
uid_t ruid, euid, suid;
if (getresuid(&ruid, &euid, &suid) == -1)
errExit("getresuid");
if (ruid != getuid() || euid != getuid() || suid != getuid())
fatal("Failed to drop privileges fully");
Linux 还有规则 (来自 §9.7.4):改多个 ID 时,最后才升 root euid、最先才降 root euid——避免 root 半途中介态。
38.3 执行其他程序前的注意事项
What:set-UID/set-GID 程序要 exec 其他程序时,必须 (1) 把所有 user/group ID 复位到 real (group) ID;(2) 不 exec shell;(3) 关闭特权 fd 或设 FD_CLOEXEC。
Why:(1) 让新进程「既不拿到特权,也不能再拿回」;(2) shell 太强大($PATH/$IFS/通配/alias 全可被攻击者利用),漏洞面太大;(3) 防止特权文件描述符被无关进程继承。
How:
执行清场 (来自 §38.3):
/* Drop privileges permanently before exec: */
if (setuid(getuid()) == -1) /* euid nonzero → only changes euid,
but a successful exec() copies
euid to saved set-user-ID */
errExit("setuid");
execlp("/bin/some-tool", "some-tool", (char *)NULL);
不可 exec shell 的理由——system(cmd) 把 cmd 传给 /bin/sh -c,攻击者可以传 ; rm -rf / 或者用 $IFS/$PATH 拼凑陷阱命令。即使 shell 不允许交互,它的内部命令 (eval, alias, function) 也构成可编程的攻击面。
例外:popen()/system() 在程序是特权且无法改写为无特权版本时偶尔必需——必须 先 永久降权到非特权 UID,才能用。
关闭 fd:所有通过特权获取的 fd (root 读到的 /etc/shadow、打开的 raw socket 等) 必须在 exec 前 close(),或用 fcntl(fd, F_SETFD, FD_CLOEXEC)。
When:任何 execve() / system() / popen() 之前。
Example:
// 摘自《The Linux Programming Interface》 第 38 章
/* Set close-on-exec for privilege-bearing descriptors */
int fd = open("/etc/shadow", O_RDONLY);
if (fd == -1) errExit("open");
fcntl(fd, F_SETFD, FD_CLOEXEC); /* auto-close on exec() */
/* Later, if we exec(), fd is gone */
38.4 不要让敏感信息驻留内存
What:密码、密钥等敏感数据使用后立即清零(覆盖原 buffer 再 free),并防止 core dump 带走 (setrlimit(RLIMIT_CORE, 0))。
Why:虚拟内存页可能被换出到 swap,其他特权进程可读;如果进程崩溃生成 core dump,敏感信息落地。攻击者常用这两条路径偷凭证。
How:memset(buf, 0, sizeof(buf)) (编译器不优化掉) 或者 explicit_bzero();读密码用 getpass()/读后立刻清零 buffer;信号 handler 别碰敏感数据;用 mlock() 锁 page 也是一种思路。
When:每次读完密码/密钥/token 之后立刻清。
Example:
// 摘自《The Linux Programming Interface》 第 8.5 + 第 38 章
char *password = getpass("Password: ");
/* ... use password ... */
for (p = password; *p != '\0'; ) *p++ = '\0'; /* scrub */
38.5 进程沙箱:capabilities / chroot / 虚拟化
What:现代 Linux 提供三种「限制一个进程能做什么」的能力:(1) Capabilities(细粒度特权拆分,详细见第 39 章);(2) chroot() 限制可见的目录树;(3) 虚拟服务器 (UML、Xen、KVM) 把进程放到独立内核里。
Why:即使程序被攻陷,沙箱把爆炸半径卡死在最小集合里。chroot 不防 root (第 18.12),但对非特权进程有效;Caps 对 root 进程也有效。
How:把 daemon 改成:drop capabilities 至最小集合 + chroot() 到 /var/empty + setuid() 到 nobody + chdir() 到 chroot 内 → 然后做实际工作。虚拟化用 UML/Xen/KVM。
When:所有长期运行的网络 daemon 应该用此模式启动。
Example:
// 现代 sandbox 模式
cap_set_proc(minimal_caps); /* 来自 libcap */
chroot("/var/empty");
chdir("/");
setuid(65534); /* nobody */
setgroups(0, NULL);
/* 现在进程能访问的最糟就是 /var/empty 里的内容 */
38.6 小心信号与竞态 (TOCTOU)
What:set-UID 进程会被攻击者发送任意信号;特别是 SIGTSTP/SIGSTOP 暂停进程,让攻击者修改外部资源(权限、符号链接、文件内容),再 SIGCONT 继续。
Why:暂停期间改变了检查的假设(permission/路径/inode),resume 时使用过时假设 → TOCTOU 漏洞。
How:
-
用 fd 而非路径:先
open()拿到 fd,再fstat(fd)、fchown(fd)、fchmod(fd)——基于 fd 的操作不经过路径。 -
设
O_NOFOLLOW/O_EXCL:防止符号链接替换、O_CREAT | O_EXCL防止覆盖已有文件。 -
对「检查后使用」的窗口要短:原子操作(
rename/open(O_EXCL)/O_NOFOLLOW)优先。 -
signal handler 保持简单:复杂的 handler 自身就可能制造竞态。
When:所有特权进程都应用 fd 代替路径;用 O_RDONLY | O_NOFOLLOW 打开符号链接。
Example:
// 摘自《The Linux Programming Interface》 第 38 章
// BAD: stat + open 都用路径
struct stat sb;
stat("/tmp/x", &sb); /* 检查——攻击者可能改符号链接 */
int fd = open("/tmp/x", O_RDWR); /* 使用——已是不同的文件了 */
// GOOD: 用 open + fstat 走 fd
int fd = open("/tmp/x", O_RDWR | O_NOFOLLOW); /* 防 symlink */
if (fd == -1) errExit("open");
struct stat sb;
fstat(fd, &sb); /* 检查走 fd,无 TOCTOU */
38.7 文件操作与文件 I/O 的陷阱
What:特权进程创建文件时所有权、权限、路径都有隐患;umask 必须防「公开可写」;不要在 /tmp 这类公开可写目录建文件,除非用 mkstemp() 不可预测名字。
Why:攻击者可以构造 symlink 让 set-UID 程序覆盖关键文件;或者预占路径让程序读到攻击者的内容。
How:
-
umask:
umask(0077)或更严——保证创建的文件 owner 之外不可读/写。 -
O_EXCL:
open(path, O_CREAT | O_EXCL | O_WRONLY, 0600)保证自己就是创建者。 -
文件所有权切换:需要换 owner 时,用
fchown()先改 owner,再调fchmod(),中间不能让 file 处于「其他用户可写」状态(§38.7 第 3 条)。 -
mkstemp():在 /tmp 生成不可预测名字——
mkstemp(template),template 末尾必须是XXXXXX。
When:每个 open(O_CREAT, …) 之前检查 umask + O_EXCL。
Example:
// 摘自《The Linux Programming Interface》 第 38 章
#include <unistd.h>
umask(0077);
char tpl[] = "/tmp/myapp.XXXXXX";
int fd = mkstemp(tpl);
if (fd == -1) errExit("mkstemp");
unlink(tpl); /* 用完即删 */
38.8 不要信任输入或环境
What:set-UID 程序绝不信任 PATH、IFS、命令行参数、环境变量、stdin/stdout/stderr、CGI 输入、网络包等。
Why:攻击者可以设 PATH=/tmp 让 system(cmd) 跑到攻击者的脚本;IFS='/' 让 shell 把命令词切开成多个参数;不存在的 fd 在 open() 时可能被复用。
How:
-
PATH:用绝对路径;exec 时
execl("/full/path", …)而非execlp/execvp/system/popen。 -
IFS:在 fork+exec shell 之前清空/重置。
-
信任度盘点:识别每个输入源(用户、文件、网络、环境)的可信度,并据此校验。
-
stdin/stdout/stderr:若可能被关闭,
open()可能复用 fd 0/1/2——检查open()的返回是否就是 0/1/2。
When:每个特权程序的入口清空/重置可信环境变量;exec 前 if (pfd[1] != STDOUT_FILENO) 守护。
Example:
// 摘自《The Linux Programming Interface》 第 38 章
/* Use absolute pathnames + reset PATH/IFS */
setenv("PATH", "/usr/sbin:/usr/bin:/sbin:/bin", 1);
setenv("IFS", " \t\n", 1);
execl("/usr/sbin/some-tool", "some-tool", arg1, (char *)NULL);
38.9 缓冲区溢出(栈崩溃)
What:用 gets() 或无长度限制的 strcpy/strcat/sprintf 让攻击者写入超过 buffer 的内容,覆盖栈上的返回地址——然后程序被劫持执行任意代码 (栈崩溃/stack smashing)。
Why:缓冲区溢出是 UNIX 系统最常见的漏洞来源 (CERT/Bugtraq 几十年统计榜首);在网络 server 上尤其致命——远程可攻击。
How:
-
永不用
gets():用fgets(buf, sizeof buf, stdin)。 -
用带长度的版本:`snprintf()
、`strncpy()/strncat()+ 检查返回值/截断;注意strncpy()不保证 null 终止(buf 不够大时)、性能差。 -
编译选项:`-D_FORTIFY_SOURCE=2` (glibc)、
-fstack-protector(gcc)。 -
运行时防护:2.6.12+ 内核地址空间随机化 (ASLR);x86 NX 位阻止栈上执行代码。
When:每个接受外部输入的函数都该用长度受限版本。
Example:
// 摘自《The Linux Programming Interface》 第 38 章
/* BAD: */
char buf[64];
gets(buf); /* never */
strcpy(buf, user_input); /* depends on size */
/* GOOD: */
char buf[64];
if (snprintf(buf, sizeof buf, "%s", user_input) >= sizeof buf)
fatal("input too long");
38.10 DoS 攻击与处理
What:网络 server 容易被恶意客户端用畸形数据 / 大量请求 / 算法复杂度攻击 (algorithmic-complexity attack) 拖垮。
Why:远程 DoS 让合法用户得不到服务。
How:
-
throttle: 超过负载阈值拒绝新请求。
-
timeout: 通信用
SO_RCVTIMEO/alarm 限时。 -
限 logging: logging 本身能拖垮系统 (写满磁盘)。
-
数据结构防退化: 用平衡树 (红黑树/
std::map) 而非裸 BST,避免精心构造的输入把树变成链表。 -
不崩溃: 输入验证 + 边界检查。
When: 每个面向网络的 server 实现。
38.11 检查返回值与 fail safe
What:每个 system call / library function 都要检查返回值;错误时不要尝试修复,要终止或拒绝。
Why:root 也可能遇到 open() 失败 (只读文件系统)、fork() 失败 (per-UID 进程数上限);试图修复往往基于未经验证的假设,会创造新的攻击面。
How:
-
每个 syscall 后:`if (syscall(…) == -1) errExit("syscall");`。
-
open() 后:确认没返回 0/1/2。
-
fail closed:遇错 →
exit(EXIT_FAILURE)或对 serverdrop request。
When: 写每个调用都做这个检查——不需要思考「这个会失败吗」,直接检查。
三、关键图表
|
非可视化条目(系统调用 / 准则表)
|
|
核心 set-user-ID 操作对比
|
四、思维导图
mindmap
root((第 38 章 安全特权程序))
是否需要 set-UID
不写优先
helper 隔离
专用 group 代替 root
最小特权
seteuid 临时升降
setresuid 永久降
getresuid 验证
saved setuserID 复活风险
exec 前清场
setuid getuid 清 saved
不 exec shell
FD_CLOEXEC 关特权 fd
沙箱隔离
chroot
capabilities
虚拟化
信号 TOCTOU
fd 替 path
O_NOFOLLOW
atomic ops
I/O 陷阱
umask O_EXCL
mkstemp 路径不可预测
fchown 不留公开可写窗口
不可信输入
PATH IFS 复位
绝对路径
fd 守护
栈溢出
永不用 gets
snprintf strncpy
ASLR NX
DoS 与返回值
throttle timeout
检查所有 syscall
fail closed
五、重点与易错点
-
「能不写 set-UID 就别写」 是第 1 条准则,比其他一切「如何安全写 set-UID」更根本——少 1 个 set-UID 程序就少 1 个攻击面。
-
最小特权原则对应到 saved set-UID 机制:saved 是「升级回路」——如果只清 effective 而不清 saved,攻击者可借
/bin/sh或execve复活。 -
永久降权常见陷阱:
setuid(getuid())在 effective != 0 时只改 effective,不改 saved——必须先seteuid(orig_euid)让 effective 回到 0,或用setreuid(getuid(), getuid())/setresuid()一步到位。 -
检查返回值*:不要用
if (syscall())草草判断——每个open/read/write/fork/exec都查。root 也能EMFILE/ENOSPC/EACCES。 -
不 exec shell:
system()/popen()/execlp()/execvp()都通过/bin/sh -c执行,shell 的$PATH/$IFS/alias/function太复杂,几乎无法保证安全。要 exec shell 必须先永久降权。 -
关特权 fd:
open("/etc/shadow")拿到的 fd 留给新程序就是大漏洞——FD_CLOEXEC或显式close()。 -
不信任 PATH/IFS:清空
IFS(shell 的 word separator),重设PATH,用绝对路径 exec。 -
不信任 stdin/stdout/stderr:可能被关闭后被
open()复用,造成「以为写到 stdout,实则写到攻击者文件」。例:open()后检查if (fd ⇐ 2)。 -
fd 而非 path:
stat()+open()之间就是攻击窗口;改用open()+fstat()/O_NOFOLLOW一步到位。 -
mkstemp vs /tmp:在公开可写目录构造文件必须用
mkstemp()不可预测名,否则攻击者预先创建符号链接让你覆盖/etc/passwd。 -
永远不要
gets():永远带长度限制;snprintf()检查返回值判断截断;strncpy不保证 null 终止且性能差。 -
TOCTOU 不只是
access():所有「先 stat 再 open」「先 readdir 再 open」「先 readlink 再 open」都是——只要「检查」与「使用」经过路径就会出问题。 -
Umask: 创建文件前
umask(0077);否则 set-UID 程序创建的文件可能同组/其他可读。 -
fail safe: 遇错
exit()或 drop request——不要试着修;试着修常意味着对环境做未经验证的假设。 -
DoS 不要在网络 server 忽视:truncate 输入大小、限 logging、设置 timeout;防 Crosby-Wallach 风格算法复杂度攻击(哈希冲突、二叉树退化)。
-
跨章衔接:第 9 章 set-user-ID 与 saved set-user-ID 基础;第 18 章 chroot;第 39 章 capabilities (把特权拆成独立能力);第 27 章 execve;第 21/22 章信号;第 30 章线程;第 36 章资源限制。