LabHub
开始
学习 学习路径 课程

会话与令牌 — 从浏览器到服务网格

别让令牌告诉你怎么验证它

在 LabHub 中继续学习

한국어 원문으로 표시합니다.

한 줄 요약

JWT 는 토큰이 스스로 증거를 들고 다닌다. 서버에 물어볼 필요 없이 서명만 검증하면 되는 대신, 그 검증을 조금만 느슨하게 짜면 토큰 스스로가 "나를 어떻게 검증하라"고 시키는 대로 검증기가 따라가 위조가 통과한다. 그 느슨함이 alg 혼동kid 주입이다.

왜 이게 필요했나

JWT 한 장은 점 두 개로 나뉜 세 조각이다 — 헤더, 페이로드(클레임), 서명. 헤더에는 alg(서명 알고리즘)와 흔히 kid(키 식별자)가 들어간다. 검증기는 헤더의 alg 를 읽어 "아, RS256 이구나" 하고 그 방식으로 서명을 검증한다. 문제는 그 alg 도, 그 kid아직 검증되지 않은, 공격자가 마음대로 쓴 값이라는 데 있다.

여기서 두 가지 고전 공격이 나온다. 첫째, RFC 7519 §6alg"none" 인 서명 없는 토큰(Unsecured JWS)을 규격으로 허용한다. 검증기가 헤더의 alg 를 곧이곧대로 믿으면, 공격자는 alg:none 에 원하는 클레임을 담고 서명 조각을 비워 던진다 — 검증기는 "none 이니 검증할 서명이 없네" 하고 통과시킨다. 둘째가 alg 혼동이다. RFC 8725 의 표현 그대로다 — "'RS256' parameters can be altered to 'HS256', leading libraries to validate using the RSA public key as an HMAC secret." RS256 은 비대칭이라 공개키로 검증하고, 공개키는 누구나 안다(JWKS 로 공개한다). 공격자는 헤더 algHS256 으로 바꾸고, 공개키 문자열을 HMAC 비밀로 삼아 직접 서명한다. 검증기가 헤더의 alg 를 믿어 "HS256 이니 그 키로 HMAC 검증" 하면, 그 키가 곧 공개키라 검증이 통과한다. 공개된 키만으로 유효한 서명을 위조한 것이다.

어떻게 동작하나

방어의 핵심은 RFC 8725 §3.1 한 문장이다 — "Libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms." 그리고 "each key MUST be used with exactly one algorithm, and this MUST be checked."

이걸 코드로 옮기면 규칙이 셋이다.

허용 alg 화이트리스트   검증기가 받아들일 alg 를 코드에 고정한다({RS256, HS256}).
                       none 은 목록에 없으니 자동으로 거절된다.
alg 를 키에 고정        헤더의 alg 를 믿지 않는다. kid 로 키를 찾고, 그 키의
                       타입(RSA→RS256, oct→HS256)이 정한 alg 로만 검증한다.
                       rs-1 은 RSA 키라 RS256 으로만 쓴다 — HS256 을 요구하면
                       서명을 보기도 전에 거절한다. 이것이 혼동을 막는다.
kid 는 조회 키일 뿐     kid 를 파일 경로나 URL 로 해석하지 않는다. 고정된 키
                       목록의 딕셔너리 키로만 쓰고, 없는 kid 는 거절한다.

kid 를 왜 이렇게까지 조심하나. RFC 7515 §4.1.4 는 "The structure of the 'kid' value is unspecified" 라 한다 — 아무 문자열이나 올 수 있다는 뜻이다. 검증기가 kid 를 받아 open(kid) 로 키 파일을 읽거나 fetch(kid) 로 URL 을 당기면, 공격자는 kid 에 자기 키 파일 경로나 자기 서버 URL 을 넣어 검증에 쓸 키 자체를 지정한다. RFC 8725 §3.10 이 이 경로 주입(그리고 jku·x5u 를 통한 SSRF)을 경고한다. kid 는 오직 내가 아는 키들 중 하나를 고르는 손잡이여야 한다.

서명을 검증했다고 끝이 아니다. RFC 7519 의 클레임을 봐야 한다 — exp(이 시각 이후 거절), nbf(이 시각 전 거절), iss(발급자가 내가 아는 곳인가), 그리고 특히 aud. RFC 는 "If the principal processing the claim does not identify itself with a value in the 'aud' claim when this claim is present, then the JWT MUST be rejected" 라 못박는다. 다른 API 용으로 발급된 토큰을 내 API 가 받아 주면 안 되기 때문이다. 시계 오차를 위해 몇 분 이내의 여유(leeway)만 둔다.

키가 여럿인 이유와 kid 의 정당한 쓰임은 RFC 7517 §4.5 에 있다 — "to choose among a set of keys within a JWK Set during key rollover." 서명 키를 새로 바꿀 때 옛 키로 서명된 토큰이 아직 돌아다니므로, JWKS 에 옛 키와 새 키를 함께 두고 kid 로 골라 검증한다. 옛 키를 너무 일찍 지우면 아직 유효한 토큰이 한꺼번에 거절된다.

현장에서 만나는 모습

alg 혼동은 라이브러리 한 줄에서 난다. 옛 API 가 jwt.decode(token, key) 처럼 algorithms 를 지정하지 않고 호출하면, 라이브러리가 헤더의 alg 를 그대로 받아들여 혼동에 뚫린다. 그래서 요즘 라이브러리는 algorithms=["RS256"] 을 필수 인자로 만들었다. 우리는 이 실습에서 그 한 줄이 왜 필수인지를 손으로 검증기를 짜서 겪는다.

kid 주입은 로그를 봐도 정상 검증으로 보인다. "검증 통과, 사용자 admin" 이라 찍히는데, 실은 공격자가 심은 키로 만든 토큰이다. 검증기가 kid 로 무엇을 하는지는 코드를 열어야만 드러난다.

다음 실습에서 할 것

cryptography 로 RS256 키쌍과 HS256 비밀을 만들어 자기 JWT 검증기를 8305 에 세운다. 자기 발급 토큰이 손검증과 PyJWT 양쪽에서 통과하는지 보고, 그다음 alg:none·혼동·잘못된 kid 토큰을 직접 만들어 던져 401 로 거절되는지 확인한다. JWKS 로 키를 여럿 두고 kid 로 고르는 것, 키 롤오버, 그리고 exp/nbf/iss/aud 검증까지 한 번에 증명한다.