Linux 中的凭证
作者:David Howells <dhowells@redhat.com>
概述
当一个对象对另一个对象进行操作时,Linux 会执行包含多个部分的安全检查:
对象。
对象是系统中可以被用户空间程序直接操作的东西。Linux 有多种可操作的对象,包括:
任务
文件/inode
Socket
消息队列
共享内存段
信号量
密钥
作为所有这些对象描述的一部分,都有一组凭证。凭证集中的具体内容取决于对象的类型。
对象所有权。
在大多数对象的凭证中,会有一个子集用来指示该对象的所有权。这用于资源审计和限制(例如磁盘配额和任务资源限制)。
例如,在一个标准的 UNIX 文件系统中,磁盘配额和任务资源限制将由 inode 上标记的 UID 定义。
客观上下文。
同样,在这些对象的凭证中,会有一个子集用来指示该对象的“客观上下文”。这个集合可能与 (2) 中的集合相同,也可能不同——例如,在标准 UNIX 文件中,这是由 inode 上标记的 UID 和 GID 定义的。
当一个对象被操作时,客观上下文被用作安全计算的一部分。
主体。
主体是正在对另一个对象进行操作的对象。
系统中的大多数对象都是非活动性的:它们不作用于系统内的其他对象。进程/任务是明显的例外:它们执行操作;它们访问和操纵事物。
在某些情况下,除了任务之外的其他对象也可能是主体。例如,一个打开的文件可以使用调用
fcntl(F_SETOWN)
的任务赋予它的 UID 和 EUID 向一个任务发送 SIGIO 信号。在这种情况下,文件结构体也将拥有一个主观上下文。主观上下文。
主体对其凭证有一个额外的解释。其凭证的一个子集构成了“主观上下文”。当主体执行操作时,主观上下文被用作安全计算的一部分。
例如,一个 Linux 任务在操作文件时,有 FSUID、FSGID 和附加组列表——这些与通常构成任务客观上下文的真实 UID 和 GID 是完全分开的。
操作。
Linux 有许多可用的操作,主体可以对一个对象执行这些操作。可用的操作集合取决于主体和对象的性质。
操作包括读取、写入、创建和删除文件;以及派生、发送信号和跟踪任务。
规则、访问控制列表和安全计算。
当一个主体对一个对象进行操作时,会进行一次安全计算。这涉及到获取主观上下文、客观上下文和操作,并搜索一个或多个规则集,以确定在这些上下文下,主体是否被授予或拒绝以期望的方式对该对象进行操作的权限。
有两个主要的规则来源:
a. 自主访问控制 (DAC):
有时,对象会将其描述的一部分包含规则集。这被称为“访问控制列表”或“ACL”。一个 Linux 文件可能提供不止一个 ACL。 例如,一个传统的 UNIX 文件包含一个权限掩码,它是一个简化的 ACL,有三个固定的主体类别('user'、'group' 和 'other'),每个类别都可以被授予某些权限('read'、'write' 和 'execute'——无论这些对于相关对象映射到什么)。然而,UNIX 文件权限不允许任意指定主体,因此用途有限。 一个 Linux 文件也可能带有一个 POSIX ACL。这是一个规则列表,它向任意主体授予各种权限。
b. 强制访问控制 (MAC):
整个系统可能有一个或多个规则集,这些规则集适用于所有主体和对象,无论其来源如何。SELinux 和 Smack 就是这方面的例子。 在 SELinux 和 Smack 的情况下,每个对象都会在其凭证中获得一个标签。当请求一个操作时,它们会获取主体标签、对象标签和操作,然后查找一条规则,该规则说明此操作是被允许还是被拒绝。
凭证类型
Linux 内核支持以下类型的凭证:
传统的 UNIX 凭证
真实用户 ID
真实组 ID
大多数(如果不是全部)Linux 对象都携带 UID 和 GID,即使在某些情况下必须虚构出来(例如,源自 Windows 的 FAT 或 CIFS 文件)。这些(主要)定义了该对象的客观上下文,但在某些情况下任务略有不同。
有效的、保存的和文件系统用户 ID
有效的、保存的和文件系统组 ID
附加组
这些是仅由任务使用的附加凭证。通常,EUID/EGID/GROUPS 将被用作主观上下文,而真实 UID/GID 将被用作客观上下文。对于任务来说,需要注意的是,这并非总是如此。
能力
许可能力集
可继承能力集
有效能力集
能力边界集
这些仅由任务携带。它们表示被零散地授予任务的、普通任务本不具备的更高权限。这些凭证会随着传统 UNIX 凭证的改变而被隐式地操纵,但也可以通过
capset()
系统调用直接操纵。许可能力是进程可能通过
capset()
授予其自身有效集或许可集的能力。可继承集也可能受到如此约束。有效能力是任务实际被允许使用的能力。
可继承能力是可能通过
execve()
传递的能力。边界集限制了可能通过
execve()
继承的能力,特别是在执行一个将以 UID 0 身份运行的二进制文件时。安全管理标志
这些仅由任务携带。它们管理上述凭证在某些操作(如 execve())中的操纵和继承方式。它们不直接用作客观或主观凭证。
密钥和密钥环。
这些仅由任务携带。它们携带和缓存不适合放入其他标准 UNIX 凭证的安全令牌。它们的用途是使网络文件系统密钥等可用于进程执行的文件访问,而不需要普通程序了解所涉及的安全细节。
密钥环是一种特殊类型的密钥。它们携带其他密钥的集合,可以被搜索以找到所需的密钥。每个进程可以订阅多个密钥环:
每个线程的密钥环 每个进程的密钥环 每个会话的密钥环
当一个进程访问一个密钥时,如果它尚不存在,通常会被缓存在这些密钥环中的一个,以供未来的访问查找。
有关使用密钥的更多信息,请参阅
Documentation/security/keys/*
。LSM
Linux 安全模块 (LSM) 允许对任务可以执行的操作施加额外的控制。目前 Linux 支持多种 LSM 选项。
有些通过给系统中的对象打上标签,然后应用规则集(策略)来工作,这些规则集说明一个带有某个标签的任务可以对另一个带有不同标签的对象执行哪些操作。
AF_KEY
这是一种基于 Socket 的、用于网络栈的凭证管理方法 [RFC 2367]。本文档不讨论它,因为它不直接与任务和文件凭证交互;相反,它维护系统级别的凭证。
当一个文件被打开时,打开任务的主观上下文的一部分被记录在创建的文件结构体中。这允许使用该文件结构体的操作使用这些凭证,而不是发出该操作的任务的主观上下文。一个例子是在网络文件系统上打开的文件,其中打开文件的凭证应该被呈现给服务器,而不管实际上是谁在对其进行读或写操作。
文件标记
磁盘上或通过网络获取的文件可能带有构成该文件客观安全上下文的注解。根据文件系统的类型,这可能包括以下一项或多项:
- UNIX UID、GID、模式;
- Windows 用户 ID;
- 访问控制列表;
- LSM 安全标签;
- UNIX 执行权限提升位 (SUID/SGID);
- 文件能力执行权限提升位。
这些会与任务的主观安全上下文进行比较,结果是允许或禁止某些操作。在 execve()
的情况下,权限提升位会起作用,并可能根据可执行文件上的注解,授予结果进程额外的权限。
任务凭证
在 Linux 中,一个任务的所有凭证都保存在一个类型为 ‘struct cred’ 的引用计数结构中,通过 (uid, gid) 或通过 (groups, keys, LSM security) 保存。每个任务通过其 task_struct 中的一个名为 ‘cred’ 的指针指向其凭证。
一旦一组凭证被准备好并提交,它就不能被更改,但有以下例外:
- 它的引用计数可以被改变;
- 它所指向的 group_info 结构体的引用计数可以被改变;
- 它所指向的安全数据的引用计数可以被改变;
- 它所指向的任何密钥环的引用计数可以被改变;
- 它所指向的任何密钥环可以被撤销、过期或其安全属性可以被改变;以及
- 它所指向的任何密钥环的内容可以被改变(密钥环的全部意义就在于作为一组共享的凭证,可由任何有适当权限的人修改)。
要修改 cred 结构中的任何内容,必须遵守“复制并替换”的原则。首先进行复制,然后修改副本,然后使用 RCU 更改任务指针,使其指向新的副本。有一些辅助函数来帮助完成这个过程(见下文)。
一个任务只能修改它 自己的 凭证;不再允许一个任务修改另一个任务的凭证。这意味着 capset()
系统调用不再允许接受除当前进程 PID 之外的任何 PID。同样,keyctl_instantiate()
和 keyctl_negate()
函数不再允许附加到请求进程中的进程特定密钥环,因为实例化进程可能需要创建它们。
不可变凭证
一旦一组凭证被公开(例如通过调用 commit_creds()
),它就必须被视为不可变的,但有两个例外:
- 引用计数可以被改变。
- 虽然一组凭证的密钥环订阅不能被改变,但所订阅的密钥环的内容可以被改变。
为了在编译时捕获意外的凭证修改,struct task_struct 有指向其凭证集的 const
指针,struct file 也是如此。此外,某些函数如 get_cred()
和 put_cred()
操作于 const 指针,从而使得类型转换变得不必要,但需要临时放弃 const 限定符以便能够修改引用计数。
访问任务凭证
一个任务只能修改自己的凭证,这使得当前进程可以读取或替换自己的凭证而无需任何形式的锁定——这极大地简化了事情。它可以直接调用:
const struct cred *current_cred()
来获取指向其凭证结构的指针,并且之后不需要释放它。
有一些方便的包装函数用于检索任务凭证的特定方面(在每种情况下,值都会被直接返回):
uid_t current_uid(void) 当前进程的真实 UID
gid_t current_gid(void) 当前进程的真实 GID
uid_t current_euid(void) 当前进程的有效 UID
gid_t current_egid(void) 当前进程的有效 GID
uid_t current_fsuid(void) 当前进程的文件访问 UID
gid_t current_fsgid(void) 当前进程的文件访问 GID
kernel_cap_t current_cap(void) 当前进程的有效能力
struct user_struct *current_user(void) 当前进程的用户账户
还有一些方便的包装函数用于检索任务凭证的特定关联对:
void current_uid_gid(uid_t *, gid_t *);
void current_euid_egid(uid_t *, gid_t *);
void current_fsuid_fsgid(uid_t *, gid_t *);
这些函数在从当前任务的凭证中检索到值后,通过它们的参数返回这些值对。
此外,还有一个函数用于获取对当前进程当前凭证集的引用:
const struct cred *get_current_cred(void);
以及用于获取对那些实际上不在 struct cred 中但与之相关的凭证的引用的函数:
struct user_struct *get_current_user(void);
struct group_info *get_current_groups(void);
它们分别获取对当前进程的用户账户结构和附加组列表的引用。
一旦获得引用,必须分别使用 put_cred()
、free_uid()
或 put_group_info()
来释放它。
访问另一个任务的凭证
虽然一个任务可以无需锁定地访问自己的凭证,但对于一个想要访问另一个任务凭证的任务来说,情况并非如此。它必须使用 RCU 读锁和 rcu_dereference()
。
rcu_dereference()
被包装在:
const struct cred *__task_cred(struct task_struct *task);
这应该在 RCU 读锁内部使用,如下例所示:
void foo(struct task_struct *t, struct foo_data *f)
{
const struct cred *tcred;
...
rcu_read_lock();
tcred = __task_cred(t);
f->uid = tcred->uid;
f->gid = tcred->gid;
f->groups = get_group_info(tcred->groups);
rcu_read_unlock();
...
}
如果需要长时间持有另一个任务的凭证,并且可能在此期间休眠,那么调用者应该使用以下函数获取对它们的引用:
const struct cred *get_task_cred(struct task_struct *task);
这个函数在内部处理了所有的 RCU 逻辑。调用者在完成使用后必须对获得的凭证调用 put_cred()
。
注意
__task_cred()
的结果不应直接传递给 get_cred()
,因为这可能与 commit_cred()
发生竞争。
有几个方便的函数可以访问另一个任务凭证的某些部分,向调用者隐藏了 RCU 逻辑:
uid_t task_uid(task) 任务的真实 UID
uid_t task_euid(task) 任务的有效 UID
如果调用者当时已经持有了 RCU 读锁,那么应该使用:
__task_cred(task)->uid
__task_cred(task)->euid
来代替。类似地,如果需要访问一个任务凭证的多个方面,应该使用 RCU 读锁,调用 __task_cred()
,将结果存储在一个临时指针中,然后在释放锁之前从该指针调用凭证的各个方面。这可以防止多次调用可能开销很大的 RCU 逻辑。
如果需要访问另一个任务凭证的某个其他单一非指针成员,可以使用:
task_cred_xxx(task, member)
这里的 ‘member’ 是 cred 结构的一个非指针成员。例如:
uid_t task_cred_xxx(task, suid);
将从任务中检索 ‘struct cred::suid’,并执行适当的 RCU 逻辑。这不能用于指针成员,因为它们指向的内容可能在 RCU 读锁被释放的那一刻就消失了。
修改凭证
如前所述,一个任务只能修改自己的凭证,而不能修改其他任务的凭证。这意味着它在修改自己的凭证时不需要使用任何锁定。
要修改当前进程的凭证,一个函数应该首先通过调用以下函数来准备一套新的凭证:
struct cred *prepare_creds(void);
这个函数会锁定 current->cred_replace_mutex
,然后分配并构造当前进程凭证的一个副本,如果成功,则在持有互斥锁的情况下返回。如果失败(内存不足),则返回 NULL。
该互斥锁防止 ptrace()
在凭证构造和更改的安全检查进行时改变进程的 ptrace 状态,因为 ptrace 状态可能会改变结果,尤其是在 execve()
的情况下。
新的凭证集应被适当地修改,并完成任何安全检查和钩子调用。当前和提议的凭证集都可用于此目的,因为 current_cred()
在此时仍将返回当前的凭证集。
在替换组列表时,新的列表必须在添加到凭证之前进行排序,因为使用了二分搜索来测试成员资格。在实践中,这意味着应该在调用 set_groups()
或 set_current_groups()
之前调用 groups_sort()
。groups_sort()
不能在一个共享的 struct group_list
上调用,因为它可能会在排序过程中置换元素,即使数组已经排序。
当凭证集准备好后,应该通过调用以下函数将其提交给当前进程:
int commit_creds(struct cred *new);
这个函数将修改凭证和进程的各个方面,给 LSM 一个机会也这样做,然后它将使用 rcu_assign_pointer()
实际将新的凭证提交给 current->cred
,它将释放 current->cred_replace_mutex
以允许 ptrace()
进行,并通知调度器和其他组件这些变化。
这个函数保证返回 0,这样它就可以在诸如 sys_setresuid()
这样的函数的末尾被尾调用。
注意,这个函数会消耗调用者对新凭证的引用。调用者之后 不应该 对新凭证调用 put_cred()
。
此外,一旦这个函数被调用到一个新的凭证集上,这些凭证就 不能 被进一步更改。
如果在调用 prepare_creds()
后安全检查失败或发生其他错误,则应调用以下函数:
void abort_creds(struct cred *new);
这个函数会释放 prepare_creds()
获取的 current->cred_replace_mutex
上的锁,然后释放新的凭证。
一个典型的凭证修改函数看起来像这样:
int alter_suid(uid_t suid)
{
struct cred *new;
int ret;
new = prepare_creds();
if (!new)
return -ENOMEM;
new->suid = suid;
ret = security_alter_suid(new);
if (ret < 0) {
abort_creds(new);
return ret;
}
return commit_creds(new);
}
管理凭证
有一些函数可以帮助管理凭证:
-
void put_cred(const struct cred *cred);
这个函数释放对给定凭证集的引用。如果引用计数达到零,该凭证将被 RCU 系统调度销毁。
-
const struct cred *get_cred(const struct cred *cred);
这个函数获取对一个活动凭证集的引用,返回一个指向该凭证集的指针。
-
struct cred *get_new_cred(struct cred *cred);
这个函数获取对一个正在构建中因此仍然是可变的凭证集的引用,返回一个指向该凭证集的指针。
打开文件的凭证
当一个新文件被打开时,会获取对打开任务凭证的一个引用,并将其附加到文件结构体中作为 f_cred
,以替代 f_uid
和 f_gid
。过去访问 file->f_uid
和 file->f_gid
的代码现在应该访问 file->f_cred->fsuid
和 file->f_cred->fsgid
。
访问 f_cred
是安全的,无需使用 RCU 或锁定,因为该指针在文件结构体的生命周期内不会改变,其指向的 cred 结构的内容也不会改变,除了上面列出的例外情况(见“任务凭证”部分)。
为了避免“混淆代理人”权限提升攻击,在对一个打开的文件进行后续操作时的访问控制检查应该使用这些凭证,而不是“当前”进程的凭证,因为该文件可能已经被传递给一个更高权限的进程。
覆盖 VFS 对凭证的使用
在某些情况下,需要覆盖 VFS 使用的凭证,这可以通过使用一组不同的凭证调用诸如 vfs_mkdir()
之类的函数来完成。这在以下地方被使用:
sys_faccessat()
。do_coredump()
。- nfs4recover.c。