FlawOpen
● CWE-601 / CWE-352

OAuth 2.0 redirect_uri 공격: 코드 탈취와 로그인 CSRF

인증 서버의 느슨한 redirect URI 검사나 등록된 callback의 오픈 리디렉션은 인증 코드를 노출할 수 있습니다. 악용 가능성은 코드 바인딩과 토큰 엔드포인트 검사에도 달려 있으며, 로그인 흐름의 바인딩 누락은 별도로 login CSRF를 일으킬 수 있습니다.

💡 쉽게 설명

인증 서버를 신뢰할 수 있는 우체국이라고 생각해 보세요. 우체국은 앱에 등록된 반송 주소로만 일회용 코드를 보냅니다. 주소를 느슨하게 비교하거나 등록된 엔드포인트가 방문자를 임의의 사이트로 보내면 코드가 탈취될 수 있습니다. 코드를 얻었다고 자동으로 접근할 수 있는 것은 아닙니다. 공격자는 PKCE와 필요한 경우 클라이언트 인증 등 토큰 교환 요건도 충족해야 합니다. 별도의 login CSRF 문제는 사이트가 브라우저에서 시작한 로그인인지 확인하지 못할 때 발생합니다.

핵심 개념과 주요 용어

Authorization Code
브라우저 리디렉션으로 반환되는 단기 자격 증명입니다. 클라이언트는 토큰 엔드포인트에서 교환하며, 코드는 클라이언트와 redirect URI에 바인딩되고 PKCE 사용 시 verifier에도 바인딩됩니다.
redirect_uri 정확히 일치
RFC 9700은 요청된 URI와 사전 등록된 URI의 문자열이 정확히 일치해야 한다고 규정합니다. 네이티브 앱의 localhost 리디렉션에는 동적 포트에 대한 제한적인 예외가 있습니다.
state와 login CSRF
브라우저 세션에 연결된 예측 불가능한 값을 callback에서 검증하면 응답이 사용자가 시작한 로그인에 속하는지 확인할 수 있습니다. 강제 적용되는 PKCE도 CSRF 방어를 제공하며 둘 다 사용해 심층 방어를 할 수 있습니다.
PKCE (Proof Key for Code Exchange)
클라이언트는 비밀 verifier를 만들고 파생된 challenge를 인증 요청에 보냅니다. 토큰 엔드포인트는 코드를 교환할 때 verifier를 요구해 public 및 confidential client의 코드 가로채기 위험을 줄입니다.

리디렉션 및 로그인 CSRF 공격 단계

Path A · 1

인증 서버가 안전하지 않은 redirect URI를 허용

잘못된 서버는 요청된 redirect_uri를 등록값과 정확히 비교하지 않고 와일드카드, 접두사 또는 부분 문자열 규칙을 사용합니다. RFC 9700은 네이티브 앱 localhost 포트의 제한된 예외를 제외하고 정확한 일치를 요구합니다.

Path A · 2

등록된 엔드포인트가 신뢰할 수 없는 사이트로 전달

허용된 URI가 https://app.example/redirect?next=...처럼 클라이언트가 제어하는 오픈 리디렉션으로 이어질 수 있습니다. 이는 인증 서버의 느슨한 검사와 앱의 별도 CWE-601 취약점이 결합된 경우입니다.

Path A · 3

브라우저가 리디렉션을 통해 코드를 전달

사용자가 승인하면 인증 서버는 허용된 URI로 코드를 반환합니다. 오픈 리디렉션이 코드를 보존해 요청을 전달하면 공격자가 코드를 얻을 수 있습니다. 코드는 그 자체로 bearer 액세스 토큰이 아닙니다.

Path A · 4

토큰 교환도 검사를 통과해야 함

탈취한 코드를 사용하려면 토큰 엔드포인트 검사를 통과해야 합니다. 원래 verifier가 없으면 PKCE가 교환을 차단하고 confidential-client 인증과 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: Authorization Code Redirect URI Manipulation → CWE-601: 오픈 리디렉션 → CWE-352: 교차 사이트 요청 위조 →