本期文章,我们来深入学习 Linux 用户和权限管理。
Linux 是一个多用户操作系统,所有权限判断最终都落脚到一个核心数据结构:进程凭证(struct cred),理解了cred 凭证就抓住了 Linux 用户和权限管理的本质。
Linux 每个进程都有一个cred 凭证,由 struct cred 结构体描述,定义在 include/linux/cred.h 中。其简化定义如下:
struct cred { atomic_t usage; /* 引用计数 */ kuid_t uid; /* 真实用户 ID */ kgid_t gid; /* 真实组 ID */ kuid_t suid; /* 保存的用户 ID */ kgid_t sgid; /* 保存的组 ID */ kuid_t euid; /* 有效用户 ID */ kgid_t egid; /* 有效组 ID */ kuid_t fsuid; /* 文件系统用户 ID */ kgid_t fsgid; /* 文件系统组 ID */ struct group_info *group_info; /* 附加组信息 */ kernel_cap_t cap_effective; /* 有效能力集 */ kernel_cap_t cap_permitted; /* 允许能力集 */ kernel_cap_t cap_inheritable; /* 可继承能力集 */ kernel_cap_t cap_bset; /* 能力边界集 */ kernel_cap_t cap_ambient; /* 环境能力集 */ struct user_struct *user; /* 指向用户资源结构 */ struct user_namespace *user_ns;/* 用户命名空间 */ void *security; /* LSM 安全模块数据 */};
进程(task_struct)通过两个指针cred 和 real_cred持有 cred 凭证,两者通常指向同一个cred 实例,cred 指向修改后的新凭证(权限检查使用),real_cred始终指向原始凭证(追溯真实身份)。线程或进程可以共享凭证或单独持有凭证。

用户id和权限验证
组id和权限验证
Capabilities 验证
user_namespace 验证
特定的资源会验证特定的权限,并不是每次都会验证所有的权限。cred 凭证验证成功后,才能访问系统资源。
Linux 用户 ID 体系由四对 UID/GID 组成,全部存储在 struct cred 中:
uid | ||
euid | ||
suid | ||
fsuid |
正常启动的程序,真实 UID 等于有效 UID。当程序设置了 SUID 位时,程序启动后有效 UID 变为文件所有者的 UID,而真实 UID 保持不变。这就是 passwd 命令以普通用户身份修改 /etc/shadow(root 所有)的原因——/usr/bin/passwd 设置了 SUID 位且文件所有者为 root。
保存的 UID 的设计目的是支持"权限切换与恢复"。SUID 程序可以在不需要特权时将有效 UID 切回真实 UID,需要时再从保存的 UID 恢复特权。文件系统 UID 的存在是为了兼容旧版 NFS 服务器。
UID/GID 在内核中不是普通的整数,而是封装为 kuid_t 和 kgid_t 类型:
typedef struct { uid_t val; /* 数值形式的 UID */} kuid_t;typedef struct { gid_t val; /* 数值形式的 GID */} kgid_t;
使用 kuid_t 封装是为了支持用户命名空间——同一数值在不同命名空间中代表不同的用户。内核使用 uid_eq()、from_kuid()、make_kuid() 等辅助函数操作 kuid_t,而不是直接比较数值。
Linux 从 2.2 内核开始引入能力(Capabilities)模型,将超级用户的全部权限拆分为多个独立的能力位,每个位控制一类操作。传统 UNIX 的"root 拥有所有权限"被替换为"进程拥有的能力位决定其能做什么"。
能力集存储为 kernel_cap_t 类型,定义在 include/uapi/linux/capability.h 中:
#define _LINUX_CAPABILITY_VERSION_3 0x20080522/* 内核内部使用两个 __u32 数组,共支持 64 个能力位 */typedef struct kernel_cap_struct { __u32 cap[_KERNEL_CAPABILITY_U32S]; /* 两个 32 位 */} kernel_cap_t;
kernel_cap_t 本质上是一个位掩码类型,使用两个 32 位字共支持 64 个能力位。每个能力位对应一个 CAP_* 宏,如 CAP_SYS_ADMIN 对应第 21 位。内核对能力集的操作通过位运算宏完成:cap_raised(c, cap) 检查某个能力位是否置位,cap_lower(c1, c2) 判断 c1 是否是 c2 的子集。合并(|)、交集(&)、差集(& ~)都是 O(1) 的按位运算。
struct cred 中包含五个能力集:
cap_permittedcap_effectivecapable(cap) 宏检查的就是这组位。cap_inheritableexecve 后可与文件能力做交集运算,决定新程序的 cap_permitted。cap_bsetcap_ambientexecve 后保留预设的能力。内核通过 capable() 宏进行能力检查,核心逻辑是调用 ns_capable(),提取当前进程的 user_namespace 后比较 cap_effective。常见的检查包括 capable(CAP_DAC_OVERRIDE)(绕过文件权限)、capable(CAP_NET_RAW)(原始套接字)、capable(CAP_SYS_ADMIN)(系统管理)等。
典型使用示例:隔离 ping 命令的能力
使用文件能力(File Capabilities)替代 SUID root,是能力模型最经典的应用场景。ping 命令只需要 CAP_NET_RAW 创建原始套接字,不需要完整的 root 权限:
# 移除 SUID 位,改用文件能力$ sudo chmod u-s /usr/bin/ping$ sudo setcap cap_net_raw+ep /usr/bin/ping# 验证——普通用户也可以正常 ping$ getcap /usr/bin/ping/usr/bin/ping = cap_net_raw+ep# ping 进程只拥有 CAP_NET_RAW,无法执行其他特权操作
文件能力存储在文件的 security.capability 扩展属性中,格式由 struct vfs_cap_data 定义,包含 permitted 和 inheritable 两组能力位。执行文件时,内核在 cap_bprm_creds_from_file() 中将其与进程当前能力做计算,合并出新的 cap_permitted。相比 SUID root 将所有者全部权限授予执行者,文件能力实现了精确到能力位的权限控制,符合最小权限原则。
用户命名空间(User Namespace)将 UID/GID 做一层映射隔离,使容器获得"虚拟 root"能力。struct cred 中的 user_ns 成员指向进程所属的命名空间,由 struct user_namespace 描述:
struct user_namespace { struct uid_gid_map uid_map; /* UID 映射表 */ struct uid_gid_map gid_map; /* GID 映射表 */ struct user_namespace *parent; /* 父命名空间 */ kuid_t owner; /* 命名空间所有者 */ kgid_t group; /* 命名空间所属组 */ struct ns_common ns; /* 命名空间通用信息 */};
树形结构
parent 指针将用户命名空间组织成一棵树。系统启动时内核创建初始命名空间 init_user_ns,它是整棵树的根节点,parent 为 NULL。之后每创建一个新的用户命名空间,都将其 parent 指向创建者的命名空间,形成父子关系。这条父子链最终都会回溯到 init_user_ns。典型的树形结构如图1所示。

图2 用户命名空间树形结构
uid_map 的类型是 struct uid_gid_map,内部维护最多 5 条映射区间:
#define UID_GID_MAP_MAX_EXTENTS 5struct uid_gid_map { u32 nr_extents; /* 当前映射区间数 */ struct uid_gid_extent { u32 first; /* 子命名空间起始 UID */ u32 lower_first; /* 父命名空间起始 UID */ u32 count; /* 连续映射数量 */ } extent[UID_GID_MAP_MAX_EXTENTS];};
每条 uid_gid_extent 定义一个连续区间映射。例如 0 1000 1 表示子命名空间的 UID 0 映射到父命名空间的 UID 1000。内核通过 from_kuid(ns, kuid) 和 make_kuid(ns, uid) 完成双向转换——沿着 parent 链逐层向上转换,直到 init_user_ns。
能力检查中的命名空间归属
权限检查函数 ns_capable() 是命名空间在权限判断中发挥作用的关键入口,其实现逻辑在 include/linux/capability.h 中:
static inline bool ns_capable(struct user_namespace *ns, int cap){ /* 逐层向上检查,直到初始命名空间 */ if (capable(cap)) return true; /* 在指定命名空间内检查能力 */ if (ns_capable_noaudit(ns, cap)) return true; return false;}
ns_capable(ns, cap) 与 capable(cap) 的区别在于命名空间归属——capable(cap) 只检查当前进程的 cap_effective,没有任何命名空间约束。而 ns_capable(ns, cap) 除了检查能力位之外,还验证请求者在 ns 这个命名空间内是否拥有该能力。核心判断逻辑在 ns_capable_noaudit() → ns_capable_common() 中——它会检查当前进程的 user_ns 是否是目标 ns 的祖先或等于 ns 本身。如果进程在 ns 的子树中(即 ns 是进程命名空间的祖先),则进程对该命名空间内的资源拥有控制能力。
一个具体的例子:容器内 UID 0 的进程拥有 CAP_SYS_ADMIN,它可以挂载文件系统、创建网络设备——但这些操作只在自己的容器命名空间内生效。当该进程试图操作宿主机资源(如挂载宿主机的块设备)时,内核调用 ns_capable(init_user_ns, CAP_SYS_ADMIN),检查发现容器的 user_ns 并非 init_user_ns 本身或其后代(从宿主机角度),而且沿 parent 链向上追溯时,容器 UID 0 在 init_user_ns 中被映射为普通 UID,不具备该能力——操作被拒绝。
典型使用示例:创建独立用户命名空间
使用 unshare 命令创建一个独立的用户命名空间,可以在其中获得"root"身份:
# 创建一个新的用户命名空间,映射 UID 0 到宿主 UID 1000$ unshare --user --map-root-user /bin/bash# 在新命名空间中,当前用户显示为 root$ iduid=0(root) gid=0(root) groups=0(root)# 在命名空间内拥有全部能力$ cat /proc/self/status | grep CapEffCapEff: 000001ffffffffff # 所有能力位全开# 但实际上对宿主机资源的操作受限于宿主 UID 1000 的权限$ touch /root/test # 失败——宿主机 UID 1000 不能写 /root
这就是 Docker/Podman rootless 模式的内核基础——容器内的 root 在宿主机上被映射为普通用户,即使容器被攻破,攻击者也只拥有普通用户的权限,无法在宿主机上执行特权操作。
Linux 用户管理的基础是三个关键文本文件:
/etc/passwdusername:x:uid:gid:comment:home:shell,第二个字段 x 表示密码已移入 /etc/shadow。/etc/shadowusername:password:last_change:min:max:warn:inactive:expire,只有 root 可读写。/etc/groupgroupname:x:gid:member_list,每行定义组的名称、GID 和成员列表。日常管理使用以下标准命令:
useradd 创建用户,内部流程为:读取 /etc/default/useradd 和 /etc/login.defs 中的默认配置 → 解析命令行参数覆盖 → 在 /etc/passwd、/etc/shadow 末尾追加记录 → 创建家目录 → 拷贝 /etc/skel 骨架文件。usermod 修改已有用户属性,-g 改主组、-G 修改附加组(不带 -a 会覆盖)、-L/-U 通过给密码前加 ! 实现锁定/解锁。userdel -r 删除用户时连带删除家目录和邮件池。passwd 修改密码,普通用户需输入旧密码验证,root 可无需验证直接修改他人密码。
用户管理操作的最终落脚点都是进程凭证 struct cred。以 useradd 创建用户为例,它所做的只是修改 /etc/passwd 等文件——真正让该用户"存在"的内核层面体现,是该用户 UID 可以被填入某个进程的 cred->uid 中。当用户登录时,login 程序将 /etc/passwd 中的 UID/GID 填入新进程的 struct cred,权限系统由此开始运转。用户ID和进程cred的关系如图3所示。

图3 用户ID和进程cred的关系
UID 0(root 用户)
UID 0 是 Linux 中的超级用户,它的"特权"本质上来自两点:
cap_permitted 和 cap_effective 包含全部 64 个能力位。capable() 对所有能力检查都返回真,因而可以绕过文件权限、收发原始网络包、挂载文件系统、加载内核模块等。uid == 0,如 kill() 允许 root 向任意进程发信号。反过来看:如果只移除 CAP_DAC_OVERRIDE,root 同样无法读写本无权访问的文件。root 的特殊不在于 UID 的值,而在于能力集的默认配置——它只是恰好拥有所有能力的一个普通用户。
应用层修改 UID/GID 使用一组标准 C 库函数,声明在 <unistd.h> 中:
#include <unistd.h>/* 查询函数 */uid_t getuid(void); /* 获取真实用户 ID */uid_t geteuid(void); /* 获取有效用户 ID */gid_t getgid(void); /* 获取真实组 ID */gid_t getegid(void); /* 获取有效组 ID *//* 修改函数 */int setuid(uid_t uid); /* 设置用户 ID */int seteuid(uid_t euid); /* 设置有效用户 ID */int setreuid(uid_t ruid, uid_t euid); /* 设置真实和有效用户 ID */int setresuid(uid_t ruid, uid_t euid, uid_t suid); /* 设置三个用户 ID */int setgid(gid_t gid); /* 设置组 ID */int setegid(gid_t egid); /* 设置有效组 ID */int setregid(gid_t rgid, gid_t egid); /* 设置真实和有效组 ID */int setresgid(gid_t rgid, gid_t egid, gid_t sgid); /* 设置三个组 ID */
各个函数的行为差异:
setuid(uid)uid 等于当前真实 UID 或保存的 UID 时才能成功,且会将有效 UID 和保存的 UID 一并设为 uid——彻底切换,不可恢复。seteuid(euid)setreuid(ruid, euid)-1 表示保持不变。setresuid(ruid, euid, suid)-1 同样表示保持不变。SUID 程序的典型权限管理模式——获取特权 → 降权 → 恢复特权 → 永久放弃:
#include <unistd.h>#include <stdio.h>int main(){ uid_t orig_uid = getuid(); /* 记录真实 UID */ uid_t priv_uid = geteuid(); /* 记录特权 UID (文件所有者) */ printf("原始: ruid=%d, euid=%d\n", orig_uid, priv_uid); /* 降权:不需要特权时切回真实 UID */ seteuid(orig_uid); printf("降权后: euid=%d\n", geteuid()); /* 需要特权时恢复 */ seteuid(priv_uid); printf("恢复特权: euid=%d\n", geteuid()); /* 操作完成,永久放弃特权 */ setuid(orig_uid); return 0;}
seteuid 和 setuid 的关键区别:seteuid 可在真实 UID 和保存的 UID 之间切换(临时升降权),而 setuid 一旦成功调用,保存的 UID 也被覆盖,进程将永久失去特权。这正是"最小权限原则"在系统编程中的具体体现。
Linux 中每个文件在内核中由 struct inode 表示,权限信息就存储在 inode 中:
struct inode { umode_t i_mode; /* 文件类型和权限位 */ kuid_t i_uid; /* 文件所有者 UID */ kgid_t i_gid; /* 文件所属组 GID */ const struct inode_operations *i_op; /* inode 操作 */ unsigned long i_ino; /* inode 号 */ /* ... */};
i_mode 的格式如下:

文件权限检查流程如下:
用户程序调用 open(path, flags) │ ▼ do_sys_openat2() │ ▼ may_open() ─── 检查文件打开标志与 inode 标志是否冲突 │ ▼ inode_permission() │ ├── generic_permission() ─── 经典 rwx 权限检查 │ └── security_inode_permission() ─── LSM 钩子(SELinux/AppArmor) │ ▼ 允许 / 拒绝
generic_permission() 体现了经典 UNIX 文件权限模型的检查逻辑:
static int generic_permission(struct inode *inode, int mask){ /* 文件所有者检查 */ if (uid_eq(current_fsuid(), inode->i_uid)) { return (inode->i_mode & (mask << 6)) ? 0 : -EACCES; } /* 文件所属组检查(含附加组) */ if (gid_eq(current_fsgid(), inode->i_gid) || in_group_p(inode->i_gid)) { return (inode->i_mode & (mask << 3)) ? 0 : -EACCES; } /* 其他用户检查 */ return (inode->i_mode & mask) ? 0 : -EACCES;}
检查顺序是"所有者 → 所属组 → 其他",匹配即返回,不继续检查后续权限位。inode_permission() 在调用 generic_permission() 之前先检查 capable(CAP_DAC_OVERRIDE)——如果进程的能力集中包含该能力位,则直接放行,跳过所有权权限检查。这就是 root 用户"无视文件权限"的内核真相。
SUID/SGID/Sticky 三个特殊标志也存储在 i_mode 的高位。S_ISUID(0o4000)使程序以文件所有者的有效 UID 执行——bprm_fill_uid() 检查文件的 S_ISUID 位后,将 bprm->cred->euid 设置为 inode->i_uid。S_ISGID(0o2000)类似,作用于组 ID;目录上设置 SGID 后,在其中创建的文件继承目录的组 ID。S_ISVTX(Sticky 位)只对目录有意义——设置了 Sticky 位的目录中(如 /tmp),只有文件所有者、目录所有者或具备 CAP_FOWNER 的进程才能删除文件,may_delete() 函数负责这项检查。
最后:
本期文章到此结束,如果对您有帮助,欢迎关注物联网心球。