FlawOpen
● CWE-601 / CWE-352

OAuth 2.0 redirect_uri攻撃:認可コード窃取とログインCSRF

認可サーバーのredirect URI検証が緩い場合や、登録済みcallbackにオープンリダイレクトがある場合、認可コードが漏れる可能性があります。悪用にはコードの結び付きやトークンエンドポイントの検証も関係し、ログイン処理の結び付き不足は別途login CSRFを招くことがあります。

💡 やさしい解説

認可サーバーを信頼できる郵便局だと考えてください。郵便局はアプリに登録された返送先にだけ一度限りのコードを送ります。住所の照合が曖昧だったり、登録済みの窓口が訪問者を任意のサイトへ転送したりすると、コードが横取りされるおそれがあります。ただし、コードを盗んだだけでアクセスできるわけではありません。攻撃者はPKCEや必要に応じたクライアント認証など、トークン交換の条件も満たす必要があります。別種のlogin CSRFは、サイトがブラウザー自身の開始したログインかどうかを確認できない場合に発生します。

主要な概念と用語

認可コード
ブラウザーのリダイレクトで返される短時間有効の資格情報です。クライアントはトークンエンドポイントで交換します。コードはクライアントとredirect URIに結び付き、PKCE利用時はverifierにも結び付きます。
redirect_uriの完全一致
RFC 9700は、要求されたredirect URIを事前登録URIと文字列で完全一致させるよう求めています。ネイティブアプリのlocalhostリダイレクトには、動的ポートを認める限定的な例外があります。
stateとlogin CSRF
ブラウザーセッションに結び付けた予測困難な値をcallbackで検証すると、応答がユーザーの開始したログインに対応することを確認できます。PKCEを必須化すればCSRF対策にもなり、両方を使う多層防御も可能です。
PKCE(Proof Key for Code Exchange)
クライアントは秘密のverifierを生成し、その派生challengeを認可リクエストで送信します。トークンエンドポイントはコード交換時にverifierを要求し、パブリッククライアントとコンフィデンシャルクライアントの両方でコード傍受のリスクを下げます。

リダイレクト攻撃とログインCSRFの手順

Path A · 1

認可サーバーが危険なredirect URIを受け入れる

不備のある認可サーバーは、要求されたredirect_uriと登録値を完全一致で比較せず、ワイルドカード、前方一致、部分文字列一致を使います。RFC 9700はネイティブアプリのlocalhostポートに関する例外を除き、完全一致を求めています。

Path A · 2

登録済みのendpointが信頼できないサイトへ転送する

受け入れられたURIが、https://app.example/redirect?next=...のようなクライアント管理下のオープンリダイレクトに到達することがあります。これは認可サーバーの緩い検証と、アプリ側の独立したCWE-601の問題が組み合わさった状態です。

Path A · 3

ブラウザーが転送先までコードを運ぶ

ユーザーの認可後、サーバーはコードを受け入れたURIへ返します。オープンリダイレクトがコードを保持して転送すると、攻撃者に取得される可能性があります。認可コード自体はbearerアクセス トークンではありません。

Path A · 4

コード交換には別途検証が必要

取得したコードを利用するには、トークンエンドポイントの検証も通過しなければなりません。PKCEは元のverifierがなければ交換を拒否します。コンフィデンシャルクライアント認証とredirect URI照合も追加の結び付きを提供します。

Path B

応答をログイン試行に結び付けないとlogin CSRFが起きる

callbackがブラウザーのログイン試行に結び付かない認可応答を受け入れると、攻撃者は被害者に攻撃者のアカウントでログインを完了させることがあります。これはアカウントの取り違えであり、被害者のアカウントへ自動的に入れるという意味ではありません。

ソースコード:脆弱な例と安全な実装

脆弱なパターン
# VULNERABLE: user input is embedded in redirect_uri; state and PKCE are absent.
from urllib.parse import urlencode
from flask import Flask, request, redirect

app = Flask(__name__)
AUTHORIZATION_ENDPOINT = "https://oauth-provider.example/authorize"

@app.route("/auth/login")
def oauth_login():
    next_url = request.args.get("next", "")
    # The callback is an open redirect, and this URI varies with user input.
    redirect_uri = "https://app.example/redirect?next=" + next_url
    params = {
        "client_id": "example-client",
        "response_type": "code",
        "redirect_uri": redirect_uri,
    }
    # Exploitation also requires the authorization server to accept this URI
    # under an unsafe wildcard or prefix rule.
    return redirect(AUTHORIZATION_ENDPOINT + "?" + urlencode(params))

@app.route("/auth/callback")
def oauth_callback():
    # No state check ties this response to the browser's login attempt.
    code = request.args.get("code")
    return exchange_code_for_tokens(code)  # no PKCE verifier provided
強化された防御
# SECURE: fixed registered redirect URI, session-bound state, and PKCE.
import base64
import hashlib
import secrets
from urllib.parse import urlencode
from flask import Flask, request, redirect, session, abort

app = Flask(__name__)
AUTHORIZATION_ENDPOINT = "https://oauth-provider.example/authorize"
REDIRECT_URI = "https://app.example/auth/callback"  # exact registered value
CLIENT_ID = "example-client"

@app.route("/auth/login")
def oauth_login():
    state = secrets.token_urlsafe(32)
    verifier = secrets.token_urlsafe(64)
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode("ascii")).digest()
    ).rstrip(b"=").decode("ascii")
    session["oauth_state"] = state
    session["pkce_verifier"] = verifier
    params = {
        "client_id": CLIENT_ID,
        "response_type": "code",
        "redirect_uri": REDIRECT_URI,
        "state": state,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    }
    return redirect(AUTHORIZATION_ENDPOINT + "?" + urlencode(params))

@app.route("/auth/callback")
def oauth_callback():
    expected = session.pop("oauth_state", None)
    if not expected or not secrets.compare_digest(
        request.args.get("state", ""), expected
    ):
        abort(403, "Invalid OAuth state")

    code = request.args.get("code")
    verifier = session.pop("pkce_verifier", None)
    if not code or not verifier:
        abort(400, "Missing authorization response")

    # The token request must use this exact redirect_uri and verifier.
    tokens = exchange_code_for_tokens(
        code=code, redirect_uri=REDIRECT_URI, code_verifier=verifier
    )
    session["user_id"] = tokens["user_id"]
    return redirect("/dashboard")

エンジニア向け強化チェックリスト

← セキュリティディレクトリを見る 脆弱性対策ガイドを見る → RFC 9700:OAuth 2.0セキュリティのベストプラクティス → RFC 7636:Proof Key for Code Exchange (PKCE) → RFC 6749:認可コードのredirect URI操作 → CWE-601:オープンリダイレクト → CWE-352:クロスサイトリクエストフォージェリ →