20. setuid/setgid安全:特权提升风险,最小权限原则,capability机制,安全执行外部程序

好,咱们今天聊一个老生常谈,但又特别容易翻车的话题——setuid/setgid。

说白了,这就是个“临时借权限”的机制。你一个普通用户,想改密码,但密码文件只有root能写,怎么办?系统就让你临时“变成”root,改完再退回来。setuid就是干这个的。

但问题来了——这个“借权限”的机制,一旦没设计好,就成了后门。我在项目中见过太多次了,一个setuid程序写得糙一点,整个服务器就裸奔了。

20.1 setuid/setgid 的基本原理

先看一个最简单的例子。你写了一个C程序,想让它以root身份运行:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    printf("当前有效用户ID: %d\n", geteuid());
    printf("当前实际用户ID: %d\n", getuid());
    return 0;
}

编译后,用root执行:

chown root:root myprogram
chmod u+s myprogram   # 设置setuid位

这时候,普通用户运行这个程序,geteuid()返回的就是0(root),而不是他自己的UID。

嗯,这里要注意——setuid只影响有效用户ID,不影响实际用户ID。实际用户ID还是那个普通用户。这个区别很关键,后面会用到。

20.2 特权提升的风险

为什么说setuid危险?我举个例子你就明白了。

假设你写了一个setuid程序,功能是让用户读取某个日志文件:

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]) {
    char cmd[256];
    snprintf(cmd, sizeof(cmd), "cat %s", argv[1]);
    system(cmd);  // 危险!
    return 0;
}

这个程序以root权限运行。用户传一个文件名进去,它就用system()去读。

你想想看,如果用户传的是 ; rm -rf / 呢?

这就是典型的命令注入。我在一次安全审计中遇到过类似的代码,当时那个程序是用来备份数据库的,结果因为没做输入校验,攻击者直接删了整库。

警告: 永远不要在setuid程序中使用 system()、popen() 或 exec() 执行用户可控的命令。这是最经典的特权提升漏洞。

20.3 最小权限原则

我个人习惯,写setuid程序时,脑子里始终绷着一根弦:用完权限就扔

什么意思?就是程序启动后,先完成需要特权的操作,然后立刻把权限降下来。

看代码:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    // 先以root身份打开一个需要特权的文件
    FILE *fp = fopen("/etc/shadow", "r");
    if (!fp) {
        perror("fopen");
        return 1;
    }

    // 读取完关键数据后,立刻降权
    if (setuid(getuid()) == -1) {
        perror("setuid");
        return 1;
    }

    // 现在程序以普通用户身份运行
    // 即使后面有漏洞,攻击者也拿不到root权限
    printf("权限已降为普通用户\n");
    // ... 后续处理

    fclose(fp);
    return 0;
}

这里的关键是 setuid(getuid())。它把有效用户ID设回实际用户ID,也就是那个普通用户。一旦降权,就再也升不回去了——除非你保留了capability。

提示: 降权后,记得检查 setuid() 的返回值。如果失败,程序应该立即退出,不要继续执行。我曾经见过一个程序,setuid() 失败了还继续跑,结果以root身份执行了后续所有操作——那叫一个惨。

20.4 capability机制

setuid有个大问题:它给的是全部root权限。你只想让程序绑定一个低端口(1024以下),结果它连改密码、删文件的能力都有了。

这就像你只想借把螺丝刀,结果对方把整个工具箱都给你了。

Linux的capability机制就是来解决这个问题的。它把root权限拆成一个个小单元,你可以只给程序需要的那个。

Capability 说明
CAP_NET_BIND_SERVICE 绑定低于1024的端口
CAP_DAC_OVERRIDE 绕过文件权限检查
CAP_SYS_ADMIN 系统管理操作(慎用)
CAP_SETUID 允许改变UID
CAP_KILL 允许发送信号给任意进程

用法很简单。比如你写了一个HTTP服务,想监听80端口:

# 给程序添加绑定低端口的能力,不需要setuid
setcap cap_net_bind_service=+ep ./myhttpd

# 查看capability
getcap ./myhttpd
# 输出: ./myhttpd = cap_net_bind_service+ep

这样,程序运行时只有绑定低端口的能力,其他root权限一概没有。即使被攻破,攻击者也干不了别的。

我个人强烈建议:能用capability就别用setuid。这是Linux安全机制里最优雅的设计之一。

20.5 安全执行外部程序

有时候,你的setuid程序需要调用外部程序。比如一个监控工具,需要调用df命令查看磁盘使用情况。

这时候要格外小心。我见过一个案例:程序用system("df -h"),结果攻击者修改了PATH环境变量,把df指向了自己的恶意程序。然后以root权限执行了。

正确的做法是:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    // 1. 先降权
    if (setuid(getuid()) == -1) {
        perror("setuid");
        return 1;
    }

    // 2. 清理环境变量
    clearenv();
    setenv("PATH", "/usr/bin:/bin", 1);

    // 3. 使用绝对路径执行
    execl("/usr/bin/df", "df", "-h", NULL);

    // 如果execl返回,说明出错了
    perror("execl");
    return 1;
}

这里做了三件事:

  • 降权:先把自己变成普通用户,再执行外部程序
  • 清环境:防止攻击者通过环境变量注入恶意代码
  • 绝对路径:避免PATH劫持
核心原则: 执行外部程序时,永远假设环境是恶意的。清理环境、使用绝对路径、先降权,三步缺一不可。

20.6 知识体系图

下面这张图总结了setuid/setgid安全的核心逻辑:

setuid/setgid 安全知识体系 setuid/setgid 安全 特权提升风险 命令注入 缓冲区溢出 环境变量劫持 最小权限原则 用完即降权 setuid(getuid()) 检查返回值 Capability机制 细粒度权限拆分 setcap / getcap 替代setuid 安全执行外部程序 先降权再执行 清理环境变量 使用绝对路径 核心:能不用root就不用,用了赶紧扔

20.7 避坑指南

最后,分享几个我踩过的坑:

  • 别用system():它启动一个shell,shell会继承环境变量和权限。用exec系列函数替代。
  • 降权要彻底:setuid()只降有效用户ID,别忘了检查setgid()和补充组。我曾经只降了UID,没降GID,结果程序还能写一个只有root组才能写的目录。
  • capability不是万能的:有些老系统不支持。而且capability只在文件系统支持的情况下才生效(比如ext4、xfs)。
  • setuid脚本是噩梦:很多系统会忽略shell脚本的setuid位。如果你非要写,用C封装一下。

嗯,setuid/setgid这个话题,说深了可以讲一整天。但核心就一句话:能不用root就不用,用了赶紧扔。你想想看,权限这东西,给出去容易,收回来可就难了。


公众号:蓝海资料掘金营,微信deep3321