来复现一下这次的CVE-2021-3493

漏洞成因

该漏洞是通过创建一个虚拟环境,在虚拟环境当中通过某软件赋予某文件高权限,由于程序检查不严密,该权限逃逸到现实环境中也生效。

前置芝士

overlayfs :虚拟的,堆叠文件系统

capability:权限管理机制

namespace:一种命名空间

overlayfs

能把多个文件夹里的文件合并为到同一个文件夹当中,这么听起来这个文件系统好像挺鸡肋的,但是它支持了一个我们最喜欢用的软件:docker。docker里面分容器和镜像的概念,一个镜像可以派生出多个容器,跟虚拟机差不多,一个镜像可以创建多个虚拟机。容器分公有数据和私有数据,docker比虚拟机优势的一点就是docker中的公有数据所有容器共享,这样就能省磁盘空间,私有数据则可以各个容器独占,保证数据独立。docker的实现机制就是通过 overlayfs 文件系统实现的。

overlayfs 依赖并建立在其它的文件系统之上(例如ext4fs和xfs等等),并不直接参与磁盘空间结构的划分,仅仅将原来底层文件系统中不同的目录进行“合并”,然后向用户呈现。

其中lower dirA / lower dirB目录和upper dir目录为来自底层文件系统的不同目录,用户可以自行指定,内部包含了用户想要合并的文件和目录,merge dir目录为挂载点。当文件系统挂载后,在merge目录下将会同时看到来自各lower和upper目录下的内容,并且用户也无法(无需)感知这些文件分别哪些来自lower dir,哪些来自upper dir,用户看见的只是一个普通的文件系统根目录而已(lower dir可以有多个也可以只有一个)。

overlayfs挂载

挂载一个overlay文件系统,可以通过mount -t overlay -o overlay 来实现。

是最终overlay的挂载点。

其中overlay的options有如下:

  • lower dir=:指定用户需要挂载的lower层目录,lower层支持多个目录,用“:”间隔,优先级依次降低。最多支持500层。
  • upper dir=:指定用户需要挂载的upper层目录,upper层优先级高于所有的lower层目录。
  • work dir=:指定文件系统挂载后用于存放临时和间接文件的工作基础目录。

下面将lower和upper进行overlay,挂载到merge目录,临时workdir为work目录。

1
$mount -t overlay -o lowerdir=lower,upperdir=upper,workdir=work overlay merge

如下同样将lower和upper进行overlay到merge,但是merge为只读属性。

1
$mount -t overlay -o lowerdir=upper:lower overlay merge

在使用如上mount进行overlayfs合并之后,遵循如下规则:

  1. lower dirupper dir两个目录存在同名文件时,lower dir的文件将会被隐藏,用户只能看到upper dir的文件。
  2. lower dir低优先级的同目录同名文件将会被隐藏。
  3. 如果存在同名目录,那么lower dirupper dir目录中的内容将会合并。
  4. 当用户修改merge dir中来自upper dir的数据时,数据将直接写入upper dir中原来目录中,删除文件也同理。
  5. 当用户修改merge dir中来自lower dir的数据时,lower dir中内容均不会发生任何改变。因为lower dir是只读的,用户想修改来自lower dir数据时,overlayfs会首先拷贝一份lower dir中文件副本到upper dir中。后续修改或删除将会在upper dir下的副本中进行,lower dir中原文件将会被隐藏。

docker如何使用overlayfs

在docker当中,我们为了方便理解,假设只有三个目录:upper dir,lower dirmerge dir。我们的镜像处于lower dir当中,初始情况下,我们通过镜像创建出来一个容器,lower dir 中就是一个镜像,upper dir 中为空,我们创建多个容器得到的都是和镜像一模一样的系统。当我尝试查看容器中的某个文件,根据规则1,因为 upper dir 为空,我们看的的内容是 lower dir 中的内容,也就是镜像的内容;当我尝试修改容器中的文件内容时,根据规则5,lower dir 中的内容只读,因此拷贝一份到 upper dir 中,根据规则1,我们之后将只能看到该文件 upper dir 中的内容,修改完成会将结果保存在 upper dir 当中,之后再次修改这个文件,将只在upper dir 当中进行。但是在我们的视角当中,我们跟操作一个完整的操作系统并没有很大的区别。并且多个容器大部分数据是共享的,因此比较节省磁盘空间。

demo

我们新建四个文件夹:upperlower workmerge

1
$mount -t overlay overlay -o lowerdir=./lower,upperdir=./upper,workdir=./work ./merge

mount 命令用于挂载操作,第一个 overlay 指定挂载类型为 overlay 第二个 overlay 指定挂载点,-o 选项指定上层目录,下层目录,工作目录,最后挂载到 merge 目录下。

挂载完成之后我们在 lowerupper 中分别创建 1.txt2.txt。我们使用 ls -lR 来查看目录

我们可以发现, merge 目录中也出现了 1.txt2.txt

我们修改 upperlower 中文件对应的内容,可以发现,merge 目录中也会有相同的改变,这非常符合 overlayfs 的规则。

我们尝试直接在 merge 目录中修改在 upper 目录中出现的文件再观察一下变化。

可以发现我们在 merge 目录中修改 upper 目录中出现的文件,对应也修改了 upper 目录的主体文件。

我们尝试在 merge 目录中修改只在 lower 目录出现的文件再观察一下变化。

我们发现,lower 目录中对应的 1.txt 并没有发生改变,反而是 upper 目录多了一个 1.txt 文件,并且内容与我们填写的一致。

那么这个 1.txt 就可以理解为docker中的镜像,2.txt 就是我容器中不同于镜像的文件。

capability

首先介绍几个概念:uidruideuidsuid

uid(ruid)

标识用户身份, 比如常见的 root 就是0,我们安装完操作系统获得的第一个账号就是1000,当登录完成之后,这个用户的ruid就是确定的了。

euid

euid是用户的有效id,用于系统决定对系统资源的访问权限,通常情况下,euid=ruid。我们都知道:只有进程的创建者和root用户才有权利对该进程进行操作(kill,或者挂起,又或者是 fork)。于是,记录一个进程的创建者(也就是属主)就显得非常必要,进程的 uid 通常就是进程创建者的 uid,若创建者为另一个进程(fork),那么这个进程的 uid 会被继承,除非子进程被设置了 suid

suid

用于对外权限的开放。跟ruideuid是用一个用户绑定不同,它是跟文件而不是跟用户绑定,在运行这个文件时,用户会暂时获得属主的身份。

引入

进程运行之后,会获得和运行者一样的权限,它们同样受到了自身的权限访问控制。事实上这样的管理是比较安全的,我如果想自己无法直接访问这个文件,那么我通过创建进程访问文件同样会没有权限。但是如果这样管理则不能满足一些需要,比如密码文件 /etc/shadow,这个文件的权限是 r--------,属主和数组均为 root,那就意味着,除了 root 用户没有人可以查看或者修改这个文件,但是里面同时也存了我自己的密码,如果我不管怎样都获得不了 root 权限,那意味着我自己都修改不了我自己的密码,那这显然不太合理。于是乎就出现了 suidSet User ID execution),我们都知道在 linux 当中,我们想修改自己的密码是使用 passwd 命令,那我们查看 passwd 的权限发现它被设置了 suid 选项。它允许我在执行这个程序的时候短暂地获得 root 权限,这个进程拥有 root 权限之后,我们就能修改 /etc/shadow 文件,修改完成之后,进程直接退出。

这么一看确实挺方便了,但是会带来很大的安全问题:假设, passwd 文件在编写的时候,存在漏洞,若在执行 passwd 的过程中,能通过漏洞创建一个 shell 进程,那么这个 shell 进程也会是 root 权限,简而言之,**SUID 机制增大了系统的安全攻击面。**

为了对 root 权限进行更细粒度的控制,实现按需授权,Linux 引入了另一种机制叫 capability

capability是什么

Capabilities 机制是在 Linux 内核 2.2 之后引入的一个权限管理机制,原理就是把超级用户 root(uid=0) 的特权划分为不同的功能组,每个功能组都可以独立启用和禁用。其本质上就是将内核调用分门别类,具有相似功能的内核调用被分到同一组中。

这样一来,我权限检查就变成了:如果非 root 用户,那么检查进程是否有对应的操作权限,决定是否可以进行该操作。同样,这个权限可以在执行的时候赋予:根据进程创建者或者 setuid 获得,也可以从父进程继承。假如我给 nginx 可执行文件赋予了 CAP_NET_BIND_SERVICE capabilities ,那么它就能以普通用户的身份运行并监听一个1024以内的端口。

进程的capability

每一个进程,具有 5 个 capabilities 集合,每一个集合使用 64 位掩码来表示,显示为 16 进制格式。这 5 个 capabilities 集合分别是:

  • Permitted
  • Effective
  • Inheritable
  • Bounding
  • Ambient

这5个集合的具体含义如下:

Permitted

在进程执行时,该可执行文件的 Permitted 集合中的 capabilites 自动被加入到进程的 Permitted 集合中。进程可以通过系统调用 capset() 来从 EffectiveInheritable 集合中添加或删除 capability,前提是添加或删除的 capability 必须包含在 Permitted 集合中。

Effective

内核检查线程是否可以进行特权操作时,检查的对象便是 Effective 集合。如之前所说,Permitted 集合定义了上限,线程可以删除 Effective 集合中的某 capability,随后在需要时,再从 Permitted 集合中恢复该 capability,以此达到临时禁用 capability 的功能。

比如我可能一个程序可能中间需要用户来操作,但是呢,我不希望它有过高的权限,那么我在交给用户操作的时候,我把一些权限较高的capability 禁用了,如果用户通过漏洞获取持久权限那将也不能够获取较高的权限。

Inheritable

当执行exec() 系统调用时,能够被新的可执行文件继承的 capabilities,被包含在 Inheritable 集合中。这里需要说明一下,包含在该集合中的 capabilities 并不会自动继承给新的可执行文件,即不会添加到子进程的 Effective 集合或 Inheritable,它只会影响新线程的 Permitted 集合。

Bounding

Bounding 集合,它定义了能被继承的权限的上限,是 Inheritable 集合的超集,如果某个 capability 不在 Bounding 集合中,即使它在 Permitted 集合中,该线程也不能将该 capability 添加到它的 Inheritable 集合中。

Bounding 集合的 capabilities 在执行 fork() 系统调用时会传递给子进程的 Bounding 集合,并且在执行 execve 系统调用后保持不变。

  • 当线程运行时,不能向 Bounding 集合中添加 capabilities
  • 一旦某个 capability 被从 Bounding 集合中删除,便不能再添加回来。
  • 将某个 capabilityBounding 集合中删除后,如果之前 Inherited 集合包含该 capability,将继续保留。但如果后续从 Inheritable 集合中删除了该 capability,便不能再添加回来。
Ambient

Linux 4.3 内核新增了一个 capabilities 集合叫 Ambient ,用来弥补 Inheritable 的不足。Ambient 具有如下特性:

  • PermittedInheritable 未设置的 capabilitiesAmbient 也不能设置。
  • PermittedInheritable 关闭某权限后,Ambient 也随之关闭对应权限。这样就确保了降低权限后子进程也会降低权限。
  • 非特权用户如果在 Permitted 集合中有一个 capability,那么可以添加到 Ambient 集合中,这样它的子进程便可以在 AmbientPermittedEffective 集合中获取这个 capability

文件的capability

文件的 capabilities 被保存在文件的扩展属性中。如果想修改这些属性,需要具有 CAP_SETFCAPcapability。文件与进程的 capabilities 共同决定了通过 execve() 运行该文件后的线程的 capabilities

文件的 capabilities 功能,需要文件系统的支持。如果文件系统使用了 nouuid 选项进行挂载,那么文件的 capabilities 将会被忽略。

类似于进程的 capabilities,文件的 capabilities 包含了 3 个集合:

  • Permitted
  • Inheritable
  • Effective

这3个集合的具体含义如下:

Permitted

这个集合中包含的 capabilities,在文件被执行时,会与进程的 Bounding 集合计算交集,然后添加到该进程的 Permitted 集合中。

Inheritable

这个集合与线程的 Inheritable 集合的交集,会被添加到执行完 execve() 后的线程的 Permitted 集合中。

Effective

这不是一个集合,仅仅是一个标志位。如果设置开启,那么在执行完 execve() 后,线程 Permitted 集合中的 capabilities 会自动添加到它的 Effective 集合中。对于一些旧的可执行文件,由于其不会调用 capabilities 相关函数设置自身的 Effective 集合,所以可以将可执行文件的 Effective bit 开启,从而可以将 Permitted 集合中的 capabilities 自动添加到 Effective 集合中。

常见的capability

共40个

capability 名称 描述
CAP_AUDIT_CONTROL 启用和禁用内核审计;改变审计过滤规则;检索审计状态和过滤规则
CAP_AUDIT_READ 允许通过 multicast netlink 套接字读取审计日志
CAP_AUDIT_WRITE 将记录写入内核审计日志
CAP_BLOCK_SUSPEND 使用可以阻止系统挂起的特性
CAP_CHOWN 修改文件所有者的权限
CAP_DAC_OVERRIDE 忽略文件的 DAC 访问限制
CAP_DAC_READ_SEARCH 忽略文件读及目录搜索的 DAC 访问限制
CAP_FOWNER 忽略文件属主 ID 必须和进程用户 ID 相匹配的限制
CAP_FSETID 允许设置文件的 setuid 位
CAP_IPC_LOCK 允许锁定共享内存片段
CAP_IPC_OWNER 忽略 IPC 所有权检查
CAP_KILL 允许对不属于自己的进程发送信号
CAP_LEASE 允许修改文件锁的 FL_LEASE 标志
CAP_LINUX_IMMUTABLE 允许修改文件的 IMMUTABLE 和 APPEND 属性标志
CAP_MAC_ADMIN 允许 MAC 配置或状态更改
CAP_MAC_OVERRIDE 忽略文件的 DAC 访问限制
CAP_MKNOD 允许使用 mknod() 系统调用
CAP_NET_ADMIN 允许执行网络管理任务
CAP_NET_BIND_SERVICE 允许绑定到小于 1024 的端口
CAP_NET_BROADCAST 允许网络广播和多播访问
CAP_NET_RAW 允许使用原始套接字
CAP_SETGID 允许改变进程的 GID
CAP_SETFCAP 允许为文件设置任意的 capabilities
CAP_SETPCAP 参考 capabilities man page
CAP_SETUID 允许改变进程的 UID
CAP_SYS_ADMIN 允许执行系统管理任务,如加载或卸载文件系统、设置磁盘配额等
CAP_SYS_BOOT 允许重新启动系统
CAP_SYS_CHROOT 允许使用 chroot() 系统调用
CAP_SYS_MODULE 允许插入和删除内核模块
CAP_SYS_NICE 允许提升优先级及设置其他进程的优先级
CAP_SYS_PACCT 允许执行进程的 BSD 式审计
CAP_SYS_PTRACE 允许跟踪任何进程
CAP_SYS_RAWIO 允许直接访问 /devport、/dev/mem、/dev/kmem 及原始块设备
CAP_SYS_RESOURCE 忽略资源限制
CAP_SYS_TIME 允许改变系统时钟
CAP_SYS_TTY_CONFIG 允许配置 TTY 设备
CAP_SYSLOG 允许使用 syslog() 系统调用
CAP_WAKE_ALARM 允许触发一些能唤醒系统的东西(比如 CLOCK_BOOTTIME_ALARM 计时器)

比如我们熟知的 ping 命令,它所用到的底层是使用 socket 实现的,而 socketroot 用户才有权限使用的。在 Ubuntu 18.04LTS 的发行版当中,我们看看它是怎么解决这个权限问题的。

它设置了 s 权限位,意味着我运行 ping 的时候, ping 这个 process uid0,也就是 root 用户。

若我取消设置它的 s 权限位,它将不再具有 ping 的功能。

原因就如上所示,底层的 socket 并不允许普通用户运行。

而当我把自己权限提升之后又能够使用 ping 命令了,是因为 root 用户执行读写和某些底层操作时不检查权限。

在这里我们只需要使用 setcap 命令将 ping 加上 socket 权限就可以让我们运行的时候获得 socket 权限,正常使用 ping 命令,这么做的好处就是假如我的 ping 命令有漏洞存在,那么当别人借着 ping 命令来提权我的计算机时会发现它获得的 shell 只拥有 socket 这么一个特权操作,其它的操作与普通用户并没有区别,这样极大地降低了安全风险,而如果我使用 s 权限位,那么别人通过这个获取漏洞之后将能直接获得 root 权限能操作计算机的一切资源。

在添加完权限之后,我们发现又可以使用 ping 命令了,这是因为我们通过 setcap/bin/ping 重新拥有了 socket 权限。

在这个地方我们对 capability 也不再深入下去了。

namespace

引用一下 wikinamespace 的定义

Namespaces are a feature of the Linux kernel that partitions kernel resources such that one set of processes sees one set of resources while another set of processes sees a different set of resources. The feature works by having the same namespace for a set of resources and processes, but those namespaces refer to distinct resources.

直观翻译就是

namespace 是 Linux 内核的一项特性,它可以对内核资源进行分区,使得一组进程可以看到一组资源;而另一组进程可以看到另一组不同的资源。该功能的原理是为一组资源和进程使用相同的 namespace,但是这些 namespace 实际上引用的是不同的资源。

简单来说 namespace 是由 Linux 内核提供的,用于进程间资源隔离的一种技术。将全局的系统资源包装在一个抽象里,让进程(看起来)拥有独立的全局资源实例。同时 Linux 也默认提供了多种 namespace,用于对多种不同资源进行隔离。

Linux2.4 版本加入了 namespace 机制到 3.8 版本实现了 User namespace

Cgroup namespace 是进程的 cgroups 的虚拟化视图,通过 /proc/[pid]/cgroup/proc/[pid]/mountinfo 展示。

namespace名称 系统调用参数 控制内容 内核版本
UTS CLONE_NEWUTS 主机名和域名 2.6.19
IPC CLONE_NEWIPC 信号量,消息队列,共享内存 2.6.19
PID CLONE_NEWPID Process IDs进程号 2.6.24
Network CLONE_NEWNET 网络设备,协议栈,端口等等 2.6.29
Cgroup CLONE_NEWCGROUP Cgroup root directory cgroup 根目录 2.6.29
Mount CLONE_NEWNS Mount points挂载点 2.4.19
User CLONE_NEWUSER 用户和组 ID 3.8

有了namespace之后,PID,IPC,Network等系统资源不再是全局性的,而是属于特定的Namespace。每个Namespace里面的资源对其他Namespace都是透明的。要创建新的Namespace,只需要在调用clone时指定相应的flag。

以上为自己搜集的资料整理,以下为自己个人解读。

电脑开机的时候,系统会创建7个 initnamespace,一个进程只能切必须属于七个特定不同的 namespace,那么这个就是我们默认的 namespace。使用 ls -l /proc/$$/ns 可以查看本进程的 namespace 在这里 $$ 变量表示自己的进程号。

在这之前我一直有一个疑问,就是为什么我普通用户 -map-root-user 会导致我没有 root 的操作权限而 root 用户创建的 namespace 即使是普通用户也有操作权限。比如如下两个例子。

unshare 命令用于取消子进程的共享 namespace,通过--user --map-root-user 选项可以新建一个 user namespace 并使新建进程的用户为 root 用户。

此时出现了 root 用户无法操作 /etc/shadow 的场面,但是我们无论是 id 还是 whoami 看上去都跟真的 root 一样,确没有操作权限,确实也是比较奇怪的。但是,又合情合理,因为我普通用户我不通过 su 或者是 sudo 命令去正常提权那都是利用漏洞。

然后再来看另一个例子

虽然看起来我是普通用户,但是实际上我有 root的权限。

因此我在这里一直不理解 namespace 的组织形式,直到我看到一篇博客上面画着树状图,我才猛然顿悟。

namespace 是树状图的一种形式,然后文件系统中在标记属主的时候会标记一个 namespace 字段,标识由哪一个 namespace 的用户创建的,然后再检查权限的时候若当前用户不属于当前 namespace 那么就会向上寻找,直到找到对应的 namespace,然后检查是谁创建的。然后对应的权限就是那个 namespace 的创建者的。如果是这样的话,那么就能解释通了,我之前疑惑的点不在于为什么我没有操作权限而是它怎么判断的我没有操作权限,因为没有操作权限属于正常现象,如果我的想法不对也请师傅们指正,这只是一个我认为比较合理能解释得通的解释。

漏洞利用步骤

我们先创建好 overlayfs 的那几个文件夹,准备挂载,然后在其中的 upper 目录中写上我们的 exp 并编译好。

1
2
3
4
5
6
7
8
9
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<fcntl.h>
int main(){
setuid(0);
setgid(0);
execve("/bin/bash",0,0);
}

exp 非常简单,就是 setuidsetgid0,也就是 root

然后我们再创建一个 user namepsacemount namespace

./merge 当中,我们为刚刚编译的 exp 设置 setuid 的权限。

然后再开一个终端,我们发现 upper 目录中的 exp 同样具有了 setuid 的权限,说明我们的权限逃逸成功了。

我们运行 exp 成功获得了真实的 root 权限。

内核代码分析

namespace结构

首先我第一步呢,就是去求证了一下我上面的猜想是否正确,在 github 上找到对应的 namespace 的代码,这里不用管什么版本了,大体变化是不会很大的,我们先来看 user_namespace 结构体的定义:

我们很清楚地能看到里面的一个定义:user_namespace *parent,这里也能说明,它是存在父子关系的,和我们之前的猜测大体是一样的,并且会标注 ownergroup,这里应该是创建这个 namespace 的属主和属组。我们同时也看到还有一个 level 变量,这里我大概猜测一下,是 namespace 的深度,也就是往后迭代了多少次,这个学过算法设计应该还是好理解的,我在建立树的时候,我们一般也会标记深度方便去查找,我猜测在这里我们需要的就是进行权限检查,如果 namespace 双方为父子关系,那么我们直接看父亲的权限即可,然而实际情况比较复杂,首先谁是父亲谁是儿子就很难判断,所以我跟上深度能很容易知道谁是父亲谁是儿子,如果不是父子关系,那么我们可以查 LCA 找到最近公共祖先,看看两个 namespace 的创建者权限如何。

我们找到对应的 user_namespace.c 文件,看看创建一个 namespace 的时候发生了什么。这里推荐给大家读内核代码的一些思路:大部分的代码都会写一个完全不带安全检查的函数,比如我创建一个 namespace,那么我们一定能找到只实现创建 namespace 的一个函数,这个函数通常会在进行了一系列安全检查之后才允许被调用,包括我们平时做一些网站开发之类的也一样,我们会写一个定向只做某些事情的接口,但是接口不会直接被调用而是会进行一系列安全检查,诸如非法数据判断和权限问题,我们默认传进去的参数都是合法的,它常规的三部曲就是:检查,执行,善后。那么言归正传,看到代码

part1

参数应该是一个父进程,因为它在第一行写了 parent_ns=new->user_nsparent_ns 我们很容易知道是父 namespace,而这里传进去的是一个 cred 结构体,结构体中有一个 user_ns 应该是 user_namespace。下面两行设置了 euidegid,那么很清晰了,ownergroup 就是创建这个 namespace 的属主和属组。

下面有一个如果父进程的 user namepsace 层数超过 32 那么直接 goto fail,那就是说这里不允许这棵树创建超过32的深度。

后面执行一个 inc_user_namespaces 函数并判断是否执行成功,我们往下深挖一下代码,这里因为代码比较短,就贴这里了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct ucounts *inc_ucount(struct user_namespace *ns, kuid_t uid,
enum ucount_type type)//in kernel/ucount.c
{
struct ucounts *ucounts, *iter, *bad;
struct user_namespace *tns;
ucounts = alloc_ucounts(ns, uid);
for (iter = ucounts; iter; iter = tns->ucounts) {
long max;
tns = iter->ns;
max = READ_ONCE(tns->ucount_max[type]);
if (!atomic_long_inc_below(&iter->ucount[type], max))
goto fail;
}
return ucounts;
fail:
bad = iter;
for (iter = ucounts; iter != bad; iter = iter->ns->ucounts)
atomic_long_dec(&iter->ucount[type]);

put_ucounts(ucounts);
return NULL;
}
static struct ucounts *inc_user_namespaces(struct user_namespace *ns, kuid_t uid)
{
return inc_ucount(ns, uid, UCOUNT_USER_NAMESPACES);
}

不难看出来,这里应该只是分配一个 ucounts 结构体的内存,我猜测 ucounts 应该是 namespace 的衍生类,因为我们看到 inc_user_namespace 增加 user namespace 实际就是调用增加 ucounts 的一个方法,并且估计其它的 namespace 也需要通过这个调用来分配内存,并且我们观察枚举类也能发现有我们所有 namespace 的一个定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
enum ucount_type {
UCOUNT_USER_NAMESPACES,
UCOUNT_PID_NAMESPACES,
UCOUNT_UTS_NAMESPACES,
UCOUNT_IPC_NAMESPACES,
UCOUNT_NET_NAMESPACES,
UCOUNT_MNT_NAMESPACES,
UCOUNT_CGROUP_NAMESPACES,
UCOUNT_TIME_NAMESPACES,
#ifdef CONFIG_INOTIFY_USER
UCOUNT_INOTIFY_INSTANCES,
UCOUNT_INOTIFY_WATCHES,
#endif
#ifdef CONFIG_FANOTIFY
UCOUNT_FANOTIFY_GROUPS,
UCOUNT_FANOTIFY_MARKS,
#endif
UCOUNT_RLIMIT_NPROC,
UCOUNT_RLIMIT_MSGQUEUE,
UCOUNT_RLIMIT_SIGPENDING,
UCOUNT_RLIMIT_MEMLOCK,
UCOUNT_COUNTS,
};//in user_namespace.h

但是去看了 ucounts 结构体的定义发现里面就定义了一个 user_namespace 的指针和一个链表,队列,以及标识了一个 uid。这个 ucounts 可能只是一个用于做某些标记的东西,我们暂且不管把先。

后面有一个 current_chrooted 函数,我们同样看看它的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool current_chrooted(void)
{
/* Does the current process have a non-standard root */
struct path ns_root;
struct path fs_root;
bool chrooted;

/* Find the namespace root */
ns_root.mnt = &current->nsproxy->mnt_ns->root->mnt;
ns_root.dentry = ns_root.mnt->mnt_root;
path_get(&ns_root);
while (d_mountpoint(ns_root.dentry) && follow_down_one(&ns_root))
;

get_fs_root(current->fs, &fs_root);

chrooted = !path_equal(&fs_root, &ns_root);

path_put(&fs_root);
path_put(&ns_root);

return chrooted;
}

根据注释以及关键的语句 chrooted = !path_equal(&fs_root, &ns_root); 我们大概也能猜测出来,它应该就是判断 namespace 的根目录是否于文件系统一致,一致才允许你创建这个 namespace

part2

然后在这里需要判断一下属主和数组是否映映射到了父 namespace 上。

后面的话基本上和我们复现的漏洞无关了,我们这么来了解了一下构成形式,namespace 确实是树状图形式,而且下面我们很清楚地能看到 ns->level=parent_ns->level+1

权限设置

这里我们来查看对应版本的代码,链接贴上

先来看到 416 行,对 setxattr 函数进行分析,这里解释一下 setxattr 的一个名字由来(自己意淫的,非官方说法,经供参考),set 就是设置, x 其实它可以代表 extended 扩展的,attr 就是属性了,连起来就是设置扩展属性,这里的扩展属性就是指 capability。其实我感觉吧, x 好像能表示一切 ex 开头的单词,比如我们经常见到的三个权限位,用 x 标识 execute

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/*
* Extended attribute SET operations
*/
static long
setxattr(struct dentry *d, const char __user *name, const void __user *value,
size_t size, int flags)
{
int error;
void *kvalue = NULL;
char kname[XATTR_NAME_MAX + 1];

if (flags & ~(XATTR_CREATE|XATTR_REPLACE))
return -EINVAL;

error = strncpy_from_user(kname, name, sizeof(kname));
if (error == 0 || error == sizeof(kname))
error = -ERANGE;
if (error < 0)
return error;

if (size) {
if (size > XATTR_SIZE_MAX)
return -E2BIG;
kvalue = kvmalloc(size, GFP_KERNEL);
if (!kvalue)
return -ENOMEM;
if (copy_from_user(kvalue, value, size)) {
error = -EFAULT;
goto out;
}
if ((strcmp(kname, XATTR_NAME_POSIX_ACL_ACCESS) == 0) ||
(strcmp(kname, XATTR_NAME_POSIX_ACL_DEFAULT) == 0))
posix_acl_fix_xattr_from_user(kvalue, size);
else if (strcmp(kname, XATTR_NAME_CAPS) == 0) {
error = cap_convert_nscap(d, &kvalue, size);
if (error < 0)
goto out;
size = error;
}
}

error = vfs_setxattr(d, kname, kvalue, size, flags);
out:
kvfree(kvalue);

return error;
}

乍一看逻辑有点小复杂,主要是很多的宏定义和很多没见过的函数,也不太能够望文生义,于是我找到了Linux手册对于 setxattr 的说明:

1
$man 2 setxattr

setxattr() sets the value of the extended attribute identified by name and associated with the given path in the filesystem. The size argument specifies the size (in bytes) of value; a zero-length value is permitted.

貌似介绍的也比较笼统,还是靠自己试试吧。

part1

flags & ~(XATTR_CREATE|XATTR_REPLACE),这其实是很常见的掩码写法,差不多意思就是 flag 标志只在 createreplace 位上设置,如果设置了其它位则退出。

strncpy_from_user(kname, name, sizeof(kname)) 对传入的 name 参数进行拷贝,拷贝到了 kname 也就是内核栈当中。第一个判断应该是判断空字符串和防止溢出,因为如果 sizeof(kname) 字节都被占满了那么这个字符串还会跟下面连续的字符串相连,造成一些错误。

这里出现了我的知识盲区,这里也来解释一下,在内核里面,看见全大写字母的变量基本都不是变量,都是宏定义。而我实在不知道字符串常量有直接拼接的做法:

1
2
3
4
5
6
7
8
9
10
#include<stdio.h>
#define s1 "123"
#define s2 "456"
#define s3 s1 s2
int main(){
puts(s3);
}
/*output:
123456
*/

我还以为是宏定义的特殊写法呢,这里mark一下。

这里给出这些宏定义的最终结果

1
2
3
#define XATTR_NAME_POSIX_ACL_ACCESS "system.posix_acl_access"
#define XATTR_NAME_POSIX_ACL_DEFAULT "system.posix_acl_default"
#define XATTR_NAME_CAPS "security.capability"

那么第一个 if 我们 duck 不必关心,我们主要关心第二个跟 capability 相关的分支。

我们具体逻辑也不进一步分析了,我们就看看这个函数给的注释:

This function will then take care to map the inode according to @mnt_userns before checking permissions.

我们也不难看出来,在检查权限之前就是会对文件系统和 user namespace 进行映射,这个函数叫 cap_convert_nscap,那其实就是对 capabilityuserns 进行一个映射了(应该是这个意思。

就是可能,它会在不同的 namespace 上嘛,比如这个文件夹是其中一个 user namespace 创建的,不可能我换一个 namespace 去检测权限也是相同的手法,肯定是要把权限映射一下的,映射到同一个 namespace 上才能进行权限检查。

经过一系列检查之后,走到了 vfs_setxattr,也就是虚拟文件系统的扩展属性设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int
vfs_setxattr(struct dentry *dentry, const char *name, const void *value,
size_t size, int flags)
{
struct inode *inode = dentry->d_inode;
int error;

error = xattr_permission(inode, name, MAY_WRITE);
if (error)
return error;

inode_lock(inode);
error = security_inode_setxattr(dentry, name, value, size, flags);
if (error)
goto out;

error = __vfs_setxattr_noperm(dentry, name, value, size, flags);

out:
inode_unlock(inode);
return error;
}

第一条不深入挖下去了,就是判断有没有写的权限,然后上锁,防止发生竞争,然后进行 security_inode_setxattr 函数进行进一步的权限校验,最后执行 __vfs_setxattr_noperm 函数,它的后缀 noperm 就是还没有进行权限检查的 __vfs_setxattr 与我们之前说的分析思路是一致的。在这个函数里面有一个大 if 判断文件是否有权限,最终调用一个 __vfs_setxattr 去真实设置 xattr

因此我们可以发现,在调用设置文件扩展属性时候,会有一系列的检查,比如你是否是 root,你对文件操作是否有权限之类的,因为即使你是 root 也得看看那个文件系统的权限是否归你所有,有可能是其它 user_namespace 的用户创建的,那么你有可能也是没有权限的,这个地方是不会出现越权行为的。

然后我们看到 overlayfs 的设置文件扩展属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int ovl_xattr_set(struct dentry *dentry, struct inode *inode, const char *name,
const void *value, size_t size, int flags)
{
int err;
struct dentry *upperdentry = ovl_i_dentry_upper(inode);
struct dentry *realdentry = upperdentry ?: ovl_dentry_lower(dentry);
const struct cred *old_cred;

err = ovl_want_write(dentry);
if (err)
goto out;

if (!value && !upperdentry) {
err = vfs_getxattr(realdentry, name, NULL, 0);
if (err < 0)
goto out_drop_write;
}

if (!upperdentry) {
err = ovl_copy_up(dentry);
if (err)
goto out_drop_write;

realdentry = ovl_dentry_upper(dentry);
}

old_cred = ovl_override_creds(dentry->d_sb);
if (value)
err = vfs_setxattr(realdentry, name, value, size, flags);
else {
WARN_ON(flags != XATTR_REPLACE);
err = vfs_removexattr(realdentry, name);
}
revert_creds(old_cred);

out_drop_write:
ovl_drop_write(dentry);
out:
return err;
}///fs/overlayfs/inode.c

其它的我们不看,我们解释比较容易理解的。

1
2
3
4
5
6
7
if (!upperdentry) {
err = ovl_copy_up(dentry);
if (err)
goto out_drop_write;

realdentry = ovl_dentry_upper(dentry);
}

这个地方其实就是我们说的,如果文件在 lower 当中,那么拷贝一份到 upper 当中去,然后把新的文件节点指向 upper

1
2
3
4
5
6
if (value)
err = vfs_setxattr(realdentry, name, value, size, flags);
else {
WARN_ON(flags != XATTR_REPLACE);
err = vfs_removexattr(realdentry, name);
}

然后直接调用 vfs_setxattr 函数了,我们知道在 vfs_setxattr 之前有一个入口,也就是 setxattr 这个地方会有一个调用,调用 cap_convert_nscap 函数去检查 user namespace 是否一致。而这里直接调用 vfs_setxattr 这个函数就绕过了 namespace 的检查。

所以我们之前的利用步骤就是先创建了一个 namespace,然后挂载了一个 overlayfs,在 merge 文件夹中是我们创建的 fs namespace,因此我们创建的 root 用户对这个 fs namespace 有设置 capability 的操作权限,这个其实没有问题,因为我即使运行了这个 a.out 也不会有真正的 root 权限,有的只是我们创建的这个 user namespaceroot 权限,而这个权限实际是 inituser 创建的,因此实际操作还是获得不了真实的 root,但是问题就是 overlayfs 的这个特性:我们修改了 merge 中的 a.out 会反向修改之前在 upper 中的 a.out,因此我们给它 setuid 的权限导致了 upper/a.out 也有 setuid 的权限,而 upper/a.out 是在实际的 init user namespace 创建的,因此它有了 init user namespacesetuid 权限。我们运行 upper/a.out 直接获取真实 root 权限。

修复方案

这个其实我个人认为应该是 overlayfs 的问题,在修改的时候应该检查 user namespace 才对,但是它修改了 xattr.c 中的 vfs_setxattr 函数,这个函数重新用了一个 cap_convert_nscap 函数检查 namespace

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//https://elixir.bootlin.com/linux/v5.19.6/source/fs/xattr.c
int
vfs_setxattr(struct user_namespace *mnt_userns, struct dentry *dentry,
const char *name, const void *value, size_t size, int flags)
{
struct inode *inode = dentry->d_inode;
struct inode *delegated_inode = NULL;
const void *orig_value = value;
int error;

if (size && strcmp(name, XATTR_NAME_CAPS) == 0) {
error = cap_convert_nscap(mnt_userns, dentry, &value, size);//这里是新增的namespace检查
if (error < 0)
return error;
size = error;
}

retry_deleg:
inode_lock(inode);
error = __vfs_setxattr_locked(mnt_userns, dentry, name, value, size,
flags, &delegated_inode);
inode_unlock(inode);

if (delegated_inode) {
error = break_deleg_wait(&delegated_inode);
if (!error)
goto retry_deleg;
}
if (value != orig_value)
kfree(value);

return error;
}

当然这样也能完成漏洞的修复,不过我认为在其它文件系统中这里检查了两次就比较没有必要,也是比较困惑的点吧,也可能防止其它文件系统调用这个函数也没有检查,大概是这样的。