补充组ID(supplementary group IDs)是Linux进程权限模型中的重要组成部分,它允许一个用户同时属于多个组,从而获得多个组的资源访问权限。用户态程序通常使用getgroups系统调用获取当前进程的补充组列表,而在内核态或eBPF观测场景中,需要直接读取内核数据结构。eBPF提供了专门的辅助函数以及通用的BTF解析能力来完成这一任务,本文将深入讨论这两种途径的实现细节与选择依据。

Linux补充组ID的存储结构与getgroups系统调用
在Linux内核中,每个进程的凭据信息保存在struct cred结构体中,该结构体由task_struct的cred指针引用。补充组ID并不直接平铺在cred里,而是通过一个名为group_info的结构体间接管理。group_info定义在include/linux/cred.h中,核心字段包括atomic_t usage用于引用计数、int ngroups表示当前组数量,以及一个灵活的small_block数组,实际存放gid_t类型的组ID值。
当进程的补充组数量较少时(通常不超过NGROUPS_SMALL),组ID直接存储在group_info内嵌的small_block数组中,避免额外的内存分配。一旦组数量超过阈值,内核会分配一个更大的内存块并让group_info的blocks指针指向它。这种设计使得读取补充组ID时必须同时关注ngroups和存储位置,不能简单地假设数据连续存放在某个固定地址。
用户态调用getgroups系统调用时,内核会从current进程的cred中取出group_info,然后把ngroups个gid_t值复制到用户提供的缓冲区。如果用户传入的大小参数为0,系统调用只返回实际的组数量而不复制数据,这是常见的查询长度用法。以下代码展示了标准用户态获取补充组ID的流程:
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
int ngroups = getgroups(0, NULL);
if (ngroups < 0) {
perror("getgroups");
return 1;
}
gid_t *groups = malloc(ngroups * sizeof(gid_t));
if (!groups) {
perror("malloc");
return 1;
}
int ret = getgroups(ngroups, groups);
if (ret < 0) {
perror("getgroups");
free(groups);
return 1;
}
printf("补充组数量: %d\n", ret);
for (int i = 0; i < ret; i++) {
printf("组ID: %u\n", groups[i]);
}
free(groups);
return 0;
}
虽然系统调用简单易用,但在eBPF上下文中无法直接调用getgroups,因为eBPF程序运行在内核态,不能发起系统调用,只能读取内核数据结构或使用内核提供的辅助函数。
使用bpf_get_current_ancillary_group_ids辅助函数
为了简化eBPF程序中获取补充组ID的操作,内核提供了一个专门的辅助函数bpf_get_current_ancillary_group_ids。该函数定义在include/uapi/linux/bpf.h中,原型为long bpf_get_current_ancillary_group_ids(void *groups, int size)。调用时,eBPF程序需要提供一个缓冲区指针和缓冲区能容纳的组ID数量上限,辅助函数会把当前进程的补充组ID复制到该缓冲区,并返回实际复制的组数量。
与用户态getgroups类似,如果size参数为0,函数只返回当前进程的补充组总数而不写入任何数据。这个特性可以用于先探测数量再分配合适大小的BPF map存储结果。需要注意的是,该辅助函数从内核5.7版本开始引入,并且要求目标内核编译时开启CONFIG_BPF_SYSCALL以及相应的eBPF支持。在使用前最好通过内核特性探测确认可用性。
下面是一个完整的eBPF程序示例,它使用该辅助函数在每次进程执行时记录其补充组ID列表,并通过perf event输出到用户态:
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/ptrace.h>
#define MAX_GROUPS 64
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(int));
__uint(value_size, sizeof(int));
} events SEC(".maps");
struct event_data {
u32 pid;
u32 count;
u32 groups[MAX_GROUPS];
};
SEC("tp/sched/sched_process_exec")
int trace_exec(struct trace_event_raw_sched_process_exec *ctx) {
struct event_data data = {};
data.pid = bpf_get_current_pid_tgid() >> 32;
long ret = bpf_get_current_ancillary_group_ids(data.groups, MAX_GROUPS);
if (ret < 0) {
data.count = 0;
} else {
data.count = (u32)ret;
}
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &data, sizeof(data));
return 0;
}
char LICENSE[] SEC("license") = "GPL";
该方法的优点在于代码简洁,内核已经封装好了所有复杂的group_info解析逻辑,开发者无需关心cred结构布局的变化。缺点是缓冲区大小必须在编译时确定,无法动态适应所有场景;同时该辅助函数只返回gid_t数组,不包含其他cred相关信息。此外,旧版本内核无法使用,需要提前做好兼容性处理。
通过task_struct解析group_info的底层方法
如果目标内核版本较老,或者需要获取更多关于cred的信息而不仅仅是组ID,开发者可以使用BPF CO-RE和BTF来直接读取task_struct及其关联结构。第一步是通过bpf_get_current_task_btf辅助函数获取当前进程的task_struct指针。该函数返回一个指向struct task_struct的指针,该结构体类型信息由内核BTF提供,使得eBPF程序可以安全地使用成员访问语法。
拿到task_struct后,需要沿着cred指针找到struct cred,再通过group_info指针找到struct group_info。由于这些结构体在不同内核版本中字段偏移可能变化,必须使用BPF CO-RE的内置函数(如bpf_core_read)进行读取。特别是group_info中的blocks数组和small_block内嵌数组的访问方式差异较大,需要根据ngroups与NGROUPS_SMALL的比较结果判断数据实际存储位置。
以下示例展示了一个使用BPF CO-RE读取当前进程前若干个补充组ID的程序片段:
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <linux/sched.h>
#define MAX_GROUPS 32
SEC("kprobe/__x64_sys_getuid")
int trace_getuid(struct pt_regs *ctx) {
struct task_struct *task = bpf_get_current_task_btf();
struct cred *cred;
struct group_info *group_info;
unsigned int ngroups;
u32 groups[MAX_GROUPS];
int i, ret;
if (!task)
return 0;
cred = BPF_CORE_READ(task, cred);
if (!cred)
return 0;
group_info = BPF_CORE_READ(cred, group_info);
if (!group_info)
return 0;
ngroups = BPF_CORE_READ(group_info, ngroups);
if (ngroups > MAX_GROUPS)
ngroups = MAX_GROUPS;
for (i = 0; i < ngroups; i++) {
gid_t gid;
if (i < NGROUPS_SMALL) {
gid = BPF_CORE_READ(group_info, small_block[i]);
} else {
gid_t *blocks = BPF_CORE_READ(group_info, blocks[0]);
if (!blocks)
return 0;
bpf_probe_read_kernel(&gid, sizeof(gid), &blocks[i]);
}
groups[i] = (u32)gid;
}
// 此处可以将groups输出到map或perf buffer
return 0;
}
char LICENSE[] SEC("license") = "GPL";
这段代码完整演示了手动遍历group_info的全部逻辑。需要注意几点:NGROUPS_SMALL的值通常为4,但不同架构可能不同,最好通过内核头文件获取;blocks数组是二级指针结构,group_info中的blocks字段是gid_t *blocks[ ],读取时需要先取blocks[0]得到指向实际数据块的指针,再按偏移读取。另外,bpf_probe_read_kernel用于安全读取任意内核内存地址,避免直接解引用导致验证器报错。
两种方案的对比与实际应用选择
对比bpf_get_current_ancillary_group_ids辅助函数和手动解析task_struct的方法,各有优劣。辅助函数方式代码量小、可读性强,内核抽象屏蔽了底层细节,适合大多数只需要补充组ID列表的场景;但它要求内核版本在5.7及以上,且无法获取除组ID外的其他cred字段。手动解析方式灵活度极高,可以同时读取uid、gid、capabilities等所有cred相关信息,也兼容更多内核版本,前提是内核开启BTF且BPF CO-RE可用。
在性能方面,辅助函数内部实现也是直接读取cred和group_info,但经过内核优化,通常比用户在eBPF中手动逐字段读取略快。手动解析方式因为包含多次bpf_core_read和条件判断,指令数较多,但在大多数观测场景中差异可以忽略不计。如果eBPF程序运行在热路径(如每次系统调用),建议优先使用辅助函数以减少指令开销。
实际选择时可以从三个维度考虑:目标内核版本、信息需求范围、维护成本。如果只关心补充组ID且内核较新,直接调用bpf_get_current_ancillary_group_ids是最优解;如果需要同时监控多个凭据字段,或者需要兼容大量旧内核,则采用BPF CO-RE手动解析方式。无论选择哪种,都需要充分理解内核cred与group_info的结构设计,这样才能在出现异常时快速定位问题。