- Published on
OAuth 2.0 PKCE 검증 누락 취약점 재현·차단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인(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_method로 plain을 허용하면서, 서버가 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_method는 S256만 허용
가능하면 plain은 아예 거부하세요.
- 허용 목록:
S256 - 거부:
plain, 미지정, 기타
규칙 2) Authorization Code 레코드에 PKCE 파라미터를 강하게 바인딩
authorize 단계에서 발급한 Authorization Code(서버 DB/캐시)에 다음을 저장합니다.
code_challengecode_challenge_methodclient_idredirect_uriuser_id또는 세션 참조expires_atused_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) state 및 nonce도 함께 강제
PKCE는 코드 탈취를 줄이지만, CSRF/리플레이 전반을 해결하진 않습니다.
state: CSRF 방어- OIDC 사용 시
nonce: ID 토큰 재생 공격 방어
운영 점검 체크리스트(30분 내 확인)
다음 질문에 하나라도 No가 있으면 취약 가능성이 큽니다.
- 토큰 엔드포인트가
code_verifier누락 시 항상 실패하는가 code_challenge_method가S256만 허용되는가authorize단계에서 받은code_challenge가 Authorization Code 레코드에 저장되는가- 토큰 교환 시
client_id와redirect_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 검증 누락” 계열의 우회는 대부분 차단됩니다.