来复现一下这次的CVE-2021-3493
(update in 2022/09/02:完成源码分析)
漏洞成因
该漏洞是通过创建一个虚拟环境,在虚拟环境当中通过某软件赋予某文件高权限,由于程序检查不严密,该权限逃逸到现实环境中也生效。
前置芝士
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 <options> overlay <mount point>来实现。
<mount point>是最终overlay的挂载点。
其中overlay的options有如下:
- lower dir=<dir>:指定用户需要挂载的lower层目录,lower层支持多个目录,用“:”间隔,优先级依次降低。最多支持500层。
- upper dir=<dir>:指定用户需要挂载的upper层目录,upper层优先级高于所有的lower层目录。
- work dir=<dir>:指定文件系统挂载后用于存放临时和间接文件的工作基础目录。
下面将lower和upper进行overlay,挂载到merge目录,临时workdir为work目录。
$mount -t overlay -o lowerdir=lower,upperdir=upper,workdir=work overlay merge
如下同样将lower和upper进行overlay到merge,但是merge为只读属性。
$mount -t overlay -o lowerdir=upper:lower overlay merge
在使用如上mount进行overlayfs
合并之后,遵循如下规则:
lower dir
和upper dir
两个目录存在同名文件时,lower dir
的文件将会被隐藏,用户只能看到upper dir
的文件。
lower dir
低优先级的同目录同名文件将会被隐藏。
- 如果存在同名目录,那么
lower dir
和upper dir
目录中的内容将会合并。
- 当用户修改
merge dir
中来自upper dir
的数据时,数据将直接写入upper dir
中原来目录中,删除文件也同理。
- 当用户修改
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 dir
和merge 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
我们新建四个文件夹:upper
,lower
,work
和 merge
。
$mount -t overlay overlay -o lowerdir=./lower,upperdir=./upper,workdir=./work ./merge
mount
命令用于挂载操作,第一个 overlay
指定挂载类型为 overlay
第二个 overlay
指定挂载点,-o
选项指定上层目录,下层目录,工作目录,最后挂载到 merge
目录下。
挂载完成之后我们在 lower
和 upper
中分别创建 1.txt
和 2.txt
。我们使用 ls -lR
来查看目录
我们可以发现, merge
目录中也出现了 1.txt
和 2.txt
。
我们修改 upper
和 lower
中文件对应的内容,可以发现,merge
目录中也会有相同的改变,这非常符合 overlayfs
的规则。
我们尝试直接在 merge
目录中修改在 upper
目录中出现的文件再观察一下变化。
可以发现我们在 merge
目录中修改 upper
目录中出现的文件,对应也修改了 upper
目录的主体文件。
我们尝试在 merge
目录中修改只在 lower
目录出现的文件再观察一下变化。
我们发现,lower
目录中对应的 1.txt
并没有发生改变,反而是 upper
目录多了一个 1.txt
文件,并且内容与我们填写的一致。
那么这个 1.txt
就可以理解为docker中的镜像,2.txt
就是我容器中不同于镜像的文件。
capability
首先介绍几个概念:uid
,ruid
,euid
,suid
。
uid(ruid)
标识用户身份, 比如常见的 root
就是0,我们安装完操作系统获得的第一个账号就是1000,当登录完成之后,这个用户的ruid就是确定的了。
euid
euid是用户的有效id,用于系统决定对系统资源的访问权限,通常情况下,euid=ruid
。我们都知道:只有进程的创建者和root用户才有权利对该进程进行操作(kill
,或者挂起,又或者是 fork
)。于是,记录一个进程的创建者(也就是属主)就显得非常必要,进程的 uid
通常就是进程创建者的 uid
,若创建者为另一个进程(fork
),那么这个进程的 uid
会被继承,除非子进程被设置了 suid
。
suid
用于对外权限的开放。跟ruid
及euid
是用一个用户绑定不同,它是跟文件而不是跟用户绑定,在运行这个文件时,用户会暂时获得属主的身份。
引入
进程运行之后,会获得和运行者一样的权限,它们同样受到了自身的权限访问控制。事实上这样的管理是比较安全的,我如果想自己无法直接访问这个文件,那么我通过创建进程访问文件同样会没有权限。但是如果这样管理则不能满足一些需要,比如密码文件 /etc/shadow
,这个文件的权限是 r--------
,属主和数组均为 root
,那就意味着,除了 root
用户没有人可以查看或者修改这个文件,但是里面同时也存了我自己的密码,如果我不管怎样都获得不了 root
权限,那意味着我自己都修改不了我自己的密码,那这显然不太合理。于是乎就出现了 suid
(Set 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()
来从 Effective
或 Inheritable
集合中添加或删除 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
集合中删除,便不能再添加回来。
- 将某个
capability
从 Bounding
集合中删除后,如果之前 Inherited
集合包含该 capability
,将继续保留。但如果后续从 Inheritable
集合中删除了该 capability
,便不能再添加回来。
Ambient
Linux 4.3
内核新增了一个 capabilities 集合叫 Ambient
,用来弥补 Inheritable
的不足。Ambient
具有如下特性:
Permitted
和 Inheritable
未设置的 capabilities
,Ambient
也不能设置。
- 当
Permitted
和 Inheritable
关闭某权限后,Ambient
也随之关闭对应权限。这样就确保了降低权限后子进程也会降低权限。
- 非特权用户如果在
Permitted
集合中有一个 capability
,那么可以添加到 Ambient
集合中,这样它的子进程便可以在 Ambient
、Permitted
和 Effective
集合中获取这个 capability
。
文件的capability
文件的 capabilities
被保存在文件的扩展属性中。如果想修改这些属性,需要具有 CAP_SETFCAP
的 capability
。文件与进程的 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
实现的,而 socket
是 root
用户才有权限使用的。在 Ubuntu 18.04LTS
的发行版当中,我们看看它是怎么解决这个权限问题的。
它设置了 s
权限位,意味着我运行 ping
的时候, ping
这个 process
uid
为 0
,也就是 root
用户。
若我取消设置它的 s
权限位,它将不再具有 ping
的功能。
原因就如上所示,底层的 socket
并不允许普通用户运行。
而当我把自己权限提升之后又能够使用 ping
命令了,是因为 root
用户执行读写和某些底层操作时不检查权限。
在这里我们只需要使用 setcap
命令将 ping
加上 socket
权限就可以让我们运行的时候获得 socket
权限,正常使用 ping
命令,这么做的好处就是假如我的 ping
命令有漏洞存在,那么当别人借着 ping
命令来提权我的计算机时会发现它获得的 shell
只拥有 socket
这么一个特权操作,其它的操作与普通用户并没有区别,这样极大地降低了安全风险,而如果我使用 s
权限位,那么别人通过这个获取漏洞之后将能直接获得 root
权限能操作计算机的一切资源。
在添加完权限之后,我们发现又可以使用 ping
命令了,这是因为我们通过 setcap
让 /bin/ping
重新拥有了 socket
权限。
在这个地方我们对 capability
也不再深入下去了。
namespace
引用一下 wiki
对 namespace
的定义
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
,用于对多种不同资源进行隔离。
Linux
从 2.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个 init
的 namespace
,一个进程只能切必须属于七个特定不同的 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
并编译好。
#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
非常简单,就是 setuid
和 setgid
为 0
,也就是 root
。
然后我们再创建一个 user namepsace
和 mount namespace
。
在 ./merge
当中,我们为刚刚编译的 exp
设置 setuid
的权限。
然后再开一个终端,我们发现 upper
目录中的 exp
同样具有了 setuid
的权限,说明我们的权限逃逸成功了。
我们运行 exp
成功获得了真实的 root
权限。
内核代码分析
namespace结构
首先我第一步呢,就是去求证了一下我上面的猜想是否正确,在 github 上找到对应的 namespace
的代码,这里不用管什么版本了,大体变化是不会很大的,我们先来看 user_namespace
结构体的定义:
我们很清楚地能看到里面的一个定义:user_namespace *parent
,这里也能说明,它是存在父子关系的,和我们之前的猜测大体是一样的,并且会标注 owner
和 group
,这里应该是创建这个 namespace
的属主和属组。我们同时也看到还有一个 level
变量,这里我大概猜测一下,是 namespace
的深度,也就是往后迭代了多少次,这个学过算法设计应该还是好理解的,我在建立树的时候,我们一般也会标记深度方便去查找,我猜测在这里我们需要的就是进行权限检查,如果 namespace
双方为父子关系,那么我们直接看父亲的权限即可,然而实际情况比较复杂,首先谁是父亲谁是儿子就很难判断,所以我跟上深度能很容易知道谁是父亲谁是儿子,如果不是父子关系,那么我们可以查 LCA
找到最近公共祖先,看看两个 namespace
的创建者权限如何。
我们找到对应的 user_namespace.c
文件,看看创建一个 namespace
的时候发生了什么。这里推荐给大家读内核代码的一些思路:大部分的代码都会写一个完全不带安全检查的函数,比如我创建一个 namespace
,那么我们一定能找到只实现创建 namespace
的一个函数,这个函数通常会在进行了一系列安全检查之后才允许被调用,包括我们平时做一些网站开发之类的也一样,我们会写一个定向只做某些事情的接口,但是接口不会直接被调用而是会进行一系列安全检查,诸如非法数据判断和权限问题,我们默认传进去的参数都是合法的,它常规的三部曲就是:检查,执行,善后。那么言归正传,看到代码
part1
参数应该是一个父进程,因为它在第一行写了 parent_ns=new->user_ns
,parent_ns
我们很容易知道是父 namespace
,而这里传进去的是一个 cred
结构体,结构体中有一个 user_ns
应该是 user_namespace
。下面两行设置了 euid
和 egid
,那么很清晰了,owner
和 group
就是创建这个 namespace
的属主和属组。
下面有一个如果父进程的 user namepsace
层数超过 32
那么直接 goto fail
,那就是说这里不允许这棵树创建超过32的深度。
后面执行一个 inc_user_namespaces
函数并判断是否执行成功,我们往下深挖一下代码,这里因为代码比较短,就贴这里了。
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
的一个定义。
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
函数,我们同样看看它的定义:
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 = ¤t->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
。
/*
* 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
的说明:
$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
标志只在 create
和 replace
位上设置,如果设置了其它位则退出。
strncpy_from_user(kname, name, sizeof(kname))
对传入的 name
参数进行拷贝,拷贝到了 kname
也就是内核栈当中。第一个判断应该是判断空字符串和防止溢出,因为如果 sizeof(kname)
字节都被占满了那么这个字符串还会跟下面连续的字符串相连,造成一些错误。
这里出现了我的知识盲区,这里也来解释一下,在内核里面,看见全大写字母的变量基本都不是变量,都是宏定义。而我实在不知道字符串常量有直接拼接的做法:
#include<stdio.h>
#define s1 "123"
#define s2 "456"
#define s3 s1 s2
int main(){
puts(s3);
}
/*output:
123456
*/
我还以为是宏定义的特殊写法呢,这里mark一下。
这里给出这些宏定义的最终结果
#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
,那其实就是对 capability
的 userns
进行一个映射了(应该是这个意思。
就是可能,它会在不同的 namespace
上嘛,比如这个文件夹是其中一个 user namespace
创建的,不可能我换一个 namespace
去检测权限也是相同的手法,肯定是要把权限映射一下的,映射到同一个 namespace
上才能进行权限检查。
经过一系列检查之后,走到了 vfs_setxattr
,也就是虚拟文件系统的扩展属性设置。
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
的设置文件扩展属性。
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
其它的我们不看,我们解释比较容易理解的。
if (!upperdentry) {
err = ovl_copy_up(dentry);
if (err)
goto out_drop_write;
realdentry = ovl_dentry_upper(dentry);
}
这个地方其实就是我们说的,如果文件在 lower
当中,那么拷贝一份到 upper
当中去,然后把新的文件节点指向 upper
。
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 namespace
的 root
权限,而这个权限实际是 init
的 user
创建的,因此实际操作还是获得不了真实的 root
,但是问题就是 overlayfs
的这个特性:我们修改了 merge
中的 a.out
会反向修改之前在 upper
中的 a.out
,因此我们给它 setuid
的权限导致了 upper/a.out
也有 setuid
的权限,而 upper/a.out
是在实际的 init user namespace
创建的,因此它有了 init user namespace
的 setuid
权限。我们运行 upper/a.out
直接获取真实 root
权限。
修复方案
这个其实我个人认为应该是 overlayfs
的问题,在修改的时候应该检查 user namespace
才对,但是它修改了 xattr.c
中的 vfs_setxattr
函数,这个函数重新用了一个 cap_convert_nscap
函数检查 namespace
。
//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;
}
当然这样也能完成漏洞的修复,不过我认为在其它文件系统中这里检查了两次就比较没有必要,也是比较困惑的点吧,也可能防止其它文件系统调用这个函数也没有检查,大概是这样的。