Alpine Linux 下 SUID Bash 获取 euid=0 Shell 后 BusyBox 降权机制分析

发布于: 2026-06-27 20:59

0. 快速了解

摘要:在大多数 Linux 发行版中,从 SUID Bash 拿到 ruid=1000,euid=0 的进程的 shell 后,此时已经具备执行大多数 root 权限操作的能力,但在 Alpine Linux 下,大多数工具都是 busybox 的链接文件,通过查阅 busybox 源码了解到入口处的 check_suid 函数会首先做权限检查,发现 ruid!=0 时,大多数工具按照编译时的策略主动放弃特权,主动放弃特权,降权后以普通用户权限执行操作,出现一些提权操作失败的情况,此时可以通过使用独立编译的二进制程序,或利用 shell 自身的文件操作能力绕过 BusyBox 的权限处理逻辑完成权限利用。

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

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

问题:在Alpine Linux 环境下,从 SUID Bash 拿到 euid=0 的 shell 后,为什么看起来提权失败了

1. 实验现象与分析

1.1 现象:SUID Bash 下的 "提权失败"

实验环境为 Alpine Linux。Alpine 以 BusyBox 作为核心系统工具集,/bin/sh/bin/ls/bin/id 等绝大多数基础命令均为指向 /bin/busybox 的符号链接。每个这样的命令称为一个 applet——BusyBox 通过 argv[0] 识别要执行哪个 applet,并在其 main() 之前运行统一的 check_suid() 进行特权管理

实验环境:在常见非nosuid挂载的/var/tmp目录下,准备suid bash

cp /bin/bash /var/tmp/bash
chmod +s /var/tmp/bash

以普通用户 demo(uid=1000)运行suid bash并使用-p选项保留特权:

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

在另一个终端中查看进程状态,也能观察到 bash -p 的进程是root权限在运行

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

此时执行id 命令实际为 BusyBox 的 id applet:

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

shell 提示符是 #,但 id 输出却是 uid=1000(demo)。原因在于 BusyBox 1.38.0 的 coreutils/id.c 第 28 行声明:

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

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

bash -p 进程 (ruid=1000, euid=0, suid=0)
  → exec /usr/bin/id → /bin/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 的真正权限,不能经过busybox的降权处理,可以通过编译独立程序来实现或使用非 BusyBox 的链接工具

1.2 绕过 BusyBox 验证真实状态

编译独立程序,直接打印 getuid()geteuid()

#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());
}
bash-5.3# ./b
before: r=1000 e=0
ret=0 errno=0
after : r=0 e=0

确认 bash -p 后的进程确实处于 ruid=1000, euid=0, suid=0。且 setuid(0) 调用成功(ret=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 工具

1.3 setuid(0) 实验

另一个实验:在 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)

此时 id 输出 uid=0(root)——与之前 uid=1000(demo) 截然不同。两次 id 调用的都是同一个 BusyBox applet,输出却完全相反

拆解

执行 ./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)

2. 恢复完整 Root 身份

通过 SUID Bash 获得的 shell 处于 ruid=1000, euid=0, suid=0。此时大量 BusyBox applet 会触发 DROP 降权。三种绕过方案:

2.1 方案一:输出重定向

POSIX 规定 Shell 在执行命令前完成重定向。当输出重定向使用 O_CREAT 创建新文件时,POSIX 要求新文件的属主设为调用进程的 effective user ID。Linux 内核在执行 open() 的访问权限检查时使用 fsuid,普通情况下 fsuid 与 euid 相同。因此,对于保留 euid=0 的 bash -p,输出重定向仍以 root 身份执行

参考:POSIX Redirection | POSIX open

验证:

bash-5.3# id
uid=1000(demo) gid=1000(demo) euid=0(root)
bash-5.3# cat <<'EOF' > /tmp/test
hello
EOF
bash-5.3# ls -l /tmp/test
-rw-r--r-- 1 root root 6 Jul 2 04:45 /tmp/test

此处 id 输出中出现了 euid=0(root) 行,与 1.1 节的输出不同。这可能源于 BusyBox 编译配置差异或 id applet 模式声明的版本变化,属于实际场景中可能遇到的正常现象

直接提权:

cat > /etc/sudoers.d/a << EOF
ALL ALL=(ALL) NOPASSWD: ALL
EOF

2.2 方案二:编译 setuid(0) 程序

#include <unistd.h>
int main() {
    setgid(0);
    setuid(0);
    execl("/bin/sh", "sh", NULL);
}

静态编译(应对靶机无编译环境):

gcc --static -o s s.c

目标机上执行 ./s 即可获得完整 root shell。

2.3 方案三:非 BusyBox 独立工具

使用独立的二进制文件,不经过 BusyBox 的check_suid()的降权操作:

在 Alpine Linux 中,apk 是独立的二进制文件,非 BusyBox applet,并且在suid的bash -p进程下,鉴权操作似乎只关注euid=0,允许执行,这意味着我们可以安装独立的GNU版本工具覆盖 BusyBox 的链接文件

apk add setpriv
setpriv --reuid=0 --regid=0 --init-groups /bin/bash
# 安装 coreutils 获取独立的 id、ls 等工具
apk add coreutils

3. busybox细节说明

Linux 前置内容补充

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

字段 含义 获取方式
ruid 真实用户 ID,原始登录用户 getuid()
euid 有效用户 ID,传统 DAC 权限检查依据 geteuid()
suid 保存的设置用户 ID,用于临时降权后恢复特权 getresuid() 第三字段

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

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

euid 和 suid 都被设为文件属主的值,ruid 保持为原用户。这正是实验中 ./bash -p 之后的状态

3.1 执行流程

main() — 解析名字、准备环境

libbb/appletlib.c:1042-1143:
int main(int argc UNUSED_PARAM, char **argv)
{
    // ...
    lbb_prepare("busybox" IF_FEATURE_INDIVIDUAL(, argv));  // 1112: 初始化

    applet_name = argv[0];          // 1120: "cat" 或 "id" 或 "su" 等
    if (applet_name[0] == '-')      // 1121: 去除登录 shell 的 "-sh" 前缀
        applet_name++;
    applet_name = bb_basename(applet_name);  // 1123: "/bin/ls" → "ls"

    parse_config_file();            // 1140: 若 FEATURE_SUID_CONFIG,读 /etc/busybox.conf
                                    //       无论如何都会执行 ruid = getuid()
    run_applet_and_exit(applet_name, argv);  // 1141: 进入分发
}
parse_config_file() 里关键的一行(appletlib.c:532):
IF_FEATURE_SUID(ruid = getuid();)   // ← 记录"谁在按键"

run_applet_and_exit() — 找到 applet

appletlib.c:987-1007:
static NORETURN void run_applet_and_exit(const char *name, char **argv)
{
    if (is_prefixed_with(name, "busybox"))     // 990: "busybox cat" → 进 busybox_main
        exit(busybox_main(0, argv));

    int applet = find_applet_by_name(name);     // 996: 在 applets[] 表里二分查找 "cat"
    if (applet >= 0)
        run_applet_no_and_exit(applet, name, argv);  // 998: 找到了,进入

    // 没找到 → 输出 "xxx: applet not found",exit(127)
}

run_applet_no_and_exit() — 降权检查点

appletlib.c:963-983:
void FAST_FUNC run_applet_no_and_exit(int applet_no, const char *name, char **argv)
{
    applet_name = name;

    show_usage_if_dash_dash_help(applet_no, argv);  // 973: 处理 --help

    if (ENABLE_FEATURE_SUID)
        check_suid(applet_no);           // 976: ← ★ 降权发生在这里

    argc = string_array_len(argv);
    xfunc_error_retval = applet_main[applet_no](argc, argv);  // 979: 进入业务逻辑
}

关键:check_suid() 在 applet_main() 之前执行。applet 的 main() 函数拿到的是已经降权后的身份

check_suid() 内部有两层决策:若有 /etc/busybox.conf,按配置文件做运行时鉴权和 uid/gid 切换;若无,则退回到编译期声明的 BB_SUID_REQUIRE/BB_SUID_DROP/BB_SUID_MAYBE 逻辑。Alpine Linux 默认不启 FEATURE_SUID_CONFIG,因此本实验全程走回退路径,下文代码仅展示该路径。

appletlib.c:555-651,已精简:
static void check_suid(int applet_no)
{
    gid_t rgid;

    // ── 分支 0:已是 root → 直接跳过 ──
    if (ruid == 0)
        return;                          // 559: root 登录,不需要任何处理

    rgid = getgid();                     // 561: 记录真实 gid

    // ── 分支 1:BB_SUID_REQUIRE → 必须有 root,否则拒绝 ──
    if (APPLET_SUID(applet_no) == BB_SUID_REQUIRE) {
        if (geteuid())                   // 625: euid 不是 0?
            bb_simple_error_msg_and_die(
                "must be suid to work properly");  // 626: 报错退出
        // euid==0 → 通过,保留 root 权限进入 main()
    }

    // ── 分支 2:BB_SUID_DROP → 主动丢弃特权 ──
    else if (APPLET_SUID(applet_no) == BB_SUID_DROP) {
        setgid(rgid);                    // 641: rgid = demo 的真实 gid
        setuid(ruid);                    // 642: ruid = demo 的真实 uid
        // 此时: ruid=demo, euid=demo, suid=demo  ← euid、suid均被丢弃
    }

    // ── 分支 3:BB_SUID_MAYBE → 什么都不做,保留当前状态 ──
    // (ping、traceroute 等:euid=0 就留着用,euid=demo 也接受)
}

check_suid() 入口判断是 if (ruid == 0) return;——检查的是 real uid,而非 effective uid。这意味着即使 euid=0,只要 ruid≠0,检查继续执行。ruid≠0 时,根据每个 applet 在注册时声明一个编译期策略,由 check_suid() 统一执行。以下为 BusyBox 1.38.0 默认配置下的代表性分类:

Applet 模式 运行时行为
passwd REQUIRE euid=0 放行;非 root 仅可改自己密码,写入 /etc/passwdxsetuid(0) 恢复
su REQUIRE euid=0 放行;getuid() 非 root 须认证密码
login REQUIRE euid=0 放行;认证后 change_identity() 切换
vlock REQUIRE euid=0 放行;以 getuid() 身份锁定
crontab REQUIRE euid=0 放行;sanitize_env_if_suid() 后以 getuid() 确定操作对象
wall REQUIRE euid=0 放行;xopen_as_uid_gid(..., getuid(), ...) 以真实用户读文件
crond DROP 降权后 fork,以目标用户执行任务
adduser / deluser / addgroup DROP 降权后 geteuid()!=0 拒绝,实际需 root 直接运行
chpasswd DROP 同上
httpd / tcpsvd / udpsvd DROP 降权后 bind,再降权到指定用户
cat / ls / id / ash / vi ... DROP 直接降权为 ruid 运行
traceroute / ping / findfs MAYBE 不主动降权;实际权限取决于编译选项与 capability 设置
mount MAYBE/DROP DESKTOP 版本为 MAYBE,否则 DROP

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

其中,passwd、su等场景涉及权限问题,在常见发行版会給 passwd、su设置suid位,而 Busybox 是一个集成了众多工具的单一二进制文件,如果給busybox设置suid位,所有工具都会受到影响,虽然可以通过 check_suid和applet的drop策略来控制,但仍然可能会引入安全问题。Alpine 选择了更简单、更激进的另一条路径:写 100 行 C 做 SUID 代理,白名单硬编码,攻击面压缩到极致,剩下全部交给 /bin/busybox + check_suid(),bbsuid 凭借自己的 SUID bit 获得 euid=0,然后把这个 euid 通过 execv 传给 /bin/busybox,让 BusyBox 的 check_suid() 看到 euid=0,放行 su、passwd 等 applet

check_suid 这个函数本质上是 SUID 特权状态的统一管理,不管 busybox 有没有 suid 位,它只根据进程当前的 uid 状态和每个 applet 声明的需求,决定是否允许执行、是否放弃特权

  • 对于 BB_SUID_DROP 模式的applet,进入 busybox 的 check_suid() 并调用 setuid(ruid),该进程的 euid 和 suid 同时被设为 ruid,再进入 applet 业务逻辑
  • 对于 BB_SUID_REQUIRE 模式的applet,check_suid() 会检查 euid 是否为 0,如果不是则报错退出;如果是,则允许继续执行,保持原有的 euid=0 状态

那么常见情况 busybox 并没有 suid 位,那么对于 BB_SUID_REQUIRE 的 applet,该怎么正确运行?下文揭晓。

3.2 sanitize_env_if_suid() — 环境变量清理

当检测到 getuid() != geteuid()(即处于 setuid 场景),BusyBox 会在进入 applet 前清理以下危险环境变量:

ENV BASH_ENV HOME IFS SHELL LD_LIBRARY_PATH LD_PRELOAD LD_TRACE_LOADED_OBJECTS LD_BIND_NOW LD_AOUT_LIBRARY_PATH LD_AOUT_PRELOAD LD_NOWARN LD_KEEPDIR

该函数并非全局自动调用,只有crontabloginmount 3 个 applet 显式调用它

miscutils/crontab.c:107   → sanitize_env_if_suid()
loginutils/login.c:357    → sanitize_env_if_suid()
util-linux/mount.c:2293   → sanitize_env_if_suid()

同时会重置 PATH,阻止通过环境变量注入实现提权