Published on

JWT kid 헤더 악용 키혼동 취약점 차단법

Authors

서버가 JWT를 검증할 때 헤더의 kid(Key ID)를 그대로 신뢰하면, 공격자가 kid 값을 조작해 검증에 쓰일 키를 바꿔치기하는 문제가 발생할 수 있습니다. 이 유형은 흔히 kid 헤더 악용, 키 선택 취약점, 또는 상황에 따라 **키혼동(key confusion)**으로 불립니다. 특히 다음 조건이 겹치면 위험도가 급격히 올라갑니다.

  • 검증 키를 kid로 조회할 때 입력값 검증이 약함
  • JWK URL을 동적으로 가져오거나, 파일 경로/DB 키를 kid로 직접 매핑함
  • 알고리즘(alg)을 토큰이 주장하는 값대로 수용하거나, 대칭키/비대칭키를 혼용함
  • 멀티 테넌트/멀티 발급자 환경에서 발급자(iss) 경계가 흐림

이 글에서는 kid 악용이 실제로 어떤 흐름으로 성립하는지, 그리고 설계·구현·운영 레벨에서 확실히 차단하는 방법을 코드와 함께 정리합니다.

관련해서 인프라 경계(게이트웨이/ALB)에서 JWT 헤더 점검이 필요한 경우는 EKS ALB Ingress 401 반복 - OIDC·JWT·헤더 점검도 같이 보면 맥락이 이어집니다.

1) kid가 왜 위험해지는가: 키 선택(lookup) 자체가 공격면

JWT는 header.payload.signature 구조이며, 헤더에는 보통 alg, kid, typ 같은 메타가 들어갑니다. 문제는 많은 구현이 다음처럼 동작한다는 점입니다.

  1. 토큰 헤더에서 kid를 읽는다
  2. kid로 키 저장소에서 검증 키를 찾는다
  3. 그 키로 서명을 검증한다

여기서 2번이 외부 입력(kid) 기반의 키 선택입니다. 즉, 검증자는 “어떤 키로 검증할지”를 공격자에게 부분적으로 위임하게 됩니다.

자주 나오는 취약 패턴

  • 경로 주입/파일 읽기형: kid를 파일명처럼 써서 keys/${kid}.pem 읽기
  • SSRF/원격 JWK 주입형: kid 또는 jku(JWK Set URL)를 이용해 원격에서 키셋을 가져오기
  • DB 키-레코드 혼동형: kid로 DB에서 키를 조회하는데 테넌트/발급자 경계가 없음
  • 알고리즘 혼동형: alg를 신뢰해 RS256로 검증해야 할 토큰을 HS256으로 검증(또는 반대)

이 중 kid 자체는 “식별자”일 뿐인데, 구현이 kid리소스 위치동적 네트워크 요청 트리거로 사용하면 위험이 커집니다.

2) 공격 시나리오 예시: kid로 키를 바꿔치기

아래는 개념적 시나리오입니다.

  • 서버는 kid를 받아 keys/{kid}.pem에서 공개키를 읽어온다
  • 공격자는 kid../../tmp/attacker 같은 값을 넣어 서버가 공격자 키를 읽게 만든다
  • 공격자는 자신의 개인키로 JWT에 서명한다
  • 서버는 공격자 공개키로 검증을 통과시킨다

핵심은 “서명 검증이 깨졌다”가 아니라, 검증자가 잘못된 키를 선택했다는 점입니다.

또 다른 축은 알고리즘 혼동입니다. 예를 들어 서버가 RS256을 기대해야 하는데, 구현이 alg를 신뢰하고 HS256도 허용하면, 공격자가 공개키를 HMAC 시크릿처럼 사용해 위조 서명을 만들 수 있는 케이스가 역사적으로 존재했습니다(라이브러리/버전/설정에 따라 다름).

3) 차단 전략의 원칙: “토큰이 고르는 키”를 없애라

kid를 완전히 무시할 수는 없습니다. 키 로테이션을 하려면 키를 식별해야 하니까요. 다만 안전한 설계는 다음 원칙을 지킵니다.

  1. 허용 목록(allowlist) 기반으로만 키를 선택
  2. kid는 “키 ID”로만 쓰고, 경로/URL/쿼리로 확장하지 않기
  3. iss(발급자)와 aud(대상) 검증을 먼저 하고, 그 경계 안에서만 키를 선택
  4. alg는 토큰이 주장하는 값을 믿지 말고, 서버 정책으로 고정
  5. JWK를 가져오는 경우에도 고정된 JWKS URL과 강한 캐시 정책, 네트워크 제한을 적용

이제 구현 레벨에서 체크리스트로 내려가 보겠습니다.

4) 구현 체크리스트: kid 악용을 막는 10가지

4.1 alg 고정: 라이브러리 기본값에 기대지 말기

  • 기대 알고리즘이 RS256이면 RS256만 허용
  • 토큰 헤더의 alg가 다르면 즉시 거부
  • none 알고리즘은 무조건 거부

4.2 kid 포맷 검증 + 길이 제한

  • 정규식으로 허용 문자만 통과(예: 영숫자, _, -)
  • 길이 제한(예: 1~64)
  • 디코딩/정규화 과정에서 예외 처리

kid는 식별자일 뿐이므로, 슬래시(/), 점(.), 콜론(:) 등을 굳이 허용할 이유가 없습니다.

4.3 kid로 파일 경로를 만들지 말기

키를 파일로 보관하더라도 kid를 파일명에 직접 매핑하는 패턴은 피하세요.

  • 좋은 패턴: kid를 맵의 키로 사용하고, 값은 미리 로드된 공개키 객체
  • 나쁜 패턴: readFile("/keys/" + kid + ".pem")

4.4 kid로 원격 URL을 조립하지 말기

JWKS를 쓰더라도 아래는 금지에 가깝습니다.

  • kid에 따라 JWKS URL을 바꿈
  • 헤더의 jku를 신뢰해 그 URL에서 키를 다운로드

허용 가능한 패턴은 “발급자별로 고정된 JWKS URL”을 서버 설정으로 들고 있는 형태입니다.

4.5 발급자(iss)별 키셋 분리

멀티 테넌트/멀티 IdP 환경에서 자주 터집니다.

  • issA인데 B의 JWKS에서 키를 찾으면 안 됨
  • 즉, 키 선택의 1차 키는 iss여야 하고 kid는 2차 키여야 합니다

4.6 aud, iss, exp, nbf는 “항상” 검증

키혼동은 키 선택 문제지만, 토큰이 다른 서비스에서 발급된 것이라면 aud 검증만으로도 많은 공격이 차단됩니다.

4.7 키 검색 실패 시 “폴백” 금지

  • kid가 없거나 매칭 실패하면 “기본 키로 검증” 같은 폴백을 두지 마세요.
  • 키 로테이션 중이라면 “현재 키 + 이전 키”를 허용 목록으로 두고, 그래도 없으면 실패가 맞습니다.

4.8 JWK 캐시와 갱신 정책

  • JWKS는 캐시하고, 장애 시 무한 재시도/동적 갱신 폭주를 막기
  • 캐시 TTL과 백오프를 설계
  • 키가 추가되었을 때만 갱신하도록 kid 미스 시 제한적으로 리프레시

4.9 네트워크 이그레스 제한(SSRF 방어)

애플리케이션이 외부로 JWKS를 가져오는 구조라면, 네트워크 레벨에서 다음을 강제하세요.

  • 허용 도메인 allowlist
  • 메타데이터 IP 대역 차단(예: 클라우드 메타데이터)
  • 프록시 강제 및 DNS 리바인딩 방어

4.10 로깅/탐지: kid 이상징후를 지표화

  • kid 길이 초과, 정규식 불일치, 존재하지 않는 kid의 반복 시도
  • 특정 IP나 사용자 에이전트에서 kid 미스가 급증하면 알람

운영 중 디스크/파일 핸들 누수나 로그 폭주로 장애가 나면, 원인 분석에 lsof가 유용합니다. 관련 내용은 리눅스 디스크 100%? 열린 삭제파일 lsof로 찾기도 참고할 만합니다.

5) 안전한 구현 예제 1: Node.js에서 kid allowlist로 검증

아래 예시는 jose 계열 라이브러리 사용을 가정한 패턴입니다. 핵심은 다음입니다.

  • kid는 정규식/길이 검증
  • 키는 미리 로드된 맵에서만 선택
  • 허용 알고리즘을 서버 정책으로 고정
import { jwtVerify, importSPKI } from 'jose'

const EXPECTED_ISS = 'https://issuer.example.com'
const EXPECTED_AUD = 'my-api'
const EXPECTED_ALG = 'RS256'

// kid -> CryptoKey (미리 로드)
const keyMap = new Map()

async function loadKeysOnce() {
  const spki1 = `-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----`
  const spki2 = `-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----`

  keyMap.set('k1', await importSPKI(spki1, EXPECTED_ALG))
  keyMap.set('k2', await importSPKI(spki2, EXPECTED_ALG))
}

function validateKid(kid) {
  if (typeof kid !== 'string') throw new Error('kid must be string')
  if (kid.length < 1 || kid.length > 64) throw new Error('kid length invalid')
  if (!/^[A-Za-z0-9_-]+$/.test(kid)) throw new Error('kid format invalid')
}

export async function verifyAccessToken(token) {
  const getKey = async (protectedHeader) => {
    const { alg, kid } = protectedHeader

    if (alg !== EXPECTED_ALG) throw new Error('alg not allowed')
    validateKid(kid)

    const key = keyMap.get(kid)
    if (!key) throw new Error('unknown kid')
    return key
  }

  const { payload } = await jwtVerify(token, getKey, {
    issuer: EXPECTED_ISS,
    audience: EXPECTED_AUD,
  })

  return payload
}

await loadKeysOnce()

포인트는 getKey가 “외부 입력으로 리소스를 찾는 함수”가 되지 않도록, 이미 준비된 키셋에서만 선택하게 만드는 것입니다.

6) 안전한 구현 예제 2: JWKS를 쓰되, iss별 고정 URL + 제한적 리프레시

현실적으로 OIDC를 쓰면 JWKS를 가져와야 합니다. 이때도 kid를 URL로 쓰지 말고, iss에 따라 서버 설정으로 고정된 JWKS URL을 사용하세요.

import { createRemoteJWKSet, jwtVerify } from 'jose'

const issuers = {
  'https://issuer.example.com': {
    jwksUrl: 'https://issuer.example.com/.well-known/jwks.json',
    audience: 'my-api',
    alg: 'RS256',
  },
}

function getIssuerConfig(iss) {
  const cfg = issuers[iss]
  if (!cfg) throw new Error('issuer not allowed')
  return cfg
}

export async function verifyTokenWithFixedJWKS(token) {
  // 1) 토큰을 "그냥" 디코드해서 iss를 얻는 단계가 필요할 수 있음
  //    단, 이 단계의 값은 신뢰하지 말고 allowlist 조회에만 사용
  const parts = token.split('.')
  if (parts.length !== 3) throw new Error('invalid token')
  const payloadJson = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))

  const cfg = getIssuerConfig(payloadJson.iss)

  const JWKS = createRemoteJWKSet(new URL(cfg.jwksUrl), {
    // jose 내부 캐시 사용. 운영에서는 타임아웃/에이전트/프록시 정책도 같이 보강 권장
  })

  const { protectedHeader } = await jwtVerify(token, JWKS, {
    issuer: payloadJson.iss,
    audience: cfg.audience,
    algorithms: [cfg.alg],
  })

  // `kid`는 jose 내부가 JWKS에서 찾지만, URL은 고정되어 있어 SSRF/주입 위험이 줄어듦
  // 필요하면 여기서 protectedHeader.kid 포맷 검증 및 로깅도 추가

  return protectedHeader
}

주의할 점은 “토큰을 디코드해서 iss를 읽는 행위” 자체는 서명 검증 이전이므로 신뢰할 수 없습니다. 하지만 allowlist 인덱싱 용도로만 쓰고, 이후 jwtVerify에서 issuer 검증을 다시 수행하면 안전한 흐름을 만들 수 있습니다.

7) Spring Security에서의 실전 방어 포인트

스프링 부트에서 NimbusJwtDecoder를 쓰는 경우가 많습니다. 방어 포인트는 다음과 같습니다.

  • issuer-uri 기반 자동 설정을 쓰되, 멀티 발급자면 JwtDecoder를 발급자별로 분리
  • JwtValidatorsiss, aud를 강제
  • 허용 알고리즘을 제한하고, 불필요한 대칭키 검증 경로를 열지 않기

개념 예시(구성 아이디어):

@Bean
JwtDecoder jwtDecoder() {
  NimbusJwtDecoder decoder = NimbusJwtDecoder
      .withJwkSetUri("https://issuer.example.com/.well-known/jwks.json")
      .build();

  OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(
      "https://issuer.example.com"
  );

  OAuth2TokenValidator<Jwt> withAudience = (jwt) -> {
    return jwt.getAudience().contains("my-api")
        ? OAuth2TokenValidatorResult.success()
        : OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token"));
  };

  decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, withAudience));
  return decoder;
}

여기서도 핵심은 kid를 애플리케이션 코드가 직접 다루며 파일/URL로 확장하지 않는 것입니다. 가능하면 검증 라이브러리의 안전한 경로(고정된 JWKS URI, 캐시, 엄격한 validator)를 사용하세요.

8) 운영 관점: 키 로테이션과 장애 없이 안전하게

kid를 안전하게 쓰려면 로테이션 정책이 함께 있어야 합니다.

  • 키는 최소 2개를 동시 허용: 현재 키와 직전 키
  • 토큰 TTL을 짧게 가져가면(예: 5~15분) 로테이션 윈도우가 줄어듦
  • JWKS 캐시를 쓰는 경우, 새 키 배포 후 캐시 갱신 지연을 고려해 롤아웃 순서를 설계
  • kid 미스가 증가하면 즉시 알람: 잘못된 토큰 공격일 수도, 로테이션 배포 순서 문제일 수도 있음

게이트웨이/인그레스에서 JWT 검증을 병행하는 구조라면, 애플리케이션과 인그레스가 서로 다른 JWKS 캐시 정책을 가져 401이 반복될 수 있습니다. 이 경우 점검 항목은 EKS ALB Ingress 401 반복 - OIDC·JWT·헤더 점검에 정리된 흐름대로 보면 빠르게 좁힐 수 있습니다.

9) 최종 요약: kid는 힌트일 뿐, 결정권이 아니다

kid 헤더 악용을 막는 가장 확실한 방법은 “토큰이 검증 키를 선택하게 두지 않는 것”입니다. 실무적으로는 다음 4가지만 지켜도 사고 확률이 크게 떨어집니다.

  1. alg는 서버 정책으로 고정하고, 토큰의 alg 주장에 끌려가지 않기
  2. iss/aud를 강제하고, 발급자 경계 밖의 키를 절대 섞지 않기
  3. kid는 allowlist 맵에서만 조회하고, 파일 경로/URL/네트워크 요청에 쓰지 않기
  4. 키 미스 시 폴백 금지 + 관측(로깅/알람)으로 공격과 운영 이슈를 구분하기

이 원칙을 기반으로 구현을 점검하면, kid 기반 키혼동 취약점은 대부분 구조적으로 차단할 수 있습니다.