【実務・中級編】 パスワードリセット時のメールアドレス列挙攻撃(Enumeration) – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

「またか」と思わずため息が出るような脆弱性診断レポートを、私はこれまで腐るほど見てきた。その筆頭が、今回話す「パスワードリセット機能におけるメールアドレスの列挙(Enumeration)」だ。

派手なSQLインジェクションやRCE(リモートコード実行)に比べれば地味に見えるかもしれない。だが、攻撃者の視点に立てば、これは「標的リスト」をノーコストで作成できる魔法の杖だ。特定の企業ドメインを狙い撃ちし、誰がそのサービスを使っているかを特定できれば、そこから精巧なフィッシング詐欺やパスワードスプレー攻撃へと繋げられる。

今日は、現場で後手に回りやすいこの「仕様という名の脆弱性」を、どうやってロジカルに、かつ実務的に潰し切るかを解説しよう。

—

1. 攻撃者の視点:なぜ「メッセージの差異」が命取りになるのか

攻撃者は、ログイン画面で総当たりをする前に、まず「有効なアカウント」を探す。パスワードリセット画面は、そのための格好の偵察ポイントだ。

例えば、以下のような素朴な実装を見てみよう。

脆弱な実装のロジック(概念図)

// 脆弱な例:入力されたメールアドレスが存在するか正直に答えてしまう
$user = findUserByEmail($_POST['email']);
if ($user) {
    sendResetEmail($user);
    echo "ご登録のメールアドレスにリセットURLを送信しました。";
} else {
    echo "そのメールアドレスは登録されていません。"; // ← これが致命的
}

このコードは、ユーザー親和性(UX)を優先した結果、攻撃者に「このメールアドレスは実在する」という確証を与えてしまう。攻撃者はPythonで数行のスクリプトを書くだけで、数万件の名簿から「自社サービス利用者リスト」を抽出できるんだ。

攻撃PoC(Pythonによる自動列挙)

攻撃者が使うスクリプトのイメージはこうだ。レスポンスの文字列やHTTPステータスコードの違いを機械的に判別する。

import requests

target_url = "https://example.com/api/password-reset"
emails_to_test = ["admin@target.co.jp", "ceo@target.co.jp", "dummy@test.com"]

for email in emails_to_test:
    response = requests.post(target_url, data={'email': email})
    # レスポンス内容に「登録されていません」が含まれるかどうかで判定
    if "登録されていません" in response.text:
        print(f"[-] {email}: Not Found")
    else:
        print(f"[+] {email}: REGISTERED!")

—

2. 盲点:メッセージを統一しても「レスポンス時間」でバレる

「メッセージを『メールを送信しました(存在する場合)』に統一すれば解決だ」と考えるエンジニアは多い。だが、それでは二流だ。レッドチームのエンジニアは、「レスポンスの返却時間(応答速度)」を見る。

1. ユーザーが存在する場合:データベースを検索し、メール送信ライブラリを呼び出し、SMTPサーバーと通信する。
2. ユーザーが存在しない場合:データベースを検索して終わり。

この数ミリ秒〜数百ミリ秒の差を統計的に分析(サイドチャネル攻撃)すれば、メッセージが同じでもアカウントの有無は丸見えだ。これを防ぐには、「非同期処理」と「固定時間の待機」を組み合わせる必要がある。

—

3. 実務で使える「完全防御」の実装サンプル

では、バックエンドとフロントエンド、そしてインフラ層でどう守るべきか、具体的なコードを見ていこう。

PHP (Laravel/プレーンPHP共通の考え方)

ポイントは、ユーザーの有無にかかわらず「同じメッセージを出す」ことと、メール送信を「バックグラウンドジョブ」に逃がすことだ。

<?php
/**
 * セキュアなパスワードリセット要求ハンドラ
 */
function handlePasswordResetRequest($email) {
    // 1. データベースからユーザーを検索(常に一定の計算コストをかけるのが理想)
    $user = findUserByEmail($email);

    // 2. ユーザーが存在する場合のみ、メール送信ジョブをキューに投入
    // ここで直接メールを送らず、非同期(Queue)にすることでレスポンス時間を一定にする
    if ($user) {
        // キューへの投入は非常に高速なため、存在しない場合との時間差を最小化できる
        dispatch(new SendPasswordResetEmailJob($user));
    } else {
        // ユーザーが存在しない場合、攻撃者を欺くために「偽の処理時間」をわずかに入れることもある
        // ただし、基本的には非同期キューを使っていれば無視できる差になる
        usleep(rand(500, 1500)); // わずかなジッター(揺らぎ)を加えて解析を困難にする
    }

    // 3. 常に「成功とも失敗とも取れる同一のメッセージ」を返す
    return [
        'status' => 'success',
        'message' => '入力されたメールアドレスが登録されている場合、再設定用のリンクをお送りしました。フォルダに届いていないか確認してください。'
    ];
}

フロントエンド(JavaScript/Vue/React)

フロントエンドでは、ユーザーに「そのメールアドレスは存在しません」と親切に教えるのをやめる。代わりに、次のアクション(受信箱の確認)を促すデザインに統一する。

// 常に共通の通知を表示する設計
async function handleResetSubmit(email) {
  try {
    const response = await fetch('/api/password-reset', {
      method: 'POST',
      body: JSON.stringify({ email }),
      headers: { 'Content-Type': 'application/json' }
    });

    // サーバーのステータスが200なら、成否に関わらず同じ画面へ
    if (response.ok) {
      showNotification("リセットメールを送信しました。メールボックスを確認してください。");
    }
  } catch (error) {
    // ネットワークエラーなどは別途ハンドリング
    showNotification("システムエラーが発生しました。時間を置いて再度お試しください。");
  }
}

—

4. インフラ・WAF層での多層防御

アプリケーション側で対策しても、大量のメールアドレスを流し込まれればサーバー負荷(DoS)に繋がる。インフラ層でのレート制限(Rate Limiting)は必須だ。

Nginxでのレート制限設定

特定のIPアドレスからのパスワードリセット要求を1分間に5回までに制限する例だ。

# nginx.conf の http ブロックに記述
limit_req_zone $binary_remote_addr zone=reset_limit:10m rate=1r/s;

server {
    location /api/password-reset {
        # 1分間に数回程度の試行に制限し、それを超えると 429 Too Many Requests を返す
        limit_req zone=reset_limit burst=5 nodelay;
        proxy_pass http://app_backend;
    }
}

WAF (AWS WAF等) の活用

クラウド環境であれば、同一IPからのリクエスト数だけでなく、「複数の異なるメールアドレスを短時間に投げているか」を検知するルールを検討しよう。Managed Rulesの「Account Takeover Prevention (ATP)」などを使うのも手だ。

—

5. まとめ:エンジニアが持つべき「防御の哲学」

「親切なエラーメッセージ」は、正規のユーザーにとっては利便性だが、攻撃者にとっては脆弱性という名のヒントになる。

1. メッセージの統一: 「登録されていれば送ります」というスタンスを貫く。
2. 時間の隠蔽: メールの実送信は非同期で行い、レスポンス時間の差異を消す。
3. レート制限: そもそも大量の列挙試行をさせない仕組みをインフラで持つ。

セキュリティは「点」ではなく「面」で守るものだ。コード1行の書き換え、設定ファイルへの数行の追記。その積み重ねが、泥臭い攻撃からユーザーの大切なアカウントを守る唯一の道なんだ。

後輩諸君、UXとセキュリティのトレードオフに悩んだときは、常に「この情報は攻撃者にヒントを与えていないか?」と自問自答してみてくれ。それが、プロのセキュアプログラミングの第一歩だ。

コメント

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