Kodokon kodokon.com

ウェブセキュリティ:XSS、CSRF、インジェクション

XSS、CSRF、SQLインジェクションを分解し、htmlspecialchars、トークン、プリペアドステートメントが実際に何を防いでいるのかを正確に理解しましょう。

12 分 · 3 問

このレッスンを Kodokon で開く

ウェブの脆弱性の三大ファミリー、XSSCSRFインジェクションは、どれも同じ根本原因を共有しています。ユーザーから提供されたデータが、最終的にコード(HTML、SQL、あるいは正当なリクエスト)として解釈されてしまうのです。まずはXSSから始めましょう。$_GETの値をエスケープせずに表示すると、攻撃者は<script>タグを注入し、あなたの他の訪問者のブラウザでJavaScriptを実行できてしまいます。セッションを盗んだり、本人の知らないうちに操作を行ったりするのです。?comment=<script>alert(1)</script>とすると、下の最初の出力はスクリプトを実行してしまい、二番目はそれを無害化します。ここでは二つのフラグが重要です。ENT_QUOTESはシングルクォートも変換します(そうしないと、シングルクォートで囲まれた属性は注入可能なままになります)。そしてENT_SUBSTITUTEは、不正なUTF-8シーケンスを、空文字列を返す代わりに置き換えます。PHP 8.1以降、両方のフラグがようやくデフォルトになりましたが、明示的に書くことで意図が文書化され、古いバージョンで動くコードも守られます。

PHP
<?php
declare(strict_types=1);

$comment = $_GET['comment'] ?? '';

echo '<div>' . $comment . '</div>';

function e(string $value): string
{
    return htmlspecialchars(
        $value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );
}

echo '<div>' . e($comment) . '</div>';
最初の出力は脆弱で、二番目は適切にエスケープされている

CSRFは一つの単純な事実を悪用します。ブラウザは、あなたのドメインへ向かうあらゆるリクエストに、たとえサードパーティのサイトから発生したものであっても、自動的にセッションクッキーを付けるのです。したがって悪意あるフォームは、被害者のセッションを使って/account/deleteを送信できてしまいます。古典的な防御策はシンクロナイザートークンです。セッションに保存されたランダムな秘密で、あらゆる変更を伴うリクエストで要求されます。サードパーティのサイトはそれを読むことも推測することもできないので、提供できません。

HTML
<?php
session_start();

if (!isset($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}

$sent = $_POST['csrf'] ?? '';
$valid = is_string($sent)
    && hash_equals($_SESSION['csrf'], $sent);

$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
if ($isPost && !$valid) {
    http_response_code(403);
    exit('Invalid CSRF token');
}
?>
<form method="post">
  <input type="hidden" name="csrf"
    value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
  <button>Delete my account</button>
</form>
トークンはセッションごとに一度生成され、変更を伴う操作の前に検証される

SQLインジェクションも同じパターンをたどります。ユーザーの値をクエリに連結するのは、ユーザーにSQLを書かせるのと同じことです。' OR 1=1 --はフィルターをテーブル全体へと変えてしまいます。解決策はエスケープではなくプリペアドステートメントです。クエリのテキスト(:emailプレースホルダー付き)と値は、別々にエンジンへ送られるので、エンジンはデータとコードを決して混同できません。専門家向けの細かい点を挙げると、MySQLではPDOはデフォルトでプリペアドステートメントをエミュレートし、クライアント側でエスケープします。本当にネイティブなプリペアドステートメントにするには、PDO::ATTR_EMULATE_PREPARESfalseに設定しましょう。

PHP
<?php
declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION
);
$pdo->exec(
    'CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        email TEXT NOT NULL
    )'
);

$email = $_GET['email'] ?? '';

$stmt = $pdo->prepare(
    'SELECT id FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);

var_dump($stmt->fetch(PDO::FETCH_ASSOC));
値がSQLのテキストに加わることは決してない。インジェクションは不可能だ

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. なぜhtmlspecialcharsにENT_QUOTESフラグを渡すのですか?
    • シングルクォートも変換するため。これがないと、シングルクォートで囲まれた属性は注入可能なままになる
    • 文字列をUTF-8に変換させるため
    • コンテンツからスクリプトタグを取り除くため
    • 長い文字列のエスケープを高速化するため
  2. なぜCSRFトークンを===ではなくhash_equalsで比較するのですか?
    • hash_equalsは一定時間で比較するので、応答時間を測ることでトークンを1バイトずつ推測されるのを防げる
    • hash_equalsは比較する前にトークンのハッシュを自動的に再計算する
    • ===は二つの文字列のエンコーディングが異なるとすぐに失敗する
  3. プリペアドステートメントがSQLインジェクションに耐性を持つのはなぜですか?
    • 値に含まれるクォートを自動的にエスケープするから
    • クエリと値が別々にエンジンへ送られ、エンジンは値をSQLとして再解釈することが決してないから
    • 各値が対象カラムの型と一致するかを確認するから
    • 実行を単一のSQL文に制限するから