Published on

OAuth 2.0 PKCE 검증 누락 취약점 재현·차단

Authors

서드파티 로그인(OAuth 2.0 Authorization Code)을 붙일 때 PKCE를 켰다고 안심하는 경우가 많습니다. 하지만 실제 사고는 PKCE를 “사용한다”는 선언이 아니라, **토큰 교환 단계에서 code_verifier를 “반드시 검증한다”**는 구현에서 갈립니다.

이 글은 다음을 목표로 합니다.

  • PKCE의 핵심 보안 속성(왜 필요한지) 빠르게 복기
  • code_verifier 검증 누락 또는 약화로 발생하는 취약점(사실상 PKCE 무력화) 재현
  • 서버(Authorization Server)·클라이언트(RP) 관점의 차단 방법과 점검 체크리스트
  • 로그/모니터링으로 탐지하는 실전 팁

참고로 OAuth에서 흔한 설정 오류는 redirect_uri에서도 많이 터집니다. 관련 트러블슈팅은 OAuth redirect_uri 불일치 8원인과 즉시 해결도 같이 보세요.

PKCE가 막으려는 것: “코드 탈취”가 “토큰 탈취”로 번지는 문제

OAuth 2.0 Authorization Code 플로우에서 브라우저를 경유하는 authorization_code는 여러 경로로 유출될 수 있습니다.

  • 커스텀 스킴 딥링크를 쓰는 모바일 앱에서 리다이렉트 인터셉트
  • 브라우저 히스토리/리퍼러/로그에 코드가 남는 실수
  • 프록시/미들박스에서 쿼리 스트링 수집
  • 악성 앱이 OS 레벨에서 리다이렉트 URI 핸들러를 가로채는 시나리오

PKCE는 이를 “코드를 훔쳐도 토큰으로 바꿀 수 없게” 만드는 장치입니다.

  • 클라이언트는 임의의 code_verifier를 만들고
  • code_challenge = BASE64URL(SHA256(code_verifier))authorize 요청에 포함
  • 토큰 교환 시 code_verifier를 제출
  • 서버는 최초에 저장한 code_challenge와 제출된 code_verifier로 재계산한 값이 일치하는지 검증

여기서 중요한 포인트는 하나입니다.

  • 토큰 엔드포인트에서 code_verifier 검증이 “필수”가 아니면 PKCE는 무력화됩니다.

취약점 유형: PKCE “설정은 했는데 검증이 없다/약하다”

현장에서 자주 보는 패턴은 다음과 같습니다.

1) code_verifier를 선택 파라미터로 처리

토큰 엔드포인트가 code_verifier가 없어도 토큰을 발급해버리면, 공격자는 탈취한 code만으로 토큰을 얻습니다. 이는 PKCE를 적용하지 않은 것과 동일합니다.

2) plain 허용 + 검증 로직 오류

code_challenge_methodplain을 허용하면서, 서버가 S256만 가정하고 비교하거나(혹은 반대로) 비교 자체를 잘못 구현하면 우회가 생깁니다.

권장: 가능하면 plain은 비활성화하고 S256만 허용.

3) code_challenge를 저장하지 않거나 세션에만 의존

authorize 단계에서 받은 code_challenge를 Authorization Code 레코드에 영속적으로 묶지 않고, 브라우저 세션/쿠키에만 저장했다가 토큰 교환에서 참조하면 서버 간 분산/멀티 인스턴스 환경에서 검증이 빠지거나 실패 시 “fallback 발급” 같은 위험한 예외처리가 들어가기 쉽습니다.

4) Authorization Code 재사용 가능

PKCE 검증이 있어도 code를 1회성으로 소모하지 않으면, 공격자가 경쟁 조건(race)으로 먼저 교환하거나 여러 번 시도해 성공 확률을 높일 수 있습니다.

5) 클라이언트 타입 혼동(Confidential/Public)

Confidential client(서버가 client_secret을 안전히 보관)인데도 PKCE를 붙이면서, 토큰 엔드포인트에서 client_secret 검증과 PKCE 검증을 둘 다 느슨하게 처리하면 우회 조합이 생깁니다.

재현 시나리오: “코드만 훔쳐서 토큰으로 교환”

아래는 교육/점검 목적의 재현 흐름입니다. 본인 시스템 또는 허가된 테스트 환경에서만 수행하세요.

전제 조건

  • Authorization Code 플로우 사용
  • authorize 응답으로 code가 브라우저/딥링크/로그 등에서 유출 가능
  • 토큰 엔드포인트가 code_verifier 검증을 누락하거나 optional 처리

1) 정상 로그인에서 code 획득

정상 사용자가 다음과 같은 요청을 보냅니다.

GET /oauth2/authorize?response_type=code&client_id=app&redirect_uri=https%3A%2F%2Frp.example%2Fcb&code_challenge=...&code_challenge_method=S256&state=... HTTP/1.1
Host: as.example

이후 리다이렉트로 다음이 반환됩니다.

HTTP/1.1 302 Found
Location: https://rp.example/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=...

공격자는 어떤 방식으로든 code=SplxlOBeZ... 값을 획득했다고 가정합니다.

2) 공격자가 토큰 엔드포인트에 code만 제출

정상이라면 code_verifier 없이는 실패해야 합니다. 하지만 취약한 서버는 성공합니다.

curl -sS -X POST https://as.example/oauth2/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'grant_type=authorization_code' \
  --data 'client_id=app' \
  --data-urlencode 'redirect_uri=https://rp.example/cb' \
  --data 'code=SplxlOBeZQQYbYS6WxSbIA'

취약한 경우 응답 예:

{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "def...",
  "scope": "openid profile"
}

이 시점에서 PKCE는 사실상 작동하지 않습니다.

3) 변형 재현: 잘못된 code_verifier도 통과

검증이 “존재”하는 것처럼 보여도, 실패 시 에러를 내지 않고 토큰을 발급하는 경우가 있습니다.

curl -sS -X POST https://as.example/oauth2/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'grant_type=authorization_code' \
  --data 'client_id=app' \
  --data-urlencode 'redirect_uri=https://rp.example/cb' \
  --data 'code=SplxlOBeZQQYbYS6WxSbIA' \
  --data 'code_verifier=totally-wrong-verifier'

정상이라면 invalid_grant 또는 invalid_request로 떨어져야 합니다.

차단 전략(서버): 토큰 엔드포인트에서 “강제”하고 “원자적으로 소모”

PKCE 취약점의 대부분은 Authorization Server 구현에서 발생합니다. 아래는 방어의 핵심 규칙입니다.

규칙 1) code_challenge_methodS256만 허용

가능하면 plain은 아예 거부하세요.

  • 허용 목록: S256
  • 거부: plain, 미지정, 기타

규칙 2) Authorization Code 레코드에 PKCE 파라미터를 강하게 바인딩

authorize 단계에서 발급한 Authorization Code(서버 DB/캐시)에 다음을 저장합니다.

  • code_challenge
  • code_challenge_method
  • client_id
  • redirect_uri
  • user_id 또는 세션 참조
  • expires_at
  • used_at (또는 consumed 플래그)

그리고 토큰 교환 시 동일 레코드에서 검증합니다.

규칙 3) code_verifier 누락은 즉시 실패

code_verifier가 없으면 무조건 실패해야 합니다.

  • 에러 예: invalid_request 또는 invalid_grant
  • 절대 금지: “없으면 PKCE 미적용으로 간주하고 통과” 같은 fallback

규칙 4) 비교는 상수 시간(constant-time) 비교 사용

해시 비교는 타이밍 공격 가능성이 낮더라도, 보안 구현은 원칙적으로 상수 시간 비교를 권장합니다.

규칙 5) Authorization Code는 1회성으로 원자적 소모

토큰 교환 트랜잭션에서 다음을 원자적으로 처리하세요.

  • code 조회
  • 만료/클라이언트/리다이렉트 검증
  • PKCE 검증
  • used_at 업데이트(소모)

멀티 인스턴스 환경이면 DB 트랜잭션 또는 원자적 업데이트(예: WHERE used_at IS NULL)로 레이스를 막습니다.

예시 구현(Node.js/TypeScript, 개념 코드)

아래 코드는 “검증 누락이 생기기 쉬운 지점”을 명시적으로 방지하는 형태로 작성했습니다.

import crypto from 'crypto';

function base64url(buf: Buffer) {
  return buf.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

function sha256Base64url(input: string) {
  return base64url(crypto.createHash('sha256').update(input, 'utf8').digest());
}

function timingSafeEqualStr(a: string, b: string) {
  const ab = Buffer.from(a, 'utf8');
  const bb = Buffer.from(b, 'utf8');
  if (ab.length !== bb.length) return false;
  return crypto.timingSafeEqual(ab, bb);
}

type AuthCodeRecord = {
  code: string;
  clientId: string;
  redirectUri: string;
  codeChallenge: string;
  codeChallengeMethod: 'S256';
  expiresAt: Date;
  usedAt: Date | null;
  userId: string;
};

async function exchangeToken(params: {
  code: string;
  clientId: string;
  redirectUri: string;
  codeVerifier?: string;
}) {
  const { code, clientId, redirectUri, codeVerifier } = params;

  if (!codeVerifier) {
    throw Object.assign(new Error('code_verifier is required'), { oauth: 'invalid_request' });
  }

  // 1) code 조회
  const rec: AuthCodeRecord | null = await dbFindAuthCode(code);
  if (!rec) throw Object.assign(new Error('code not found'), { oauth: 'invalid_grant' });

  // 2) 바인딩 검증
  if (rec.clientId !== clientId) throw Object.assign(new Error('client mismatch'), { oauth: 'invalid_grant' });
  if (rec.redirectUri !== redirectUri) throw Object.assign(new Error('redirect_uri mismatch'), { oauth: 'invalid_grant' });
  if (rec.usedAt) throw Object.assign(new Error('code already used'), { oauth: 'invalid_grant' });
  if (rec.expiresAt.getTime() < Date.now()) throw Object.assign(new Error('code expired'), { oauth: 'invalid_grant' });

  // 3) PKCE 강제 검증
  if (rec.codeChallengeMethod !== 'S256') {
    throw Object.assign(new Error('unsupported code_challenge_method'), { oauth: 'invalid_request' });
  }

  const computed = sha256Base64url(codeVerifier);
  if (!timingSafeEqualStr(computed, rec.codeChallenge)) {
    throw Object.assign(new Error('pkce verification failed'), { oauth: 'invalid_grant' });
  }

  // 4) 원자적 소모(경쟁 조건 방지)
  const consumed = await dbConsumeAuthCode({ code, expectedUsedAtNull: true });
  if (!consumed) throw Object.assign(new Error('code already used (race)'), { oauth: 'invalid_grant' });

  // 5) 토큰 발급
  return issueTokens({ userId: rec.userId, clientId: rec.clientId });
}

// 아래 함수들은 구현체에 맞게 연결
declare function dbFindAuthCode(code: string): Promise<AuthCodeRecord | null>;
declare function dbConsumeAuthCode(input: { code: string; expectedUsedAtNull: boolean }): Promise<boolean>;
declare function issueTokens(input: { userId: string; clientId: string }): Promise<{ access_token: string }>;

핵심은 codeVerifier가 없으면 즉시 실패, 그리고 code 소모를 원자적으로 처리하는 것입니다.

차단 전략(클라이언트/RP): “PKCE를 생성하고 끝”이 아니라 “검증 실패를 관측”

PKCE 검증은 서버가 하지만, RP도 다음을 지키면 사고 가능성을 크게 낮출 수 있습니다.

1) code_verifier 저장 위치를 안전하게

브라우저 SPA는 일반적으로 메모리 또는 세션 스토리지에 보관합니다.

  • 권장: 탭 단위 격리 필요 시 sessionStorage
  • 주의: localStorage는 장기 보관 + XSS에 취약

가능하면 BFF(Backend For Frontend) 패턴으로 code_verifier를 서버 세션에 보관하고, 브라우저에는 최소 정보만 두는 구성이 더 안전합니다.

2) 토큰 교환 실패를 “정상”으로 취급하지 않기

서버가 invalid_grant를 반환했는데도 UI에서 자동 재시도/우회 플로우로 넘어가면, 취약점이 숨겨진 채 운영될 수 있습니다.

  • PKCE 관련 에러는 사용자에게 재로그인 유도
  • 재시도는 제한(예: 1회)하고 원인 로깅

3) statenonce도 함께 강제

PKCE는 코드 탈취를 줄이지만, CSRF/리플레이 전반을 해결하진 않습니다.

  • state: CSRF 방어
  • OIDC 사용 시 nonce: ID 토큰 재생 공격 방어

운영 점검 체크리스트(30분 내 확인)

다음 질문에 하나라도 No가 있으면 취약 가능성이 큽니다.

  • 토큰 엔드포인트가 code_verifier 누락 시 항상 실패하는가
  • code_challenge_methodS256만 허용되는가
  • authorize 단계에서 받은 code_challenge가 Authorization Code 레코드에 저장되는가
  • 토큰 교환 시 client_idredirect_uri가 Authorization Code와 정확히 매칭되는가
  • Authorization Code가 1회성으로 원자적으로 소모되는가
  • PKCE 검증 실패가 로깅/모니터링되며 알림이 설정되어 있는가

JWT를 쓰는 환경이라면 토큰 검증 측면의 기본 방어도 같이 점검해야 합니다. 예를 들어 alg=none이나 kid 인젝션 같은 구성 취약점은 별개의 축에서 치명적입니다. 관련 체크는 JWT alg=none·kid 인젝션 30분 차단 체크리스트도 참고하세요.

탐지와 포렌식: 로그에서 “PKCE 우회”의 냄새 찾기

PKCE 검증 누락은 기능적으로는 “정상 발급”처럼 보이기 때문에, 로그 설계가 중요합니다.

권장 로깅 포인트(민감정보는 마스킹):

  • 토큰 교환 요청에 code_verifier 파라미터 존재 여부(값 자체는 저장 금지)
  • PKCE 검증 결과(성공/실패) 및 실패 사유
  • 동일 code의 중복 교환 시도 횟수
  • 동일 사용자/클라이언트에서 짧은 시간 내 다수 실패

예: 애플리케이션 로그 이벤트(개념)

{
  "event": "oauth_token_exchange",
  "client_id": "app",
  "has_code_verifier": false,
  "result": "issued",
  "suspicious": true
}

위와 같이 has_code_verifier=false인데 issued가 나오면 즉시 장애/사고로 취급해야 합니다.

마무리: PKCE는 “옵션”이 아니라 “계약”이다

PKCE는 OAuth 2.0에서 가장 효과적인 보강책 중 하나지만, 구현이 한 군데라도 느슨하면 공격자는 그 지점을 정확히 파고듭니다.

  • 토큰 엔드포인트에서 code_verifier를 강제하고
  • S256만 허용하며
  • Authorization Code를 PKCE 파라미터와 강하게 바인딩하고
  • 1회성 소모를 원자적으로 처리

이 네 가지를 지키면 “PKCE 검증 누락” 계열의 우회는 대부분 차단됩니다.