Docker逃逸中被忽略的 pid namespace


背  景

最近在研究基于内核漏洞进行Docker逃逸的原理,发现漏洞利用中都会使用如下三行代码,用于切换Docker中exp进程的三种namespace:

Docker逃逸中被忽略的 pid namespace

然而实际测试中,发现exp进程的 pid namespace 并未切换成功,具体表现为:

  • 通过 kill -9 无法终止任何host进程;

  • 通过 echo $$ 获得的进程号跟执行exp前没有变化;

  • 通过 ls -al /proc/<exp-host-pid>/ns 查看pid项的值也没有发生变化


是什么原因导致只有 pid namespace 切换失败?以及如何完成 pid namespace 的逃逸呢?这篇文章中记录了我对这些问题的理解。


Docker逃逸历史漏洞及分类

目前公开的Docker逃逸漏洞可以分为三种类型:Docker配置的问题,Docker实现的问题,和Linux内核的问题。

  1. Docker配置的问题:主要是由于用户使用Docker时不规范,指定了不安全的启动参数(–privileged),给了不必要的启动权限(SYS_MODULE、SYS_PTRACE、SYS_ADMIN),或者挂载了特殊文件(/var/run/docker.sock),基于此可以轻易实现Docker逃逸。


  2. Docker实现的问题:Docker架构中各个组件中可能出现一些漏洞,如:

    • runc中的漏洞:CVE-2019-5736、CVE-2024-21626等;

    • Docker cp/Docker build的漏洞:CVE-2019-14271、CVE-2019-13139等;

    • containerd的漏洞:CVE-2020-15257等。


  3. 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依赖的内核安全机制

Docker的本质是一个linux用户态进程,它呈现出来的隔离状态依赖于linux内核这个底座提供的几种安全机制 —— capability,namespace,seccomp,apparmor/selinux,cgroups。


  • capability:将普通用户和特权用户进一步区分,实现更细粒度的访问控制;

  • namespace:资源隔离,使同一namespace中的进程看到相同的系统资源,并且对其他namespace不可见;

  • seccomp:禁止进程调用某些系统调用;

  • apparmor/selinux:强制访问控制;

  • cgroups:资源限制,限制进程对计算机资源的使用(如CPU、memory、disk I/O、network等)。

01

查看状态


如何查看当前环境中这些安全机制的状态呢?


在Docker中起一个bash,host上找到它对应的pid号(8089),然后我们可以在系统命令行中观察这些安全机制作用到每个进程的状态。


  • capability

查看进程具备哪些capability:

Docker逃逸中被忽略的 pid namespace

  • namespace

查看进程所属的namespace:

Docker逃逸中被忽略的 pid namespace

  • seccomp

查看进程是否被seccomp限制了系统调用:

Docker逃逸中被忽略的 pid namespace

Seccomp字段数字的含义:

  • 为0表示未开启seccomp;

  • 为1表示严格模式,只允许进程使用特定的几个系统调用 — sys_read/sys_write/sys_exit;

  • 为2表示过滤模式,通过配置文件自定义允许和禁用的系统调用。


显然,本例中进程seccomp是过滤模式。


  • apparmor/selinux

这两个机制不是针对某个进程的,而是对整个系统生效。

查看系统selinux的状态:

Docker逃逸中被忽略的 pid namespace


查看系统apparmor的状态:

Docker逃逸中被忽略的 pid namespace

  • cgroups

查看进程所属的cgroup:

Docker逃逸中被忽略的 pid namespace

如果需要查看或修改资源,可在 /sys/fs/cgroup/ 目录中进行。


02

进程角度


上述从用户态查看到的状态,都是从内核中读取到的进程数据。所以,可以通过调试Linux内核进程描述符 task_struct 结构体,查看其中存放的当前进程 capability、namespace 和 seccomp 的信息:

Docker逃逸中被忽略的 pid namespace

各个结构体内容:

Docker逃逸中被忽略的 pid namespace

正常情况下,这些在内核中的数据是安全的,可以很好地实现容器间的隔离。但当存在可被用户态利用的内核漏洞时,通过更改结构体中数据或者替换掉结构体指针,就可以让一个Docker进程变成一个host进程,从而完成逃逸。


在利用内核漏洞进行Docker逃逸时,目前主要考虑突破 capability、namespace和seccomp 三种限制,所以后续内容只涉及这三个方面。


利用方法发展历史

从公开的文档来看,利用Linux内核漏洞进行Docker逃逸已经有几年的历史了,目前能看到的方法有三种:

时间 方法
条件
20190306

(ret2usr)改cred + 改current→fs

可关闭SMEP

20190304

20190808

(ret2usr)改cred + 切namespace + setns()

可关闭SMEP,有CAP_SYS_ADMIN
20210707

(kernel ROP)改cred + 切namespace  + 用户态setns()

有CAP_SYS_ADM

01

改进程fs_struct


最初的逃逸方案是基于SMEP已关闭的前提下,ret2usr后:

  • commit_creds(prepare_kernel_cred(0))提权 —— 改了进程的capability;

  • copy_fs_struct() —— 将hostpid为1的进程其 task_struct->fs 复制一份;

  • 用上一步的返回值替换 current->fs —— 更改文件系统。


ret2usr后利用代码片段:

Docker逃逸中被忽略的 pid namespace

该方法可以让Docker进程能够以host的root权限任意读写host文件系统,但是无法kill进程,如下图:


Docker逃逸中被忽略的 pid namespace


因为只替换了文件系统,并未改变任何namespace。


02

改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后利用代码片段:

Docker逃逸中被忽略的 pid namespace

这里有两个小插曲:

1. 在无法关闭SMEP的情况下,这段shellcode能否直接在内核中执行?

如果在内核中以shellcode形式执行该代码,会在执行open()时返回错误码-14(EFAULT)。


通过调试定位到错误产生的代码位置在 do_sys_open() -> do_sys_openat2() -> getname() -> getname_flags() -> strncpy_from_user() 函数中:


Docker逃逸中被忽略的 pid namespace


所以,在内核中使用shellcode做以上利用逻辑时,需将sys_open()的第二个字符串参数设置为用户态地址。


2. pid namespace是否切换成功?

没有切换成功。虽然通过 ps -ef 列出host的所有进程,但这只是因为mnt namespace切换成功后可以读取到host 的 /proc/ 目录中的内容而已。详细分析见后续章节。


03

改cap+ns v2.0


由于高版本Linux内核镜像中不再存在操作CR4的gadget,也就无法直接通过ROP关闭SMEP,因此上述ret2usr执行逃逸代码的方案失效。


新的利用思路跟上一小节1.0版本基本相同,只是把代码逻辑分成内核和用户态两部分:

  • 内核态ROP执行:commit_creds() + switch_task_namespaces()

Docker逃逸中被忽略的 pid namespace

  • 返回用户态后执行:setns()

Docker逃逸中被忽略的 pid namespace

执行效果如下:


Docker逃逸中被忽略的 pid namespace


看上去逃逸成功了?但 setns(fd_pid, 0); 这句执行出错并返回错误码22,对应的意思是”Invalid argument”。


假如此时我们尝试kill一个进程,会发现pid namespace依然还在Docker中!无法终止任何host进程。


Docker逃逸中被忽略的 pid namespace


然后,对比一下当前Docker进程和host中pid为1进程的ns目录,发现mnt和net的值相同,表明二者都切换成功了。但pid的值不相同,说明pid namespace切换失败。这个结果跟上图中 setns() pid 时返回失败能对应上。


Docker逃逸中被忽略的 pid namespace


但是为什么公开的exp中都未提及该问题?因为他们没有检查setns()的返回值,所以没发现这个问题。


Docker逃逸中被忽略的 pid namespace


那么使用 setns() 切换 pid namespace 时报 “Invalid argument”这个错误,其背后的原因是什么呢?如何避免该错误的发生从而完成 pid namespace 的切换呢?


04

被忽略的 pid namespace


跟踪 sys_setns 过程

setns() 系统调用在内核中的入口函数如下:

Docker逃逸中被忽略的 pid namespace

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逃逸中被忽略的 pid namespace

通过分析源码可以看到,将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 即指向该结构体。

Docker逃逸中被忽略的 pid namespace

在  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 中,如下图:


Docker逃逸中被忽略的 pid namespace


此时可 kill 任意 host 进程:


Docker逃逸中被忽略的 pid namespace


所以,逃逸 pid namespace 只需更改进程 task_struct->thread_pid->level 的值。


05

较少被提及的 seccomp


实际上,现在 Docker 的默认启动配置并不存在 CAP_SYS_ADMIN 权限,并且 seccomp 默认规则也会禁止进程调用 setns() 来切换 namespace。所以在写内核利用时需考虑绕过这两点的限制,CAP_SYS_ADMIN 可以通过更改进程 task_struct->cred 来完成,而 seccomp 的限制应如何绕过呢?


在 CVE-2017-1000112 的 exp 中看到一种绕过seccomp的方法:


Docker逃逸中被忽略的 pid namespace


他通过将 task_structseccomp 结构体中的 modefilter_count 两个成员清零的方式来绕过seccomp

Docker逃逸中被忽略的 pid namespace

但是在我的环境中(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 的定义如下:

Docker逃逸中被忽略的 pid namespace

有没有可能标记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 的开关。


Docker逃逸中被忽略的 pid namespace


调试查看开启seccomp和未开启seccomp的进程其 task_struct 结构体,发现确实是偏移 0x8 的位置处存放的值不一样。


Docker逃逸中被忽略的 pid namespace


所以只需将该值(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所有

未经作者同意,不得转载

Docker逃逸中被忽略的 pid namespace

clingcling


天工实验室安全研究员

专注于Linux内核漏洞挖掘与利用


往期回顾

01
关于C++ 迭代器失效特性的研究
02
【BlackHat 2024】SystemUI As EvilPiP: 针对现代移动设备的劫持攻击
03
Palo Alto CVE-2024-3400 漏洞分析
04
MikroTik RouterOS CVE-2023-32154 认证前RCE漏洞分析


Docker逃逸中被忽略的 pid namespace
每周三更新一篇技术文章  点击关注我们吧!

原文始发于微信公众号(破壳平台):Docker逃逸中被忽略的 pid namespace

版权声明:admin 发表于 2024年5月15日 上午11:50。
转载请注明:Docker逃逸中被忽略的 pid namespace | CTF导航

相关文章