SpringMVC的URI解析和权限绕过
B0T1eR 发表于 北京 技术文章 1133浏览 · 2024-11-29 13:48

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之后建立的。引用一张很形象的图体现就是:(图片来源):

  1. 发包过来的HTTP请求首先由Web服务器(tomcat)接收,然后Web服务器将该请求传递到Servlet。
  2. 根据路由映射,将经过Servlet过滤器,然后请求会被转发到Springboot。其中的DispatcherServlet是所有传入SpringMVC中间件请求的默认入口点。
  3. 当DispatcherServlet收到请求,它会查阅URL的HandlerMapping来确定应该将请求传递给哪个Controller控制器。
  4. 但在被控制器处理之前,请求会经过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的分析也会围绕这俩个展开

  1. 从 request 中解析出请求的路由 lookupPath
  2. 根据 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 是以斜杠 /​ 为间隔的路径模式,遵循以下规则:

  1. ?​ 匹配单个字符;
  2. *​ 匹配单个路径段(path segment)之间的零个或者多个字符;
  3. **​ 匹配零个或者多个路径段,直至路径结尾;
  4. {spring}: 匹配一个路径段并且捕捉该段保存为变量 “spring”;
  5. {spring:[a-z]+}: 同上,但要求路径段满足正则表达式 [a-z]+​;
  6. {*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抽象类

  1. spring-contoller.xml类似这种命名的配置文件,将controller和url进行绑定然后注册
  2. @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 进行以下操作:

  1. removeSemicolonContent:移除 ;​ 到/​之间的字符串或者是;​后面的内容
  2. uri 解码
  3. //​替换为/
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

1 条评论
某人
表情
可输入 255