Spring 中 .. 的处理机制为何时灵时不灵

前言

在某一次分析漏洞的时候,我发现了一个有趣的现象,当时使用的 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
contextPathWeb 应用部署路径,通常来自配置/demo
servletPath匹配到的 Servlet 的路径前缀/hello
pathInfoServlet 映射后的剩余路径(可选)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 导致 handlerMethodnull,所以我们的请求结果变成了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/../hello,我们没有匹配到任何一个 handler:

看来问题出在这个 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,于是匹配到实际的上下文 canonicalContextPathmatch 值为 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 中的归一化完成利用。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇