XXEとSSTIを根絶する:Webアプリを堅牢化するための実践ガイド

Security tutorial - IT technology blog
Security tutorial - IT technology blog

午前2時の緊急呼び出し:2つの脆弱性をめぐる物語

PagerDutyはエンジニアの睡眠スケジュールなど考慮してくれません。午前2時14分、スマートフォンのバイブレーションがナイトスタンドを激しく揺らし、本番APIで500エラーが400%急増したことを知らせました。

ログインした頃には、SIEM(セキュリティ情報イベント管理)が不審なアウトバウンドトラフィックについて警告を発していました。Webサーバーが、地球の裏側にあるデータセンター内の不正なIPアドレスと通信しようとしていたのです。誰かが単に調査をしていたのではありません。現代の技術スタックにおいて最も危険な2つの脆弱性、XML外部実体参照(XXE)とサーバーサイドテンプレート注入(SSTI)を悪用して、データを持ち出そうとしていたのです。

アクセスログを精査すると、組織的な攻撃であることが判明しました。一方は古いSOAPエンドポイントに対して不正なXMLペイロードを送り込み、同時にもう一方は動的レポートエンジンに対して奇妙な波括弧({})構文で攻撃を仕掛けていました。これはランダムなボットではありません。データ処理のために信頼していたパーサーそのものを武器化し、WAF(Webアプリケーションファイアウォール)を回避しようとする標的型攻撃でした。

原因1:XMLパーサーが「見知らぬ人」と会話するとき(XXE)

最初に鎮火すべき火種はXXEの脆弱性でした。XMLは過去の遺物のように感じられるかもしれませんが、今でもSAMLトークンやレガシーAPIのバックボーンとして広く使われています。危険なのはフォーマットそのものではなく、多くのライブラリでデフォルトで有効になっているドキュメントタイプ定義(DTD)機能です。

攻撃の仕組み

攻撃者は「外部実体(external entity)」を定義した XMLドキュメントを送信します。パーサーの設定が不適切だと、ローカルディスクからファイルを読み込んだり、攻撃者に代わってネットワークリクエストを送信したりします。インシデント中、私たちの /api/v1/shipping エンドポイントに対して以下のペイロードが送信されているのを見つけました。

<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
  <!ELEMENT foo ANY >
  <!ENTITY xxe SYSTEM "file:///etc/passwd" >]>
<foo>&xxe;</foo>

パーサーは &xxe; を見つけると、その指示に従って /etc/passwd の内容に置き換えます。その結果がAPIレスポンスに反映されると、攻撃者はWebサービスがアクセス可能なあらゆるファイルを読み取ることができます。今回のケースでは、攻撃者は http:// スキームを使用して内部のRedisインスタンス(ポート6379)をスキャンしており、事実上、私たちのサーバーを偵察用のプロキシとして悪用していました。

なぜ起きたのか

バックエンドで堅牢化されていないバージョンの lxml 3.7.1 が動作していました。WAFがこれらの試行をブロックすると想定していましたが、攻撃者はUTF-16エンコーディングを使用して正規表現フィルターをすり抜けました。これは、コード自体をセキュアにする代わりに境界防御に依存するという、典型的なミスでした。

原因2:SSTIによるロジック実行

XMLパーサーのパッチを当てている間に、2つ目のアラートが発生しました。ユーザーがメールテンプレートをカスタマイズできる自社のダッシュボードツールが悪用されていたのです。私たちはJinja2エンジンを使用していましたが、eval() を使っていないので安全だと思い込んでいました。それは間違いでした。

攻撃の仕組み

サーバーサイドテンプレート注入(SSTI)は、ユーザー入力がテンプレート文字列に直接結合(連結)されたときに発生します。攻撃者は、「会社名」フィールドに {{ 7*7 }} と入力すると、プレビュー画面に 49 と表示されることに気づきました。これがコード実行の決定的な証拠です。

彼らは単なる計算では終わりませんでした。Pythonのメソッド解決順序(MRO)を利用してオブジェクトツリーを遡り、os モジュールを奪取しました。実際にシェルを奪取するために使われたペイロードは以下の通りです。

{{ self.__init__.__globals__['__builtins__']['__import__']('os').popen('id').read() }}

これにより、彼らはリモートコード実行(RCE)を可能にしました。単にファイルを読み取るだけでなく、Webワーカーの権限でシステムコマンドを実行していたのです。

対策の比較:応急処置 vs 本質的なセキュリティ

本番環境が炎上しているとき、ENTITY{{ をブロックする急ごしらえの正規表現を書きたくなるかもしれません。しかし、それはやめてください。攻撃者は、16進エンコーディング、空白のバリエーション、あるいは {% のような代替タグを使用して、これらのフィルターを容易に回避できます。フィルターは単なる減速帯(スピードバンプ)に過ぎません。

失敗するアプローチ:ブラックリスト方式

SYSTEM__globals__ といったキーワードをブロックしようとするチームを何度も見てきましたが、これは負け戦です。XMLでは、文字エンコーディングを変更することで文字列マッチングを回避できます。SSTIでは、ドット記法のフィルターを避けるために request['__class__'] のような辞書アクセスを使用できます。決意の固い攻撃者を正規表現で出し抜くことは不可能です。

決定的な解決策:ライブラリの堅牢化

XXEを阻止する唯一の確実な方法は、パーサーにDTDを完全に無視するよう指示することです。SSTIについては、コードとデータを厳密に分離しなければなりません。例外はありません。

実践的な保護の実装

当面の脅威を排除した後、ユーティリティライブラリをリファクタリングしました。本番環境でこれらの問題に対処する方法を以下に示します。

1. XMLパーサーの保護(Pythonの例)

lxml を使用している場合は、外部実体とDTDの読み込みを明示的に無効にする必要があります。共有設定を更新し、以下の安全なセットアップを使用するようにしました。

from lxml import etree

# セキュアなパーサー設定
parser = etree.XMLParser(
    resolve_entities=False,
    no_network=True,
    dtd_validation=False,
    load_dtd=False
)

def safe_parse_xml(xml_string):
    # DTDや外部実体が検出された場合にエラーをスローする
    return etree.fromstring(xml_string, parser=parser)

JacksonやDocumentBuilderFactoryを使用するJava環境では、disallow-doctype-decl 機能を true に設定する必要があります。常にOWASPの脆弱性防止チートシートに照らして、使用しているライブラリのデフォルト設定を確認してください。

2. ロジックの分離によるSSTIの撃退

SSTIの修正には規律が必要です。**テンプレートの構築に文字列フォーマット(連結)を決して使用しないでください。**

脆弱な例:
template = "Hello " + user_input
render_template_string(template)

安全な例:
render_template_string("Hello {{ name }}", name=user_input)

入力を変数として渡すことで、テンプレートエンジンはそれをリテラル文字列として扱います。これにより、入力内部のコードが実行されることはなくなります。ユーザーに独自のテンプレートの提供を許可する必要がある場合は、許可された関数のみを厳格なホワイトリストで管理するサンドボックス環境を使用してください。

最善のアプローチ:多層防御

セキュリティは単一行のコードではなく、積み重ね(スタック)です。脆弱性を修正した後、インフラを堅牢化するためにさらに3つのステップを実行しました。

  1. 最小権限の原則: Webワーカーを制限されたユーザープロファイルに移動しました。たとえRCEが発生したとしても、攻撃者は /root/ を読み取ったり、nmap のようなツールをインストールしたりすることはできません。
  2. エグレスフィルタリング(送信検閲): ホワイトリストに登録された内部サービス以外へのWeb層からのすべてのアウトバウンド接続をブロックするようにファイアウォールを構成しました。これにより、XXEベースのSSRFを無効化し、リバースシェルが攻撃者に接続し直すのを防ぎます。
  3. 安全なシークレット生成: クリーンアップ作業中、いくつかの管理者パスワードをローテーションする必要がありました。その際、toolcraft.app/ja/tools/security/password-generator のブラウザベースのジェネレーターを使用しました。完全にクライアントサイドで動作するため、新しいシークレットがネットワーク経由で傍受される心配がありませんでした。

まとめチェックリスト

  • XXE対策: すべてのパーサーでDTDと外部実体参照を無効にする。新しいエンドポイントにはJSONを採用する。
  • SSTI対策: 静的テンプレートを使用する。ユーザー提供のデータに対して文字列結合を決して使用しない。
  • モニタリング: アプリケーションログ内の {{<!ENTITY/etc/passwd に対するアラートを設定する。

日の出までにはパッチが適用され、ログもクリーンになりました。これらの脆弱性は古いものですが、ライブラリがデフォルトで安全であると信じ込んでしまうため、依然として有効な攻撃手法です。エンジニアとしての私たちの仕事は、午前2時にページャーが鳴る前に、それらのデフォルト設定を検証することです。

Share: