azraelxuemo's Studio.

CVE-2022-21701 yaml解析漏洞

2023/11/29

前言

之前一直有过这样的想法
服务端使用snakeyaml去解析字符串的时候,但是我们能控制的只是其中的一个value,类似于有一个这样的json格式的数据

1
2
3
{
"a":payload
}

他解析成yaml应该就是类似的结构

1
a: payload

一般的snakeyaml反序列化要求都是控制整个yaml文件,这种是无法利用的。
但我经常也在想,能不能利用换行来达到注入的效果呢,比如说我们希望输入test\n!!javax.script.ScriptEngineManager来达到破坏yaml解析的效果

1
2
a: test
!!javax.script.ScriptEngineManager

但是一般这样是不行的,因为yaml会变成下面这种支持多行的结构

1
2
3
4
5
text: |-
这是一段多行字符串,
它会完全按照这里的格式
来保留换行和缩进,
但是末尾的换行符会被移除。

但是今天带来的这个跟k8s相关的漏洞,就是利用解析生成yaml内容时破坏了原有的yaml结构,进而在后面解析yaml内容时产生问题,创造预期之外的资源达到提权的效果

漏洞细节

流程

整体描述

istio gateway controller 使用api server提供的watch机制监听etcd里存储的资源变化,当controller通过Watch API监听到资源变化时做出相应的动作。
当用户提交 kind 为 Gateway 的资源时,istio gateway controller 会做以下几件事:

  1. 解析 Gateway 配置: 控制平面首先解析 Gateway CRD 中定义的配置,这包括监听的端口、使用的协议和其他 TLS 相关设置。
  2. 生成 Envoy 配置: Istio 使用 Envoy 作为其边缘和内部服务代理。控制平面将 Gateway CRD 配置转换为 Envoy 配置,并将这些配置推送到相应的 Envoy 实例。
  3. 创建 Service 和 Deployment: 为了让 Envoy 代理可以在 Kubernetes 集群内运行,Istio 需要创建相应的 Service 和 Deployment。Service 确保 Envoy 可以通过固定的 IP 地址和端口从集群外部访问,而 Deployment 确保了 Envoy 代理的持续运行和管理。
    • Service 确保网关可以接受流量。它通常是一个 LoadBalancer 类型,这样可以从集群外部访问。
    • Deployment 确保有一组运行 Envoy 代理的 Pod 来处理经过网关的流量。
  4. 提交 Gateway 资源请求: 最后,控制平面会提交 Gateway 资源请求,这个请求本身不会在 Kubernetes 中创建一个实际的运行实体,而是被 Istio 的控制平面监视和解释,以确定如何配置 Envoy 代理。

先创建 Service 和 Deployment 的原因: Istio 先创建这些资源是因为它们是实际接收和处理流量的实体。Envoy 代理需要首先被部署和配置,以便它们可以根据 Gateway 资源的定义来正确地路由流量。
简而言之,Gateway CRD 本身只是一个配置对象,不会直接处理流量。是通过这个配置,Istio 控制平面指挥 Envoy 代理以确定的方式处理流量。因此,Istio 需要先创建 Service 和 Deployment,以便实际的代理可以运行并准备好根据 Gateway CRD 配置来接收流量。

代码层面

把给定的gateway资源按照模版解析成对应的service和deployment资源
截屏2023-11-29 13.48.17.png
解析的逻辑具体是在ApplyTemplate里做的,最后把资源提交给k8s api server
截屏2023-11-29 13.49.22.png
在service和deployment都提交之后,会提交一个Gateway的资源请求
截屏2023-11-30 13.56.45.png

问题1 模版解析不当导致yaml结构破坏

demo

首先我们看一个go的demo

1
2
apiVersion: v1
name: {{ .Demo_str }}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"os"
"sigs.k8s.io/yaml"
"strings"
"text/template"
)

type demo struct {
Demo_str string
}

func toYaml(str string) string {
data, err := yaml.Marshal(str)
if err != nil {
return ""
}
return strings.TrimSuffix(string(data), "\n")
}

var funcMap = template.FuncMap{
"toYaml": toYaml,
}

func main() {
templates, _ := template.New("test").Funcs(funcMap).ParseFiles("test.yaml")
p := demo{"test\napiVersion: v1"}
templates.ExecuteTemplate(os.Stdout, "test.yaml", p)
}

上面这个demo就是利用go的模版语法,但我们这里不是谈go的ssti,而是谈go模版会有其他的什么问题,运行一下这个样例,你会对这个输出结果感到惊讶
截屏2023-11-27 21.03.07.png
go的template支持换行这种操作,同时他在解析的时候不会去关注到底是什么格式,同时他也不会考虑到yaml本身的一些语法要求,你可以理解为他做的就只是从文件里面读内容,然后去把对应的表达式解析填上对应的值。
这种存在问题的解析造成了yaml结构被破坏
而我们在使用的时候,可以通过自定义一些函数,用来适配特殊场景,比如说如果是yaml字段,安全的使用方法就是使用我们这里的toYaml函数

1
2
apiVersion: v1
name: {{ toYaml .Demo_str }}

截屏2023-11-28 14.11.32.png
可以看到,这样使用的话就无法造成yaml格式被破坏

漏洞本身

那么回到漏洞本身,我们在这个测试文件里面简单修改一下pilot/pkg/config/kube/gateway/deploymentcontroller_test.go,把里面的无关字段都去除了,只留下关键的部分,这里面相当于就是定义了一个gw资源截屏2023-11-28 14.13.25.png
这里我们以service.yaml来验证,稍微修改一下service.yaml,只留下有问题的部分
截屏2023-11-28 15.54.06.png
可以看到,在metadata里面显示annotations的时候,用了toYamlMap来处理,所以这里不会有问题,当然他这里定义toYamlMap的本意可能也只是为了显示数据,毕竟传入的是map类型,无法直接输出出来
截屏2023-11-28 14.15.21.png
但是在spec的type里面,他相当于获取了Annotations[“networking.istio.io/service-type”]的值,默认是LoadBalancer截屏2023-11-28 14.24.28.png
他在这里没有考虑到yaml语法的问题,导致存在解析问题
在进入ApplyTemplate之前,可以看到我们发起的gw资源请求,里面的annotations的value是\ntest
截屏2023-11-29 14.02.28.png
在ExecuteTemplate这里使用了go的模版语法进行解析,将我们传入的资源对象的值填写到service.yaml里
截屏2023-11-27 21.33.31.png
我们查看buf里面的值,可以看到确实破坏了yaml结构
截屏2023-11-28 14.18.55.png
调整一下内容
截屏2023-11-27 21.59.43.png
可以看到解析的效果如下图
截屏2023-11-28 14.20.38.png
到目前为止,问题也显而易见,如果是java的话,那么后面我们完全有能力去控制写一个snakeyaml反序列化的exp,导致反序列化漏洞

题外话

字段校验

我们光看原始的service.yaml,可能会感觉这个name和namespace好像也受影响
截屏2023-11-28 15.58.38.png
但是我们如果拿这个yml去创建资源的话会报错
截屏2023-11-29 16.31.24.png
The Gateway "\"LoadBalancer\"\nkind: Pod\n" is invalid: metadata.name: Invalid value: "\"LoadBalancer\"\nkind: Pod\n": a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')
这是因为k8s存在校验机制,可以帮助我们避免一部分的 bug 甚至是一些安全漏洞,主要分为apimachinery validation和自定义的crd

apimachinery validation

对于字段合法性的具体验证,通常会在每种 API 资源的策略实现中进行。例如,在 Kubernetes 的 pkg/apis/ 目录下,每种资源类型(如 Deployments、Services 等)都有对应的 Go 代码文件,这些文件包含了资源的规范定义(Spec)和状态定义(Status)
我们这里以Deployment为例,在pkg/apis/apps/validation/validation.go
找到ValidateDeployment为例,ValidateDeployment调用了apivalidation的ValidateObjectMeta去验证metadata,调用ValidateDeploymentSpec去验证spec
截屏2023-11-30 09.54.53.png
这里第三个参数是一个验证name格式的函数
截屏2023-11-30 10.03.08.png
截屏2023-11-30 10.03.26.png
apivalidation的ValidateObjectMeta直接调用了apimachineryvalidation的ValidateObjectMeta
截屏2023-11-30 09.57.45.png
重点处理逻辑在staging/src/k8s.io/apimachinery/pkg/api/validation/objectmeta.go这个文件里,调用了ValidateObjectMetaAccessor
截屏2023-11-30 10.02.02.png
ValidateObjectMetaAccessor的前部分就是,如果有GenerateName,那么就用传入的验证函数,也就是刚才的ValidateDeploymentName进行验证,如果有Name,那么也调用ValidateDeploymentName进行验证,如果有Namespace用ValidateNamespaceName进行验证
截屏2023-11-30 10.06.40.png
截屏2023-11-30 10.45.53.png
在ValidateObjectMetaAccessor的后面会调用不同的Validate函数
截屏2023-11-30 09.24.40.png
我们可以使用staging/src/k8s.io/apimachinery/pkg/api/validation/objectmeta_test.go进行测试
我们修改一下这个测试用例,加上Annotations,同时替换第三个参数
截屏2023-11-30 10.49.00.png
那么在这里就会进行一个Name的验证
截屏2023-11-30 10.51.33.png
对应Annotations来说
截屏2023-11-30 09.25.27.png
IsQualifiedName会check key的合理性
截屏2023-11-30 09.27.13.png
然后check一下大小,所以他没有对value进行限制,这个也解释了为什么payload里面输入\n这种字符不会报错
截屏2023-11-30 09.28.07.png

crd

在 Kubernetes 中,自定义资源的验证通过 Custom Resource Definitions (CRDs) 中的 OpenAPI v3 schemas 实现。这些 schemas 定义了 CRD 的各个字段的类型和结构。API server执行编译好的 schema 验证逻辑,检查提交的 custom resource 实例是否符合 CRD 中定义的 schema。这包括字段的类型检查、必填字段的存在性检查、字符串的格式检查(如日期格式或正则表达式)、数值的范围检查等。
我们查看gateways的crd

1
kubectl get crd gateways.gateway.networking.k8s.io -o yaml

简单看一下里面的内容,以下图为例,这里就要求spec.listeners.name必须要是pattern里面组成,同时也有长度等要求
截屏2023-11-29 16.32.42.png
如果按照下面这个demo部署也会报错
截屏2023-11-30 10.55.25.png
The Gateway "a" is invalid: spec.listeners.name: Invalid value: "a\n": spec.listeners.name in body should match '^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'
刚好对应crd里面的要求

所以有一些字段看起来能利用,但其实过不了合法性校验

怎么编写异常的yaml

同时如果不清楚这种类型的yaml要怎么写,也可以按照下面这样操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"fmt"
"k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/gateway-api/apis/v1alpha2"
"sigs.k8s.io/yaml"
)

func main() {
gateway := v1alpha2.Gateway{
ObjectMeta: v1.ObjectMeta{
Annotations: map[string]string{"networking.istio.io/service-type": "a\ntest"},
},
}
data, _ := yaml.Marshal(gateway)
fmt.Println(string(data))
}

截屏2023-11-29 16.45.02.png
测试的时候也可以通过调试看一下值是否被正常赋值了

1
2
3
4
5
6
7
func main() {

var gw v1alpha2.Gateway
yamlData, _ := ioutil.ReadFile("test.yaml")
yaml.Unmarshal(yamlData, &gw)
fmt.Println(gw)
}

问题2 使用了非strict模式

这个漏洞的第二个问题就是解析yaml数据存在问题
在ExecuteTemplate模版解析完之后使用Unmarshal去解析yaml
截屏2023-11-28 09.00.33.png
这里可以注意到,默认设置的strict为false
截屏2023-11-28 09.01.18.png
我们如果换成true来解析的话,他会报错,因为他发现kind已经在之前定义过了
截屏2023-11-28 09.02.13.png
核心逻辑在这里
截屏2023-11-28 09.07.55.png
参看一下函数的定义,相当于如果开启了strict模式,并且发现返回值不是zeroValue,也就是代表之前已经出现过,那么他就会报错,already set in map
截屏2023-11-28 09.08.13.png
那么如果没有开启strict的话,那么就会调用SetMapIndex,他做的就是类似于v[key]=elem的操作
截屏2023-11-28 14.51.21.png
简单的demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"reflect"
)

func main() {
originalMap := make(map[string]string)
mapValue := reflect.ValueOf(originalMap)

key := reflect.ValueOf("kind")
value := reflect.ValueOf("Service")
mapValue.SetMapIndex(key, value)
fmt.Println("Updated map:", mapValue.Interface())

value = reflect.ValueOf("pod")
mapValue.SetMapIndex(key, value)
fmt.Println("Updated map:", mapValue.Interface())
}

截屏2023-11-28 14.57.33.png
可以发现,他会覆盖已有的key
那么经过yaml处理后,可以发现,本来kind应该为Service的资源,结果变成了pod
截屏2023-11-28 14.58.53.png
如果不进行覆盖的,最后传给api server的资源类似于下面这个结构,这种是正常的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
apiVersion: v1
kind: Service
metadata:
annotations:

networking.istio.io/service-type: aaa
labels:

gateway.istio.io/managed: istio.io-gateway-controller
name:
namespace:
ownerReferences:
- apiVersion: gateway.networking.k8s.io/v1alpha2
kind: Gateway
name:
uid:
spec:
ports:
- name: status-port
port: 15021
protocol: TCP
selector:
istio.io/gateway-name:
type: aaa


但是如果我们覆盖的话,传入exp
截屏2023-11-29 16.59.09.png
在最后这里,查看j和us.Object
截屏2023-11-29 17.13.50.png
可以看到最后发送的api server的资源如下图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: apps/v1
kind: Deployment
metadata:
name: pwned-deployment
namespace: istio-ingress
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx:1.14.3
name: nginx
ports:
- containerPort: 80
securityContext:
privileged: true

可以看到,这个发送到api server资源完全被覆盖成我们自己定义的恶意Deployment了,本来只允许创造gateway的我们成功创建出了特权容器,也刚好对应漏洞描述
截屏2023-11-29 19.47.32.png

修复

在对应yaml里面修改了一下,加入了quote
截屏2023-11-29 17.26.25.png
传入之前的payload,可以看到不会破坏yaml结构了
截屏2023-11-29 17.32.27.png

思考

这种有解析问题的场景一般都是会结合模版解析+yaml解析,因为正常的yaml解析来说不会产生这种yaml结构破坏的问题,但是如果结合上模版解析了以后就可能有这种问题。说白了,如果模版解析没有考虑到yaml格式的问题,那么就会产生有问题的yaml,进而就会产生解析的问题。
同时另一方面,我们回顾一下k8s创造资源的流程,这里还是以Gateway为例

  1. 用户发送创建请求:使用kubectl apply -f命令发送创建Gateway资源的请求。
  2. API Server接收请求:Kubernetes API Server接收到创建资源的请求。
  3. 身份认证(Authentication):API Server首先对发送请求的用户进行身份认证。这个过程通常涉及到检查提供的证书、令牌(例如Bearer Token)或其他身份验证机制来确认用户的身份。
  4. 权限授权(Authorization):身份认证成功后,API Server将进行权限授权。在Kubernetes中,这通常是通过RBAC来进行的。RBAC使用Role和RoleBinding资源(或者ClusterRole和ClusterRoleBinding,如果权限是跨整个集群的)来定义谁可以执行哪些操作。
  5. 权限验证
    • Role/ClusterRole:定义了一组权限,这些权限规定了可以对哪些资源执行哪些动作(例如,创建、获取、更新、删除等)。
    • RoleBinding/ClusterRoleBinding:将特定的用户、组或服务账户与一个Role或ClusterRole绑定,从而授予这些用户或服务账户相应的权限。
  6. 访问控制决策:API Server使用请求者的身份和请求的内容来确定是否允许该操作。如果请求者具有执行请求操作的足够权限,则请求被允许;否则,请求将被拒绝,并返回一个错误信息。
  7. 资源存储:如果用户有权限创建资源,API Server将继续处理请求,将资源定义存储到etcd,并触发后续的Istio控制平面组件进行处理。
  8. 控制平面的响应:一旦资源被存储,Istio的控制平面组件(如Istio’s Pilot,现在称为Istiod)会监听Kubernetes API Server的资源变化。当控制平面发现新的Gateway定义或者对现有的Gateway资源的更改时,它将开始处理这些变化。

控制平面在处理资源的时候,他会有一些自己内部的逻辑,这些逻辑可能会涉及到和api server进行通信,而这里的通信用的就是对应组件的权限,而如果用户可以通过代码里面的漏洞,去修改\劫持掉本来正常的通信,换成恶意的请求,那么就可以把用户的权限提升到对应组件的权限。
这个点又和CVE-2018-1002105这个漏洞很类似,这个漏洞就是构造一个特殊的请求,攻击者能够借助k8s API Server作为代理,建立一个到后端服务器的连接,并以k8s API Server的身份发送任意请求
同时其他组件可能也会有类似的问题,我们可以关注组件的controller里从处理资源请求到最后资源成功创建这个过程。

这种类似的风险在云服务厂商里面也普遍存在,云产品之前避免不了互相通信,有些通信用的是产品的特权,如果攻击者可以修改\劫持这些通信,那么就会被攻击

CATALOG
  1. 1. 前言
  2. 2. 漏洞细节
    1. 2.1. 流程
      1. 2.1.1. 整体描述
      2. 2.1.2. 代码层面
    2. 2.2. 问题1 模版解析不当导致yaml结构破坏
      1. 2.2.1. demo
      2. 2.2.2. 漏洞本身
      3. 2.2.3. 题外话
        1. 2.2.3.1. 字段校验
          1. 2.2.3.1.1. apimachinery validation
          2. 2.2.3.1.2. crd
        2. 2.2.3.2. 怎么编写异常的yaml
    3. 2.3. 问题2 使用了非strict模式
  3. 3. 修复
  4. 4. 思考