BusyBox 1.38.0 权限模型分析:从 Linux UID 机制到 check_suid() 实现

发布于: 2026-06-28 04:59

关于本文

本文基于 BusyBox 1.38.0 源码、实际实验环境以及 Alpine Linux 发行版行为进行分析。

写作过程中使用 AI 工具辅助发现可能遗漏的问题、审查逻辑链条以及优化表达,但最终结论均以源码验证和实验结果为准,并已由作者逐项复核,若存在错误欢迎指出。

背景

UID 是 Linux 内核为每个进程维护的用户身份标识。一个进程同时持有三份 UID:

  • ruid(真实 UID):原始的登录用户身份
  • euid(有效 UID):权限检查时实际使用的身份
  • suid(保存的 UID):用于临时降权后恢复特权

setuid 位(chmod u+s)允许普通用户以文件属主的 euid 执行程序——这是 Linux 中最经典的提权机制。

BusyBox 是一个将所有常用 Unix 工具打包进一个二进制文件的工具箱。在 Alpine Linux 等发行版中,/bin/ls/bin/id/bin/cat 等实际上都是指向 /bin/busybox 的符号链接。每个这样的命令称为一个 applet——BusyBox 通过 argv[0] 识别要执行哪个 applet,并在其 main() 之前运行统一的 check_suid() 进行特权管理。

本文要回答的是一个具体的安全问题:从 SUID Bash 拿到 euid=0 的 shell 后,BusyBox 的每个 applet 到底以什么身份运行?


0. 实验起点:SUID Bash + BusyBox

busybox 项目源码下载:https://busybox.net/downloads/busybox-1.38.0.tar.bz2

0.1 现象

实验环境为 Alpine Linux。Alpine 以 BusyBox 作为核心系统工具集,/bin/sh/bin/ls/bin/id/bin/cat 等绝大多数基础命令均为指向 /bin/busybox 的符号链接。因此任何在 Alpine 中执行的常用命令,最底层几乎都经过 BusyBox 的 check_suid() 特权管理。

# 准备一个 setuid bash(bash 是额外安装的,不属于 BusyBox)
cp /bin/bash /var/tmp/bash
chmod 4755 /var/tmp/bash

以普通用户 demo(uid=1000)运行它,加上 -p 保持特权:

demo@localhost:/var/tmp$ ./bash -p
bash-5.3#    # ← prompt 为 #,说明 euid=0

./bash 的 setuid 位使 exec 后的进程处于 ruid=1000, euid=0, suid=0。bash 的 -p 选项禁止 bash 自行 setuid(getuid()) 降权。

此时执行 BusyBox 的 id applet:

bash-5.3# id
uid=1000(demo) gid=1000(demo) groups=1000(demo)

BusyBox 1.38.0 的 coreutils/id.c 第 27 行明确声明:

//applet:IF_ID(APPLET_NOEXEC(id, id, BB_DIR_USR_BIN, BB_SUID_DROP, id))

因此 check_suid() 在进入 id_main() 之前已执行 setuid(ruid),将 euid 降为 1000。整个调用链如下——掉特权的是 id 这个 applet 自己,不是 bash -p 那个 shell

id_main() 内部调用 getuid()geteuid() 后,发现 euid == ruid,因此不额外打印 euid 行。所以虽然 shell 提示符是 #(进程原始 euid 为 0),但 id 看到的已经是降权后的结果。

bash -p 进程 (ruid=1000, euid=0, suid=0)
          │
          │  exec /usr/bin/id → /bin/busybox
          ▼
     busybox 入口
          │
          ▼
     check_suid()
          │  ruid=1000 ≠ 0
          │  APPLET_SUID(id) == BB_SUID_DROP
          ▼
     setuid(1000)    ← euid 降为 1000, suid 降为 1000
          │
          ▼
     id_main()
          │  getuid()=1000, geteuid()=1000
          │  euid == ruid → 不打印 euid 行
          ▼
     uid=1000(demo) gid=1000(demo) groups=1000(demo)

⚠ 实验陷阱:在 BusyBox 环境中,catidls 等命令本身就是 BusyBox applet。使用这些工具观察 UID 状态时,你观察到的是 applet 自身被 check_suid() 处理后的身份,而非调用者(如 bash -p)的原始 UID。要验证 shell 的真正权限,必须用独立编译的测试程序直接调用 getuid()/geteuid()

用一个小程序绕过 BusyBox 层,直接打印 getuid()geteuid(),可以确认真实状态:

bash-5.3# cat b.c
#include <stdio.h>
#include <unistd.h>
#include <errno.h>

int main()
{
    printf("before: r=%d e=%d\n",
           getuid(),
           geteuid());

    int ret = setuid(0);

    printf("ret=%d errno=%d\n",
           ret,
           errno);

    printf("after : r=%d e=%d\n",
           getuid(),
           geteuid());

    execl("/bin/sh","sh",NULL);
}
bash-5.3# ./b
before: r=1000 e=0
ret=0 errno=0
after : r=0 e=0
/var/tmp #

./b 直接输出 r=1000 e=0,确认 bash -p 后的进程处于 ruid=1000, euid=0, suid=0setuid(0) 调用成功(ret=0),之后 r=0 e=0——拥有 CAP_SETUID 的进程调用 setuid() 会将 ruid、euid、suid 三个字段全部设为指定值。

对比 BusyBox id 的输出与 ./b 的输出:同一个 bash -p 中,id 只显示 uid=1000(因为 check_suid() 已经降权),而 ./b 直接输出 r=1000 e=0(绕过了 BusyBox)。这正是本实验的核心教训——在 BusyBox 环境中验证 shell 权限,必须用独立程序而非 BusyBox 工具。

另一个实验:在 bash -p 中运行仅含 setuid(0)./a,然后调用 BusyBox 的 id 观察效果:

bash-5.3# ./a
/var/tmp # id
uid=0(root) gid=1000(demo) egid=0(root) groups=1000(demo)

/var/tmp # which id
/usr/bin/id

/var/tmp # ls -alh /usr/bin/id
lrwxrwxrwx    1 root     root          12 Jan 27 08:17 /usr/bin/id -> /bin/busybox

此时 id 输出 uid=0(root),ruid 已经变成 0。两次 id 调用的都是同一个 BusyBox applet,输出却完全不同。

0.2 拆解:./a 里的 setuid(0) 做了什么

执行 ./a 前:   ruid=1000, euid=0, suid=0

setuid(0):     当前进程 euid=0,具备 CAP_SETUID。
               Linux 将此次视为特权 UID 切换,
               setuid() 会同时修改 ruid、euid、suid 三者。

执行 ./a 后:   ruid=0, euid=0, suid=0   ← 全部三个 UID 被同步为 0

之后进程中 ruid == 0。BusyBox 的 check_suid() 看到 ruid == 0,直接 return,跳过所有检查。因此再次执行 busybox id 时,整个进程已是完整 root 身份,输出 uid=0(root)

0.3 三个问题

  1. ./bash -p 之后 euid=0,为什么 id 输出却是 uid=1000
  2. ./a 一个 setuid(0),为什么能把 ruid 从 1000 变成 0?
  3. 如果拿到一个 euid=0 的 shell,BusyBox 的每一个 applet 到底以什么身份运行?

答案在 Linux 的 UID 模型和 BusyBox 的 check_suid() 机制里。


1. Linux 身份模型

1.1 进程的四种 UID

每个 Linux 进程维护以下 UID 属性:

字段 含义 用途
ruid (real UID) 真实用户 ID,原始登录用户 getuid() 获取
euid (effective UID) 有效用户 ID,传统 DAC 权限检查依据 geteuid() 获取
suid (saved set-user-ID) 保存的设置用户 ID,用于特权恢复 getresuid() 获取第三字段
fsuid (filesystem UID) 文件系统访问 UID(Linux 特有,通常与 euid 同步) setfsuid()

核心原则:传统 DAC 权限检查(文件读写、信号发送等)依据 euid,而非 ruid。 现代 Linux 还同时依赖 Capability、LSM 和用户命名空间,详见 1.4 节。

1.2 setuid 位的作用

当可执行文件的 setuid 位(chmod u+s)置位且文件属主为 root 时,exec 后:

ruid = 调用者原有 ruid (例如 1000)
euid = 文件属主 (0, root)
suid = 0 (root)

euid 和 suid 都被设为文件属主的值,ruid 保持为原用户。

这正是实验中 ./bash -p 之后的状态:ruid=1000, euid=0, suid=0

1.3 特权恢复与丢失

假设一个 setuid root 进程启动后状态为 ruid=1000, euid=0, suid=0

场景一:seteuid(1000) — 临时降权

seteuid(1000);   // 只改 euid
// 结果: ruid=1000, euid=1000, suid=0

seteuid(0);      // 仍可恢复!因为 suid 还是 0
// 结果: ruid=1000, euid=0, suid=0

场景二:setuid(1000) — 永久降权(核心区别)

setuid(1000);    // 在 setuid-root 程序降权场景下,setuid(ruid) 会同时清除 euid 与 suid
// 结果: ruid=1000, euid=1000, suid=1000  ← suid 也变了!

seteuid(0);      // 失败!suid 已经是 1000,无法恢复
setuid(0);       // 同样失败

这正是 BusyBox BB_SUID_DROP 做的事情,也是"BusyBox applet 看起来像普通用户"的根本原因。

场景三:setresuid(-1, 1000, 0) — 精细控制

setresuid(-1, 1000, 0);  // 不变更 ruid,euid=1000, suid=0
// 结果: ruid=1000, euid=1000, suid=0  ← suid 仍为 0

seteuid(0);      // 仍可恢复

场景四:setresuid(-1, 1000, 1000) — 彻底封锁

setresuid(-1, 1000, 1000);  // euid=1000, suid=1000
// 结果: ruid=1000, euid=1000, suid=1000

seteuid(0);      // 失败!特权永久丢失

关键理解:对于不具备 CAP_SETUID 的进程,setuid() 无法任意切换身份——参数必须是 ruid、euid、suid 三者中已有的值。在最常见的 setuid 程序降权场景(euid=0, suid=0, ruid≠0)下,调用 setuid(ruid) 会同时将 euid 和 suid 设为 ruid,表现为"彻底清理"特权。seteuid() 只改 euid,不改 suid,所以 suid 中保存的 0 允许后续 seteuid(0) 恢复。这正是 BusyBox check_suid()/etc/busybox.conf 机制的核心安全考量。此处省略 Linux 内核关于 CAP_SETUID、用户命名空间和历史兼容语义的细节,仅讨论 BusyBox 场景下最常见的行为。

1.4 Linux Capability 与 UID

本文聚焦于 BusyBox 的传统 setuid 权限模型,讨论 ruid/euid/suid 的变化过程。但需注意,现代 Linux 中大量特权操作已从"全有或全无的 root"拆分为细粒度的 Capability(能力集):

# 仅授予 ping 所需的最小能力,而非整个 root
setcap cap_net_raw+ep /bin/ping

此时进程的 ruid 与 euid 均为普通用户,但仍可创建 raw socket。

因此"权限检查只看 euid"这一论断仅在传统 setuid 模型下成立。实际内核的权限判断同时依赖:

  • euid(传统 DAC 检查)
  • Capability 集合CAP_NET_RAWCAP_SYS_ADMIN 等)
  • 用户命名空间(容器场景下的 UID 映射)

BusyBox 的 check_suid() 自身仅围绕 UID 切换设计,不涉及 Capability 管理(libbb/capability.c 仅提供了 CAP_SETUID 等名称常量),因此本文的讨论范围限于 ruid/euid/suid 维度。


2. check_suid() 执行流程

2.1 流程图

关键check_suid() 的第一个判断是 ruid == 0(real uid),而非 euid。这意味着只有真正以 root 登录(或 ruid 已被改为 0)的场景才会跳过所有检查。euid=0ruid≠0(如 setuid 场景)仍需继续执行后续逻辑。

              exec busybox applet
                      │
                      ▼
              run_applet_no_and_exit()
                 (appletlib.c:963)
                      │
                      ▼
           ┌─── check_suid(applet_no) ───┐
           │      (appletlib.c:555)       │
           │                              │
      ruid == 0 ? ───yes──→ return (skip all checks)
           │                ⚠ 注意:判断的是 real uid,不是 effective uid
          no
          (euid=0 但 ruid≠0 仍会走到这里)
           │
     /etc/busybox.conf
      可读且有匹配?
           │
   ┌───────┼───────┐
   │ yes            │ no
   ▼                ▼
 [Config 分支]    [Fallback 分支]
   │                │
   │          APPLET_SUID(applet_no)
   │                │
   │       ┌────────┼────────┐
   │       ▼        ▼        ▼
   │    REQUIRE    DROP    MAYBE
   │       │        │        │
   │  geteuid()  setgid(rgid)  保持
   │  != 0 ?    +setuid(ruid)  不变
   │  报错退出   特权丢失
   │   applet     applet
   │  以 root    以 ruid
   │  身份运行   身份运行
   │
   ▼
 检查 mode 权限位:
 UID/GID 匹配?
   │
  ┌─┼─┐
 no│ yes
   ▼  ▼
 拒绝 setresuid(-1, uid, uid)
      setresgid(-1, gid, gid)
      (同时设 euid 和 suid,
       阻止后续提权)

2.2 三种模式详解 (libbb/appletlib.c:555-650)

模式 条件 行为 suid 状态 能否后续提权
BB_SUID_REQUIRE geteuid() == 0(即维持 setuid 状态或由 root 运行) 不做变更,保持 euid=0 ruid=1000, euid=0, suid=0
BB_SUID_DROP 无条件 setgid(rgid) + setuid(ruid) ruid=1000, euid=1000, suid=1000 否(当前进程永久失权;父进程不受影响)
Config 模式 配置匹配,setresuid(-1, uid, uid) 同时改 euid 和 suid 取决于配置 取决于 suid 值
BB_SUID_MAYBE 无条件 不干预 保持原始 取决于原始

2.3 关键代码片段

// libbb/appletlib.c:620-642 (精简)
static void check_suid(int applet_no)
{
    gid_t rgid;

    if (ruid == 0)           // root 运行,跳过一切检查
        return;

    rgid = getgid();

    // === /etc/busybox.conf 配置模式 ===
    // ... 匹配配置条目 ...
    // setresuid(-1, uid, uid);
    // setresgid(-1, rgid, rgid);

    // === Fallback: 硬编码模式 ===
    if (APPLET_SUID(applet_no) == BB_SUID_REQUIRE) {
        if (geteuid())
            bb_simple_error_msg_and_die("must be suid to work properly");
    } else if (APPLET_SUID(applet_no) == BB_SUID_DROP) {
        setgid(rgid);        // 放弃组特权
        setuid(ruid);        // 放弃用户特权 (suid 也被设为 ruid!)
    }
}

3. 运行时特权管理工具

3.1 环境清理 sanitize_env_if_suid() (libbb/login.c:138-168)

// 禁用的危险环境变量列表 (libbb/login.c:138-151)
static const char forbid[] ALIGN1 =
    "ENV" "\0"
    "BASH_ENV" "\0"
    "HOME" "\0"
    "IFS" "\0"
    "SHELL" "\0"
    "LD_LIBRARY_PATH" "\0"
    "LD_PRELOAD" "\0"
    "LD_TRACE_LOADED_OBJECTS" "\0"
    "LD_BIND_NOW" "\0"
    "LD_AOUT_LIBRARY_PATH" "\0"
    "LD_AOUT_PRELOAD" "\0"
    "LD_NOWARN" "\0"
    "LD_KEEPDIR" "\0";

// 入口条件: getuid() != geteuid() 时才生效
int FAST_FUNC sanitize_env_if_suid(void)
{
    if (getuid() == geteuid())
        return 0;                  // 不是 setuid 场景,放行

    // 逐一 unset 危险变量
    p = forbid;
    do {
        unsetenv(p);
        p += strlen(p) + 1;
    } while (*p);
    putenv((char*)bb_PATH_root_path);  // 设置安全 PATH

    return 1;  // 确实检测到了 setuid 场景
}

3.2 文件访问特权切换 xopen_as_uid_gid() (libbb/xfuncs_printf.c:187-200)

// 临时切换到指定 UID 打开文件后立即恢复
int FAST_FUNC xopen_as_uid_gid(const char *pathname, int flags, uid_t u, gid_t g)
{
    uid_t old_euid = geteuid();
    gid_t old_egid = getegid();
    xsetegid(g);
    xseteuid(u);
    fd = xopen(pathname, flags);
    xseteuid(old_euid);     // 恢复
    xsetegid(old_egid);     // 恢复
    return fd;
}

3.3 完整身份切换 change_identity() (libbb/change_identity.c:33-58)

void FAST_FUNC change_identity(const struct passwd *pw)
{
    res = initgroups(pw->pw_name, pw->pw_gid);  // 设置附加组
    // ...
    xsetgid(pw->pw_gid);    // 先设 GID
    xsetuid(pw->pw_uid);    // 再设 UID (顺序重要: 先 gid 后 uid)
}

3.4 封装函数 (libbb/xfuncs_printf.c:415-432)

void xsetuid(uid_t uid)   { if (setuid(uid))   bb_simple_perror_msg_and_die("setuid"); }
void xseteuid(uid_t euid) { if (seteuid(euid)) bb_simple_perror_msg_and_die("seteuid"); }
void xsetgid(gid_t gid)   { if (setgid(gid))   bb_simple_perror_msg_and_die("setgid"); }
void xsetegid(gid_t egid) { if (setegid(egid)) bb_simple_perror_msg_and_die("setegid"); }

4. 各 Applet 的 SUID 策略与业务权限检查

4.1 编译期 SUID 分类

每个 applet 在注册时声明一个编译期策略,由 check_suid() 统一执行。以下为 BusyBox 1.38.0 默认配置下的分类:

Applet 模式 含义
passwd BB_SUID_REQUIRE 必须以 setuid root 运行,否则拒绝
su BB_SUID_REQUIRE 同上
login BB_SUID_REQUIRE 同上
vlock BB_SUID_REQUIRE 同上
crontab BB_SUID_REQUIRE 同上
wall BB_SUID_REQUIRE 同上
crond BB_SUID_DROP 进入 main() 前主动降权为 ruid
adduser BB_SUID_DROP 同上
deluser BB_SUID_DROP 同上
addgroup BB_SUID_DROP 同上
chpasswd BB_SUID_DROP 同上
httpd BB_SUID_DROP 同上
tcpsvd/udpsvd BB_SUID_DROP 同上
id BB_SUID_DROP 同上
ash BB_SUID_DROP 同上
vi BB_SUID_DROP 同上
test BB_SUID_DROP 同上
cat BB_SUID_DROP 同上
ls BB_SUID_DROP 同上
traceroute BB_SUID_MAYBE 不主动降权;是否保留特权取决于编译选项与 capability 设置
ping BB_SUID_MAYBE 同上(含 ping6)
findfs BB_SUID_MAYBE 同上
mount BB_SUID_MAYBE/DROP DESKTOP 版本为 MAYBE,否则 DROP

4.2 运行时业务权限检查

check_suid() 处理之后,各 applet 的 main() 内部还会进行独立的业务级权限判断:

Applet SUID 策略 业务权限检查 说明
passwd REQUIRE getuid() 非 root 时仅允许改自己密码;写入 /etc/passwdxsetuid(0) 恢复 root 保持 euid=0 → 检查 ruid → 恢复 root → 写入
su REQUIRE getuid() 非 root 必须认证密码;getuid() 非 root 不能绕过受限 shell;change_identity() 切换身份 保持 euid=0 → 认证 → 切换
login REQUIRE 无额外 UID 检查;change_identity() 切换身份 保持 euid=0 → 认证 → 切换
vlock REQUIRE xgetpwuid(getuid()) 以真实用户锁定 保持 euid=0,以 ruid 锁定
crontab REQUIRE sanitize_env_if_suid() 清理环境;getuid() 确定操作哪个用户的 crontab;-u/-c 拒绝非 root 保持 euid=0 → 清理环境 → 以 ruid 确定操作对象
wall REQUIRE xopen_as_uid_gid(argv, ..., getuid(), getgid()) 以真实用户身份读文件参数 保持 euid=0 → 以 ruid 打开用户文件
traceroute MAYBE getuid() != 0 拒绝;创建 raw socket 后 xsetuid(getuid()) 释放 保持原始 euid → 权限检查 → 降权。实际权限需求取决于内核配置与编译选项(FEATURE_TRACEROUTE_USE_ICMP 等),并非一定需要 raw socket
crond DROP change_identity() 在 vfork 子进程中以对应用户执行任务 降权 → fork → 以目标用户执行
adduser DROP geteuid() != 0 拒绝非 root 降权后自查也是 ruid,实际需 root 直接运行
deluser DROP geteuid() != 0 拒绝非 root 同上
addgroup DROP geteuid() != 0 拒绝非 root 同上
chpasswd DROP geteuid() != 0 拒绝非 root 同上
httpd DROP -u USER[:GROUP] 在 bind 后 setgroups() + xsetgid() + xsetuid() 降权 → bind → 再次降权
tcpsvd/udpsvd DROP root 且无 -u 拒绝;有 -uxsetgid()+xsetuid() 降权 → bind → 降权到指定用户
ash DROP 非 Linux: getuid()==geteuid() && getgid()==getegid() 时才读 $ENV check_suid 已降权,但仍保留此检查以防御非 Linux 平台
vi DROP .exrc 属主 == getuid() 且非组/他人可写才加载 check_suid 已降权,防御更深
test DROP -r/-w/-xgeteuid() 判断;-Ogeteuid() 判断属主 降权后以 ruid 身份进行权限测试

注意BB_SUID_REQUIRE 的 applet(crontab、wall 等)在 check_suid() 中不做降权,保持 euid=0 进入 main()。它们内部通过 getuid() 获取真实用户身份,通过 sanitize_env_if_suid()xopen_as_uid_gid() 实现"以 root 运行,以 ruid 决策"的模式。这与 BB_SUID_DROP 的"先降权再运行"是两种不同的安全策略。

BB_SUID_DROP 的 applet(如 adduser)通过 geteuid() != 0 自查,但这发生在 check_suid() 已执行 setuid(ruid) 之后。所以这些 applet 实际上要求的是以 root 身份直接运行(而非通过 setuid 位),因为 setuid 来的特权在进入 main() 之前就被丢弃了。


5. 回看实验:完整状态转换表

现在可以用第 0 节的实验数据,结合 2-4 节的分析,做一次完整的状态追踪。

场景 ruid euid suid BusyBox DROP 类 BusyBox REQUIRE 类 traceroute (MAYBE)
直接以普通用户执行 1000 1000 1000 以 1000 运行 报错退出 以 1000 运行
./bash -p 后进入 1000 0 0 setuid(1000) 降权 保持 euid=0 保持 euid=0
以 setuid busybox 执行 1000 0 0 setuid(1000) 降权 保持 euid=0 保持 euid=0
./a (setuid(0)) 后 0 0 0 ruid==0 → 跳过 → root 跳过 → root 跳过 → root
root 直接执行 0 0 0 ruid==0 → 跳过 → root 跳过 → root 跳过 → root

关键洞察check_suid() 的入口判断是 if (ruid == 0) return;——依据的是 real uid,而非 effective uid。这意味着即使 euid=0,只要 ruid≠0,检查继续执行。这是全文最重要的发现,很多人会默认认为"euid=0 即 root 即跳过所有检查",而 BusyBox 并非如此。


6. 编译配置对权限模型的影响

6.1 三个关键配置项

在 BusyBox 的 Config.in 中定义:

配置项 默认值 含义
FEATURE_SUID y 是否启用 suid 机制,禁用后整个 check_suid() 变为空操作
FEATURE_SUID_CONFIG y 是否支持 /etc/busybox.conf 运行时配置
FEATURE_PREFER_APPLETS n 是否优先使用 BusyBox 内置 applet(影响 shell/find/xargs)

6.2 FEATURE_SUID=n 的影响

当发行版编译 BusyBox 时关闭此选项:

// appletlib.c 中的实际效果

// 变量声明: 不存在
IF_FEATURE_SUID(static uid_t ruid;)  →  (nothing)

// check_suid(): 变为空宏
#define check_suid(x) ((void)0)

// applet_suid[] 表: 不生成
// APPLET_SUID() 宏: 不定义

后果

  • 所有 applet 不做任何特权管理
  • 如果 busybox 二进制是 setuid root,则每个 applet 都以 root 运行——这是巨大的安全漏洞(Config.in 帮助文本明确警告:"think cp /some/file /etc/passwd")
  • 如果 busybox 二进制不是 setuid,则所有 applet 以普通用户运行——需要 root 的 applet 直接失败

6.3 FEATURE_SUID_CONFIG=n 的影响

// parse_config_file(): 退化为只保存 ruid
static inline void parse_config_file(void)
{
    IF_FEATURE_SUID(ruid = getuid();)
}

// check_suid() 中: config 相关代码块被 #if 移除
// setresuid/setresgid 的精细控制逻辑不存在
// 只能使用硬编码的 BB_SUID_REQUIRE / BB_SUID_DROP

后果

  • 无法通过配置文件细粒度控制每个 applet 的权限
  • suid_config_t 结构体、ingroup() 函数、get_trimmed_slice() 均不存在
  • 只支持二元的"保留 root / 放弃 root"模式,不支持"以指定用户身份运行"

6.4 FEATURE_PREFER_APPLETS=n 的影响

// BB_EXECVP / BB_EXECLP 退化为普通 libc 调用
#define BB_EXECVP(prog,cmd)     execvp(prog,cmd)
#define BB_EXECLP(prog,cmd,...) execlp(prog,cmd,__VA_ARGS__)

// NOFORK / NOEXEC 优化全部关闭
#define APPLET_IS_NOFORK(i) 0
#define APPLET_IS_NOEXEC(i) 0

// spawn_and_wait() 回退到 fork+exec
// run_nofork_applet() / run_noexec_applet_and_exit() 全部编译剔除

后果

  • Shell 中的 busybox 内置命令需要真正 fork+exec(而非直接函数调用)
  • 不影响权限模型,但影响性能和行为(进程名显示、/proc/self/exe 重执行等)
  • 在 chroot 环境中(无 /proc)不会有 /proc/self/exe 相关的兼容问题

6.5 Alpine Linux 的编译策略

Alpine 使用 BusyBox 作为核心系统工具集,其编译配置直接影响容器安全性。以下为常见配置趋势,具体应以目标版本的构建参数为准(可通过 zcat /proc/config.gzbusybox --help 查看实际 CONFIG_*):

  • FEATURE_SUID 通常开启:提供基础的 suid 保护,避免 setuid busybox 导致所有 applet 获得 root
  • FEATURE_SUID_CONFIG 可选:在需要精细化控制的场景中启用
  • FEATURE_PREFER_APPLETS:不同 Alpine 版本配置不一,某些版本会关闭以减少对 /proc/self/exe 的依赖
  • bbsuid 分离:Alpine 系列发行版通常会将需要 setuid 的 applet 从普通 busybox 中分离,由 /bin/bbsuid(历史路径还包括 /usr/libexec/bbsuid 等)或类似机制承载。bbsuid 本身仍是同一份 BusyBox 源码编译产物,并非独立实现的 su/login/passwd。该设计使绝大多数 applet 根本不会通过 setuid busybox 入口执行,因此在 Alpine 中 check_suid() 更多承担纵深防御角色,而非第一道防线。

以上为一般规律,具体配置应以目标发行版的 busybox 版本及实际编译参数为准。不同 Alpine 版本间可能存在差异。


7. 设计总结

BusyBox 的权限管理分为三个层次:

  1. 编译时:每个 applet 通过 BB_SUID_REQUIRE / BB_SUID_DROP / BB_SUID_MAYBE 声明自身对特权的需求
  2. 启动时(check_suid:根据声明统一完成预处理——要求特权则验证、不需要特权则放弃、配置文件则精细控制
  3. 运行时:各 applet 自行调用 sanitize_env_if_suid()(环境清理)、xopen_as_uid_gid()(临时切换)、change_identity()(完整切换)、xsetuid(getuid())(特权释放)、xsetuid(0)(特权恢复)等函数

整体设计在安全性与功能完整性之间取得了平衡,特权最小化原则贯穿始终。


8. 安全意义

8.1 为什么 BusyBox 必须做特权分离

BusyBox 将数十个系统工具集中到单一二进制文件中。如果没有 check_suid()

┌─ /bin/busybox (setuid root) ─────────────────────┐
│                                                    │
│  任何一个用户都能通过任意 applet 获得 root:       │
│                                                    │
│    $ busybox cp /dev/null /etc/passwd   ← root 写入│
│    $ busybox cat /etc/shadow            ← root 读取│
│    $ busybox mv /bin/busybox /tmp       ← root 操作│
│                                                    │
└────────────────────────────────────────────────────┘

因此 check_suid() 的三层模型(REQUIRE / DROP / MAYBE)是 BusyBox 安全设计的基石:

  • REQUIRE:必须使用 root 的程序保留特权(passwd、su、login)
  • DROP:不需要特权的程序主动丢弃(cp、mv、cat、ls 等数百个 applet)
  • MAYBE:不主动降权,是否保留特权取决于编译选项、内核 capability 及发行版配置(traceroute、ping、findfs 等)

8.2 从攻击者视角看

获得 euid=0 的 shell(如通过 SUID bash -p)
              │
              ├── busybox cat     → check_suid → DROP → setuid(ruid) → 降为普通用户
              ├── busybox ls      → check_suid → DROP → setuid(ruid) → 降为普通用户
              ├── busybox id      → check_suid → DROP → setuid(ruid) → 降为普通用户
              ├── busybox ash     → check_suid → DROP → setuid(ruid) → 无法获得 root shell
              ├── busybox vi      → check_suid → DROP → setuid(ruid) → 降为普通用户
              ├── traceroute      → check_suid → MAYBE → 不主动降权
              ├── busybox passwd  → check_suid → REQUIRE → 保持 euid=0 → root 身份
              └── ./a(setuid(0))  → ruid=0 → check_suid 全部跳过 → 完整 root

攻击者在 euid=0 环境下,绝大多数 applet 会在 main() 之前主动放弃特权。真正保持 root 的只有:

  • REQUIRE 类(6 个):passwd、su、login、vlock、crontab、wall
  • MAYBE 类(数量依赖编译配置):常见包括 traceroute、ping、ping6、arping、ether-wake、findfs 等

其他全部 applet 都是 DROP,包括 ashvicatlsid 等——无法直接利用。

8.3 Alpine 的纵深防御

Alpine 在此基础上增加了两层额外防护:

  1. bbsuid 分离:将 BusyBox 编译为两份二进制——

    • /bin/busybox:无 setuid 位,承载所有 DROP 类 applet(绝大多数)
    • /bin/bbsuid:带 setuid 位,仅承载 REQUIRE 类 applet(passwd、su、login 等)

    这样 /bin/busybox 本身不存在 euid=0 的攻击面,check_suid() 和 DROP 分类更多是防御"万一 busybox 意外获得 setuid 位"的纵深措施。

  2. SUID 分类收敛:BusyBox 1.38.0 中绝大多数 applet 被归类为 DROP,MAYBE 仅有 traceroute、ping、findfs 等少数几个。这意味着即使通过 SUID bash 获得 euid=0,可利用的 BusyBox 攻击面也局限在 REQUIRE(6 个)和 MAYBE(约 5 个)两类 applet 内。


附录:grep 定位命令速查

本附录可作为独立参考卡使用。建议用 grep -rn <pattern> --include='*.c' --include='*.h' . 在 busybox 源码根目录下执行。

A.1 核心 UID 函数调用定位

# 定位所有 getuid / geteuid / getresuid 调用点
grep -rn '\bgetuid\b\|\bgeteuid\b\|\bgetresuid\b' --include='*.c' --include='*.h' .

# 定位所有 setuid / seteuid / setreuid / setresuid 调用点
grep -rn '\bsetuid\b\|\bseteuid\b\|\bsetreuid\b\|\bsetresuid\b' --include='*.c' --include='*.h' .

# 精确定位 setuid(0) 提权操作(恢复 root)
grep -rn '\bsetuid(0)\|\bxsetuid(0)\b' --include='*.c' --include='*.h' .

# 精确定位 setuid(getuid()) 降权操作(释放特权)
grep -rn 'setuid(getuid())' --include='*.c' --include='*.h' .

A.2 check_suid 及 Suid 模式定位

# 定位 check_suid 函数定义及所有调用点
grep -rn 'check_suid' --include='*.c' --include='*.h' .

# 定位三种 Suid 模式的使用
grep -rn 'BB_SUID_REQUIRE\|BB_SUID_DROP\|BB_SUID_MAYBE' --include='*.c' --include='*.h' .

# 定位 applet_suid 表生成逻辑
grep -n 'applet_suid\|APPLET_SUID' applets/applet_tables.c include/busybox.h

A.3 特权切换封装函数定位

# 定位 xsetuid / xseteuid / xsetgid / xsetegid 定义及调用
grep -rn '\bxsetuid\b\|\bxseteuid\b\|\bxsetgid\b\|\bxsetegid\b' --include='*.c' --include='*.h' .

# 定位 change_identity 调用链
grep -rn 'change_identity' --include='*.c' --include='*.h' .

# 定位 xopen_as_uid_gid 使用点
grep -rn 'xopen_as_uid_gid' --include='*.c' --include='*.h' .

# 定位 sanitize_env_if_suid 调用点
grep -rn 'sanitize_env_if_suid' --include='*.c' --include='*.h' .

A.4 权限检查定位(含 ruid/euid 比较)

# 定位 real uid == effective uid 比较(判断是否为 setuid 运行)
grep -rn 'getuid()\s*==\s*geteuid()\|getuid()\s*!=\s*geteuid()' --include='*.c' .

# 定位 getpwuid/getgrgid 与 UID 关联的调用
grep -rn 'getpwuid(getuid())\|xgetpwuid(getuid())' --include='*.c' .

# 定位非 root 拒绝的防护检查
grep -rn 'geteuid()\|\bgetuid()\s*!=\s*0\|you must be root\|perm_denied_are_you_root\|must be suid' --include='*.c' .

# 定位所有 you_must_be_root 消息的触发条件
grep -rn 'bb_msg_you_must_be_root' --include='*.c' .

A.5 /etc/busybox.conf 配置解析定位

# 定位 parse_config_file 函数
grep -rn 'parse_config_file\|busybox.conf' --include='*.c' --include='*.h' .

# 定位 setresuid 调用(含 saved uid 设置)
grep -rn 'setresuid' --include='*.c' --include='*.h' .

A.6 编译配置影响定位

# 定位 FEATURE_SUID 条件编译块
grep -rn 'FEATURE_SUID\|IF_FEATURE_SUID\|ENABLE_FEATURE_SUID' --include='*.c' --include='*.h' libbb/ applets/

# 定位 FEATURE_SUID_CONFIG 条件编译块
grep -rn 'FEATURE_SUID_CONFIG' --include='*.c' --include='*.h' libbb/

# 定位 FEATURE_PREFER_APPLETS 影响范围
grep -rn 'FEATURE_PREFER_APPLETS\|PREFER_APPLETS' --include='*.c' --include='*.h' libbb/

# 查看 Config.in 中的功能说明
grep -A 40 'config FEATURE_SUID$' Config.in

A.7 按文件精确跳转

# 核心特权管理文件 - 查看与 UID 相关的关键行
grep -n 'ruid\|check_suid\|parse_config\|APPLET_SUID\|BB_SUID' libbb/appletlib.c

# 环境清理 - 查看禁用变量列表
grep -n 'forbid\|sanitize_env' libbb/login.c

# 网络类 - 查看特权释放点
grep -n 'setuid\|setgid\|getuid\|geteuid\|xsetuid\|xsetgid' networking/traceroute.c networking/httpd.c networking/tcpudp.c networking/arping.c networking/ether-wake.c

# 用户管理类 - 查看身份切换点
grep -n 'setuid\|change_identity\|getuid\|myuid\|cur_uid' loginutils/passwd.c loginutils/su.c loginutils/login.c

# Shell 安全 - ash 中的 suid 检查
grep -n 'getuid\|geteuid\|setuid\|suid' shell/ash.c

A.8 快速统计

# 统计各文件中 UID 相关 API 调用次数
grep -rc '\bgetuid\|geteuid\|setuid\|seteuid\|setreuid\|setresuid\|getresuid\b' --include='*.c' . | sort -t: -k2 -rn | head -20

# 统计每种 Suid 模式的使用频次
grep -r 'BB_SUID_REQUIRE' --include='*.c' . | wc -l   # REQUIRE 数量
grep -r 'BB_SUID_DROP'    --include='*.c' . | wc -l   # DROP 数量
grep -r 'BB_SUID_MAYBE'   --include='*.c' . | wc -l   # MAYBE 数量