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 공격 단계
인증 서버가 안전하지 않은 redirect URI를 허용
잘못된 서버는 요청된 redirect_uri를 등록값과 정확히 비교하지 않고 와일드카드, 접두사 또는 부분 문자열 규칙을 사용합니다. RFC 9700은 네이티브 앱 localhost 포트의 제한된 예외를 제외하고 정확한 일치를 요구합니다.
등록된 엔드포인트가 신뢰할 수 없는 사이트로 전달
허용된 URI가 https://app.example/redirect?next=...처럼 클라이언트가 제어하는 오픈 리디렉션으로 이어질 수 있습니다. 이는 인증 서버의 느슨한 검사와 앱의 별도 CWE-601 취약점이 결합된 경우입니다.
브라우저가 리디렉션을 통해 코드를 전달
사용자가 승인하면 인증 서버는 허용된 URI로 코드를 반환합니다. 오픈 리디렉션이 코드를 보존해 요청을 전달하면 공격자가 코드를 얻을 수 있습니다. 코드는 그 자체로 bearer 액세스 토큰이 아닙니다.
토큰 교환도 검사를 통과해야 함
탈취한 코드를 사용하려면 토큰 엔드포인트 검사를 통과해야 합니다. 원래 verifier가 없으면 PKCE가 교환을 차단하고 confidential-client 인증과 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 경로의 오픈 리디렉션을 제거하고 허용 목록의 로컬 경로 식별자만 받으세요.
- 모든 클라이언트 유형에서 PKCE S256을 사용하는 Authorization Code Flow를 적용하세요. 인증 요청과 토큰 요청에 같은 등록된
redirect_uri를 사용하세요. - callback을 로그인 시도에 바인딩하세요. 세션에 묶인 일회용
state를 검증하거나, 인증 서버가 PKCE를 강제할 때만 CSRF 방어로 PKCE에 의존하세요. 둘 다 사용하는 것도 심층 방어입니다. - 탈취한 인증 코드만으로는 충분하지 않습니다. 토큰 교환에 올바른 클라이언트 인증과 PKCE verifier를 요구하고 액세스 토큰을 리디렉션 URL에 넣지 마세요.
- 인증 코드를 로그에 남기지 말고 callback 페이지에 엄격한
Referrer-Policy를 설정하세요. 민감한 쿼리 매개변수를 제거하기 전에 서드파티 콘텐츠를 렌더링하지 마세요.