前言
在某一次分析漏洞的时候,我发现了一个有趣的现象,当时使用的 Spring 环境为:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
这是一个比较高的 Spring 版本,许多师傅应该知道当 spring boot 版本< 2.3.1 时,Spring 可以直接支持 ../ ,这时我们可以使用 ../ 的归一化特性通过 /static/../api 访问接口 /api,许多情况下我们可以利用这个 trick 满足开发者设定的白名单。
我当时的环境显然不在这个特性的适用范围之内,但是我却仍然能利用 ../ 构造白名单开头的 url 访问接口,经过我的测试,我发现这样的特性有一个潜在的适用条件,只有在 application.properties 中配置了 server.servlet.context-path 才能成功。比如我们设置 server.servlet.context-path=/demo,这时我们可以通过访问 /static/../demo/hello 访问 hello 接口:

但如果我们没有配置 server.servlet.context-path 或者直接配置为 /,我们却不能通过访问 /static/../hello 访问 hello 接口:

Spring 是如何解析我们的 url 的
Spring 的路由转发其实是一个非常复杂的过程,也有许多讲的非常好的文章分析了具体的访问流程:【spring】DispatcherServlet详解:

简单来说从浏览器到 Controller 的访问流程如下:
浏览器请求:GET http://localhost:8080/demo/hello
↓
Tomcat(Servlet 容器)
↓
DispatcherServlet(Spring MVC 前端控制器)
↓
HandlerMapping(根据 URL 寻找匹配的 Controller 方法)
↓
HandlerAdapter(调用 Controller)
↓
Controller 方法执行(HelloController.hello())
↓
返回 ModelAndView → 渲染视图 / JSON 输出
首先 Tomcat 作为 Servlet 容器,接收到请求 /demo/hello,它会解析出以下三部分:
| 名称 | 含义 | context-path = /demo |
|---|---|---|
| contextPath | Web 应用部署路径,通常来自配置 | /demo |
| servletPath | 匹配到的 Servlet 的路径前缀 | /hello |
| pathInfo | Servlet 映射后的剩余路径(可选) | null |
Tomcat 决定将此请求分发给某个 Servlet,在 Spring Boot 中,DispatcherServlet 是唯一的前端控制器 Servlet,所有请求都会交给 DispatcherServlet 处理,它会接管请求进行统一的分派;接下来它会从 HttpServletRequest 提取出 Spring 内部使用的路径,即 LookupPath,无论是否配置 contextPath,Spring 内部的匹配路径都是去掉 contextPath 后的路径,即 /hello.
获取 LookupPath 后,HandlerMapping 会匹配 Controller 方法,在这里也就是:
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello fushuling";
}
}
匹配的原则是HTTP 方法一致、LookupPath 一致(/hello)且其他条件一致(headers、params、produces 等)。然后 DispatcherServlet 会调用对应的 HandlerAdapter(通常是 RequestMappingHandlerAdapter),完成返回值处理(视图渲染或 JSON 输出)、参数解析(@RequestParam, @PathVariable, @RequestBody 等)和方法执行;最后,我们才终于执行到 Controller,在浏览器中返回 hello fushuling.
调试 Spring
分析上面的流程,既然当我们不配置上下文时,/static/../hello 的访问结果为404,很显然是因为 LookupPath 不正确,导致我们没有找到 Spring 内部使用的路径(pathWithinApplication) 从而匹配到 /hello.
在 getHandlerInternal 打一个断点,当我们配置了上下文 /demo 之后访问 /static/../demo/hello ,可以看到此时的 LookupPath 为 /hello,自然也能找到对应的 handler:

而当我们没有配置上下文直接访问 /static/../hello,此时 LookupPath 竟然是 /static/../hello,这当然会找不到 handler 导致 handlerMethod 为 null,所以我们的请求结果变成了404:

从图里可以看出来 lookupPath 的值来自于 initLookPath(request),在这里再打个断点分析一下。当我们配置了上下文 /demo 之后访问 /static/../demo/hello ,此时解析出来的 context-path 是 /static/../demo/,pathWithinApplication 的值为 /hello,所以返回的 lookupPath 为 /hello,我们成功找到到对应的 handler :

而当我们没有配置上下文直接访问 /static/../hello,此时 contextPath 为空,pathWithinApplication 的值为/static/../hello,所以返回的 lookupPath 为 ,我们没有匹配到任何一个 /static/../hellohandler:

看来问题出在这个 contextPath 上,这个值是通过 getContextPath 获取的:

继续打个断点分析,当我们没有配置上下文或者将上下文配置为 / 时,lastSlash 的值会为0,tomcat 会认为当前的服务中不存在上下文,直接返回 contextPath 为空:

而当我们配置了上下文,lastSlash 的值就不为0了,会继续走下面的处理逻辑:

首先会获取我们配置的上下文 /demo 以及我们具体访问的 /static/../demo/hello:

接下来这个循环会处理多重前导斜杠,如果配置不允许多重前导 /,则把 URI 前面的多余 / 跳过,将 pos 指向第一个非 / 的位置:

在这里会遍历 URI 找到 context path 尾部,循环的目的是找到 URI 中第 lastSlash 个 / 的位置吗,这个位置可能就是 context path 的结束位置,由于这里我们的 lastSlash 值是1,上下文 /demo 只有一个斜杠,它会找到从 start 开始的下一个 / 的索引位置,即 /static/ 中右边的那个 /,所以 pos 的值是 7

然后它会通过 uri.substring(0, pos) 获取备选的 context-path,在这里是 /static,然后去掉类似 ;jsessionid=xxx 的路径参数,对 %XX 编码的 URI 解码,最后进行一次 url 规范化处理(把 /a/../b 处理成 /b):

最后它会将选出来的备选 candidate 和实际的上下文 canonicalContextPath 进行比较,显然,现在选出来的 candidate 是 /static,和 /demo 肯定不一样,所以 match 结果为 false :

这是一个迭代的过程,当 /static 失败后,它会再找下一个 / 的位置,此时的 candidate 是 /static/..

这一轮的迭代再次失败后,它会选择 /static/../demo:

经过 normalize,/static/../demo 变成 /demo,于是匹配到实际的上下文 canonicalContextPath ,match 值为 true,返回上下文 /static/../demo

自此,这个困扰我的问题终于解决了,原来不是只有配置了上下文之后 Spring 才能解析 ../,这个解析 ../ 的操作其实是发生在 spring-boot-starter-parent 内嵌的 Servlet 容器 Tomcat 解析 context-path 里的!
为什么老版本的 spring 不需要配置上下文
那么为什么老版本的 spring 不需要配置上下文就可以解析 ../ 呢,我们更换一次 spring 版本再打一个断点:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<!-- <version>2.7.18</version>-->
<version>2.2.10.RELEASE</version>
<relativePath/>
</parent>
继续在 getHandlerInternal 这里打一个断点:

可以看到这里的 lookupPath 竟然直接变成了 /hello,继续向上回溯,在 getLookupPathForRequest 打一个断点,可以看到此时由于 alwaysUseFullPath 的值为 false ,所以走了 else 那条分支,返回的结果是归一化后的 rest,即 /hello:

总结
因此结论就是,当 spring boot 版本< 2.3.1 时,我们可以直接利用 spring 解析 ../ 的归一化特性,通过 /static/../api 访问 /api. 当 spring boot 版本>= 2.3.1 时,这个特性依然存在,但需要我们配一个上下文,利用 Servlet 容器解析 context-path 中的归一化完成利用。