Linux 可执行文件上的 setuid 位,可以在 exec 成功时把进程的有效用户 ID 改为文件属主的 ID。它不会把进程全部凭据都替换成属主的登录身份。特权辅助程序因此能够代表另一个账户完成受控操作,但输入处理和特权持续时间都会成为安全边界的一部分。模式位本身既不能证明身份转换已经发生,也不能证明这个辅助程序安全。

文件位与进程凭据分开观察

通常的 set-user-ID 转换会改变有效 UID,再将结果复制到保存的 set-user-ID;真实 UID 保持不变。组凭据是另一套状态:setuid 不会自动装入文件属主的附加组。Linux 还存在文件系统身份和能力集合,因此真实、有效与保存 UID 是有用的观察,却不是权限的完整描述。不能看到有效身份变成某个账户,就假设已经拥有该账户登录会话的全部上下文。

应检查实际部署的文件,不要推断整个发行版都采用同一权限。用于修改密码的特权程序,在容器或定制镜像里可能有不同安装方式。属主执行位位置的小写 s 表示同时具备 setuid 和属主执行位,大写 S 则表示存在 setuid 但没有那个执行位。两种显示都不能替代属主核验、目标文件核验,以及对真实启动上下文的检查。

执行环境可以抑制转换

Linux 文档列出了 no_new_privs、nosuid 挂载和被跟踪等条件,它们可能抑制特权转换;某些文档记录的组合也可能直接让执行失败。文件能力同样受到执行约束。不能为了让演示成功,就关闭这些保护。应确定目标路径与调用进程实际适用哪些政策,如实说明限制,而不是把受保护环境当作必须修复的故障。

Linux 忽略解释器脚本上的 set-user-ID 与 set-group-ID 位。因此,把原生辅助程序改成脚本并保留模式,不会保留原先的提权行为。给通用解释器增加特权也不是安全替代方案。特权程序另外启动的可执行文件,还可能主动重置凭据,所以运行独立的 id 命令,不一定忠实显示原辅助程序内部的身份。观测点必须与要验证的进程一致。

降权需要完整设计

setuid 系统调用不同于文件上的模式位。具有足够权限的调用者可以改变真实、有效和保存 UID;非特权调用者能够进行的转换范围更窄。若仍保留允许恢复的保存身份,seteuid 可以支持有效 UID 的可逆切换。无论哪种调用,都必须先检查返回值,再确认状态,不能因为进程原来拥有高权限,就假设降权调用不会失败。

去掉保存 UID 只是永久放弃权限的一部分。附加组、保留能力、已经打开的特权描述符、命名空间和后续执行路径,同样需要考虑。一条 setuid 调用不是通用降权配方,正确顺序取决于程序原有的凭据和资源。安全敏感的辅助程序需要针对设计进行审查,不能把文章里的身份概念直接复制成未经验证的安全实现。

坏掉的样子

带有 s 的模式显示可能掩盖被抑制的转换;另一方面,权限过大的辅助程序也可能通过不安全的路径搜索、用户选择的配置文件等接口泄露真实能力。动态加载器在安全执行模式下会限制危险环境变量,却不会替应用验证输入。应避免用 shell 字符串拼接命令,并明确辅助程序允许访问哪些文件、接收哪些参数和执行哪些操作。

能力机制可以缩小某项授予,例如只允许绑定特权端口,但不会让其他权限全部消失。账户已有的访问权和其他能力仍然存在,能力边界集合与 securebits 还会限制 root 行为。同样,命名空间内部的 root 不等于宿主 root,但映射到普通用户的权限仍然有实际作用。套接字激活可以交付已经打开的监听描述符,却不能免除接收请求的服务本身应承担的安全要求。

快速验证

在具备 Bash、GNU stat、util-linux findmnt、libcap getcap 和 Python 3 的 Linux 环境运行下面完整代码。它只读取一个已安装文件的元数据,以及新 Python 进程自己的凭据状态,不执行 passwd,不改变权限或身份,不挂载文件系统,也不创建命名空间。明确写出目标路径,是为了避免搜索结果意外选中另一个程序;缺少依赖时应补齐正常测试环境,而不是删掉失败检查继续宣称完成。

BASH_ENV= bash --noprofile --norc <<'BASH'
set -eu
target=/usr/bin/passwd
test -f "$target"
stat -c 'mode=%A numeric=%a owner=%u group=%g path=%n' "$target"
findmnt --noheadings --output TARGET,VFS-OPTIONS --target "$target"
getcap -v "$target"
python3 - <<'PYTHON'
import os
from pathlib import Path

status = dict(line.split(':', 1) for line in
              Path('/proc/self/status').read_text().splitlines() if ':' in line)
uids = tuple(map(int, status['Uid'].split()))
gids = tuple(map(int, status['Gid'].split()))
assert uids[:3] == os.getresuid()
assert gids[:3] == os.getresgid()
assert len(uids) == len(gids) == 4
for key in ['Uid', 'Gid', 'Groups', 'NoNewPrivs', 'CapEff', 'CapBnd']:
    print(f'{key}:{status[key]}')
print('PASS: inspected metadata and current credentials; target not executed')
PYTHON
BASH

输出应包含实际模式、数字属主和属组、所在文件系统的挂载选项,以及目标的文件能力列表。没有文件能力,不等于没有 setuid 位;有 setuid 位,也不代表一定存在文件能力。Python 断言把 UID 与 GID 的前三个状态字段分别和对应系统调用结果比较;第四个字段代表文件系统身份。观察当前检查进程的状态,也不能推导尚未运行目标的执行结果。

最后的通过提示只证明这项只读检查完成。不同主机上的值可以不同,实验没有硬编码预期 root 身份,也没有演示提权、不可逆降权或抗攻击能力。这些结论需要另外获准的临时安全测试,以及真实辅助程序内部的观察,不能通过削弱宿主保护来取得一个看起来成功的结果。

来源