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の手順
認可サーバーが危険なredirect URIを受け入れる
不備のある認可サーバーは、要求されたredirect_uriと登録値を完全一致で比較せず、ワイルドカード、前方一致、部分文字列一致を使います。RFC 9700はネイティブアプリのlocalhostポートに関する例外を除き、完全一致を求めています。
登録済みのendpointが信頼できないサイトへ転送する
受け入れられたURIが、https://app.example/redirect?next=...のようなクライアント管理下のオープンリダイレクトに到達することがあります。これは認可サーバーの緩い検証と、アプリ側の独立したCWE-601の問題が組み合わさった状態です。
ブラウザーが転送先までコードを運ぶ
ユーザーの認可後、サーバーはコードを受け入れたURIへ返します。オープンリダイレクトがコードを保持して転送すると、攻撃者に取得される可能性があります。認可コード自体はbearerアクセス トークンではありません。
コード交換には別途検証が必要
取得したコードを利用するには、トークンエンドポイントの検証も通過しなければなりません。PKCEは元のverifierがなければ交換を拒否します。コンフィデンシャルクライアント認証とredirect URI照合も追加の結び付きを提供します。
応答をログイン試行に結び付けないと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")
エンジニア向け強化チェックリスト
- 認可サーバーで、
redirect_uriを事前登録URIと文字列で完全一致させます。RFC 9700が定めるネイティブアプリのlocalhostポート例外だけを許可します。 - OAuth callback URIにユーザー指定の転送先を含めません。callback経路のオープンリダイレクトをなくし、許可リストにあるローカルルート識別子だけを受け入れます。
- すべてのクライアントでAuthorization Code FlowとPKCE S256を使います。認可リクエストとトークンリクエストには同じ登録済み
redirect_uriを指定します。 - ログイン試行にcallbackを結び付けます。セッションに結び付いた一度限りの
stateを検証するか、認可サーバーでPKCEが必須の場合に限りPKCEをCSRF対策として使います。両方の併用も有効な多層防御です。 - 認可コードの窃取だけでは不十分です。トークン交換で正しいクライアント認証とPKCE verifierを要求し、access tokenをリダイレクトURLに決して含めないでください。
- 認可コードをログに記録しないでください。callbackページには厳格な
Referrer-Policyを設定し、機密性のあるクエリパラメーターを除去する前にサードパーティーコンテンツを読み込まないでください。