zl程序教程

您现在的位置是:首页 >  其他

当前栏目

千年的铁树开了花。聊聊account

2023-04-18 12:55:14 时间

大家好,我是二哥。今天这篇我们聊聊与容器安全相关的一个基本问题:account。

account真是一个千年铁树,但神奇的是它又总是在开新花。从我使用电脑第一天起就需要记住账号,只有输对了账号密码才可以登录实验室那台Windows玩扫雷游戏。如今大红大紫的零信任里重要的组成部分IAM也在谈账号,零信任需要基于账号来回答一个灵魂拷问:谁,在何时,于何地,访问了何种服务,这样的访问行为是否经过授权?SASE是另外一个大火的概念,你看它的全称:Secure Access Service Edge,再一次提到了access,是的,基于Identity的access。

那作为云原生代表技术的容器,它和账号之间有什么样千丝万缕的关系呢?为人处世,二哥喜欢“雾里看花”,但搞技术的话,二哥却喜欢“用黑色的眼睛看透灵魂”的感觉。所以让我们开始穿透灵魂之旅吧。

1. root container vs. rootless container

上一篇,我介绍了VM和container的最基本区别:虚拟机运行有完整的OS,而容器仅仅是一个被禁锢了的进程而已。既然说容器是一个进程,就避不开下面几个问题:

  • 这个进程是以哪个account运行的?更具体地说,这个account的uid,gid是什么?
  • 我发现容器里的进程是以root运行的,太好了,看起来好像我在容器里可以为所欲为?
  • 高兴早了,经过测试后,好像我连创建一个侦听在80端口的socket都不行。为啥我明明是root,但这个都干不了?
  • 我谷歌了一通后,发现另外一个名词capability,它和account之间的关系是什么?

我们一定听说过Docker最被人诟病的一个安全问题就是:docker daemon(后文简称dockerd)是以root运行的。当我们通过docker CLI发送“运行一个container”的命令到dockerd后,它会创建运行container所需要的各种基本环境,包括创建各类namespace,通过pivot_root将container jail到rootfs,mount一些进程运行所必须的如/proc,/dev,/sys等特殊文件系统。

rootfs中每个文件自身的file permission是dockerd控制不了的,因为那是rootfs自带的。但容器中的进程是以哪个uid运行的呢?如果不做设置,Docker container默认以root运行。是的,你没有看错,就是平时我们说的那个权力无限大的root。业界将这种运行方式称为root container。

史上,Docker发展出了两种用user namespace来隔离container的技术:userns-remap mode 和 rootless mode。 在userns-remap mode下,dockerd依旧以root运行,但对container,正如名字所暗示的,它通过在user namespace里将uid/gid 和subordinate user IDs(位于/etc/subuid)以及subordinate group IDs(位于/etc/subgid)进行remapping来达到权限隔离的目的。你可以通过在启动dockerd时指定--userns-remap来开启这个模式。 rootless mode用于对rootless container的支持。它是Docker在19.03试用,在20.10中才正式试用的模式。在此模式下,dockerd和container均以非root账号运行,安装docker daemon时也不再需要root权限。

有“最小权限原则”做指导,大家都知道Docker这么大条的姿势不对,于是就有不少竞品以安全为亮点出来打擂台了。比如Redhat大力支持的Podman。它一改dockerd运行需要root这个狂野的操作,通过对user namespace的精细操作来创建容器。容器内部的进程以为它自己是以root运行的,但在OS看来,却是一个用普通uid运行的进程。此为rootless container。

我们将在后文看到,涉及到容器访问权限的几个关键参与者,都与user namespace有关。比如capability决定了一个进程能否调用诸如socket()、bind()这样的system call,capability因为user namespace搅和,成功地变得无比难懂和无比行踪诡异。再比如我们都知道file permission的检查机制涉及到的是uid和gid,而它们又因为user namespace的存在出现了山路十八弯的奇景。

user namespace和uid/gid以及capability之间的关系,可以用“一个中心两个基本点”来概括。user namespace处于中心的位置,所以这一篇,让我们先来关注user namespace。接下来的几篇,二哥再聊聊capability以及User/group ID mappings这两个基本点。

2. user namespace

图 1:以user namespace为中心,uid/gid以及capability为两个基本点

按惯例,说明一些晦涩概念的时候,先上图。这次的图我尝试了一下素描的风格,这样看起来比较有亲切感和艺术感。嗯,二哥一直认为程序是一种艺术,因为手工品就是艺术品。

图1展示了以下几个重要的基本概念。先列在这里,下文依次细聊。

  • user namespace可以通过clone(),unshare()这两个system call创建,通过setns()加入一个已有的user namespace。
  • user namespace隔离了与安全强相关的资源,包括:user IDs、 group IDs、root directory、keys、capabilities。
  • 多个user namespace之间可以形成级联关系,也即图中的parent-child关系。图中的initial user namespace也称为root user namespace。
  • 所有其它的如network namespace,user namespace等namespace,它们的所有者都是user namespace。

三个system call

每个进程只能属于一个user namespace。clone(),unshare(),setns()这三个API将会影响进程属于哪个user namespace。

clone()用于在创建新进程的时候,通过在参数中设置CLONE_NEWUSER来创建一个新的user namespace。如下面的代码所示。

int container_pid = clone(container_main, container_stack+STACK_SIZE,CLONE_NEWUSER | SIGCHLD, NULL);

既然是clone,就意味着有parent-child进程之分。在这个示例里,parent和child进程分属不同的user namespace。

unshare()用于给调用这个API的进程创建一个独立的user namespace。代码如下所示:

unshare(CLONE_NEWUSER);

和clone()不同,unshare()的调用使得调用者自己进入了一个全新的user namespace。

setns()则用于将调用这个API的进程塞进一个已经存在了的namespace中。

int fd = open("/proc/4600/ns/user", O_RDONLY);  // 获取进程4600的user namespace文件描述符setns(fd, 0);                                   // 加入进程4600所在的user namespace

和clone()以及unshare()不同,进程通过调用setns()可以使自己加入(join)到一个现有的user namespace中。如在k8s的一个Pod里,其它的container可以通过setns()加入到pause container(image为k8s.gcr.io/pause)所属的user namespace里面去。

namespace之间的关系

如果CLONE_NEWUSER和其它namespace flag一起被设置,内核会保证user namespace被第一个创建出来。

unshare(CLONE_NEWPID|CLONE_NEWIPC|CLONE_NEWUSER|CLONE_NEWUTS|);

所有其它的namespace被称为non-user namespace。当然,这里所说的这些non-user namespace都是由位于user namepace里的进程创建出来的。如图1所示,user namespace是non-user namespace所有者,且这种隶属关系一旦确立后就无法修改。

需要强调的是这些non-user namespace的创建者不需要是同一个进程,它们的创建者可以是多个进程,只要位于同一个user namespace中即可。从Linux 3.8开始,普通进程都可以创建user namespace,但创建其它namespace需要进程在user namespace具有CAP_SYS_ADMIN capability。

多个user namespace之间是有父-子关系的。除initial user namespace外,每个user namesapce一定有一个父user namespace。如果一个进程通过unshare()或clone()创建了一个新的user namespace,那么该进程所在的user namespace即为子user usernamespace的parent,同时该进程的uid为子user namespace的owner。

parent和owner是两个不同的概念,parent-child是血缘关系,而owner则涉及到从属关系。比如你家有两只母子关系的拉布拉多,但小拉布拉多的主人却可能是你的好朋友。

图中parent(initial)表示这个user namespace之于其它user namespace既可能是parent也可能是initial。可以看到以initial user namespace为根,所有的user namespace之间的关系就是一个棵多叉树(非平衡的多路查找树)。

五个被隔离的资源

user namespace所隔离的与安全相关的资源包括:user IDs、 group IDs、root directory、keys、capabilities。二哥确定,这里每一个词你都知道,但问题是:这些资源被隔离到底是指什么意思?比如user ID被隔离了,那它和OS里那个user ID之间的关系是什么呢?

我们知道每种namespace都是用来隔离一部分系统资源的。如network namespace隔离了包括网卡(Network Interface)、回环设备(Loopback Device)、网络栈、IP地址、端口等等在内的网络资源,而uts namespace则隔离了资源hostname和NIS domain。

假如一个user namespace拥有network namespace和uts namespace,当位于这个user namespace中的一个进程想要修改hostname的时候,也就表示它尝试去修改uts namespace所隔离的资源。这个时候capabilities在其中扮演了什么样子的角色呢?

二哥先把为下一篇准备的图放在这里,敬请期待。