前言
之前一直有过这样的想法
服务端使用snakeyaml去解析字符串的时候,但是我们能控制的只是其中的一个value,类似于有一个这样的json格式的数据
1 | { |
他解析成yaml应该就是类似的结构
1 | a: payload |
一般的snakeyaml反序列化要求都是控制整个yaml文件,这种是无法利用的。
但我经常也在想,能不能利用换行来达到注入的效果呢,比如说我们希望输入test\n!!javax.script.ScriptEngineManager来达到破坏yaml解析的效果
1 | a: test |
但是一般这样是不行的,因为yaml会变成下面这种支持多行的结构
1 | text: |- |
但是今天带来的这个跟k8s相关的漏洞,就是利用解析生成yaml内容时破坏了原有的yaml结构,进而在后面解析yaml内容时产生问题,创造预期之外的资源达到提权的效果
漏洞细节
流程
整体描述
istio gateway controller 使用api server提供的watch机制监听etcd里存储的资源变化,当controller通过Watch API监听到资源变化时做出相应的动作。
当用户提交 kind 为 Gateway 的资源时,istio gateway controller 会做以下几件事:
- 解析 Gateway 配置: 控制平面首先解析 Gateway CRD 中定义的配置,这包括监听的端口、使用的协议和其他 TLS 相关设置。
- 生成 Envoy 配置: Istio 使用 Envoy 作为其边缘和内部服务代理。控制平面将 Gateway CRD 配置转换为 Envoy 配置,并将这些配置推送到相应的 Envoy 实例。
- 创建 Service 和 Deployment: 为了让 Envoy 代理可以在 Kubernetes 集群内运行,Istio 需要创建相应的 Service 和 Deployment。Service 确保 Envoy 可以通过固定的 IP 地址和端口从集群外部访问,而 Deployment 确保了 Envoy 代理的持续运行和管理。
- Service 确保网关可以接受流量。它通常是一个 LoadBalancer 类型,这样可以从集群外部访问。
- Deployment 确保有一组运行 Envoy 代理的 Pod 来处理经过网关的流量。
- 提交 Gateway 资源请求: 最后,控制平面会提交 Gateway 资源请求,这个请求本身不会在 Kubernetes 中创建一个实际的运行实体,而是被 Istio 的控制平面监视和解释,以确定如何配置 Envoy 代理。
先创建 Service 和 Deployment 的原因: Istio 先创建这些资源是因为它们是实际接收和处理流量的实体。Envoy 代理需要首先被部署和配置,以便它们可以根据 Gateway 资源的定义来正确地路由流量。
简而言之,Gateway CRD 本身只是一个配置对象,不会直接处理流量。是通过这个配置,Istio 控制平面指挥 Envoy 代理以确定的方式处理流量。因此,Istio 需要先创建 Service 和 Deployment,以便实际的代理可以运行并准备好根据 Gateway CRD 配置来接收流量。
代码层面
把给定的gateway资源按照模版解析成对应的service和deployment资源
解析的逻辑具体是在ApplyTemplate里做的,最后把资源提交给k8s api server
在service和deployment都提交之后,会提交一个Gateway的资源请求
问题1 模版解析不当导致yaml结构破坏
demo
首先我们看一个go的demo
1 | apiVersion: v1 |
1 | package main |
上面这个demo就是利用go的模版语法,但我们这里不是谈go的ssti,而是谈go模版会有其他的什么问题,运行一下这个样例,你会对这个输出结果感到惊讶
go的template支持换行这种操作,同时他在解析的时候不会去关注到底是什么格式,同时他也不会考虑到yaml本身的一些语法要求,你可以理解为他做的就只是从文件里面读内容,然后去把对应的表达式解析填上对应的值。
这种存在问题的解析造成了yaml结构被破坏
而我们在使用的时候,可以通过自定义一些函数,用来适配特殊场景,比如说如果是yaml字段,安全的使用方法就是使用我们这里的toYaml函数
1 | apiVersion: v1 |
可以看到,这样使用的话就无法造成yaml格式被破坏
漏洞本身
那么回到漏洞本身,我们在这个测试文件里面简单修改一下pilot/pkg/config/kube/gateway/deploymentcontroller_test.go,把里面的无关字段都去除了,只留下关键的部分,这里面相当于就是定义了一个gw资源
这里我们以service.yaml来验证,稍微修改一下service.yaml,只留下有问题的部分
可以看到,在metadata里面显示annotations的时候,用了toYamlMap来处理,所以这里不会有问题,当然他这里定义toYamlMap的本意可能也只是为了显示数据,毕竟传入的是map类型,无法直接输出出来
但是在spec的type里面,他相当于获取了Annotations[“networking.istio.io/service-type”]的值,默认是LoadBalancer
他在这里没有考虑到yaml语法的问题,导致存在解析问题
在进入ApplyTemplate之前,可以看到我们发起的gw资源请求,里面的annotations的value是\ntest
在ExecuteTemplate这里使用了go的模版语法进行解析,将我们传入的资源对象的值填写到service.yaml里
我们查看buf里面的值,可以看到确实破坏了yaml结构
调整一下内容
可以看到解析的效果如下图
到目前为止,问题也显而易见,如果是java的话,那么后面我们完全有能力去控制写一个snakeyaml反序列化的exp,导致反序列化漏洞
题外话
字段校验
我们光看原始的service.yaml,可能会感觉这个name和namespace好像也受影响
但是我们如果拿这个yml去创建资源的话会报错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
这里第三个参数是一个验证name格式的函数
apivalidation的ValidateObjectMeta直接调用了apimachineryvalidation的ValidateObjectMeta
重点处理逻辑在staging/src/k8s.io/apimachinery/pkg/api/validation/objectmeta.go这个文件里,调用了ValidateObjectMetaAccessor
ValidateObjectMetaAccessor的前部分就是,如果有GenerateName,那么就用传入的验证函数,也就是刚才的ValidateDeploymentName进行验证,如果有Name,那么也调用ValidateDeploymentName进行验证,如果有Namespace用ValidateNamespaceName进行验证
在ValidateObjectMetaAccessor的后面会调用不同的Validate函数
我们可以使用staging/src/k8s.io/apimachinery/pkg/api/validation/objectmeta_test.go进行测试
我们修改一下这个测试用例,加上Annotations,同时替换第三个参数
那么在这里就会进行一个Name的验证
对应Annotations来说
IsQualifiedName会check key的合理性
然后check一下大小,所以他没有对value进行限制,这个也解释了为什么payload里面输入\n这种字符不会报错
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里面组成,同时也有长度等要求
如果按照下面这个demo部署也会报错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 | package main |
测试的时候也可以通过调试看一下值是否被正常赋值了
1 | func main() { |
问题2 使用了非strict模式
这个漏洞的第二个问题就是解析yaml数据存在问题
在ExecuteTemplate模版解析完之后使用Unmarshal去解析yaml
这里可以注意到,默认设置的strict为false
我们如果换成true来解析的话,他会报错,因为他发现kind已经在之前定义过了
核心逻辑在这里
参看一下函数的定义,相当于如果开启了strict模式,并且发现返回值不是zeroValue,也就是代表之前已经出现过,那么他就会报错,already set in map
那么如果没有开启strict的话,那么就会调用SetMapIndex,他做的就是类似于v[key]=elem的操作
简单的demo
1 | package main |
可以发现,他会覆盖已有的key
那么经过yaml处理后,可以发现,本来kind应该为Service的资源,结果变成了pod
如果不进行覆盖的,最后传给api server的资源类似于下面这个结构,这种是正常的
1 | apiVersion: v1 |
但是如果我们覆盖的话,传入exp
在最后这里,查看j和us.Object
可以看到最后发送的api server的资源如下图
1 | apiVersion: apps/v1 |
可以看到,这个发送到api server资源完全被覆盖成我们自己定义的恶意Deployment了,本来只允许创造gateway的我们成功创建出了特权容器,也刚好对应漏洞描述
修复
在对应yaml里面修改了一下,加入了quote
传入之前的payload,可以看到不会破坏yaml结构了
思考
这种有解析问题的场景一般都是会结合模版解析+yaml解析,因为正常的yaml解析来说不会产生这种yaml结构破坏的问题,但是如果结合上模版解析了以后就可能有这种问题。说白了,如果模版解析没有考虑到yaml格式的问题,那么就会产生有问题的yaml,进而就会产生解析的问题。
同时另一方面,我们回顾一下k8s创造资源的流程,这里还是以Gateway为例
- 用户发送创建请求:使用kubectl apply -f命令发送创建Gateway资源的请求。
- API Server接收请求:Kubernetes API Server接收到创建资源的请求。
- 身份认证(Authentication):API Server首先对发送请求的用户进行身份认证。这个过程通常涉及到检查提供的证书、令牌(例如Bearer Token)或其他身份验证机制来确认用户的身份。
- 权限授权(Authorization):身份认证成功后,API Server将进行权限授权。在Kubernetes中,这通常是通过RBAC来进行的。RBAC使用Role和RoleBinding资源(或者ClusterRole和ClusterRoleBinding,如果权限是跨整个集群的)来定义谁可以执行哪些操作。
- 权限验证:
- Role/ClusterRole:定义了一组权限,这些权限规定了可以对哪些资源执行哪些动作(例如,创建、获取、更新、删除等)。
- RoleBinding/ClusterRoleBinding:将特定的用户、组或服务账户与一个Role或ClusterRole绑定,从而授予这些用户或服务账户相应的权限。
- 访问控制决策:API Server使用请求者的身份和请求的内容来确定是否允许该操作。如果请求者具有执行请求操作的足够权限,则请求被允许;否则,请求将被拒绝,并返回一个错误信息。
- 资源存储:如果用户有权限创建资源,API Server将继续处理请求,将资源定义存储到etcd,并触发后续的Istio控制平面组件进行处理。
- 控制平面的响应:一旦资源被存储,Istio的控制平面组件(如Istio’s Pilot,现在称为Istiod)会监听Kubernetes API Server的资源变化。当控制平面发现新的Gateway定义或者对现有的Gateway资源的更改时,它将开始处理这些变化。
控制平面在处理资源的时候,他会有一些自己内部的逻辑,这些逻辑可能会涉及到和api server进行通信,而这里的通信用的就是对应组件的权限,而如果用户可以通过代码里面的漏洞,去修改\劫持掉本来正常的通信,换成恶意的请求,那么就可以把用户的权限提升到对应组件的权限。
这个点又和CVE-2018-1002105这个漏洞很类似,这个漏洞就是构造一个特殊的请求,攻击者能够借助k8s API Server作为代理,建立一个到后端服务器的连接,并以k8s API Server的身份发送任意请求
同时其他组件可能也会有类似的问题,我们可以关注组件的controller里从处理资源请求到最后资源成功创建这个过程。
这种类似的风险在云服务厂商里面也普遍存在,云产品之前避免不了互相通信,有些通信用的是产品的特权,如果攻击者可以修改\劫持这些通信,那么就会被攻击