Spring + Jetty 场景下越权分析
date
Jun 28, 2022
slug
spring-jetty-vuln-analyse
status
Published
tags
Java安全
安全研究
summary
在群里看到两个关于权限校验的漏洞,然后想起之前遇到一个jetty+Spring环境的越权案例,毕竟比起使用 Tomcat,jetty的环境在云环境上有更高的效率,各互联网中大厂都逐渐使用jetty部署应用,这里分析一下
type
Post
前言
在群里看到两个关于权限校验的漏洞,然后想起之前遇到一个jetty+Spring环境的越权案例,毕竟比起使用Tomcat,jetty的环境在云环境上有更高的效率,各互联网中大厂都逐渐使用jetty部署应用,这里分析一下
环境搭建
新建一个Springboot版本为2.1.9.RELEASE的demo环境,因为需要jetty环境,就需要将原来的Tomcat去除掉,加载filter的config如下:
FilterConfig:
package com.example.jettytest.Config;
import com.example.jettytest.Filter.TestFilter;
import org.springframework.boot.web.servlet.DelegatingFilterProxyRegistrationBean;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FilterConfig {
@Bean
public TestFilter testFilter(){
return new TestFilter();
}
@Bean
public FilterRegistrationBean registrationBean(){
FilterRegistrationBean bean = new FilterRegistrationBean();
bean.setFilter(testFilter());
bean.addUrlPatterns("/portals/resume/*");
bean.setName("testjetty");
bean.setOrder(1);
return bean;
}
}
具体的环境供大哥们测试:
https://github.com/0v3rW4tch/Jetty-Spring-demo
触发效果对比
流程分析
diff了绕过与未绕过payload之间函数调用栈,发现不同点就在于多出了自定义filter的处理上
另外从函数调用栈也能分析出Jetty的一个运行特点,是一个链式调用的结构,从一个ServletHandler到下一个ServletHandler的处理,每个handler处理具体的操作流程
类似下面的图结构,该图是以Jetty中ScopedHandler为例,本质上ServletHandler继承了ScopedHandler,所以原理是一样的
再具体往下跟进就是Spring的处理流程了,但是此时处理的url是没有经过处理的,但还是能够命中对应Controoler的功能,所以这块地方也是有问题的:
所以经过初步调试,这个问题可以判断为是jetty+Spring场景导致的绕过,于是分析就放在两个重点上:
- jetty对filter的处理上
- Spring对路由的处理上
深入分析
jetty对filter的处理
从filter处理入口分析 ,在
org.eclipse.jetty.servlet.ServletHandler#doHandle
会初始化相关的filterchain,此外,在jetty启动服务的时候,会直接将全局的所有filter以及对应的url mapping内容存放在_filterMappings
这个变量中,后续初始化filterchain会根据这个变量进行处理通过
getFilterChain
函数去确定当前路由所需要的filterchain,jetty处理filter的关键点就在于org.eclipse.jetty.servlet.ServletHandler#getFilterChain
,这个过程中,jetty会对处理过的路由做一次缓存,缓存的filterchain内容会放在_chainCache
变量中,这是一个Map类型的变量,后续只要是重复出现的路由都会使用这个Map去匹配key,快速返回对应的filterchain如果不存在缓存就会迭代所有的
_filterMappings
,然后会把所有路由能够匹配上的filter添加到filters里面,也就是生成这个对应路由的filterchain了,这个也是jetty处理filter路由的关键了,此时会走这样一个流程,org.eclipse.jetty.servlet.FilterMapping#appliesTo(java.lang.String, int)
—>org.eclipse.jetty.http.PathMap#match(java.lang.String, java.lang.String, boolean)
—>org.eclipse.jetty.http.PathMap#isPathWildcardMatch
—> java.lang.String#regionMatches(int, java.lang.String, int, int)
最后在
java.lang.String#regionMatches(int, java.lang.String, int, int)
函数进行具体的匹配,可以在这里分析为什么//
这样的payload是可以绕过的一开始filter对应的Mapping是
/portals/resume/*
,处理之前会先把/*
删除掉,剩下/portals/resume
,以这个字符的长度再去与输入的路由/portals//resume/get
进行比较,一旦出现不一样的的字符就会认为这个当前的filter这个路由不匹配所以可以得出一个结论,只要这个filter的路由通配符之前的内容有一点点不一样都不会把相关的filter放进filterchain里面,也就不会进行相关的filter检测
Spring对路由的处理
从
org.springframework.web.servlet.DispatcherServlet#doDispatch
入口分析,相关的请求会进入到org.springframework.web.servlet.DispatcherServlet#getHandler
最后会进入到org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#getHandlerInternal
在
getHandlerInternal
函数中有一个lookupHandlerMethod
方法,是Spring处理路由的关键方法,可以分析这个函数,去了解Spring的路由解析的原理该方法首先会寻找是否存在对应的路由,在
this.mappingRegistry
里面加载了项目里面所有路由,触发org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.MappingRegistry#getMappingsByUrl
函数,以输入的/portals//resume/get
为例,一开始是找不到对应的路由在这里面的,所以directPathMatches
返回了null,所以会进入到matches.isEmpty()
的判断分支,在这个分支里面遍历所有路由进行具体路由解析操作,会把适合/portals//resume/get
的路由匹配出来,代码的注释也很清楚了// No choice but to go through all mappings...
后续跟进到
org.springframework.web.servlet.mvc.method.RequestMappingInfo#getMatchingCondition
,会在这一处地方进行具体路由的正则匹配继续往下跟,跟进到
org.springframework.util.AntPathMatcher#doMatch
,在这里可以观察到是如何进行路由解析的,对应的匹配的路由/portals/resume/get
会被分割成["portals", "resume", "get"]
,而输入的路由/portals//resume/get
也会被分割成["portals", "resume", "get"]
,然后再对这个数组内容进行相应的判断,最后返回是否匹配,可以看到代码里面关键处理path
的地方在tokenize
的相关操作跟进
tokenize
相关函数并进行分析,其实本质上的功能就是以/
进行分割 ,并取到内容不为空的作为判断的列表内容所以根据分析结果,绕过的payload其实可以在中间添加上无数的斜杠也能打
思路扩展
那为什么
;
,%2f
之类的操作也是可以绕过的呢原因在于
org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#getHandlerInternal
执行过程中会存在一个path的处理过程,在org.springframework.web.util.UrlPathHelper#getLookupPathForRequest
函数中getPathWithinServletMapping
函数有具体的操作,默认会走这个分支,因为alwaysUseFullPath
变量默认初始化未false只要path有点不同的话,就会返回
servletPath
,servletPath
是通过getServletPath
获取的,里面走的是HttpServletRequest
的getServletPath
函数,可以尝试着使用./
,../
,;
等符号扩展新的攻击向量,以上攻击向量组合最终目的都是为了经过getServletPath
都会变成中间多个斜杠的路由形式,比如:/portals///resume/get
,后面的流程就和之前触发controller的流程一样了/portals/;/resume/get
/portals/;xxxxxxx/resume/get #这里的xxxx代表任意字符
/portals/;/;/resume/get
/portals/;xxxx/;xxxxx/resume/get
/portals/abc/..//resume/get
/portals/abc/haha/../..//resume/get
/portals//abc/../resume/get
portals//abc/../;xxx/./resume/get
/portals/.//resume/get
/portals/%2f%2f/resume/get
/portals/%2f%2f/;xxxxxxx/resume/get
高版本测试
JDK 8 下使用的都应该是jetty 9(9.4.48.v20220622是目前的最高版本),高版本jetty需要jdk 11+ 的支持,所以高版本对业务并不友好
测试了以下版本:
测试很奇怪的是2.5.13的spring-boot-starter-parent版本的jetty版本比2.6.x更高,但是却还是可以打,推测应该跟spring的版本有一定关系
成功防护的版本调试发现应该是对路径做了一些防护,导致最后将请求重置,回复一个/error路由的response
最后在调试的时候发现,果然是spring的问题,修改的地方主要在
org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#lookupHandlerMethod
之前在这个函数里面存在addMatchingMappings
会遍历所有的Mapping寻找一个最合适的路由,但是从2.6.0开始这个方式就改变了,没有这个路由就是没有,不会去寻找一个类似的路由,最后返回handleNoMatch
对象,后续就会触发请求返回404了那为什么2.5.13又能够可以越过权限呢?明明spring-core版本比2.6.0的还高,分析了一下差异:主要在
org.springframework.web.servlet.handler.AbstractHandlerMapping#initLookupPath
,在这个函数里面2.6.0走的是上面的if条件,而2.5.13走的是else的分支,else分支会返回
/portals/resume/get
路径,导致后面能够寻找到这个路由,而if语句返回的路径是/portals//resume/get
根据上面严格寻找路由的方法当然在后面的流程中就找不到了,这个问题应该是跟Spring的匹配模式相关(AntPathMatcher与PathPattern之间谁用谁的问题),2.6.0之后应该是强行使用PathPattern模式了,也导致问题的修复了jetty 10测试
Jetty 10测试,必须要jdk 11以上的版本启动中间件,不然会报错
Jetty 10.0.11 (jetty 10的最新版本)是不会存在这个问题的
原理是啥?问题主要是在Jetty的处理上,存在一个解析HTTP请求内容的函数
org.eclipse.jetty.server.HttpConnection#parseRequestBuffer
—> org.eclipse.jetty.http.HttpParser#parseFields
后面会到
org.eclipse.jetty.http.UriCompliance#checkUriCompliance
这个函数里面检测路径相关的信息判断是否符合一下这些特性,如果符合这些特性就会产生一个badMessage,抛出异常,当然在respose就已经能看到这个错误信息了,
reason: Ambiguous URI empty segment
这些属性可以在官网里面找到,分别代表不同的URL特性:
查了一下,在一个changelog里面看到详细的版本更换信息,在10.0.5和11.0.5之前都是没有这个属性的,也就是这之后的版本才修的
//
的这个问题测试了一下10.0.4版本,确实没防住
总结
jetty+Spring场景下可以使用
//
进行权限绕过的测试,在知道实际路由的情况下,打实际站点的时候完全可以测试一下,可能就成了呢