리다이렉트에서 인가 코드가 새어도 토큰을 못 만드는 이유 — PKCE

seonwoo_jung·어제

모바일 앱의 OAuth 로그인에서 인가 서버가 돌려보내는 리다이렉트(myapp://cb?code=...)를 악성 앱이 가로챈다면, 그 코드로 남의 access token을 발급받을 수 있을까? PKCE가 없다면 답은 "그렇다"이고, PKCE가 있다면 "코드는 훔쳐도 소용없다"이다. 이 한 문장의 차이가 어디서 생기는지, RFC 7636을 따라가며 정리했다.

1. 왜 이걸 팠나

OAuth 2.0 grant type들을 정리하면서 "SPA·모바일은 왜 authorization code grant에 PKCE를 얹어 쓰나"를 한 줄로 넘겼던 적이 있다. 그때 나는 PKCE를 "client_secret이 없는 클라이언트를 위한 secret 대용품" 정도로 이해하고 있었는데, 이 요약이 왜 절반만 맞는지 확인하고 싶었다. PKCE가 정확히 무엇을, 어떻게 막는지 메커니즘을 손으로 따라가 보면 그 오해가 자연스럽게 풀린다.

2. 막으려는 것: 인가 코드 가로채기

네이티브·모바일 공개 클라이언트(public client)는 redirect URI로 보통 커스텀 URI 스킴(myapp://cb)을 쓴다. 문제는 OS가 이 스킴을 여러 앱이 등록하는 것을 막지 못한다는 점이다. 악성 앱이 같은 스킴을 선점하면, 인가 서버가 브라우저를 통해 돌려보내는 리다이렉트(= 인가 코드가 실린 단계)를 가로챌 수 있다.

공개 클라이언트는 client_secret이 없다(있더라도 앱 바이너리에서 추출될 수 있어 비밀이라 보기 어렵다). 그래서 공격자는 훔친 코드 + 공개된 client_id만으로 토큰 엔드포인트에서 코드를 access token으로 교환해버릴 수 있다. RFC 7636 §1이 정의하는 "authorization code interception attack"이 바로 이 시나리오다.

[정상]  앱 → /authorize → 로그인 → redirect(code) → 앱 → /token(code) → token
[공격]  앱 → /authorize ─ 로그인 ─ redirect(code) ─╳ 악성앱이 code 가로챔 → /token(code) → token 탈취

3. 핵심 아이디어: 해시는 요청에, 원본은 교환에

PKCE(Proof Key for Code Exchange, "픽시"라고 읽는다)의 발상은 단순하다.

매 인가 요청마다 클라이언트가 즉석에서 만든 1회용 난수의 해시만 인가 요청에 노출하고, 토큰 교환 때 그 난수의 원본을 제시하게 한다.

  • code_verifier: 클라이언트가 인가 요청 직전에 만드는 고엔트로피 난수 문자열. RFC 7636 §4.1의 ABNF는 43*128unreserved — 즉 43~128자의 URL-safe 문자열이다. §7.1은 최소 256비트 엔트로피를 권장하는데, 32바이트 난수를 base64url 인코딩하면 43자 verifier가 나오는 것이 전형이다.
  • code_challenge: verifier를 변환 메서드(code_challenge_method)로 가공한 값. 두 가지가 있다.
    • plain: code_challenge = code_verifier (변환 없음)
    • S256: code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

여기서 세부가 중요하다. S256에서 SHA-256의 입력은 verifier 문자열의 ASCII 옥텟이고, 출력 32바이트를 base64url(패딩 = 제거)로 인코딩한다. 그래서 S256 challenge는 항상 43자로 고정된다.

4. 흐름 — 상태가 어디에 묶이나

(1) 클라이언트: verifier = random(32B); challenge = S256(verifier)
(2) GET /authorize?response_type=code&client_id=...&redirect_uri=...
        &code_challenge=<challenge>&code_challenge_method=S256
(3) 인가 서버: 발급하는 authorization code에 (challenge, method)를 서버측에 바인딩해 저장
(4) redirect: myapp://cb?code=<code>              ← 여기서 code가 새어도
(5) POST /token grant_type=authorization_code&code=<code>
        &redirect_uri=...&client_id=...&code_verifier=<verifier>   ← 원본 verifier 제시
(6) 인가 서버 검증:
        S256 → BASE64URL(SHA256(ASCII(code_verifier))) == 저장된 challenge ?
        같으면 토큰 발급, 다르면 error=invalid_grant (§4.6)

핵심은 바인딩의 방향이다. 인가 요청(2)에는 해시(challenge)만 실려 나가고, 토큰 요청(5)에는 원본(verifier)이 실린다. 이 둘은 서로 다른 HTTP 트랜잭션이다.

인가 코드를 훔친 공격자는 (4)의 리다이렉트만 볼 수 있으니 code는 얻지만, verifier는 (5)의 토큰 요청 — TLS로 보호되고 정상 클라이언트 프로세스 내부에만 존재하는 값 — 에만 있어 얻을 수 없다. SHA-256은 단방향이라 challenge로부터 verifier를 역산할 수도 없다. 결과적으로 인가 코드가 그 코드를 요청한 바로 그 클라이언트 인스턴스에 암호학적으로 묶인다. 이것이 proof-of-possession(소유 증명)이다.

5. 코드로 확인 — S256 검증은 결정적이다

RFC 7636 §4.1–4.2, §4.6을 그대로 옮기면 검증이 결정적(deterministic)임을 손으로 확인할 수 있다. verifier가 정해지면 challenge는 유일하고, 서버는 저장한 challenge와 재계산 값을 바이트 비교만 하면 된다.

// 클라이언트측 생성 (§4.1–4.2)
byte[] octets = new byte[32];
new SecureRandom().nextBytes(octets);                  // 256비트 엔트로피 (§7.1)
String verifier = Base64.getUrlEncoder().withoutPadding()
                        .encodeToString(octets);        // 43자 URL-safe

byte[] digest = MessageDigest.getInstance("SHA-256")
                 .digest(verifier.getBytes(StandardCharsets.US_ASCII)); // ASCII(verifier)
String challenge = Base64.getUrlEncoder().withoutPadding()
                         .encodeToString(digest);       // S256, 43자 고정

// 서버측 검증 (§4.6) — 저장한 challenge와 재계산 비교
boolean ok = MessageDigest.isEqual(
    storedChallenge.getBytes(StandardCharsets.US_ASCII),   // 인가 때 저장한 값
    sha256Base64Url(receivedVerifier).getBytes(StandardCharsets.US_ASCII)); // 토큰 요청 verifier 재해시
// ok == false → error=invalid_grant

두 가지가 눈으로 확인된다. 첫째, verifier가 같으면 challenge도 같고(SHA-256 결정성), 한 글자만 달라도 눈사태 효과로 완전히 다른 43자가 나와 비교가 실패한다. 둘째, SHA-256 출력 32바이트를 base64url 무패딩으로 인코딩하면 ⌈32/3⌉×4 − 패딩 = 43자로, RFC가 말하는 "S256 challenge는 항상 43자"와 맞아떨어진다.

6. plain의 한계와 다운그레이드 방지 (§7.2)

plainchallenge == verifier라서, 공격자가 인가 요청까지 관찰할 수 있는 상황(OS 로그, 로컬 프록시 등)이면 challenge를 그대로 verifier로 획득해 방어가 무너진다. S256은 요청에 해시만 보이므로 요청을 관찰당해도 verifier를 복원할 수 없다.

그래서 §7.2는 다운그레이드를 명시적으로 막는다.

  • 클라이언트는 S256을 시도한 뒤 plain으로 내려가면 안 된다(MUST NOT).
  • PKCE를 지원하는 서버는 S256을 반드시 지원해야 한다(required).

따라서 S256 요청에 서버가 에러를 낸다면 그건 "서버 결함"이거나 "MITM이 다운그레이드를 유도 중"이라는 신호로 읽을 수 있다.

7. 내가 잘못 알고 있던 것

  • "PKCE는 client_secret의 대용품이다." — 절반만 맞다. client_secret은 클라이언트의 신원을 정적으로 증명하는 장기 비밀이고, PKCE는 이 인가 코드가 그것을 시작한 인스턴스의 것임을 동적으로 증명하는 1회용 값이다. secret은 재사용되지만 verifier는 요청마다 새로 만들어 버린다. 그래서 OAuth 2.0 Security BCP는 client_secret이 있는 confidential 클라이언트에게도 PKCE를 권장한다고 알려져 있다 — 둘은 대체재가 아니라 다른 축의 방어다.
  • "code_challenge를 훔치면 코드를 교환할 수 있다." — 아니다. 토큰 교환에 필요한 건 challenge가 아니라 원본 verifier다. 단방향 해시라 challenge→verifier 역산이 불가능해, 리다이렉트에서 보이는 challenge든 code든 훔쳐도 소용없다.

8. 정리

PKCE는 리다이렉트로 새어나갈 수 있는 인가 코드를, 그 코드를 요청한 클라이언트 인스턴스에만 동적으로 묶는 1회용 소유 증명이다. 해시는 요청에, 원본은 교환에 — 이 비대칭이 방어의 전부다.

더 파고들 만한 주제 두 가지를 남겨둔다.

  • state 파라미터(CSRF 방지)와 PKCE의 역할이 어디서 겹치고 갈리는가 — PKCE가 state의 CSRF 방어까지 흡수하는지.
  • OpenID Connect의 nonce(ID token 재생 방지)와 PKCE(인가 코드 바인딩)의 두 축이 겹치는 지점.

참고 자료

  • RFC 7636 — Proof Key for Code Exchange by OAuth Public Clients (§1 위협 모델, §4.1–4.6 파라미터·검증, §7.1–7.2 엔트로피·다운그레이드)
  • RFC 6749 — OAuth 2.0 §4.1 Authorization Code Grant
  • OAuth 2.0 Security Best Current Practice (draft) — confidential 클라이언트에도 PKCE 권장

0개의 댓글