最近在研究基于内核漏洞进行Docker逃逸的原理,发现漏洞利用中都会使用如下三行代码,用于切换Docker中exp进程的三种namespace:
然而实际测试中,发现exp进程的 pid namespace 并未切换成功,具体表现为:
-
通过
kill -9
无法终止任何host进程; -
通过
echo $$
获得的进程号跟执行exp前没有变化; -
通过
ls -al /proc/<exp-host-pid>/ns
查看pid项的值也没有发生变化。
是什么原因导致只有 pid namespace 切换失败?以及如何完成 pid namespace 的逃逸呢?这篇文章中记录了我对这些问题的理解。
目前公开的Docker逃逸漏洞可以分为三种类型:Docker配置的问题,Docker实现的问题,和Linux内核的问题。
-
Docker配置的问题:主要是由于用户使用Docker时不规范,指定了不安全的启动参数(–privileged),给了不必要的启动权限(SYS_MODULE、SYS_PTRACE、SYS_ADMIN),或者挂载了特殊文件(/var/run/docker.sock),基于此可以轻易实现Docker逃逸。
-
Docker实现的问题:Docker架构中各个组件中可能出现一些漏洞,如:
-
runc中的漏洞:CVE-2019-5736、CVE-2024-21626等;
-
Docker cp/Docker build的漏洞:CVE-2019-14271、CVE-2019-13139等;
-
containerd的漏洞:CVE-2020-15257等。
-
Linux内核的问题:Docker跟host共享同一个系统内核,因此内核中的漏洞也可能被用于容器逃逸。收集了一些用于容器逃逸的漏洞(不一定能用于Docker逃逸,或者即使能用于Docker逃逸也需要满足一些前提条件),如下:
-
通过传统内核漏洞ROP完成逃逸的有:CVE-2017-7308、CVE-2017-1000112、CVE-2020-14386、CVE-2021-22555、CVE-2022-0185;
-
通过容器机制漏洞完成逃逸的有:CVE-2018-18955(namespace)、CVE-2022-0492(cgroups);
-
通过文件读写类漏洞完成逃逸的有:CVE-2016-5195(DirtyCow)、CVE-2022-0847(DirtyPipe)。
本文基于传统内核漏洞已实现控制流劫持的场景下(通过植入内核ko实现),研究Docker逃逸过程及利用方法,从而加深对Linux内核中容器安全相关机制的理解。
Docker的本质是一个linux用户态进程,它呈现出来的隔离状态依赖于linux内核这个底座提供的几种安全机制 —— capability,namespace,seccomp,apparmor/selinux,cgroups。
-
capability:将普通用户和特权用户进一步区分,实现更细粒度的访问控制;
-
namespace:资源隔离,使同一namespace中的进程看到相同的系统资源,并且对其他namespace不可见;
-
seccomp:禁止进程调用某些系统调用;
-
apparmor/selinux:强制访问控制;
-
cgroups:资源限制,限制进程对计算机资源的使用(如CPU、memory、disk I/O、network等)。
查看状态
如何查看当前环境中这些安全机制的状态呢?
在Docker中起一个bash,host上找到它对应的pid号(8089),然后我们可以在系统命令行中观察这些安全机制作用到每个进程的状态。
-
capability
查看进程具备哪些capability:
-
namespace
查看进程所属的namespace:
-
seccomp
查看进程是否被seccomp限制了系统调用:
Seccomp字段数字的含义:
-
为0表示未开启seccomp;
-
为1表示严格模式,只允许进程使用特定的几个系统调用 — sys_read/sys_write/sys_exit;
-
为2表示过滤模式,通过配置文件自定义允许和禁用的系统调用。
显然,本例中进程seccomp是过滤模式。
-
apparmor/selinux
这两个机制不是针对某个进程的,而是对整个系统生效。
查看系统selinux的状态:
查看系统apparmor的状态:
-
cgroups
查看进程所属的cgroup:
如果需要查看或修改资源,可在 /sys/fs/cgroup/
目录中进行。
进程角度
上述从用户态查看到的状态,都是从内核中读取到的进程数据。所以,可以通过调试Linux内核进程描述符 task_struct 结构体,查看其中存放的当前进程 capability、namespace 和 seccomp 的信息:
各个结构体内容:
正常情况下,这些在内核中的数据是安全的,可以很好地实现容器间的隔离。但当存在可被用户态利用的内核漏洞时,通过更改结构体中数据或者替换掉结构体指针,就可以让一个Docker进程变成一个host进程,从而完成逃逸。
在利用内核漏洞进行Docker逃逸时,目前主要考虑突破 capability、namespace和seccomp 三种限制,所以后续内容只涉及这三个方面。
从公开的文档来看,利用Linux内核漏洞进行Docker逃逸已经有几年的历史了,目前能看到的方法有三种:
时间 | 方法 |
条件 |
20190306 |
(ret2usr)改cred + 改 |
可关闭SMEP |
(ret2usr)改cred + 切namespace + setns() |
可关闭SMEP,有CAP_SYS_ADMIN | |
20210707 |
(kernel ROP)改cred + 切namespace + 用户态setns() |
有CAP_SYS_ADM |
改进程fs_struct
最初的逃逸方案是基于SMEP已关闭的前提下,ret2usr
后:
-
commit_creds(prepare_kernel_cred(0))
提权 —— 改了进程的capability; -
copy_fs_struct()
—— 将host
中pid
为1的进程其task_struct->fs
复制一份; -
用上一步的返回值替换
current->fs
—— 更改文件系统。
ret2usr后利用代码片段:
该方法可以让Docker进程能够以host的root权限任意读写host文件系统,但是无法kill进程,如下图:
因为只替换了文件系统,并未改变任何namespace。
改cap+ns v1.0
上一种方法仅达成了读写host文件系统的能力,而Docker中的进程其namespace还在Docker中,不算是完美的逃逸,于是紧接着出现了第二种可以逃逸namespace的方法。
这个方法也是基于关闭SMEP的前提条件下,ret2usr执行提权和切namespace的操作。主要思路是:
-
commit_creds(prepare_kernel_cred(0))
提权 —— 改了进程的capability; -
switch_task_namespaces()
—— 替换Docker中pid为1进程的namespace; -
setns()
—— 将当前进程加入到/proc
目录下pid
为1进程的namespace中。
ret2usr后利用代码片段:
这里有两个小插曲:
1. 在无法关闭SMEP的情况下,这段shellcode能否直接在内核中执行?
如果在内核中以shellcode形式执行该代码,会在执行open()时返回错误码-14(EFAULT)。
通过调试定位到错误产生的代码位置在 do_sys_open() -> do_sys_openat2() -> getname() -> getname_flags() -> strncpy_from_user()
函数中:
所以,在内核中使用shellcode做以上利用逻辑时,需将sys_open()的第二个字符串参数设置为用户态地址。
2. pid namespace是否切换成功?
没有切换成功。虽然通过 ps -ef
列出host的所有进程,但这只是因为mnt namespace切换成功后可以读取到host 的 /proc/
目录中的内容而已。详细分析见后续章节。
改cap+ns v2.0
由于高版本Linux内核镜像中不再存在操作CR4的gadget,也就无法直接通过ROP关闭SMEP,因此上述ret2usr执行逃逸代码的方案失效。
新的利用思路跟上一小节1.0版本基本相同,只是把代码逻辑分成内核和用户态两部分:
-
内核态ROP执行:
commit_creds() + switch_task_namespaces()
-
返回用户态后执行:
setns()
执行效果如下:
看上去逃逸成功了?但 setns(fd_pid, 0)
; 这句执行出错并返回错误码22,对应的意思是”Invalid argument”。
假如此时我们尝试kill一个进程,会发现pid namespace依然还在Docker中!无法终止任何host进程。
然后,对比一下当前Docker进程和host中pid为1进程的ns目录,发现mnt和net的值相同,表明二者都切换成功了。但pid的值不相同,说明pid namespace切换失败。这个结果跟上图中 setns() pid 时返回失败能对应上。
但是为什么公开的exp中都未提及该问题?因为他们没有检查setns()的返回值,所以没发现这个问题。
那么使用 setns()
切换 pid namespace 时报 “Invalid argument”这个错误,其背后的原因是什么呢?如何避免该错误的发生从而完成 pid namespace 的切换呢?
被忽略的 pid namespace
跟踪 sys_setns 过程
setns()
系统调用在内核中的入口函数如下:
在 validate_ns(&nsset, ns)
中有一个函数指针的调用,会根据用户态传入的fd不同而进入不同的处理函数。举例,用户态通过 open("/proc/1/ns/pid",0)
获得的fd,调用 setns(fd, 0)
进入内核后,会转到 pidns_install()
的处理流程。mnt对应mntns_install()
、net对应netns_install()
,其他ns与之类似。
以 pid namespace 为例,我们看看它是如何通过 setns 系统调用进行切换的,pidns_install()
函数处理逻辑如下:
通过分析源码可以看到,将Docker进程通过 setns()
加入到 /proc/1/ns/pid
的namespace时,要求目标进程的 task_struct->thread_pid->level
的值,不小于当前进程的 level 值。而通过调试发现,Docker中exp进程运行到这里时, new->level
为0,active->level
为1,所以直接返回 errno 22—— “Invalid argument” 。
因此,如果想让 setns(fd_pid, 0)
; 返回正常,必须在内核中将当前进程的 current->thread_pid->level
改成0。
但是,改完这个值就能切换 pid namespace吗?继续往下看源码发现,即使我们改掉当前进程的level,setns()
系统调用中也只会切换 /proc/$$/ns/pid_ns_for_children
,而不是我们需要的 /proc/$$/ns/pid
。
所以引出下一个问题:pid namespace 在哪里?
pid namespace 在哪里?
新版本内核中,当前进程的 pid namespace 并不在 task_struct->nsproxy
指向的 struct nsproxy
结构体中。而是存放在另一个结构体 struct pid
中 ,task_struct->thread_pid
即指向该结构体。
在 struct pid
结构体中,目前只需关注 count、level、numbers[1] 这三个成员:
-
count
:pid namespace计数,表示可以看到该进程的namespace个数。一个Docker进程,本质上也是host的一个进程,所以一个进程可能存在于多个namespace中,从不同namespace中看到会看到不同的进程信息。比如在Docker中pid为10的进程,在host上它对应pid为3000; -
level
:表示进程当前所在的层级,host的level为0。当在host上起一个Docker时,Docker进程的level为1。如果在Docker中再起一个Docker,那么新Docker中进程的level为2,以此类推; -
numbers[1]
:变长的struct upid
结构体数组,用于存放不同level中该进程的 pid 信息。
所以,该结构体中 level 成员的值表明当前进程在哪个 pid namespace中,只需该掉该值便可完成 pid namespace 的逃逸。
实际测试中,将 level 改完后,再通过 setns()
设置 pid namespace,/proc/$$/ns/pid_ns_for_children
和 /proc/$$/ns/pid
都被更改成功,直接逃到 host 的 pid namespace 中,如下图:
此时可 kill 任意 host 进程:
所以,逃逸 pid namespace 只需更改进程 task_struct->thread_pid->level
的值。
较少被提及的 seccomp
实际上,现在 Docker 的默认启动配置并不存在 CAP_SYS_ADMIN 权限,并且 seccomp 默认规则也会禁止进程调用 setns()
来切换 namespace。所以在写内核利用时需考虑绕过这两点的限制,CAP_SYS_ADMIN
可以通过更改进程 task_struct->cred
来完成,而 seccomp 的限制应如何绕过呢?
在 CVE-2017-1000112 的 exp 中看到一种绕过seccomp的方法:
他通过将 task_struct
中 seccomp
结构体中的 mode
和 filter_count
两个成员清零的方式来绕过seccomp
。
但是在我的环境中(Linux Kernel 5.15.0),将这两个值清零后,进程会 segmentation fault。所以直接更改这个结构体的内容,显然不可行。那么,除了 struct seccomp
结构体可以存放 seccomp 的状态及 filter 规则,有没有类似可以控制 seccomp 的开关呢?
一些资料中提供的方法是改进程 current->thread_info.flags
,这个 flags 会标记当前进程是否启用seccomp。而在 Linux 5.15.0 环境中,更改进程 thread_info.flags
,未能关闭seccomp。该版本struct thread_info
的定义如下:
有没有可能标记seccomp的flag位置变了?那这个flag是在何时设置的呢?
于是跟踪 seccomp 系统调用定位 seccomp 开关设置的位置,流程为do_seccomp() -> seccomp_set_mode_filter()-> seccomp_assign_mode()
。
基于此信息,结合 Linux 5.15.0 的内核源码和内核ELF文件定位到该版本设置flags的位置,发现它把 task_struct+8 位置的1个字节写成了1,所以可以确定当前内核版本中 current->thread_info.syscall_work
是设置 seccomp 的开关。
调试查看开启seccomp
和未开启seccomp
的进程其 task_struct
结构体,发现确实是偏移 0x8 的位置处存放的值不一样。
所以只需将该值(current->thread_info.syscall_work
)设置成0,便可绕过seccomp的限制。
本文在 Linux 5.15.0 + Docker 24.0.6
环境中,基于一个自定义的内核ko,从Linux内核漏洞利用的角度,探索了Docker逃逸需要突破的内核安全机制 —— capability、namespace和seccomp。给出了公开利用中 pid namespace 切换失败的原因及解决方法,以及定位不同系统中进程 seccomp 开关位置的方法。
点击阅读原文查看详细的环境搭建教程,感兴趣可以搭建调试,如有疑问,欢迎留言~
【版权说明】
本作品著作权归clingcling所有
未经作者同意,不得转载
clingcling
天工实验室安全研究员
专注于Linux内核漏洞挖掘与利用
原文始发于微信公众号(破壳平台):Docker逃逸中被忽略的 pid namespace