TL;DR
su
与单纯的set{u,g}id
不一样:前者会通过setgroups
系统调用来设置进程的 Supplementary Group1;而后者不会,只会修改进程的{,s,e,fs}{u,g}id
。- Supplementary Group 在内核中体现为
struct cred
下的group_info
,本质上是一个长度不固定的gid
数组(类似 C++ 中的 string_view) - 通过内核代码(
init_cred
和init_groups
)确认,内核不会为进程添加 Supplementary Group;且/etc/group
中也没有对应的条目,但 root 用户进程的 Supplementary Group 依旧包含 root 组。具体原因暂不清楚。
故事背景
部门同事来咨询一个问题:执行 ltp 中的 dirtyc0w
用例2失败,错误码为 EACCESS
。经初步了解,以 root 用户执行的 dirtyc0w
会拉起一个子进程,子进程先用 setuid
和 setgid
切换至名为 nobody 的用户,然后用 execlp
执行另一个名为 dirtyc0w_child
的程序。而报错发生在子进程执行 dirtyc0w_child
的时候。
上述问题的原因很快被定位:root 用户的 umask 默认为 0077
,使得整个 ltp 目录下的文件/目录权限皆为 root:root:600/700
,因此 nobody 用户无权进入 dirtyc0w_child
用例所处的目录,从而无权执行该文件。将用例路径上各级目录的权限改为 711
,即可解决问题。
事情发展至此就本应结束了。然鹅,他们发现了一个奇怪的现象:将用例路径上某级目录的权限改为 701
后,又会报没有权限的错误。而理论上,nobody 作为 others,这个权限应该是足够的。
分析过程
先通过分析内核代码,初步估计是走下述调用路径走到了 generic_permission()
中进行权限检查:
1SYSCALL_DEFINE3(execve, ...)
2 do_execve
3 do_execveat_common
4 bprm_execve
5 do_open_execat
6 do_filp_open
7 path_openat
8 link_path_walk
9 may_lookup
10 inode_permission
11 do_inode_permission
12 generic_permission
随后通过 ftrace 追踪 do_execveat_common()
验证了上述判断基本正确,但随后具体的鉴权过程就较为模糊。因此重点观察 generic_permission()
及其下 acl_permission_check()
函数。先尝试在该函数中增加打印来定位最终失败的位置,但对该函数的调用非常频繁,在海量的无效日志下,内核无法启动,即使启动了也不好分析。因此为这些打印添加了一个全局开关,并采用插 ko 的方式创建一个与开关相连的用户态接口3,使得我们可以规避系统启动过程中的大量文件系统操作,待系统启动至平稳后再将开关打开使能打印。经过逐步加日志,我发现了一个神奇的现象:内核居然认为 nobody 和 root 是同组的!具体体现为 kgid = 0, in_group_p(kgid) == 1
。而这也正是该问题的关键:目录的群组权限被设置为 0
,而基于 nobody 与 root 同组的前提,内核自然会拒绝来自 nobody 的访问。
我一度笃定这肯定是内核的 bug,直到对 in_group_p()
进行一番抽丝剥茧后发现,居然是 nobody 的 Supplementary Group 包含 root 这个组……随后我又对 set{u,g}id
和 fork
的代码检查了一番,最终确定:这个 Supplementary Group 是从 root 身份的父进程继承下来的(在用户态用 id
可确认这一点);另外,修改 Supplementary Group 的方式和动作都非常局限,而 set{u,g}id
压根儿不会去动它。
最后,关于是谁给 root 用户增加 Supplementary Group 这一点,我进行了一番探究,确定这不是内核的行为:内核为进程准备的初始凭证中没有任何 Supplementary Group,应该是用户态的行为。但究竟是哪个组件做的,就不得而知了。
相关代码
ftrace 操作
1cd /sys/kernel/tracing
2
3echo > set_ftrace_filter
4echo do_execveat_common > set_graph_function # 设定要追踪的函数
5echo function_graph > current_tracer
6echo 1 > options/funcgraph-proc # 打印进程命令行和 PID
7echo 1 > options/funcgraph-tail # 打印尾部注释
8
9echo nofuncgraph-irqs > trace_options # 不追踪中断处理过程中的函数
10echo $BASH_PID > set_ftrace_pid # 只追踪当前 bash 进程
11echo function-fork > trace_options # 以及当前 bash 进程的子进程
12
13echo > trace # 清除之前的记录
14cp trace ~/test.txt
15cat ~/test.txt