在上一部分中,我们只是将脚趾浸入命名空间中,同时看到了在一个隔离的UTS命名空间中启动该过程是多么容易。 在本文中,我们将介绍用户名称空间。
在其他与安全性相关的资源中, 用户名称空间隔离了系统中用户和组的标识符。 在本文中,我们将仅专注于用户ID和组ID资源(分别为UID和GID),因为它们在执行权限检查和整个系统中与安全相关的其他活动中起着基本作用。
在Linux上,这些ID只是整数,用于标识系统中的用户和组。 并且将其中一些分配给每个进程,以设置该进程可以和不能访问的操作/资源。 进程损害的能力取决于与分配的ID相关的权限。
用户名称空间
我们将仅使用用户ID来说明用户名称空间的功能。 完全相同的操作适用于组ID,我们将在本文后面部分讨论。
用户名称空间具有自己的用户和组标识符副本。 然后,隔离使您可以将进程与另一组ID相关联,具体取决于进程当前所属的用户名称空间。 例如, $pid
进程可以从用户名称空间P中的 root
(UID 0)运行,并在切换到另一个用户名称空间Q后突然从proxy
(UID 13)运行。
用户空间可以嵌套! 这意味着自定义名称空间(父级)的实例可以具有零个或多个子名称空间,而每个子名称空间又可以具有自己的子名称空间,依此类推(直到达到32个嵌套级别的限制)。 创建新的名称空间C后 ,Linux将创建C的进程P的当前用户名称空间设置为C的父名称空间,以后将无法更改。 结果,所有用户名称空间仅具有一个父级,从而形成名称空间的树状结构。 并且,就像在树的情况下一样,此规则的例外是在顶部,在该处我们具有根(或初始,默认)名称空间。 如果您还没有做某种容器魔术,那么这很可能是您所有进程所属的用户名称空间,因为这是自系统启动以来唯一的用户名称空间。
在本文中,我们将使用命令提示符P $和C $来分别指示当前在父P和子C用户名称空间中运行的Shell。
用户标识映射
实际上,用户名称空间包含一组标识符和一些将这些ID与其他用户名称空间的ID集联系起来的信息-此二重奏定义了系统中可用进程ID的完整概念。 让我们看一下它的外观:
P$ whoami iffy P$ id uid=1000(iffy) gid=1000(iffy)
在另一个终端窗口中,让我们使用unshare
启动外壳程序( -U
标志在新的用户名称空间中创建一个进程):
P$ whoami iffy P$ unshare -U bash
等一下,谁呢? 现在我们在C的嵌套shell中,当前用户将成为没人? 我们可能已经猜到,由于C是一个新的用户名称空间,因此该进程可能具有另一种ID。 因此,我们可能没想到他会保持iffy
,但nobody
不会感到有趣。 另一方面,这很棒,因为我们得到了想要的隔离。 现在,我们的流程在系统中具有不同的(尽管已损坏)ID替换-当前,它将每个nobody
都视为nobody
,将每个组都视为nogroup
。
将UID从一个用户名称空间链接到另一个用户名称空间的信息称为用户ID映射 。 它是一个查找表,用于将当前用户名称空间中的ID与其他名称空间中的ID进行匹配,并且每个用户名称空间都与一个UID映射(除了组ID的另一个GID映射)完全相关联。
这种映射是在我们的unshare
shell中所破坏的。 事实证明,新的用户名称空间以空映射开始,因此,Linux默认情况下不使用可怕的用户nobody
。 我们需要先解决此问题,然后才能在新的名称空间中执行任何有用的工作。 例如,当前,尝试使用UID的系统调用(例如setuid
)将失败。 但是不要害怕! 遵循所有文件的传统,Linux使用/proc/$pid/uid_map
(对于GID在/proc/$pid/gid_map
中)的/proc
文件系统显示此映射,其中$pid
是进程ID。 我们将这两个文件称为映射文件。
地图文件
映射文件是系统中的特殊文件。 有什么特别的? 嗯,每次读取它们时都返回不同的内容,这取决于您正在阅读的内容。 例如,映射文件/proc/$pid/uid_maps
从$pid
进程所属的用户名称空间中的UID返回读取过程的用户名称空间中的UID的映射。 结果,返回到进程X的内容可能不同于返回到进程Y的内容 ,即使它们同时读取相同的映射文件也是如此。
特别是,读取UID映射文件/proc/$pid/uid_map
进程X接收一组字符串。 每行将连续范围的UID映射到$pid
进程的用户名称空间C ,对应于另一个名称空间中的UID范围。
每行的格式$fromID $toID $length
,其中:
$fromID
是$pid
进程的用户名称空间范围的起始UID$lenght
是范围的长度。$toID
的转换取决于读取过程X。 如果X属于另一个用户命名空间U ,则$toID
是U中从$fromID
映射的范围的起始UID。 否则, $toID
是进程C的父用户名称空间P中范围的起始UID 。
例如,如果某个进程读取文件/proc/1409/uid_map
并且在接收到的行中您可以看到15 22 5
,则进程1409
的用户名称空间中的UID 15至19映射到读取进程的单独用户名称空间的UID 22-26。
另一方面,如果某个进程从文件/proc/$$/uid_map
(或与该读取进程属于同一用户名称空间的任何进程的映射文件)中读取并接收15 22 5
,则UID从15到19 in用户名称空间C映射到C用户名称空间的父级的UID(从22到26)。
让我们尝试一下:
P$ echo $$ 1442
嗯,这不是很令人兴奋,因为这是两个极端情况,但是那说明了一些事情:
- 新创建的用户名称空间实际上将具有空的映射文件。
- UID 4294967295不可映射,也不适合在
root
用户命名空间中使用。 Linux专门使用此UID来指示缺少用户ID 。
编写UID映射文件
要修复我们新创建的用户名称空间C ,我们只需要提供必要的映射,只需将其内容写入到属于C的任何进程的映射文件即可(写入后我们无法更新此文件)。 写入该文件可以告诉Linux两件事:
- 哪些UID可用于与目标用户名称空间C相关的进程。
- 当前用户名称空间中的哪个UID对应于C中的UID 。
例如,如果我们将以下内容从父用户名称空间P写入子C名称空间的映射文件中:
0 1000 1 3 0 1
我们从本质上告诉Linux:
- 对于C中的进程,系统中唯一存在的UID是UID
0
和3
。 例如, setuid(9)
系统调用将始终以诸如无效的用户id之类的结尾。 - P中的 UID
1000
和0
对应于C中的 UID 0
和3
。 例如,如果在P中使用UID 1000
运行的进程切换到C ,它将发现切换之后,其UID已变为root
0
。
命名空间和特权所有者
在上一篇文章中,我们提到在创建新的名称空间时,需要具有超级用户级别的访问权限。 用户名称空间不强加此要求。 实际上,另一个功能是它们可以拥有其他名称空间。
每当创建非用户名称空间N时 ,Linux都会将创建N 的进程的当前用户名称空间P分配为名称空间N的所有者 。 如果在同一个clone
系统调用中与其他名称空间一起创建了P ,则Linux保证将首先创建P,并使其成为其他名称空间的所有者。
命名空间的所有者很重要,因为请求对不是用户命名空间的资源执行特权操作的进程将对此用户命名空间的所有者而不是根用户命名空间的UID特权进行检查。 例如,假设P是子C的父用户名称空间,并且P和C分别拥有自己的网络名称空间M和N。 进程可能没有权限创建M中包含的网络设备,但是它可能能够为N执行此操作。
对于我们而言,拥有名称空间所有者的后果是,如果我们还请求创建用户名称空间,则在使用unshare
或isolate
执行命令时,可以放弃sudo
要求。 例如, unshare -u bash
将需要sudo
,而unshare -Uu bash
将不再是:
不幸的是,我们将在下一篇文章中重新应用超级用户权限要求,因为isolate
需要root用户名称空间中的root用户权限才能正确配置Mount和Network名称空间。 但是我们当然会放弃团队流程的特权,以确保团队没有不必要的权限。
如何解析ID
我们只是看到一个正常用户1000
突然切换为root
运行的进程。 不用担心,特权没有升级。 请记住,这只是一个映射 ID:只要我们的进程认为它是系统上的root
,Linux就知道该root
(对于他而言)意味着一个普通的UID 1000
(这要归功于我们的映射)。 因此,当属于其新用户名称空间的名称空间(例如C中的网络名称空间)将其权限识别为root
,其他名称空间(例如P中的网络名称空间)则无法识别其权限。 因此,该过程无法执行用户1000
无法执行的任何操作。
每当嵌套用户名称空间中的进程执行需要权限检查的操作(例如,创建文件)时,都会遍历名称空间树中到根的映射,从而将该用户名称空间中其UID与根用户名称空间中的等效用户ID相比较。 例如,当他读取用户ID时,情况与之相反,就像我们使用ls -l my_file
。 所有者my_file
的UID从根用户名称空间映射到当前名称空间,并且将最终的对应ID(如果整个树的某处不存在映射,则没有最终ID)被赋予读取过程。
组号
即使我们以C为根,我们仍然与可怕的nogroup
关联为我们的组ID。 我们只需要对相应的/proc/$pid/gid_map
做同样的/proc/$pid/gid_map
。 在执行此操作之前,我们需要通过写“拒绝“到文件proc/$pid/setgroups
:
实作
这篇文章的源代码可以在这里找到。
如您所见,管理用户名称空间存在许多困难,但是实现非常简单。 我们需要做的就是在文件中写很多行-很难找出要写什么和在哪里写。 事不宜迟,这是我们的目标:
- 在自己的用户名称空间中克隆团队流程。
- 编写团队流程的UID和GID映射文件。
- 在运行命令之前,请重置所有超级用户特权。
只需将CLONE_NEWUSER
标志添加到我们的clone
系统调用中即可实现1
。
int clone_flags = SIGCHLD | CLONE_NEWUTS | CLONE_NEWUSER;
对于2
我们添加了prepare_user_ns
函数,该函数仔细地将一个普通用户1000
表示为root
。
static void prepare_userns(int pid) { char path[100]; char line[100]; int uid = 1000; sprintf(path, "/proc/%d/uid_map", pid); sprintf(line, "0 %d 1\n", uid); write_file(path, line); sprintf(path, "/proc/%d/setgroups", pid); sprintf(line, "deny"); write_file(path, line); sprintf(path, "/proc/%d/gid_map", pid); sprintf(line, "0 %d 1\n", uid); write_file(path, line); }
我们将在发出命令过程信号之前,从父用户名称空间的主过程中调用它。
...
对于第3
步3
我们更新cmd_exec
函数以确保从映射中提供的普通非特权用户1000
执行命令(请记住,团队流程的用户名称空间中的root用户0
是user 1000
):
...
仅此而已! 现在, isolate
在隔离的用户命名空间中启动该过程。
$ ./isolate sh ===========sh============ $ id uid=0(root) gid=0(root)
这篇文章中有很多关于用户名称空间如何工作的细节,但是最后,设置实例相对比较容易。 在下一篇文章中,我们将探讨使用isolate
在我们自己的Mount命名空间中运行命令的可能性(揭示Dockerfile
FROM
语句背后的秘密)。 在那里,我们将需要更多帮助Linux以正确配置实例。