Sessions and Tokens — From the Browser to the Mesh
JWT signature verification, alg confusion and kid injection defense
한국어 원문으로 표시합니다.
목표
cryptography 로 만든 RS256 키쌍·HS256 비밀로 자기 JWT 검증기를 127.0.0.1:8305 에 세우고, alg:none·alg 혼동·kid 주입 위조 토큰을 직접 만들어 던져 방어가 상태코드로 막는지 확인한다. 손검증과 PyJWT 검증을 둘 다 본다.
왜 중요한가
JWT 는 토큰이 스스로 증거를 들고 다녀 서버에 물어보지 않고 서명만 검증하면 된다. 그 편리함의 대가로, 검증을 조금만 느슨하게 짜면 토큰 헤더의 alg 와 kid 를 검증기가 곧이곧대로 믿어 위조가 통과한다. alg:none 은 서명 없는 토큰을 허용하는 것이고, alg 혼동은 RS256 공개키를 HS256 비밀로 오용해 공개된 키만으로 서명을 위조하는 것이며, kid 주입은 검증에 쓸 키 자체를 공격자가 지정하는 것이다. RFC 8725(JWT BCP)의 방어는 셋으로 요약된다 — 허용 알고리즘 화이트리스트를 두고, 알고리즘을 헤더가 아니라 키 타입에 고정하고, kid 를 파일 경로나 URL 로 해석하지 않는다. 여기에 exp/nbf/iss/aud 검증을 더한다. 이 실습은 그 규칙들이 실제 위조 요청에서 지켜지는지를 스스로 증명하게 한다.
단계
/root/st/jwt/app.py를 127.0.0.1:8305 에 띄우고 자기 발급 RS256 토큰이 200 이며 PyJWT 로도 통과함을/root/st/jwt/self.out에 남긴다.- alg:none 토큰을 만들어 던지면 401 인지
/root/st/jwt/none.out에 적는다. - rs-1 공개키를 HS256 비밀로 오용한 혼동 토큰은 401, 정당한 hs-1 HS256 토큰은 200 인지
/root/st/jwt/confusion.out에 적는다. - rs-1 토큰은 200, 헤더 kid 만 바꿔치기하면 401, 없는 kid 도 401 인지
/root/st/jwt/kid.out에 적는다. - JWKS 에 rs-1·rs-2 가 함께 있고 두 kid 토큰이 모두 200 인지
/root/st/jwt/rotation.out에 적는다. - kid 를 파일 경로로 둔 주입 토큰이 401 인지
/root/st/jwt/inject.out에 적는다. - exp/nbf/iss/aud 가 각각 어긋난 토큰이 401, 정상만 200 인지
/root/st/jwt/claims.out에 적는다. /root/st/jwt/e2e.sh로 valid·none·confusion·badkid 를 한 번에 던져/root/st/jwt/e2e.out에 네 줄을 남긴다.
참고
- 손검증은 cryptography 로 합니다 — RS256 은 padding.PKCS1v15() + SHA256, HS256 은 hmac + hashlib.sha256. PyJWT 는 jwt.decode 에 algorithms=["RS256"] 을 반드시 넘깁니다.
- 대칭 비밀(hs-1)은 JWKS 에 싣지 않습니다 — 공개하면 그 자체가 서명 위조 수단이 됩니다.
- 흔한 실수 1: 헤더의 alg 를 믿고 검증하는 것 — none 과 혼동이 그대로 뚫립니다. alg 는 kid 가 가리키는 키의 타입에 고정하세요.
- 흔한 실수 2: kid 를 open(kid) 나 fetch(kid) 로 해석하는 것 — 공격자가 키를 지정하게 됩니다. kid 는 고정된 목록의 조회 키로만 쓰세요.
검증기 기동, 자기 발급 RS256 토큰 검증
/root/st/jwt/app.py 를 127.0.0.1:8305 에 띄운다. POST /sign 으로 받은 자기 발급 RS256 토큰을 POST /verify 에 던지면 200 이고, /root/st/jwt/verify_both.py 가 PyJWT 로도 통과함을 /root/st/jwt/self.out 에 verify_code=200, pyjwt=valid ... 로 남긴다.
키는 서버가 켜질 때 cryptography 로 만들어 파일로 보존합니다. 서버는 백그라운드로 띄우고 준비될 때까지 폴링하세요. PyJWT 는 jwt.decode 에 algorithms=["RS256"] 을 반드시 넘겨야 합니다.
alg:none 토큰 거절
alg 를 none 으로 두고 서명을 비운 토큰을 만들어 POST /verify 에 던지면 401 로 거절되는지 /root/st/jwt/none.out 에 none_code=401 로 적는다.
RFC 7519 §6 의 Unsecured JWS 입니다. 방어는 허용 alg 화이트리스트 하나로 끝납니다 — none 이 목록에 없으면 서명을 보기도 전에 걸립니다.
alg 혼동(RS 공개키를 HS 비밀로) 거절
rs-1 공개키(JWKS 의 n,e 로 복원한 SPKI PEM)를 HS256 비밀로 삼아 서명한 토큰은 401 로 거절되고, 정당한 hs-1 HS256 토큰은 200 으로 통과하는지 /root/st/jwt/confusion.out 에 confusion_code=401, legit_hs256_code=200 으로 적는다.
혼동을 막는 것은 HS256 을 금지하는 게 아니라 alg 를 키 타입에 고정하는 것입니다. rs-1 은 RSA 키라 RS256 전용이므로 헤더가 HS256 을 요구하면 서명을 보기 전에 거절됩니다.
kid 로 키 선택, 없는 kid 거절
rs-1 로 서명한 토큰은 200 이고, 그 토큰의 헤더 kid 만 rs-2 로 바꿔치기하면(서명은 그대로) 401, 없는 kid(rs-9)도 401 인지 /root/st/jwt/kid.out 에 good_kid_code=200, wrong_kid_code=401, unknown_kid_code=401 로 적는다.
kid 는 어느 키로 검증할지 고르는 손잡이입니다. 헤더 kid 만 바꾸면 실제 서명은 rs-1 키의 것이라 rs-2 로는 검증에 실패합니다.
JWKS 키 롤오버
JWKS 에 rs-1 과 rs-2 가 함께 있고(jwks_kids=rs-1,rs-2), 새 kid(rs-2)로 서명한 토큰도, 옛 kid(rs-1) 토큰도 모두 200 으로 통과하는지 /root/st/jwt/rotation.out 에 jwks_kids=rs-1,rs-2, new_kid_code=200, old_kid_code=200 으로 적는다.
롤오버 중에는 옛 키로 서명된 토큰이 아직 돌아다닙니다. JWKS 에 두 키를 함께 두어 kid 로 골라 검증합니다 — 옛 키를 너무 일찍 지우면 유효한 토큰이 한꺼번에 거절됩니다.
kid 경로·URL 주입 거절
kid 를 파일 경로(/root/st/jwt/evil_pub.pem)로 두고 그 경로에 심은 키로 서명한 토큰을 던지면 401 로 거절되는지 /root/st/jwt/inject.out 에 badkid_code=401 로 적는다.
kid 는 아무 문자열이나 올 수 있습니다(RFC 7515 §4.1.4). 검증기가 kid 로 파일을 열거나 URL 을 당기면 공격자가 검증에 쓸 키를 지정하게 됩니다. kid 는 내가 아는 키 목록의 조회 키로만 쓰세요.
exp/nbf/iss/aud 검증
서명은 모두 정상이되 클레임만 어긋난 토큰들이 각각 401 이고 정상 토큰만 200 인지 /root/st/jwt/claims.out 에 valid_code=200, expired_code=401, notyet_code=401, badiss_code=401, badaud_code=401 로 적는다.
서명이 맞아도 끝이 아닙니다. exp 이 지났거나 nbf 가 미래거나 iss 가 내가 아는 발급자가 아니거나 aud 가 내 이름이 아니면 거절합니다. 시계 오차용 leeway 는 몇 분 이내로 둡니다.
종합: valid·none·confusion·badkid
/root/st/jwt/e2e.sh 로 네 가지를 한 번에 던져 /root/st/jwt/e2e.out 에 valid=200, none=401, confusion=401, badkid=401 을 남긴다.
앞 스텝들을 한 스크립트로 이어 붙입니다. 정상 토큰만 통과하고 세 위조는 모두 401 이어야 합니다.