JWT(JSON Web Token)
JWT란?
JWT(JSON Web Token): 사용자 인증 정보 및 권한 정보를 JSON 형태로 담아 서버와 클라이언트 간에 주고받는 토큰 기반 인증 방식.
JWT는 헤더(알고리즘/토큰 타입), 페이로드(사용자 정보/권한 등 클레임), 서명(위변조 방지를 위한 서명) 세 부분으로 구성되며, 점(.)으로 구분되어 하나의 문자열로 전달된다. 헤더와 페이로드는 Base64인코딩만 되어 있을 뿐 암호화된 것이 아니므로 누구나 디코딩하여 내용을 확인할 수 있고, 서명을 통해서만 위변조 여부를 검증한다.
특징은, JWT는 앞의 세 자리가 보통 eyj로 시작한다.
JWT 취약점이 생기는 원인
서버가 세션 상태를 별도로 저장하지 않고 토큰 자체에 인증 정보를 담아 검증하는 방식이기 때문에, 별도의 세션 저장소 없이도 로그인 상태를 유지할 수 있어 API 서버, 분산 시스템 등에서 세션 ID를 대체하는 용도로 널리 사용된다.
단, 각 JWT 토큰이 독립적이며 서버는 토큰의 원래 내용이나 원래 서명이 무엇이었는지 알지 못하기 때문에 서버가 서명을 제대로 검증하지 않으면 취약점이 생긴다.
JWT vs JWS vs JWE
JWT : JSON 형태로 사용자 정보(클레임)를 담아서 주고받기 위한 포맷. JWT 자체가 서명이나 암호화를 하는 건 아니고, 보통 JWS 또는 JWE와 함께 사용된다.
JWS : JSON Web Signature. JWT의 내용을 서명해서 변조 여부를 검증할 수 있게 하는 방식. 내용 자체는 암호화되지 않아 볼 수 있다. (흔히 보는 JWT가 대부분 이 형태)
JWE : JSON Web Encryption. JWT의 내용을 암호화해서 외부에서 내용을 볼 수 없게 하는 방식.
정리하면 이런 느낌. JWT(내용) → JWS(서명해서 보호) / JWE(암호화해서 보호)
JWK vs JWKS vs JKU
JWK: 암호 키 하나를 JSON 객체로 표현하는 표준 형식. 공개키를 JWT 헤더에 넣은 형태이다.
JWKS: 여러 JWK를 하나로 묶은 세트. 최상위에 keys 배열 하나만 가진 단순한 JSON 문서이다. 배열의 각 원소가 JWK이다.
JKU: JWKS가 호스팅되어 있는 URL을 가리키는 JWT 헤더 안의 파라미터. 서버가 서명을 검증할 때 ‘이 URL 가서 JWKS 받아와서 그 안에서 kid 맞는 키 찾아 써라’고 지시하는 용도. 이 JWKS를 호스팅해두는 경로는 /.well-known/jwks.json 또는 /jwks.json이 업계 관례로 굳어져 있어서, jku 값이 없어도 이 경로로 직접 접근하면 서버의 공개키를 얻을 수 있는 경우가 종종 있다.
JWT 취약점 찾는 법
JWT 사용 여부 식별 로그인 후 요청/응답의 쿠키, Authorization 헤더, localStorage 값들을 확인. 점(.) 두 개로 구분된 base64문자열 3덩어리 형태면 JWT. Burp Proxy history를 jwt_tool이나 JWT Editor 확장의 ‘JSON Web Token’ 자동 감지 기능으로 스캔하면 어디서 JWT를 쓰는지 빠르게 파악할 수 있다.
디코딩해서 구조/라이브러리 추정 header/payload를 디코드해서 alg 값, kid 포맷(숫자ID인지, UUID인지, 파일 경로 냄새 나는지), 커스텀 claim들을 확인. alg가 RS/ES(비대칭)인지 HS(대칭)인지가 이후 공격 경로를 결정하는 제일 중요한 정보.
서명 검증 여부 자체를 테스트 (가장 먼저, 가장 쉬움) payload를 아무 값이나 바꾼 뒤 서명은 원래 값 그대로 두고 보내보기 → 통과되면 애초에 서명 검증을 안 하는 것(unverified signature). 그 다음 서명 부분을 통째로 지우고 마지막 점만 남긴 채(header.payload.) 보내보기, alg를 none/None/nOnE 등으로 바꿔가며 시도 → 통과되면 alg=none 우회.
kid 파라미터 조작 kid 값을 ../../../../dev/null, ../../../../etc/passwd 등 트래버설 페이로드로 바꿔보고, alg를 HS256으로 강제 전환해서 빈 문자열(또는 추정 가능한 값)을 비밀키로 서명해보기. kid가 SQL 쿼리에 들어가는 형태로 보이면 ‘ OR ‘1’=’1 류의 SQLi 페이로드도 시도. kid가 DB row를 가리키는 것 같으면 이미 알고 있는 값(예: 다른 유저의 kid)으로 바꿔치기도 테스트. 참고로 kid 트래버설은 kid 포맷이 파일 경로스럽게 생겼을 때만 시도 할 수 있으니 숫자ID/UUID면 스킵하기.
jwk / jku 헤더 삽입 직접 생성한 RSA 키로 payload를 서명한 뒤, jwk 헤더에 내 공개키를 통째로 넣거나, jku에 내가 통제하는 서버(Burp Collaborator 또는 exploit 서버)의 JWKS URL을 넣어서 보내보기. jku가 도메인 화이트리스트로 막혀있으면 URL 파싱 불일치(예: https://trusted.com.attacker.com, @, # 트릭 등 SSRF 우회 기법)도 같이 시도. 참고로 jwk/jku는 서버가 애초에 그 파라미터를 파싱하는지부터 확인해야 함 — 헤더에 임의로 jku 넣었는데 서버가 그 필드 자체를 무시하면(그냥 자기 저장된 키로만 검증) 의미 없음. 넣고 응답 시간 차이 또는 Collaborator 콜백으로 ‘서버가 실제로 그 필드를 보고 반응했는지’부터 확인하기.
알고리즘 혼동(Algorithm Confusion) 공격 서버가 원래 RS256을 쓰던 경우, alg를 HS256으로 바꾸고 서버의 RSA 공개키를 HMAC 비밀키로 삼아 서명해보기. 공개키는 보통 /.well-known/jwks.json, SSL 인증서, API 문서 등에서 구할 수 있는지 먼저 확인.
claim 변조 + 만료/재사용 테스트 위 단계 중 하나라도 서명 우회가 되면, 그 상태에서 isAdmin, role, sub, username 같은 claim을 바꿔가며 권한 상승/계정 탈취 시도. 별개로 exp를 미래로 늘려서 만료된 토큰이 여전히 먹히는지, 로그아웃한 토큰이 서버 측에서 실제로 무효화되는지(revocation 여부)도 체크.
JWT 취약점 실습
서명 검증 누락
그냥 서명 검증을 안 하는 경우이다.
랩 주소: https://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-unverified-signature
JWT 라이브러리는 보통 두 가지 메서드를 따로 제공하는데,
verify(): 서명까지 검증decode(): 그냥 디코딩만, 서명은 확인 안 함
만약 개발자가 두 메서드를 헷갈려서 서버가 들어온 토큰을 decode()로만 처리하면, 서명이 맞든 틀리든 payload를 그대로 신뢰해버린다..
이 때 payload를 원하는 값으로 바꾸고, 서명 부분은 아무 값이나(원본 그대로 두거나, 완전히 랜덤한 값으로) 채운 채 보내면 된다.
보안 조치
서명 검증 로직에서 verify()와 decode()를 혼동하면 안 된다. 특히 미들웨어나 인증 로직을 여러 사람이 나눠서 짜는 경우 조심해야 한다.
alg: none 우회
랩 주소: https://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-flawed-signature-verification
위의 랩은 아직 신뢰 검증도 안 끝난 입력(헤더)이, 그 검증을 어떤 방식으로 할지를 서버에게 지시하는…? 이상한 경우이다. 공격자가 헤더에 “나 이 알고리즘으로 서명했어”라고 써놓으면 서버는 일단 그 말을 믿고 그 알고리즘으로 검증 로직을 태운다.
JWT 헤더의 alg 파라미터는 “이 토큰 서명할 때 어떤 알고리즘 썼는지”를 서버에게 알려주는데,
1
{ "alg": "HS256", "typ": "JWT" }
여기서 alg: none이라는, 서명을 아예 생략한다는 의미의 값도 존재한다(“unsecured JWT”). 이걸 서버가 제대로 막지 않으면, 서명 없이 payload만 있는 토큰도 그냥 통과된다.
서버 대부분은 alg: none을 문자열 매칭으로 걸러내어 보통은 막혀있다. 그런데 이 필터링이 단순 문자열 비교에 의존하는 경우, 아래의 우회 기법이 먹힌다:
- 대소문자 섞기:
None,nOnE,NONE - 예상 밖의 인코딩 등…
정리하면 이 경우 공격 방법은 payload 조작 후 alg를 none/None/NoNe 등으로 바꿔가며 서명 없이(header.payload.) 전송하여 어느 버전이 통과되는지 확인한다. 이 때 서명을 지워도 마지막 점(.)은 남겨놔야 JWT 형식이 유지되어 서버에서 파싱된다.
보안 조치
alg 값을 서버가 클라이언트 입력(헤더)에서 그대로 읽어와 검증 방식을 결정하면 안 되고, 서버 쪽에서 미리 허용할 알고리즘을 명시적으로 고정해둬야 한다(예: algorithms: [“RS256”]). none을 문자열 매칭으로 막는 방식은 대소문자나 인코딩 우회에 뚫릴 여지가 있으므로, 애초에 “허용 목록에 없는 알고리즘/값은 전부 거부”하는 화이트리스트 방식으로 처리하는 게 안전하다.
jwk 헤더 인젝션
랩 주소: https://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-jwk-header-injection
서버가 jwk 헤더에 박힌 공개키를 화이트리스트 검증 없이 그대로 신뢰해버리는 경우이다.
JWK (JSON Web Key) 헤더: 공개키를 JWT 헤더에 직접 넣는 것
공격 흐름:
RSA 키 쌍(개인키+공개키) 직접 생성
payload 조작 (isAdmin: true 등 원하는 값으로, 위의 랩 기준으로는 sub 클레임 값을 administrator로 조작함) 그 조작한 토큰을 내 개인키로 서명 헤더의 jwk에 내 공개키를 그대로 끼워넣음 (kid도 맞춰서) 서버: 헤더에 공개키가 있으니까 → 그 공개키로 서명 검증 → 통과 (내가 그 공개키의 짝인 개인키로 서명했기 때문)
서버가 ‘검증 키는 반드시 내가 미리 알고 있는 목록 중에서만 골라야 한다’는 원칙을 안 지켜서 뚫리는 구조이다.
조작한 토큰으로 admin페이지 접근 및 carlos 사용자 삭제 성공
보안 조치
서버는 jwk 헤더에 뭐가 들어오든 무시하고, 자체적으로 미리 등록/관리해둔 신뢰 키 목록에서만 검증 키를 가져와야 한다. 애초에 토큰 안에 ‘이 키로 검증해’가 실려있다는 것 자체가 이상함
jku 헤더 인젝션
랩 주소: https://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-jku-header-injection
JKU (JWK Set URL) 헤더: 공개키가 들어있는 곳의 URL을 JWT 헤더에 넣는 것.
jwk처럼 키를 직접 넣는 대신, 키 세트가 있는 URL을 알려주는 방식. 서버가 그 URL로 가서 JWK S 안의 kid가 일치하는 키를 찾아 검증에 쓴다.
1
2
3
예시:
{ "keys": [ {"kty":"RSA","kid":"...","n":"...","e":"..."} ] }
이런 걸 끼워넣음...
공격 흐름:
내 RSA 키 쌍 생성
내 공개키를 JWK Set 형태로 내가 통제하는 서버(exploit 서버)에 호스팅 payload 조작 + 헤더의 kid를 내가 올린 키의 kid로 맞춤 헤더에 jku를 내 exploit 서버 URL로 설정 내 개인키로 서명 서버: “kid 모르겠는데 jku 가서 찾아보자” → 내가 심어둔 서버로 순순히 요청 보냄 → 내 공개키 받아옴 → 검증 통과하는 시나리오
서버가 “누구 공개키를 쓸지”를 공격자가 적어준 주소에서 그대로 가져와서 쓰기 때문에, 공격자는 자기가 만든 키 쌍을 스스로 “신뢰받는 키”로 만들 수 있는 것이다.
jwk는 키를 ‘직접 넣는 방식’이고, jku는 ‘키가 있는 곳을 알려주는’ 거라는 차이만 있을 뿐, 검증 재료의 출처를 공격자가 통제할 수 있는 점에서 거의 똑같다.
(더 견고한 서버는 jku를 신뢰 도메인 화이트리스트로 제한하는데, 이 때 URL 파싱 불일치를 이용한 우회(SSRF 계열 기법)가 가능할 수도 있다고 함…?)
보안 조치
jku도 마찬가지로 헤더에 적힌 URL을 그대로 fetch하면 안 되고, 아예 이 파라미터를 무시하거나 신뢰 가능한 도메인 화이트리스트로 엄격히 제한해야 한다. 화이트리스트를 쓰더라도 문자열 단순 비교가 아니라 URL을 제대로 파싱해서 호스트를 비교해야 우회를 막을 수 있다(SSRF 방어랑 비슷한 맥락).
kid 헤더 path traversal
랩 주소: https://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-kid-header-path-traversal
kid(key id)는 그냥 ‘임의의 문자열’이라, 서버 구현에 따라 DB row를 가리킬 수도 있고 파일명을 가리킬 수도 있다. 만약 이 값이 디렉토리 트래버설에 취약하면 아래와 같이 변조하여 공격 시도할 수 있다.
1
2
예시:
{ "kid": "../../../../dev/null", "alg": "HS256", ... }
그러니까 서버가 실제로 파일시스템에서 kid가 가리키는 파일을 읽어서 그 내용을 검증키로 쓴다. /dev/null은 리눅스에 거의 항상 존재하고 항상 빈 파일이므로, 읽으면 빈 문자열이 나온다.
여기서 중요한 조건은, 서버가 대칭키(HS256) 서명도 지원해야 한다. 비대칭키(RS256)라면 검증키(공개키)를 안다고 해도 서명은 개인키로만 가능해서 위조가 안 된다. 하지만 대칭키는 서명키 = 검증키라서, 검증에 쓰일 키 값을 알아내는 순간 그 자체로 서명도 만들 수 있다.
공격 흐름:
kid를 ../../dev/null처럼 트래버설 경로로 설정
alg를 HS256으로 바꿈 (RS256 쓰던 서버라도, alg 헤더값을 서버가 그대로 믿는다면 대칭키 검증 로직을 타게 만들 수 있음) 서버가 kid 경로의 파일(/dev/null)을 읽음 → 빈 문자열을 HMAC 비밀키로 사용 공격자는 빈 문자열을 대칭키로 등록해서 직접 HS256 서명 생성 서버가 같은 빈 문자열로 검증 → 통과
위와 같이 HS256 서명에 쓸 대칭키를 새로 생성하되 Secret 값을 비워서(k: “”) /dev/null을 읽었을 때 나오는 빈 문자열과 동일한 키로 만든다.
kid를 트래버설 경로로, alg를 HS256으로 바꾼 뒤 빈 문자열 키로 서명한 최종 토큰. sub도 administrator로 변조함
이 랩은 ‘검증 키의 위치를 공격자가 통제’ (kid traversal) + ‘비대칭→대칭 알고리즘 전환을 서버가 그대로 믿음’ (alg 신뢰) 이 두 결함이 합쳐져야 성립하는 구조라서.. 실제로는 잘 못 써먹을 것 같은데… 잘 모르겠음
보안 조치
kid 값을 파일 경로나 DB 조회에 직접 쓰지 말고, 미리 정해둔 값들(화이트리스트)에 있는지만 확인하는 용도로 써야 한다. 입력값을 그대로 파일시스템 경로 조합에 넣는 구조 자체를 피해야 한다.
서명 알고리즘 혼용
랩 주소: https://portswigger.net/web-security/jwt/algorithm-confusion/lab-jwt-authentication-bypass-via-algorithm-confusion
서버가 관습적으로(?) 공개키를 /.well-known/jwks.json 또는 /jwks.json에 두는 경우가 있다고 한다. RFC로 강제된 건 아니지만 대부분의 OIDC/OAuth 구현체가 이 경로를 쓴다고 함…
여기서 이 키들을 복사해서, JWK 포맷으로 새 공개키를 생성하여 붙여넣어 등록한다.
등록한 공개키 JEK 형식을 PEM 형식(암호화 키 또는 인증서를 텍스트로 저장할 때 쓰는 포맷.—–BEGIN 뭐시기—– 헤더로 시작해서 —–END 뭐시기—– 푸터로 끝남)으로 복사한다.
복사한 PEM 텍스트를 base64 인코딩한다. 서버가 verify() 함수에 넘기는 값과 동일한 형태(파일에서 읽은 PEM 원문)를 만들기 위한 작업…
이제 대칭키를 생성해서, k값(HMAC 비밀키 자리)에 base64로 인코딩해둔 PEM 텍스트를 붙여넣는다. 공개키의 PEM 텍스트가 HMAC의 비밀키가 되게 만든다.
이제 일반 이용자 계정(wiener)으로 admin 페이지를 로드하는 요청을 JWT 탭에서 알고리즘(alg)을 RS256에서 HS256으로, sub를 wiener에서 administrator로 바꾼 뒤 만들어둔 대칭키로 서명(Don’t modify header 체크한 채로)한다.
alg는 HS256으로, sub는 administrator로 변조
서버의 공개키를 비밀키 삼아 HS256 서명을 한 상태가 된다. 이대로 요청을 보내면 admin 페이지에 접근할 수 있다.
위 과정의 공격이 먹히는 이유는,
일단 서버는 공개키(RS256)방식을 쓰고 있어서 개인키는 서버 내에 있고 공개키를 /jwks.json에 공개해 둔 상태였다.
그런데 서버 코드가 jwt.verify(token, serverPublicKey) 형태로, 검증 키를 알고리즘과는 무관하게 같은 변수 하나로 넘기고 있었다. 알고리즘은 상관없고 키 오면 그거 그냥 검증에 써라 뭐 이런 느낌….의 로직이었던 것.
그래서 대칭키(HS256)방식의 알고리즘을 쓴다고 헤더를 변조한 것이다. 서버는 이 헤더를 믿고 대칭키 방식으로 키를 검증하게 된다. 이 때 이 키를 /jwks.json에서 주운 공개키로 해버리면… 대칭키 방식이니까 서버가 그냥 맞네 하고 통과시켜버리게 된다.
결론은 서버가 헤더에 적힌 알고리즘을 그대로 믿었기 때문에 생긴 취약점이다.
보안 조치
서버가 헤더에 적힌 alg 값을 신뢰하지 말고, 검증 시 사용할 알고리즘을 코드 레벨에서 강제로 고정해둬야 한다(예: RS256만 허용). 또한 비대칭키용 변수와 대칭키용 변수를 애초에 분리해서 관리하면, alg가 뭘로 바뀌든 공개키가 엉뚱하게 HMAC 비밀키로 재활용되는 일 자체가 구조적으로 불가능해진다.










