基于 Kubernetes 集群构建多租户环境中,隔离是通过节点虚拟机来实现的,每个客户的容器都运行在一个专用的、单租户的节点上,每个节点对应一个 Kubernetes pod。这种每个租户分配一个节点的Kubernetes 多租户模式通常被称为"每租户一个节点"模式。
本文就介绍一种跨租户攻击方法,恶意用户可以从自己的容器中逃逸出来,获取一个具有高权限的Kubernetes 服务账户令牌,并进而控制 Kubernetes api-server,就能完全控制多租户集群以及集群内运行的所有客户容器。
使用 WhoC ,(一个特殊的容器镜像,它能够读取执行它的容器运行环境。它基于是 Linux 容器中设计缺陷,允许容器读取底层宿主机的容器运行时)。
部署 WhoC 后,成功获取了平台所用容器的运行时信息,运行时是行业标准的容器运行时 runC
。比较出乎意料的是它的版本,
./aci_container_runtime -v
CVE-2019-5736,在容器当前K8S Node节点宿主机获取 root Shell 权限。
虽然逃离了容器,但仍然处于租户边界之内 — — 即节点 VM。
在进行节点信息收集时,Kubelet 的凭证是十分重要的,使用该凭证,列出了集群中的 pod 和节点,确认了容器是该节点上唯一的客户容器。这个集群托管了约 100 个客户 pod,并拥有大约 120 个节点。每个客户被分配到一个专属的 Kubernetes 命名空间中运行他们的 pod,我们的命名空间名为 caas-d98056cf86924d0fad1159XXXXXXXXXX
#查看集群版本信息
kubectl version
kubectl exec <pod> <cmd>
命令时,api-server 会将这个请求转发到相应的 Kubelet 的 /exec 接口,
利用此漏洞需要满足以下前提条件:
显然,目前满足了这三个条件,ACI 支持通过 az container exec
命令在容器中执行命令,这与 kubectl exec
的功能类似,
az container exec --name --exec-command
接着创建了一个利用 CVE-2018-1002102 的自定义 Kubelet 镜像,将接收到的 exec 请求重定向到其他节点上的 pod。
为了提高危害,Unit 42 将其配置为目标指向 api-server pod,然后运行 az container exec my-ctr --exec-command /bin/bash
,期望在 api-server 容器上建立一个 shell,但利用失败了。
实际利用 Unit 42 发现重定向操作仅在目标容器和当前容器位于同一节点上时才有效,无法影响其他节点。
再次确定发送到节点的 exec 请求,我们原本预期这些请求会来自 api-server 的 IP,但请求 IP 实际上来自一个在默认命名空间运行的名为 k8s-agentpool1(下文用 'bridge' 代称) 的 pod,
ACI 将 exec 请求处理从 api-server 转移到了一个自定义服务,应该是通过将 az container exec
命令的路由指向 bridge pod,而不是直接发往 api-server 来实现的,
bridge 镜像的标签是 master_20201125.1
,表明它在 CVE-2018-1002102 发布后更新。从它的最新构建时间和拒绝重定向 exec 请求来看,看起来 CVE-2018-1002102 的补丁被移植到了 bridge pod。看来微软意识到这个漏洞会影响他们的自定义bridge pod 并给它打上了补丁。
由于 CVE-2018-1002102 利用不了,但在调试过程中,Unit 42注意到一个不寻常的情况:即从 brige pod 请求 至 K8S Node节点的 exec HTTP请求中包含了一个 Authorization 头部,携带着一个Kubernetes 服务账户令牌,
抓取 brige pod 请求 node kubelet 的包
apt update > /dev/null 2>&1 && apt install -y mitmproxy > /dev/null 2>&1
# -d -d 输出详细
mitmproxy -d -d --reverse https://localhost:10025 -p 1234 >> /root/kubelet_comms &
# 把外部请求的10250端口流量转发到 1234端口
iptables -A INPUT -i eth0 -p tcp --dport 10250 -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --dport 1234 -j ACCEPT
iptables -A PREROUTING -t nat -i eth0 -p tcp --dport 10250 -j REDIRECT --to-port 1234
Kubernetes 服务账户令牌是未加密的 JSON Web 令牌(JWTs),因此它们可以被解码。从下面的内容可以看出,收到的令牌属于 'bridge' 服务账户。鉴于请求是从 bridge pod 发起的,看着好像没啥问题,
一般拿到 token 都会通过 kubectl auth can-i
命令 查看 token 的权限,以下是 bridge 的 token 在默认命名空间的权限:
在查看其他命名空间发现权限是一样的,也就是说 token 是集群权限(而非仅限于特定命名空间的)
下面是该令牌在 kube-system 命名空间的权限。尝试找出一个可以让我们在多租户集群中都可用的权限:
pods/exec 权限,这意味着该令牌可以用于在集群中的任何 pod 上执行命令 —— 包括 api-server pod
自此,多租户 k8s 集群就拿下了,能够管理多租户集群中的所有租户的容器。
在执行 exec 时,去除 bridgePod 请求节点时携带的 JWT token。应该属于历史遗留功能,忘记删除了。
紧接上篇逃逸到 Node 宿主机后,另一条拿下K8S集群路径 —— Bridge SSRF。
当 bridge pod 处理一个 az container exec <ctr> <cmd>
命令时,它会向相应的 Kubelet 的 /exec 接口发送请求,
https://<nodeIP>:10250/exec/<customer-namespace>/<customer-pod>/<customer-ctr>?command=<url-encoded-cmd>&error=1&input=1&output=1&tty=1
bridge 需要以某种方式填充尖括号 < >
中缺失的参数,其中 <nodeIP>
的值是从客户 pod 的status.hostIP
字段中获取的。当节点被授权更新其 pod 的状态(例如,更新其 pod 的status.state 字段为 Running 、 Terminated 等)时就比较有用了。
尝试使用受控节点的 token 更改我们 pod 的 status.hostIP
字段,hostIP 字段更新了,但在一两秒后,api-server 将 hostIP 字段更正为原始值。虽然更改没有持久化,但并没有什么能阻止我们反复更新这个字段,
Unit 42 编写了一个小脚本,不断更新 pod 的状态,并将 status.hostIP 字段设置为 1.1.1.1 ,然后执行了一个 az container exec
命令。虽然失败了,但证实了 bridge 将 exec 请求发送到了1.1.1.1 而不是真实的节点 IP。接下来就是考虑如何利用特殊构造的 hostIP 欺骗 bridge 执行对其他pod 的命令,
仅将 pod 的 status.hostIP 设置为另一个节点的 IP 不起作用,因为 Kubelets 只接受指向它们托管的容器的请求。即便 bridge 将 exec 请求发送到另一个 Kubelet 的 IP,URL 仍然会指向我们的命名空间、pod 名称和容器名称,因为我们没办法修改这个请求的 <customer-namespace> <customer-pod><customer-ctr>
参数
https://<nodeIP>:10250/exec/<customer-namespace>/<customer-pod>/<customer-ctr>?command=<url-encoded-cmd>&error=1&input=1&output=1&tty=1
Unit 42 随后发现到 api-server 实际上并不验证 status.hostIP 的值是否为有效的 IP,它会接受任何字符串 —— 包括 URL 部分。经过几次尝试,Unit 42 找到了一个 hostIP 值,可以让 bridge 执行对api-server 容器的命令,而非我们自己的容器:
hostIP 值: <apiserver-nodeIP>:10250/exec/kube-system/apiserver-pod/apiserver-container?command=whoami&error=1&input=1&output=1&tty=1#
会让 bridge 将 exec 请求发送到以下 URL:
https://**<apiserver-nodeIP>:10250/exec/kube-system/<apiserver-pod-name>/<apiserver-ctr>?command=<url-encoded-command>&error=1&input=1&output=1&tty=1**<u>#</u>:10250/exec/<customer-namespace>/<customer-pod-name>/<customer-ctr-name>?command=<command>&error=1&input=1&output=1&tty=1
bridge 初始的命令模板:
https://<nodeIP>:10250/exec/<customer-namespace>/<customer-pod>/<customer-ctr>?command=<url-encoded-cmd>&error=1&input=1&output=1&tty=1
使用 #
后缀确保 URL 的其余部分被视为 URI 锚点,从而忽略后面的异常部分。
例如: https://www.baidu.com#:10250/exec///?command=&error=1&input=1&output=1&tty=1
URL 中的 # 符号后的部分被称为锚点,属于前端的功能,与 HTTP 请求无关。
我们将我们的 pod 的 status.hostIP 设置为这个值,并通过 az container exec 执行命令,利用成功并获得 api-server 容器的 shell。
az container exec <ctr> <cmd>
时,会向相应 Kubelet 的 /exec 端点发起请求。这个请求的 URL 是由多个参数拼接而成的,其中<nodeIP>
的值来自客户 Pod 的 status.hostIP 字段。bridge 在发送 exec 请求之前会验证 pod 的 status.hostIP 字段是否为有效的 IP 地址。