CVE-2022-22947复现与分析
前言
Spring Cloud Gateway 是一个基于 Spring WebFlux 构建的 API 网关服务,核心目标是提供一种简单、高效、可扩展的路由和过滤方式,替代 Zuul 1.x,在微服务场景基本属于必备项。为了完成漏洞分析,需要了解 Gateway 的一些基本概念:
- Route(路由):网关的基本构建块,由 ID、目标 URI、断言集合和过滤器集合组成。
- Predicate(断言):匹配请求的条件(如路径、请求方法、请求头等)。所有断言都满足时,请求才会被路由到指定服务。
- Filter(过滤器):在请求被路由前或后对请求/响应进行处理,如鉴权、日志、限流、修改请求路径等。
Spring Cloud Gateway 的工作原理是客户端请求到达 Gateway,Gateway Handler Mapping根据请求匹配某个Route,匹配成功后,请求经过该Route配置的一系列Filter链处理,最后被转发到目标服务,响应再经过后置过滤器返回客户端。
本文旨在分析Spring Cloud Gateway的一个经典漏洞CVE-2022-22947,漏洞类型属于SpEL表达式注入。根据CVE官方描述,在 Spring Cloud Gateway 3.1.1 及更高版本和 3.0.7 及更高版本中,如果启用了 Gateway Actuator 端点,并且该端点暴露且未加保护,则应用程序容易受到代码注入攻击。远程攻击者可以发出恶意构造的请求,从而允许在远程主机上执行任意远程代码:

本文基于 Spring Cloud Gateway 3.1.0 复现和分析。
漏洞分析
从Github下载存在漏洞的版本 v3.1.0,按照逆向审计的思路,还是先看看官方的修复补丁:
commit信息已经总结了修改方法It uses a SimpleEvaluationContext as a delegate that adds a BeanResolver for bean references in SpEL.

在ShortcutConfigurable#getValue这个方法用GatewayEvaluationContext(基于 SimpleEvaluationContext,区别在于GatewayEvaluationContext没有禁用BeanResolver,因为 Gateway 在解析 Filter 参数时,需要支持 Bean 引用)替换了StandardEvaluationContext来执行SPEL表达式。
回到漏洞版本代码的对应部分,寻找getValue方法被调用的情况:

ShortcutType的三个取值(DEFAULT、GATHER_LIST、GATHER_LIST_TAIL_FLAG)中被调用,且三处调用类似。以DEFAULT为例,继续寻找调用关系:

找到org.springframework.cloud.gateway.support.ConfigurationService.class#normalizeProperties():

这个normalizeProperties()是对filter的属性进行解析,会将filter的配置属性传入normalize()中,最后进入getValue执行SPEL表达式。
normalizeProperties()被org.springframework.cloud.gateway.support.ConfigurationService.AbstractBuilder#bind调用,bind()有多处调用,但最终会沿着org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator#loadGatewayFilters->org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator#getFilters->org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator#convertToRoute->org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator#getRoutes这条调用链找到getRoutes()。
至于怎么确定是这条链,要回到 CVE-2022-22947 的触发方式,是通过动态添加路由+refresh触发的,而不是启动时加载静态 YAML 配置。这个路由是通过/actuator/gateway/routes/{id}POST进来的,这一步是保存RouteDefinition,真正触发 SpEL 执行的是刷新路由/actuator/gateway/refresh,刷新会触发RouteDefinitionRouteLocator重新加载所有RouteDefinition并调用convertToRoute。这一步也很容易产生一个误解,以为再往上是org.springframework.cloud.gateway.actuate.GatewayControllerEndpoint#route,其实这个GET /routes/{id}调用的getRoutes()返回的是已经加载好、可执行的Route对象列表,和漏洞触发时的getRoutes()(来自RouteDefinitionRouteLocator内部的加载/刷新流程)不是同一回事。
现在可以捋一捋整个调用链了:
POST /actuator/gateway/routes/{id}
-> GatewayControllerEndpoint.addRoute() → 保存 RouteDefinition
-> POST /actuator/gateway/refresh
-> RouteDefinitionRouteLocator.getRoutes()
-> RouteDefinitionRouteLocator.convertToRoute(RouteDefinition)
-> RouteDefinitionRouteLocator.getFilters()
-> RouteDefinitionRouteLocator.loadGatewayFilters()
-> AbstractBuilder.bind()
-> ConfigurationService.ConfigurableBuilder.normalizeProperties()
-> ShortcutConfigurable.ShortcutType.normalize()
-> ShortcutConfigurable.getValue() ← SpEL 执行点(漏洞)
漏洞复现
创建一个新的Spring Boot项目,关键是pom.xml的依赖项配置:

可以看到已经示警:

还要修改下application.properties:

这里改了下端口,主要是避免跟 BurpSuite 默认监听的 8080 端口冲突;显式启用了 Gateway 模块提供的 Actuator 端点,确保 /actuator/gateway/ 系列端点可用,后面复现需要用到。
为了验证下项目正常启动,修改下启动类:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class SpringcloudCve202222947Application {
@RequestMapping("/")
public String index() {
return "Gateway is running!";
}
public static void main(String[] args) {
SpringApplication.run(SpringcloudCve202222947Application.class, args);
}
}
没有问题:

下面开始依次调用POST /actuator/gateway/routes/{id}和POST /actuator/gateway/refresh,前者的参数有RouteDefinition类,参数如下:

PredicateDefinition和FilterDefinition参数都是:

参数构造起来不算难,其中恶意 SpEL 放在filters。这里为了简化poc其实有的参数是可以省略的,比如predicates,虽然标了@NotEmpty,但是因为predicates只用于路由匹配,而 SpEL 执行发生在转换RouteDefinition为Route对象阶段(即refresh时),此时即使没有 predicates,转换过程也会正常进行;order不需要输入,metadata直接省略;uri还是要,否则在Route.async()里容易空指针;FilterDefinition的name要选择实现了 ShortcutConfigurable 的 GatewayFilterFactory:

比如Retry、RedirectTo等等。
所以现在请求体格式如下:
{
"id": "任意不可重复",
"filters": [{
"name": "如上图",
"args": {
"name": "任意",
"value": "恶意SpEL"
}
}],
"uri": "任意"
}
poc如下:
{
"id": "2333",
"filters": [{
"name": "RedirectTo",
"args": {
"name": "evil",
"value": "#{T(java.lang.Runime).getRunTime().exec(\"calc\")}"
}
}],
"uri": "http://localhost"
}

响应码是201 Created。继续请求/refresh,成功触发:
