news 2026/9/9 15:30:07

Linux内核struct user_namespace深度解析:UID映射与容器隔离

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux内核struct user_namespace深度解析:UID映射与容器隔离

1. 项目概述与核心思路

1.1 为什么需要 user_namespace 这个结构体

在实际的 Linux 系统运维和容器开发中,命名空间一直是实现资源隔离的基石。其中 user_namespace 又显得格外特殊——它不直接隔离网络、文件系统或进程,而是隔离用户和组 ID。当你在容器里看到 root 用户,实际上在宿主机上可能只是一个普通用户,这种“特权降级”机制就是通过 struct user_namespace 来承载的。

很多新手在接触内核源码或使用 clone() 系统调用创建用户命名空间时,都会遇到 struct user_namespace 这个结构体。它定义在 include/linux/user_namespace.h 中,但内核版本不同,成员略有差异。理解这个结构体,是掌握用户命名空间工作原理的关键。它不只是一个记录 UID/GID 映射的容器,还涉及安全策略、权限管控、密钥环等基础设施。

1.2 这个结构体解决了什么问题

先看一个典型场景:你希望某个进程在容器内拥有 root 权限,但又不希望它真正影响宿主机。通过 user_namespace,你可以将容器内的 UID 0 映射到宿主机的非特权 UID 1000,同时将容器内的其他 UID 映射到宿主机的高位 UID 范围。这样,容器内进程就算滥用 root 权限,也无法触及宿主机上真正的 root 资源。

struct user_namespace 就是这份映射关系的“户籍簿”。它记录了每个用户命名空间的 UID/GID 映射表、拥有者、命名空间层级、密钥环、安全策略等。只有理解这个结构体,你才能正确配置 /proc/PID/uid_map 和 gid_map,才能理解为什么有时候设置映射会失败,为什么 unshare 和 newuidmap 需要配合使用。

1.3 适合谁阅读这篇博文

如果你正在从事容器运行时开发、安全沙箱、Kubernetes 安全策略配置,或者只是好奇 Linux 内核如何实现用户隔离,这篇文章能帮你从源码层面建立清晰认知。我会从 struct user_namespace 的定义出发,逐步拆解每个成员的含义,然后结合实操演示如何查看和计算成员大小,以及如何正确初始化映射。即使你是内核新手,只要你有 C 语言基础,也能跟着走一遍。

2. 核心细节解析与实操要点

2.1 struct user_namespace 的完整定义(以内核 5.15 为例)

在内核源码中,struct user_namespace 的定义如下(我精简了部分条件编译):

struct user_namespace { struct uid_gid_map uid_map; struct uid_gid_map gid_map; struct uid_gid_map projid_map; struct user_namespace *parent; struct user_namespace *owner; atomic_t count; struct key_tag *keyring_tag; struct key *persistent_keyring_register; struct work_struct destroy_work; struct llist_node *llist; struct user_namespace *ns_releaser; struct list_head list; struct list_head watcher_list; struct idr idr; struct kset *users_kset; struct ucounts *ucounts; int level; kuid_t owner_uid; kgid_t owner_gid; kuid_t group_owner; kgid_t group_gid; struct ns_common ns; unsigned long flags; struct user_namespace *cgroup_subns; // 等等,不同版本还有差异 };

这个结构体约有 30 个成员,但核心只有几个。我先挑最重要的几个讲,其他可以查阅内核源码。

2.1.1 uid_map / gid_map / projid_map

这三个成员是 struct uid_gid_map 类型,用于存储 UID、GID 和项目 ID 的映射关系。每个映射表包含多个映射条目,每个条目定义了一个区间内的 ID 转换。例如,容器内 UID 0-65535 映射到宿主机 UID 100000-165535。这个结构体内部包含一个数组,用来存储映射条目;如果条目数超过 5 个,会动态分配内存。理解这一点很重要:当你写入 /proc/pid/uid_map 时,内核会解析你写入的内容,并填充这个结构体。

2.1.2 parent 和 owner
  • parent: 指向父用户命名空间。当创建一个新的 user_namespace 时,它继承自父命名空间,并且parent指向创建它的那个命名空间。
  • owner: 指向拥有这个命名空间的用户所属的命名空间。通常,owner 就是命名空间的创建者所在的用户命名空间。这两个指针用于遍历命名空间层级,以及权限检查。例如,只有父命名空间的 owner 才能修改子命名空间的映射。
2.1.3 count 和 flags
  • count是引用计数,使用 atomic_t 原子操作。当进程或文件引用这个命名空间时,计数增加。当计数降为 0 时,调度销毁工作。
  • flags是一个 unsigned long 字段,用于标记命名空间的状态,比如是否已经初始化、是否允许进一步嵌套等。
2.1.4 level 和 ns
  • level表示该命名空间在命名空间树中的深度。根命名空间 level 为 0,它的子命名空间 level 为 1,以此类推。这个值用于限制嵌套深度,默认最大 32 层。
  • ns是 struct ns_common 类型,包含一个 proc_inum 和 ops 指针,用于在 /proc 文件系统中识别命名空间。每个命名空间都有唯一的 proc_inum。

2.2 怎么知道 struct 中的成员大小

在写内核模块或调试时,经常需要知道结构体成员的偏移量或大小。这里分享几个实用方法。

2.2.1 使用 offsetof 宏

标准 C 库提供了offsetof(type, member)宏,可以获取成员在结构体中的字节偏移。例如:

#include <stddef.h> #include <linux/user_namespace.h> // 在内核模块中 printf("uid_map offset: %zu\n", offsetof(struct user_namespace, uid_map));

但在内核模块中,你通常不能直接使用 printf,而是用printk。不过思路一样。

2.2.2 使用 sizeof 运算符

直接sizeof(struct user_namespace)可以得到整个结构体的大小。但要注意,由于对齐和填充,这个大小不等于成员大小之和。你可以逐个成员打印 sizeof 来了解:

printk("sizeof uid_map: %zu\n", sizeof(struct uid_gid_map)); printk("sizeof count: %zu\n", sizeof(atomic_t));
2.2.3 实战:通过 bpftrace 或 gdb 查看

如果你不想修改内核代码,可以用 bpftrace 的kfunckprobe在运行时捕获结构体信息。不过更简单的是用 gdb 挂载内核调试符号:

gdb /usr/lib/debug/boot/vmlinux-$(uname -r) (gdb) p sizeof(struct user_namespace) (gdb) p &((struct user_namespace *)0)->uid_map

这样就能得到 uid_map 成员的偏移量。如果你需要编写一个工具来动态解析,这些信息非常有用。

2.2.4 结合编译器内置函数

__builtin_offsetof 是 gcc 的内置函数,用法与 offsetof 相同。在内核中,有一个offsetofend宏,用于获取成员结束的位置,即offsetof(type, member) + sizeof(((type *)0)->member)。这在计算一个成员占用的连续地址范围时很方便。

2.3 初始化 struct user_namespace 的两种方式

2.3.1 使用 create_user_ns 函数

在内核中,创建用户命名空间通常通过create_user_ns函数完成。这个函数内部会分配一个 struct user_namespace,然后初始化各个成员:

  • 设置 parent 为当前进程的 user_ns。
  • 设置 owner 为当前进程的 user_ns 的 owner。
  • 初始化 uid_map 和 gid_map 为空(无映射)。
  • 设置 level 为 parent->level + 1。
  • 初始化引用计数为 1。
  • 初始化 keyring_tag 等安全相关成员。

这个函数是核心的初始化入口。如果你在写内核模块,一般不会直接调用它,而是通过unshare(CLONE_NEWUSER)clone(CLONE_NEWUSER)触发内核调用它。

2.3.2 通过 /proc/pid/uid_map 和 gid_map 建立映射

创建命名空间后,你还需要写入映射才能让里面的进程拥有实际权限。写入映射本质上是在修改 struct user_namespace 中的 uid_map 和 gid_map。内核的处理函数是proc_uid_map_write,它会解析你写入的字符串(如 "0 100000 65536"),并调用map_id_range_down等函数来填充 uid_gid_map 结构体。

这里有一个关键点:你只能写入一次映射,而且只能以新行结尾。如果你想修改映射,必须拥有 CAP_SYS_ADMIN 权限在父命名空间中操作。另外,映射的格式是容器内ID 宿主机ID 长度,且不能有重叠区间。

3. 实操过程与核心环节实现

3.1 环境准备:如何查看当前 user_namespace 信息

在 Linux 系统中,每个进程都有与之关联的 user_namespace。你可以通过以下方式查看当前进程的命名空间信息:

# 查看当前进程的 user_namespace 的 inode 编号 ls -la /proc/self/ns/user # 输出类似:lrwxrwxrwx 1 root root 0 ... user -> 'user:[4026531837]'

4026531837 是根命名空间的 inode 编号。如果是在容器内,编号会不同。

更详细的信息可以通过cat /proc/self/uid_mapcat /proc/self/gid_map查看。默认情况下,非特权进程看到的映射可能是:

0 0 4294967295

这表示容器内 ID 0 映射到宿主机 ID 0,范围覆盖整个 32 位 ID 空间。但这是根命名空间的情况。如果在一个普通用户创建的命名空间中,映射会不同。

3.2 动手创建一个用户命名空间并查看 struct 变化

为了更直观地理解 struct user_namespace 的初始化过程,我们写一个简单的 C 程序,使用 unshare 创建用户命名空间,然后打印 /proc/self/uid_map 和 /proc/self/gid_map。

#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <stdlib.h> #include <fcntl.h> int main() { if (unshare(CLONE_NEWUSER) == -1) { perror("unshare"); exit(1); } printf("New user namespace created.\n"); printf("UID map: "); fflush(stdout); execl("/bin/cat", "cat", "/proc/self/uid_map", NULL); // 由于 execl 替换进程,后面不会执行,但为了演示,也可以先 fork return 0; }

编译运行后,你会看到 uid_map 为空(只有一行空行或没有输出),因为新创建的命名空间没有映射,里面的进程实际上是 nobody 用户。这时你需要手动写入映射。但要注意,这个进程里,你已经是新命名空间的 root(UID 0),但因为没有映射,所以它没有权限写入映射。通常的做法是父进程在创建子进程后,等待子进程告知其 PID,然后父进程写入映射。这就是 newuidmap 和 newgidmap 工具干的活。

3.3 手动写入映射并观察 struct 变化

我们可以用另一个程序来模拟这个过程。父进程先 fork 一个子进程,子进程调用 unshare(CLONE_NEWUSER),然后暂停(比如 sleep(100))。父进程获取子进程的 PID,然后向 /proc/{pid}/uid_map 写入映射,同时写入 /proc/{pid}/setgroups 以及 gid_map。注意写入顺序和权限要求。

// 父进程部分 pid_t child = fork(); if (child == 0) { // 子进程 unshare(CLONE_NEWUSER); sleep(100); // 让父进程有时间写映射 _exit(0); } // 父进程 char path[64]; snprintf(path, sizeof(path), "/proc/%d/uid_map", child); int fd = open(path, O_WRONLY); if (fd == -1) { perror("open uid_map"); exit(1); } // 写入映射:容器内0 映射到宿主机的1000,范围1 dprintf(fd, "0 1000 1\n"); close(fd); // 同样处理 gid_map 和 setgroups 文件 // ...

当你成功写入后,子进程内的 UID 0 就对应宿主机的 UID 1000。此时,子进程就可以执行一些需要 root 权限的操作,但只限于它自己的命名空间内。例如,它可以在自己的命名空间内挂载文件系统(如果同时有 mount 命名空间),但不会影响宿主机。

3.4 如何在内核模块中访问 struct user_namespace

如果你在写内核模块,需要获取当前进程或某个进程的 user_namespace,可以使用current_user_ns()宏,它返回当前进程的 user_ns 指针。例如:

#include <linux/user_namespace.h> #include <linux/sched.h> struct user_namespace *ns = current_user_ns(); printk("Current user namespace level: %d\n", ns->level); printk("Owner UID: %u\n", __kuid_val(ns->owner_uid));

注意:owner_uid是 kuid_t 类型,需要用__kuid_val转换得到普通的 uid_t。

另外,可以通过get_user_ns(ns)增加引用计数,使用完后调用put_user_ns(ns)释放。这是内核编程的基本守则。

3.5 计算 struct user_namespace 成员大小的实战

假设我们想自己编写一个简单的工具,从 /proc 文件系统中解析某个进程的 user_namespace 内部结构(当然,/proc 不直接暴露结构体内容,但我们可以通过 bpftrace 或 kprobe 来获取)。这里演示一个利用 bpftrace 动态追踪的方法:

# 安装 bpftrace 后,运行 bpftrace -e 'kfunc:create_user_ns { printf("new ns created, parent: %p, level: %d\n", args->parent, args->level); }'

这会打印每次创建用户命名空间时的 parent 指针和 level 值。level 值直接就是 struct user_namespace 中的 level 成员内容。

如果你想获取某个成员的大小,可以用sizeof在内核模块中打印,或者使用一个简单的 probe 来读取:

# 使用 drgn 工具(更高级) import drgn prog = drgn.Program() prog.set_pid(1) # 附加到进程 1 ns = prog['init_user_ns'] # 根命名空间 print("sizeof struct user_namespace:", ns.type_.size) print("uid_map offset:", ns.uid_map.offset_)

drgn 是红帽维护的一个内核调试器,可以方便地读取内核数据结构。如果你在调试环境中,这个工具非常推荐。

4. 常见问题与排查技巧实录

4.1 写入 uid_map 失败:Operation not permitted

这是最常见的问题。原因通常有以下几种:

  1. 写入者不是父命名空间中的 CAP_SYS_ADMIN 进程。只有拥有 CAP_SYS_ADMIN 权限的进程才能修改另一个命名空间的映射。如果你在容器内尝试写入宿主机的 /proc 文件,会被拒绝。解决办法是在特权模式下运行,或者使用newuidmapnewgidmap工具,它们利用 setuid 位来获得临时权限。
  2. 映射格式错误。每行必须是ID-inside ID-outside length三个整数,空格分隔,以换行结尾。不能有多个映射行同时写入?实际上可以写入多行,但每行都必须单独调用 write 系统调用。如果你一次性写入多行,内核会解析第一行,后续行会被忽略。所以你需要多次 write 或使用write循环。
  3. 重叠的映射区间。内核不允许两个映射区间重叠。例如,你写了0 1000 10 1001 1,虽然不重叠(区间 [0,0] 和 [0,0] 实际上重叠了),但注意 ID 区间是左闭右闭的,两个区间都包含 0,所以重叠。你需要确保每个内部 ID 映射到唯一的外部 ID。
  4. 在映射写入前尝试了 setgroups 文件。新命名空间默认不允许 setgroups,你需要先写入/proc/{pid}/setgroups文件,内容为denyallow。如果先写 gid_map,可能失败。正确的顺序是:先写 uid_map,再写 setgroups(写 allow),最后写 gid_map。但有些内核版本要求先写 setgroups 再写 gid_map。我建议使用newuidmapnewgidmap工具,它们会处理好顺序。

4.2 为何在子命名空间内还是无法执行特权操作

即使你成功映射了 UID 0,子命名空间内的进程也只能执行那些不超出命名空间限制的特权操作。例如,它可以在自己的命名空间内创建新的 mount 命名空间并使用mount,但前提是它同时拥有 CAP_SYS_ADMIN 在自己的命名空间内。但是,对于某些操作,如加载内核模块、修改系统时间等,即使拥有 CAP_SYS_ADMIN,也会被内核额外检查。例如,clock_settime系统调用会检查当前进程是否在根命名空间中,如果不是,则返回 EPERM。

所以,user_namespace 只是隔离用户和组 ID,不会自动授予所有特权。你需要结合其他命名空间(如 mount、net、ipc 等)来实现完整的容器隔离。

4.3 查看 struct 成员大小的最佳实践

如果你在编写需要与内核交互的用户态程序,比如一个容器运行时,可能需要了解 struct user_namespace 的布局。但用户态无法直接访问内核结构体,所以通常的做法是:

  • 通过/proc/PID/status中的UidGid行提取映射信息,但这也是经过内核抽象后的数据。
  • 如果需要更底层的信息,可以使用ioctl_nsnetlink接口,但都不直接暴露结构体成员。
  • 在调试内核模块时,使用printk打印sizeofoffsetof是最直接的方法。我建议在模块初始化时打印关键结构体的大小,这样在任何内核版本上都能快速确认。

4.4 内核版本差异带来的兼容性问题

不同内核版本对 struct user_namespace 的定义有细微差异。例如,早期内核(3.x)没有projid_map成员,keyring_tag可能以不同形式存在。如果你在编写跨兼容性的代码,强烈建议使用内核提供的接口函数,而不是直接操作结构体成员。例如,使用from_kuidmake_kuid来转换 UID,而不是手动解析 uid_map。内核保证这些接口的稳定性,而结构体内部可能随时变化。

4.5 实战中遇到的一个坑:映射被内核自动删除

有时候,当你创建了用户命名空间,写入映射,然后子进程退出后,你试图再次使用同一个命名空间(比如通过nsenter进入),会发现映射已经失效。这是因为命名空间的引用计数降为 0 后,会被销毁。另一种情况是,如果你在映射写入后,父进程退出,而子进程仍然存活,那么子进程的命名空间仍然有效,但父进程的命名空间可能被回收。所以,在编写容器管理工具时,一定要保持对命名空间的引用,例如通过打开/proc/pid/ns/user文件描述符,这个 fd 会保持命名空间存活。

5. 个人实操体会与扩展建议

5.1 从源码角度理解映射的底层实现

我在实际调试一个容器的用户命名空间问题时,发现写入映射后,内核会调用proc_uid_map_write->map_id_range_down->insert_extent等一系列函数。这些函数最终会在uid_gid_map结构体中形成一个红黑树(或有序数组,取决于内核版本)。映射的查找效率是 O(log n),对于容器内大量进程的 ID 转换,性能影响很小。

但有一个细节让我印象深刻:内核在写入映射时,会检查当前进程的euid是否等于父命名空间的owner_uid,以及是否拥有CAP_SYS_ADMIN。这个检查非常严格,防止普通用户无限创建命名空间或窃取特权。因此,在编写用户态工具时,建议使用newuidmapnewgidmap,它们依赖 setuid 二进制文件来提升权限,而不是直接写 /proc。

5.2 如何快速验证一个 struct 的大小

如果你在编写内核模块,并且需要确保结构体大小符合预期,可以添加 BUILD_BUG_ON 宏:

BUILD_BUG_ON(sizeof(struct user_namespace) != 256); // 假设预期大小

如果大小不匹配,编译时会报错。这对于跨内核版本调试非常有用。

5.3 扩展:利用 user_namespace 实现沙箱

我曾在工作中实现过一个基于 user_namespace 的轻量级沙箱,流程如下:

  1. 创建新的 user_namespace 和 mount_namespace。
  2. 在子进程内,映射 UID 0,然后挂载一个 tmpfs 作为根文件系统。
  3. 降权:在子进程内切换到普通用户(仍然映射到宿主机的高位 UID),然后执行用户代码。
  4. 由于用户代码只拥有较低的权限,无法逃逸出命名空间,也无法访问宿主机文件。

这个沙箱性能很好,因为不需要虚拟机。但需要小心处理:必须同时开启CLONE_NEWPIDCLONE_NEWNS以及其他命名空间,否则可能通过/proc或者残留的挂载点逃离。另外,还要注意setgroups的配置,以及seccomp的配合。

5.4 最后分享一个小技巧

如果你在内核模块中需要遍历所有用户命名空间,可以遍历user_namespace_kset全局变量。但更推荐的方式是使用for_each_user_ns宏(如果内核版本支持)。不过,这个宏在 5.10 以后才引入,此前需要手动遍历链表。如果你在生产环境中需要监控命名空间使用情况,推荐使用 eBPF 程序挂载到user_namespace相关的函数上,这样开销小且安全。

总之,struct user_namespace 是 Linux 用户隔离的核心,虽然它只是一个结构体,但背后承载着整个命名空间的生命周期管理和权限模型。理解它,你就能更好地掌控容器和安全沙箱的底层逻辑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 15:28:10

SolidWorks研发设计库搭建指南:从型材库到宏的完整实践

做非标自动化设备的工程师&#xff0c;大概率经历过这种场景&#xff1a;画一台机架&#xff0c;要在焊件库里一页页找型材&#xff0c;找不到就自己画截面&#xff1b;装配一台设备&#xff0c;标准件要从各种网站下载&#xff0c;下载完还要手工改名、清理配合&#xff1b;出…

作者头像 李华
网站建设 2026/9/9 15:26:40

Qt + FFmpeg 视频播放器开发指南:架构、解码与音视频同步实战

简介&#xff1a;Qt 配合 FFmpeg 实现跨平台视频播放器是不少客户端开发者的常见需求。这份资源面向已有 Qt 基础、想在项目中接入 FFmpeg 以兼容更多音视频格式的开发人员&#xff0c;通过一个可运行的播放器工程演示了完整集成思路&#xff1a;从 FFmpeg 库的编译链接&#x…

作者头像 李华
网站建设 2026/9/9 15:25:45

股票行情查询接口整理与使用教程

股票行情查询接口整理与使用教程说明&#xff1a;本文基于公开文档与网上可查的接口写法整理&#xff0c;未对每个接口做真实请求实测&#xff0c;接口可用性、字段与限流以各官方文档为准&#xff0c;集成到生产环境前请自行发请求验证。写在前面做量化、写看盘小工具、或在业…

作者头像 李华
网站建设 2026/9/9 15:25:17

Pycopy:极简Python方言,如何在STM32上省下每一KB内存

简介&#xff1a;这是Pycopy极简高效Python方言的项目资源包&#xff0c;面向希望在云、台式机、受限系统和微控制器上使用可扩展Python运行时的开发者与嵌入式工程师。Pycopy由MicroPython项目演进而来&#xff0c;在保留完整Python 3.4语法的基础上引入Python 3.5的异步特性&…

作者头像 李华