SpringMVC的URI解析和权限绕过
SpringMVC
目前的JavaWeb中非常流行SpringMVC作为Web应用的开发,本篇主要分析SpringMVC作为Web容器下对请求URI的解析到最后路由到Web接口的整个流程以及相关的权限绕过Trick,这些Trick在面对些Java产品的鉴权时候会有所帮助。SpringBoot是用SpringMVC编写构造出来的,后面的分析和测试都是用Springboot来做分析。
Tomcat和SpringMVC
SpringBoot内嵌tomcat,SpringMVC在Servlet-API的Servlet基础之上划分出DispatcherServlet来做整个SpringMVC的开始,像我们熟知的Interceptor拦截器和Controller都是在DispatcherServlet之后建立的。引用一张很形象的图体现就是:(图片来源):
- 发包过来的HTTP请求首先由Web服务器(tomcat)接收,然后Web服务器将该请求传递到Servlet。
- 根据路由映射,将经过Servlet过滤器,然后请求会被转发到Springboot。其中的DispatcherServlet是所有传入SpringMVC中间件请求的默认入口点。
- 当DispatcherServlet收到请求,它会查阅URL的HandlerMapping来确定应该将请求传递给哪个Controller控制器。
- 但在被控制器处理之前,请求会经过Interceptor拦截器。拦截器类似过滤器但并非过滤器,在给定的请求在到达控制器之前可以由不同数量的拦截器处理。
SpringMVC开发的项目鉴权往往会写在Interceptor拦截器中,所以我们再主要看下Inteceptor的处理过程。(图片来源):拦截器是通过实现handlerInterceptor接口的类,该接口具有3种方法:
- preHandle():返回一个布尔值,指示请求是否应继续到下一个拦截器/控制器。如果是true则表示进入下个拦截器/控制器,返回false则直接退出不会进行后续流程。
- postHandle():在调用控制器方法之后但在响应发送到客户端前执行。
- afterCompletion():响应发送到客户端后执行(主要做些清理任务)。
调试代码准备
准备如下的路由然后在第6行下断点开始分析
@Controller
@RequestMapping("/api")
public class HelloController {
@GetMapping("flag")
public void bindString(String id, HttpServletRequest request, HttpServletResponse response) throws IOException {
PrintWriter out = response.getWriter();
//OutputStream out = response.getOutputStream();
out.println(String.format("request.getServletPath: %s", request.getServletPath()));
out.println(String.format("request.getServletContext().getRealPath(\"/\"): %s", request.getServletContext().getRealPath("/")));
out.println(String.format("request.getServletContext().getRealPath(\"/abc\"): %s", request.getServletContext().getRealPath("/abc")));
out.println(String.format("request.getContextPath() + request.getServletPath(): %s", request.getContextPath() + request.getServletPath()));
out.println(String.format("request.getRequestURI: %s", request.getRequestURI()));
out.println(String.format("request.getRequestURL: %s", request.getRequestURL()));
out.println(String.format("request.getContextPath: %s", request.getContextPath()));
out.println(String.format("request.getPathInfo: %s", request.getPathInfo()));
out.println(String.format("request.getQueryString: %s", request.getQueryString()));
out.println(String.format("request.getPathTranslated: %s", request.getPathTranslated()));
out.println(String.format("request.getParameter(\"id\"): %s", request.getParameter("id")));
out.println(String.format("request.getParameter(\"name\"): %s", request.getParameter("name")));
return ;
}
}
调用栈如下:
bindString:21, HelloController (com.butler.springurlparser.Controller)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:497, Method (java.lang.reflect)
doInvoke:205, InvocableHandlerMethod (org.springframework.web.method.support)
invokeForRequest:150, InvocableHandlerMethod (org.springframework.web.method.support)
invokeAndHandle:117, ServletInvocableHandlerMethod (org.springframework.web.servlet.mvc.method.annotation)
invokeHandlerMethod:895, RequestMappingHandlerAdapter (org.springframework.web.servlet.mvc.method.annotation)
handleInternal:808, RequestMappingHandlerAdapter (org.springframework.web.servlet.mvc.method.annotation)
handle:87, AbstractHandlerMethodAdapter (org.springframework.web.servlet.mvc.method)
doDispatch:1067, DispatcherServlet (org.springframework.web.servlet)
doService:963, DispatcherServlet (org.springframework.web.servlet)
processRequest:1006, FrameworkServlet (org.springframework.web.servlet)
doGet:898, FrameworkServlet (org.springframework.web.servlet)
service:655, HttpServlet (javax.servlet.http)
service:883, FrameworkServlet (org.springframework.web.servlet)
service:764, HttpServlet (javax.servlet.http)
internalDoFilter:227, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, WsFilter (org.apache.tomcat.websocket.server)
internalDoFilter:189, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:100, RequestContextFilter (org.springframework.web.filter)
doFilter:117, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:189, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:93, FormContentFilter (org.springframework.web.filter)
doFilter:117, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:189, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:201, CharacterEncodingFilter (org.springframework.web.filter)
doFilter:117, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:189, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
invoke:197, StandardWrapperValve (org.apache.catalina.core)
invoke:97, StandardContextValve (org.apache.catalina.core)
invoke:541, AuthenticatorBase (org.apache.catalina.authenticator)
invoke:135, StandardHostValve (org.apache.catalina.core)
invoke:92, ErrorReportValve (org.apache.catalina.valves)
invoke:78, StandardEngineValve (org.apache.catalina.core)
service:360, CoyoteAdapter (org.apache.catalina.connector)
service:399, Http11Processor (org.apache.coyote.http11)
process:65, AbstractProcessorLight (org.apache.coyote)
process:890, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1743, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1191, ThreadPoolExecutor (org.apache.tomcat.util.threads)
run:659, ThreadPoolExecutor$Worker (org.apache.tomcat.util.threads)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:745, Thread (java.lang)
请求进入Tomcat容器
因为通常情况下Springboot内嵌Tomcat,所以我们直接按照上一篇的Tomcat分析思路直接在CoyoteAdapter这里下端点。而该处的 request.mappingData.wrapper 和之前Tomcat下的 request.mappingData.wrapper 变量并不相同,这里没有直接路由到最后的Servlet,而是路由到 DispatcherServlet。顾名思义在 DispatcherServlet中会进行分发的操作。
DispatcherServlet 分发请求
DispatcherServlet 继承自 FrameworkServlet
,实现了标准的 Java EE Servlet 接口,因此最初是调用到了 FrameworkServlet 的 service 方法。FrameworkServlet#service
地方会根据不同的请求方法调用不同的方法
这些doPost,doGet方法中又会调用processRequest方法最后调用到处理请求以及路由(分发)的方法DispatcherServlet#doService
:
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
logRequest(request);
// ...
if (this.parseRequestPath) {
previousRequestPath = (RequestPath) request.getAttribute(ServletRequestPathUtils.PATH_ATTRIBUTE);
ServletRequestPathUtils.parseAndCache(request);
}
try {
doDispatch(request, response);
}
// ...
}
doDispatch
DispatcherServlet#doDispatch
的核心简化实现如下:
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
processedRequest = ceckMultipart(request);
//根据请求获取handler和对应的拦截器
HandlerExecutionChain mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
//拦截器处理请求
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
//调用handler(bindString)来处理请求
ModelAndView mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}
HandlerExecutionChain 对象中包含通用类型的 handler 对象和 HandlerInterceptor 数组(该数组用于给开发者自定义在 handler 之前执行的代码也就是拦截器)
public class HandlerExecutionChain {
private static final Log logger = LogFactory.getLog(HandlerExecutionChain.class);
private final Object handler;
private final List<HandlerInterceptor> interceptorList = new ArrayList<>();
}
HandlerAdapter 对象可以理解为Handler适配器,这样做的目的主要是为了让 DispatcherServlet 代码中无需知晓 handler 的具体类型,从而具备可拓展的抽象能力。handle 将结果统一返回为 ModelAndView
类型,然后根据需要进行渲染并将结果写入到 response
完成响应提交的返回。
public interface HandlerAdapter {
boolean supports(Object handler);
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
}
getHandler
DispatcherServlet#getHandler
尝试从不同的 HandlerMapping 中获取当前 request 请求相对应的HandlerExecutionChain
。
@Nullable
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
if (this.handlerMappings != null) {
for (HandlerMapping mapping : this.handlerMappings) {
HandlerExecutionChain handler = mapping.getHandler(request);
if (handler != null) {
return handler;
}
}
}
return null;
}
handlerMappings
数组中包含多个 HandlerMapping 的实现,根据开发者具体定义的路由确定。我这里测试时注册的 handlerMapping 映射有以下这些:
这里借用evilpan师傅的HandlerMapping继承图,只是HandlerMapping的部分继承图。
我的请求会通过RequestMappingHandlerMapping
交予AbstractHandlerMapping#getHandler
来处理:通过 getHandlerInternal 方法寻找 handler,如果没有找到就返回默认的 handler
public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
Object handler = getHandlerInternal(request);
if (handler == null) {
handler = getDefaultHandler();
}
if (handler == null) {
return null;
}
// Bean name or resolved handler?
if (handler instanceof String) {
String handlerName = (String) handler;
handler = obtainApplicationContext().getBean(handlerName);
}
// Ensure presence of cached lookupPath for interceptors and others
// 确保拦截器和其他组件缓存查找路径的存在。
if (!ServletRequestPathUtils.hasCachedPath(request)) {
initLookupPath(request);
}
HandlerExecutionChain executionChain = getHandlerExecutionChain(handler, request);
// 看起来像cors跨域相关的处理
if (hasCorsConfigurationSource(handler) || CorsUtils.isPreFlightRequest(request)) {
CorsConfiguration config = getCorsConfiguration(handler, request);
if (getCorsConfigurationSource() != null) {
CorsConfiguration globalConfig = getCorsConfigurationSource().getCorsConfiguration(request);
config = (globalConfig != null ? globalConfig.combine(config) : config);
}
if (config != null) {
config.validateAllowCredentials();
}
executionChain = getCorsHandlerExecutionChain(request, executionChain, config);
}
return executionChain;
}
getHandlerInternal(获取当前请求handler,也就是注册的web服务接口)
AbstractHandlerMapping#getHandlerInternal
会调用父类的 AbstractHandlerMethodMapping#getHandlerInternal 方法来获取给定请求的处理方法。该方法主要做的工作就是:后面对URL Parse的分析也会围绕这俩个展开
- 从 request 中解析出请求的路由 lookupPath
- 根据 lookupPath 匹配到最合适的 handler
protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
//request中获取请求路由,如/api/flag
String lookupPath = initLookupPath(request);
this.mappingRegistry.acquireReadLock();
try {
//查找当前请求的最佳匹配处理程序方法。如果找到多个匹配项,则选择最佳匹配项。
HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
}
finally {
this.mappingRegistry.releaseReadLock();
}
}
initLookupPath 获取请求路由
从request请求中初始化路由,这里我们看到的是一组选择分支如果使用PathPattern将会进入if分支中,关于什么时候usesPathPatterns为true可以看看江南一点雨的usesPathPatterns文章。该方法中还通过UrlPathHelper类进行路径的处理,UrlPathHelper是Spring中的工具类,有很多与URL路径处理有关的方法。后续单独分析。
protected String initLookupPath(HttpServletRequest request) {
if (usesPathPatterns()) {
request.removeAttribute(UrlPathHelper.PATH_ATTRIBUTE);
RequestPath requestPath = ServletRequestPathUtils.getParsedRequestPath(request);
String lookupPath = requestPath.pathWithinApplication().value();
return UrlPathHelper.defaultInstance.removeSemicolonContent(lookupPath);
}
else {
return getUrlPathHelper().resolveAndCacheLookupPath(request);
}
}
获取请求路由中的Trick
usesPathPatterns 方法返回为 true:
- /api;jsessionid=XYZ789/flag;a=1:
removeSemicolonContent
为true场景下,调用remoceSemicolonContent
方法移除url中;
到/
之间的字符串或者是;
后面的内容; - /api/flag;jsessionid=XYZ789?param=value:
removeSemicolonContent
为false场景下调用removeJsessionid
方法移除jsessionid
参数 - /api/%66%6c%61%67:
decodeRequestString
方法下的Trick。 - /api/flag/
usesPathPatterns 方法返回为 false:
- /api/%2e%2e/api/flag:
alwaysUseFullPath
为false下的%2e Trick。 - //api/flag:
getSanitizedPath
方法下的Trick。 - /api/%66%6c%61%67:
decodeRequestString
方法下的Trick。 - 还有上面提到的移除
jsessionid
的俩条Trick。
lookupHandlerMethod 获取最佳匹配方法
该方法用于获取最适合请求的 HandlerMethod 方法,查找当前请求的最佳匹配处理程序方法,如果找到多个匹配项,则选择最佳匹配项。
@Nullable
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
List<Match> matches = new ArrayList<>();
// 根据路径获取对应的 mapping
List<T> directPathMatches = this.mappingRegistry.getMappingsByDirectPath(lookupPath);
// 将上面找到的 mapping 放到 matches 中
if (directPathMatches != null) {
addMatchingMappings(directPathMatches, matches, request);
}
// 遍历所有注册的 mappings 然后进行匹配
if (matches.isEmpty()) {
addMatchingMappings(this.mappingRegistry.getRegistrations().keySet(), matches, request);
}
if (!matches.isEmpty()) {
Match bestMatch = matches.get(0);
if (matches.size() > 1) {
Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
matches.sort(comparator);
bestMatch = matches.get(0);
if (CorsUtils.isPreFlightRequest(request)) {
for (Match match : matches) {
if (match.hasCorsConfig()) {
return PREFLIGHT_AMBIGUOUS_MATCH;
}
}
}
else {
Match secondBestMatch = matches.get(1);
if (comparator.compare(bestMatch, secondBestMatch) == 0) {
Method m1 = bestMatch.getHandlerMethod().getMethod();
Method m2 = secondBestMatch.getHandlerMethod().getMethod();
String uri = request.getRequestURI();
throw new IllegalStateException(
"Ambiguous handler methods mapped for '" + uri + "': {" + m1 + ", " + m2 + "}");
}
}
}
request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.getHandlerMethod());
handleMatch(bestMatch.mapping, lookupPath, request);
return bestMatch.getHandlerMethod();
}
else {
return handleNoMatch(this.mappingRegistry.getRegistrations().keySet(), lookupPath, request);
}
}
getMappingsByDirectPath:根据路由获取精确的Mapping
这个方法将根据后端注册的路由直接获取到相应的RequestMappingInfo
addMatchingMappings
private void addMatchingMappings(Collection<T> mappings, List<Match> matches, HttpServletRequest request) {
for (T mapping : mappings) {
T match = getMatchingMapping(mapping, request);
if (match != null) {
matches.add(new Match(match, this.mappingRegistry.getRegistrations().get(mapping)));
}
}
}
getMatchingMapping
RequestMappingInfoHandlerMapping#getMatchingMapping
根据 RequestMappingInfo 和 request 返回合适的 RequestMappingInfo 对象
@Override
protected RequestMappingInfo getMatchingMapping(RequestMappingInfo info, HttpServletRequest request) {
return info.getMatchingCondition(request);
}
getMatchingCondition
RequestMappingInfo#getMatchingCondition
,该方法在不同的版本中有不同的匹配逻辑。在该方法中会进行
- 请求方法类型的匹配
- 请求参数的匹配
- headers头的匹配
- Content-Type类型匹配
- Accept接受的媒体类型的匹配
- 路径的匹配
- URL路径匹配模式,如:/example/{id}
@Override
@Nullable
public RequestMappingInfo getMatchingCondition(HttpServletRequest request) {
// 请求方法类型的匹配
RequestMethodsRequestCondition methods = this.methodsCondition.getMatchingCondition(request);
if (methods == null) {
return null;
}
// 请求参数的匹配
ParamsRequestCondition params = this.paramsCondition.getMatchingCondition(request);
if (params == null) {
return null;
}
// headers头的匹配
HeadersRequestCondition headers = this.headersCondition.getMatchingCondition(request);
if (headers == null) {
return null;
}
// Content-Type类型匹配
ConsumesRequestCondition consumes = this.consumesCondition.getMatchingCondition(request);
if (consumes == null) {
return null;
}
// Accept接受的媒体类型的匹配
ProducesRequestCondition produces = this.producesCondition.getMatchingCondition(request);
if (produces == null) {
return null;
}
// 路径的匹配
PathPatternsRequestCondition pathPatterns = null;
if (this.pathPatternsCondition != null) {
pathPatterns = this.pathPatternsCondition.getMatchingCondition(request);
if (pathPatterns == null) {
return null;
}
}
// URL路径匹配模式,如:/example/{id}
PatternsRequestCondition patterns = null;
if (this.patternsCondition != null) {
patterns = this.patternsCondition.getMatchingCondition(request);
if (patterns == null) {
return null;
}
}
RequestConditionHolder custom = this.customConditionHolder.getMatchingCondition(request);
if (custom == null) {
return null;
}
return new RequestMappingInfo(this.name, pathPatterns, patterns,
methods, params, headers, consumes, produces, custom, this.options);
}
PathPatternsRequestCondition 路由模式匹配
在PathPatternsRequestCondition#getMatchingCondition会继续调用getMatchingPatterns进行匹配
@Override
@Nullable
public PathPatternsRequestCondition getMatchingCondition(HttpServletRequest request) {
PathContainer path = ServletRequestPathUtils.getParsedRequestPath(request).pathWithinApplication();
SortedSet<PathPattern> matches = getMatchingPatterns(path);
return (matches != null ? new PathPatternsRequestCondition(matches) : null);
}
这 getMatchingPatterns 方法中不同的 SpringBoot 版本使用的匹配器是不同的。
- 在Springboot 2.6之前默认使用的是AntPathMatcher
- 在Springboot 2.6之后默认使用的是PathPattern
关于这二者的区别可以学习 tkswifty 师傅的浅谈SpringWeb请求解析过程
我目前的版本使用的是PathPattern类,所以介绍下:PathPattern 是以斜杠 /
为间隔的路径模式,遵循以下规则:
-
?
匹配单个字符; -
*
匹配单个路径段(path segment)之间的零个或者多个字符; -
**
匹配零个或者多个路径段,直至路径结尾; -
{spring}
: 匹配一个路径段并且捕捉该段保存为变量 “spring”; -
{spring:[a-z]+}
: 同上,但要求路径段满足正则表达式[a-z]+
; -
{*spring}
: 类似于**
,但将匹配的路径段保存为变量 “spring”;
@Nullable
private SortedSet<PathPattern> getMatchingPatterns(PathContainer path) {
TreeSet<PathPattern> result = null;
for (PathPattern pattern : this.patterns) {
if (pattern.matches(path)) {
result = (result != null ? result : new TreeSet<>());
result.add(pattern);
}
}
return result;
}
重点就在 PathPattern#match 方法中,这一部分可以看evilpan师傅的分析:URL 解析与鉴权中的陷阱-Spring 篇
public boolean matches(PathContainer pathContainer) {
if (this.head == null) {
return !hasLength(pathContainer) ||
(this.matchOptionalTrailingSeparator && pathContainerIsJustSeparator(pathContainer));
}
else if (!hasLength(pathContainer)) {
if (this.head instanceof WildcardTheRestPathElement || this.head instanceof CaptureTheRestPathElement) {
pathContainer = EMPTY_PATH; // Will allow CaptureTheRest to bind the variable to empty
}
else {
return false;
}
}
MatchingContext matchingContext = new MatchingContext(pathContainer, false);
return this.head.matches(0, matchingContext);
}
PatternsRequestCondition URL路径模式匹配
PatternsRequestCondition#getMatchingCondition:调用 getMatchingPatterns 进一步:
@Override
@Nullable
public PatternsRequestCondition getMatchingCondition(HttpServletRequest request) {
String lookupPath = UrlPathHelper.getResolvedLookupPath(request);
List<String> matches = getMatchingPatterns(lookupPath);
return !matches.isEmpty() ? new PatternsRequestCondition(new LinkedHashSet<>(matches), this) : null;
}
PatternsRequestCondition#getMatchingPatterns
public List<String> getMatchingPatterns(String lookupPath) {
List<String> matches = null;
for (String pattern : this.patterns) {
String match = getMatchingPattern(pattern, lookupPath);
if (match != null) {
matches = (matches != null ? matches : new ArrayList<>());
matches.add(match);
}
}
if (matches == null) {
return Collections.emptyList();
}
if (matches.size() > 1) {
matches.sort(this.pathMatcher.getPatternComparator(lookupPath));
}
return matches;
}
PatternsRequestCondition#getMatchingPattern
:如果pattern与path相等,直接返回pattern,否则进行后缀模式匹配,这里涉及到两个属性:SuffixPatternMatch&TrailingSlashMatch。 根据这两个属性的boolean值会调用pathMatcher#match方法进行进一步的匹配
SuffixPatternMatch 和 TrailingSlashMatch 俩种匹配模式下的Trick
- 开启SuffixPatternMatch匹配:SuffixPatternMatch是后缀匹配模式,用于能以 .xxx 结尾的方式进行匹配。这里46对应的Ascii码是
.
,根据具体代码可以知道,当启用后缀匹配模式时,例如/hello和/hello.do的匹配结果是一样的。 - 开启TrailingSlashMatch匹配时,会应用尾部的/匹配,例如/hello和/hello/的匹配结果是一样的
@Nullable
private String getMatchingPattern(String pattern, String lookupPath) {
if (pattern.equals(lookupPath)) {
return pattern;
}
if (this.useSuffixPatternMatch) {
if (!this.fileExtensions.isEmpty() && lookupPath.indexOf('.') != -1) {
for (String extension : this.fileExtensions) {
if (this.pathMatcher.match(pattern + extension, lookupPath)) {
return pattern + extension;
}
}
}
else {
boolean hasSuffix = pattern.indexOf('.') != -1;
if (!hasSuffix && this.pathMatcher.match(pattern + ".*", lookupPath)) {
return pattern + ".*";
}
}
}
if (this.pathMatcher.match(pattern, lookupPath)) {
return pattern;
}
if (this.useTrailingSlashMatch) {
if (!pattern.endsWith("/") && this.pathMatcher.match(pattern + "/", lookupPath)) {
return pattern + "/";
}
}
return null;
}
在SpringMVC5.3后useSuffixPatternMatch的默认值会由true变为false
关于handler的注册
上面的俩部分记录了如何根据 request 请求中的 url 匹配最合适 handler,那AbstractUrlHandlerMapping#handlerMap
又是怎么注册的呢?
其实注册handler有俩种方式:无论下面哪种方式都要实现Controller接口或者AbstractController抽象类
- spring-contoller.xml类似这种命名的配置文件,将controller和url进行绑定然后注册
- @GetMapping,@PostMapping这种注解方式将controller和url进行绑定然后注册
参考文章:HandleMapping的注册与发现
getHandlerExecutionChain(获取当前请求的连接器链)
该方法的大致含义就是将注册拦截器与当前request进行匹配,匹配成功就塞入chain链中
UrlPathHelper 路径处理工具类
UrlPathHelper类是Spring中的工具类,主要根据相应的配置解析请求中的路径,里面实现了很多与URL路径处理有关的方法。
resolveAndCacheLookuppath
在Spring获取请求路由的时候会调用该方法
public String resolveAndCacheLookupPath(HttpServletRequest request) {
String lookupPath = this.getLookupPathForRequest(request);
request.setAttribute(PATH_ATTRIBUTE, lookupPath);
return lookupPath;
}
继续跟进,这里调用了getPathWithinApplication方法:
getLookupPathForRequest
该方法将对request的处理分为了俩部分,如果 this.alwaysUseFullPath 为true将会调用 getPathWithinApplication 来处理,如果this.alwaysUseFullPath 为false还会调用 getPathWithinServletMapping 进一步进行处理。这俩种不同的处理会导致一种权限绕过的可能。
public String getLookupPathForRequest(HttpServletRequest request) {
String pathWithinApp = getPathWithinApplication(request);
// Always use full path within current servlet context?
// 是否使用当前当前servlet context的全路径匹配
if (this.alwaysUseFullPath || skipServletPathDetermination(request)) {
return pathWithinApp;
}
// Else, use path within current servlet mapping if applicable
String rest = getPathWithinServletMapping(request, pathWithinApp);
if (StringUtils.hasLength(rest)) {
return rest;
}
else {
return pathWithinApp;
}
}
SpringBoot<=2.3.0.RELEASE下的Trick
在SpringBoot<=2.3.0.RELEASE 中alwaysUseFullPath
为默认值false,这种场景下除了调用getPathWithinApplication
方法。还会调用getPathWithinServletMapping
方法,该方法中会获取ServletPath,ServletPath会对uri标准化包括先解码然后处理跨目录等。所以在路由匹配时相当于会进行路径标准化包括对%2e
解码以及处理跨目录,这可能导致身份验证绕过。
而在SpringBoot>2.3.1.RELEASE 中alwaysUseFullPath
被设置成了true,这种场景下只会调用 getPathWithinApplication
方法,该方法中会先获取 RequestURI 然后解码但之后没有进行跨目录处理,所以保留了..
,这也导致在后面handler匹配的时候无法准确匹配到路由。
关于这部分的详细绕过可以参考:rui0师傅的Spring Boot中关于%2e的Trick
getPathWithinApplication
该方法中分别获取 contextPath 和 requestUri 然后通过 getRemainingPath 返回去除 contextPath
后的 requestUri
剩余部分
public String getPathWithinApplication(HttpServletRequest request) {
String contextPath = this.getContextPath(request);
String requestUri = this.getRequestUri(request);
String path = this.getRemainingPath(requestUri, contextPath, true);
if (path != null) {
// 如果内部路径有文本内容,则返回该路径;否则返回根路径 "/"
return StringUtils.hasText(path) ? path : "/";
} else {
return requestUri;
}
}
getContextPath
会进行对应的解码操作,decodeRequestString->decodeInternal,若设置了解码属性便进行对应的解码操作:
public String getContextPath(HttpServletRequest request) {
String contextPath = (String)request.getAttribute("javax.servlet.include.context_path");
if (contextPath == null) {
contextPath = request.getContextPath();
}
if (StringUtils.matchesCharacter(contextPath, '/')) {
contextPath = "";
}
return this.decodeRequestString(request, contextPath);
}
getRequestUri
该方法中先通过 request.getRequestURI() 方法获取当前request中的URI/URL,然后调用 decodeAndCleanUriString 进行URI解码、移除分号内容并清理斜线等进一步的处理:
public String getRequestUri(HttpServletRequest request) {
String uri = (String)request.getAttribute("javax.servlet.include.request_uri");
if (uri == null) {
uri = request.getRequestURI();
}
return this.decodeAndCleanUriString(request, uri);
}
decodeAndCleanUriString
该方法中主要就是对 uri 进行以下操作:
- removeSemicolonContent:移除
;
到/
之间的字符串或者是;
后面的内容 - uri 解码
- 将
//
替换为/
private String decodeAndCleanUriString(HttpServletRequest request, String uri) {
uri = this.removeSemicolonContent(uri);
uri = this.decodeRequestString(request, uri);
uri = getSanitizedPath(uri);
return uri;
}
removeSemicolonContent
如果 this.removeSemicolonContent
为 true 的话,将会移除url中 ;
到/
之间的字符串或者是;
后面的内容;否则将会移除url中 jsessionid
参数
public String removeSemicolonContent(String requestUri) {
return this.removeSemicolonContent ? removeSemicolonContentInternal(requestUri) : this.removeJsessionid(requestUri);
}
removeSemicolonContentInternal
private static String removeSemicolonContentInternal(String requestUri) {
int semicolonIndex = requestUri.indexOf(59);
if (semicolonIndex == -1) {
return requestUri;
} else {
StringBuilder sb;
for(sb = new StringBuilder(requestUri); semicolonIndex != -1; semicolonIndex = sb.indexOf(";", semicolonIndex)) {
int slashIndex = sb.indexOf("/", semicolonIndex + 1);
if (slashIndex == -1) {
return sb.substring(0, semicolonIndex);
}
sb.delete(semicolonIndex, slashIndex);
}
return sb.toString();
}
}
来看几个例子:
String result = removeSemicolonContentInternal("/path;sessionid=123");
// 输出: "/path"
String result = removeSemicolonContentInternal("/page;jsessionid=abc;param=value");
// 输出: "/page"
String result = removeSemicolonContentInternal("/resource;jsessionid=xyz/style.css;v=1.0");
// 输出: "/resource/style.css"
String result = removeSemicolonContentInternal("/category;id=123/subcategory;sort=asc");
// 输出: "/category/subcategory"
removeJsessionid
从给定的 requestUri
中移除 jsessionid
参数。
private String removeJsessionid(String requestUri) {
String key = ";jsessionid=";
int index = requestUri.toLowerCase().indexOf(key);
if (index == -1) {
return requestUri;
} else {
String start = requestUri.substring(0, index);
for(int i = index + key.length(); i < requestUri.length(); ++i) {
char c = requestUri.charAt(i);
if (c == ';' || c == '/') {
return start + requestUri.substring(i);
}
}
return start;
}
}
案例:
String result = removeJsessionid("/path/;jsessionid=ABC123");
// 输出: "/path/"
String result = removeJsessionid("/page;jsessionid=XYZ789?param=value");
// 输出: "/page?param=value"
String result = removeJsessionid("/resource;jsessionid=123/style.css");
// 输出: "/resource/style.css"
decodeRequestString
如果 this.urlDecode 为 true 则进行 url 解码
public String decodeRequestString(HttpServletRequest request, String source) {
return this.urlDecode ? this.decodeInternal(request, source) : source;
}
getSanitizedPath
将 uri 中的俩条//
转换为/
private static String getSanitizedPath(final String path) {
int start = path.indexOf("//");
if (start == -1) {
return path;
} else {
char[] content = path.toCharArray();
int slowIndex = start;
for(int fastIndex = start + 1; fastIndex < content.length; ++fastIndex) {
if (content[fastIndex] != '/' || content[slowIndex] != '/') {
++slowIndex;
content[slowIndex] = content[fastIndex];
}
}
return new String(content, 0, slowIndex + 1);
}
}
来看几个例子:
String result = getSanitizedPath("/path/with//double//slashes");
// 输出: "/path/with/double/slashes"
String result = getSanitizedPath("///absolute///path");
// 输出: "/absolute/path"
String result = getSanitizedPath("//root///nested//subdirectory");
// 输出: "/root/nested/subdirectory"
getRemainingPath
当调用getRemainingPath
方法时,它会比较requestUri
和mapping
,然后返回匹配成功时requestUri
的剩余部分。
@Nullable
private String getRemainingPath(String requestUri, String mapping, boolean ignoreCase) {
int index1 = 0;
int index2 = 0;
for (; (index1 < requestUri.length()) && (index2 < mapping.length()); index1++, index2++) {
char c1 = requestUri.charAt(index1);
char c2 = mapping.charAt(index2);
if (c1 == ';') {
index1 = requestUri.indexOf('/', index1);
if (index1 == -1) {
return null;
}
c1 = requestUri.charAt(index1);
}
if (c1 == c2 || (ignoreCase && (Character.toLowerCase(c1) == Character.toLowerCase(c2)))) {
continue;
}
return null;
}
if (index2 != mapping.length()) {
return null;
}
else if (index1 == requestUri.length()) {
return "";
}
else if (requestUri.charAt(index1) == ';') {
index1 = requestUri.indexOf('/', index1);
}
return (index1 != -1 ? requestUri.substring(index1) : "");
}
以下是一些调用该方法的例子:
String requestUri = "/example/path/123";
String mapping = "/example/path";
String remainingPath = getRemainingPath(requestUri, mapping, false);
// 结果应该是 "/123"
String requestUri = "/Example/Path/123";
String mapping = "/example/path";
String remainingPath = getRemainingPath(requestUri, mapping, true);
// 结果应该是 "/123"
String requestUri = "/example/wrongpath/123";
String mapping = "/example/path";
String remainingPath = getRemainingPath(requestUri, mapping, false);
// 结果应该是 null,因为路径不匹配
String requestUri = "/example/path";
String mapping = "/example/path";
String remainingPath = getRemainingPath(requestUri, mapping, false);
// 结果应该是空字符串,因为已经完全匹配
String requestUri = "/example/path";
String mapping = "/example/path";
String remainingPath = getRemainingPath(requestUri, mapping, false);
// 结果应该是空字符串,因为已经完全匹配
String requestUri = "/example/path;param=value/123";
String mapping = "/example/path";
String remainingPath = getRemainingPath(requestUri, mapping, false);
// 结果应该是 "/123",分号后的内容被正确处理
getPathWithinServletMapping
getPathWithinServletMapping
protected String getPathWithinServletMapping(HttpServletRequest request, String pathWithinApp) {
// 获取ServletPath
String servletPath = getServletPath(request);
// 将 uri 中的俩条 // 转换为 /
String sanitizedPathWithinApp = getSanitizedPath(pathWithinApp);
String path;
// 返回去除掉 servletPath 后的路径
if (servletPath.contains(sanitizedPathWithinApp)) {
path = getRemainingPath(sanitizedPathWithinApp, servletPath, false);
} else {
path = getRemainingPath(pathWithinApp, servletPath, false);
}
// 如果path不为null,表示URI包含servletPath
if (path != null) {
return path;
} else {
// URI与servletPath相同
// 获取路径信息(pathInfo)
String pathInfo = request.getPathInfo();
// 如果路径信息可用,表示在Servlet映射内的索引页
if (pathInfo != null) {
return pathInfo;
}
// 如果未启用URL解码,且没有路径信息
if (!this.urlDecode) {
// 对pathWithinApp进行解码并再次尝试匹配
path = getRemainingPath(decodeInternal(request, pathWithinApp), servletPath, false);
if (path != null) {
return pathWithinApp;
}
}
// 否则,使用完整的Servlet路径
return servletPath;
}
}
getServletPath
通过 request.getServletPath 方法获取 ServletPath,
/**
* 返回给定请求的Servlet路径,同时考虑在RequestDispatcher include请求URL内调用的情况。
* <p>由于{@code request.getServletPath()}返回值已经被Servlet容器解码,
* 因此此方法不会尝试再次解码。
* @param request 当前的HTTP请求
* @return Servlet路径
*/
public String getServletPath(HttpServletRequest request) {
// 尝试从request属性中获取Servlet路径,用于处理include请求
String servletPath = (String) request.getAttribute(WebUtils.INCLUDE_SERVLET_PATH_ATTRIBUTE);
// 如果request属性中未找到Servlet路径,则从request对象中获取
if (servletPath == null) {
servletPath = request.getServletPath();
}
// 如果Servlet路径长度大于1,且以斜杠结尾,同时需要移除末尾的Servlet路径斜杠
if (servletPath.length() > 1 && servletPath.endsWith("/") && shouldRemoveTrailingServletPathSlash(request)) {
// 在WebSphere中,对于 "/foo/" 这样的情况,在非兼容模式下,结果是 "/foo",而在所有其他servlet容器上结果是 "/foo/"。
// 移除末尾的斜杠,并继续将该剩余的斜杠作为最终查找路径...
servletPath = servletPath.substring(0, servletPath.length() - 1);
}
// 返回最终的Servlet路径
return servletPath;
}
BypassTrick(总结)
假设后端的路由是/api/flag,我们可以通过下面变形的path方法到它
获取请求路由时的 usesPathPatterns 方法返回为 true 情况下:
- /api;jsessionid=XYZ789/flag;a=1:
removeSemicolonContent
为true场景下,调用remoceSemicolonContent
方法移除url中;
到/
之间的字符串或者是;
后面的内容; - /api/flag;jsessionid=XYZ789?param=value:
removeSemicolonContent
为false场景下调用removeJsessionid
方法移除jsessionid
参数 - /api/%66%6c%61%67:
decodeRequestString
方法下的Trick。 - /api/flag/
获取请求路由时的 usesPathPatterns 方法返回为 false 情况下:
- /api/%2e%2e/api/flag:
alwaysUseFullPath
为false下的%2e Trick。在<=SpringBoot-2.3.0.RELEASE的版本中alwaysUseFullPath
为false。 - //api/flag:
getSanitizedPath
方法下的Trick。 - /api/%66%6c%61%67:
decodeRequestString
方法下的Trick。 - 还有上面提到的
jsessionid
的俩条Trick。
路径匹配Handler时开启SuffixPatternMatch匹配情况下:
- /api/flag.do:SuffixPatternMatch为true的情况下。spring-webmvc 5.3后相关useSuffixPatternMatch的默认值会由true变为false
- /api/flag.%64%6f
路径匹配Handler时开启TrailingSlashMatch匹配情况下:
- /api/flag/:TrailingSlashMatch为true的情况下
案例学习
简单的小案例:如果拦截器是这样拦截请求的,该如何绕过:
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取请求 URI
String uri = request.getRequestURI();
// 如果 URI 包含 "swagger-ui",直接放行
if (uri.contains("swagger-ui")) {
return true; // 放行请求,继续执行后续处理
}
return false; // 如果没有拦截条件,返回 true 继续请求处理
}
bypass:有如下的Trick都可以绕过
/api/swagger-ui/%2e%2e/flag <2.3.1.RELEASE spring-web-5.2.6,alwaysUseFullPath为false下的%2eTrick
/api/flag.swagger-ui <2.3.1.RELEASE spring-web-5.2.6,SuffixPatternMatch为true的情况下
/api/admin;swagger-ui 全版本
/;swagger-ui/api/flag 全版本
Reference
-
-
SpringMVC
- Tomcat和SpringMVC
- 调试代码准备
- 请求进入Tomcat容器
- DispatcherServlet 分发请求
- doDispatch
- getHandler
- getHandlerInternal(获取当前请求handler,也就是注册的web服务接口)
- initLookupPath 获取请求路由
- 获取请求路由中的Trick
- lookupHandlerMethod 获取最佳匹配方法
- getMappingsByDirectPath:根据路由获取精确的Mapping
- addMatchingMappings
- getMatchingMapping
- getMatchingCondition
- PathPatternsRequestCondition 路由模式匹配
- PatternsRequestCondition URL路径模式匹配
- SuffixPatternMatch 和 TrailingSlashMatch 俩种匹配模式下的Trick
- 关于handler的注册
- getHandlerExecutionChain(获取当前请求的连接器链)
- UrlPathHelper 路径处理工具类
- resolveAndCacheLookuppath
- getLookupPathForRequest
- SpringBoot<=2.3.0.RELEASE下的Trick
- getPathWithinApplication
- getContextPath
- getRequestUri
- decodeAndCleanUriString
- removeSemicolonContent
- removeJsessionid
- decodeRequestString
- getSanitizedPath
- getRemainingPath
- getPathWithinServletMapping
- getServletPath
- BypassTrick(总结)
- 案例学习
-