RS256 공개키는 어떻게 HS256 비밀키로 둔갑하는가

seonwoo_jung·4일 전

1. 도입

RSA 공개키는 이름 그대로 유출돼도 안전해야 하는 값이다. 그런데 특정 조건에서는 그 공개키 문자열 하나가 관리자 권한 토큰을 위조하는 재료로 그대로 쓰인다. JWT 검증 코드의 어느 지점에 그 조건이 숨어 있는지, RFC 7515·RFC 8725 스펙을 따라가며 짚어본다.

이전에 JWT는 왜 세 조각으로 나뉘고, 서버는 무엇을 검증하는가에서 "검증은 payload를 읽는 일이 아니라 믿어도 되는 조건을 확인하는 일"이라고 정리한 적이 있다. 이번 글은 그 조건 중 하나가 깨졌을 때 실제로 무슨 일이 벌어지는지에 대한 이야기다.

2. 핵심 개념

JWS(JSON Web Signature)는 Header.Payload.Signature 세 부분을 점으로 이어 붙인 문자열이고, 서명이 실제로 덮는 범위는 BASE64URL(Header) || '.' || BASE64URL(Payload) 전체다. 여기서 눈여겨봐야 할 점은 Header 자신도 서명 대상에 포함된다는 것이다. Header 안에는 어떤 알고리즘으로 서명했는지를 적는 alg 필드가 들어 있다.

문제는 이 alg 값을 "누가 정하느냐"다. 서명을 만드는 쪽은 당연히 자신이 쓴 알고리즘을 정확히 적어야 하지만, 검증하는 쪽이 이 값을 무조건 믿고 알고리즘을 고른다면, 알고리즘 선택권이 토큰을 보낸 쪽(즉 공격자가 통제할 수 있는 영역)으로 넘어가 버린다.

3. 내부 동작

RFC 7515 §5.2가 정의하는 검증 8단계 중 마지막 단계는 "alg Header Parameter 값이 나타내는 방식으로 서명을 검증하라"고만 말한다. 그런데 §10.6과 §10.7은 정반대 방향에서 경고한다 — 검증자가 여러 알고리즘을 동시에 지원하면서 alg 헤더를 그대로 신뢰하면, 공격자가 다른 알고리즘으로 같은 서명값을 만족시키는 치환 공격이 성립한다는 것이다.

RFC 8725 §2.1은 실제로 보고된 사례(CVE-2015-9235로 알려져 있다)를 든다. algRS256에서 HS256으로 바꾸면, 일부 라이브러리가 RSA 공개키 바이트열을 HMAC 공유 비밀로 그대로 재사용해 HMAC-SHA256 검증을 시도한다는 것이다. 흐름은 이렇다.

  1. 공격자가 서버의 RSA 공개키를 손에 넣는다 (JWKS 엔드포인트 등, 원래 공개 정보라 문제 없다고 여겨진다).
  2. 공격자가 {"alg":"HS256"} 헤더와 원하는 payload({"sub":"admin"} 등)를 만든다.
  3. 공격자가 HMAC-SHA256(signingInput, key=공개키바이트)를 직접 계산해 서명으로 붙인다.
  4. 검증자가 verify(token, 공개키)를 호출하는데, 내부적으로 alg 헤더가 HS256이니 HMAC 검증기로 분기하고, 같은 공개키를 이번엔 "HMAC 비밀키"로 사용한다.
  5. 공격자가 3)에서 이미 같은 값을 계산해 두었으므로 검증이 통과한다.

키 자체는 바뀐 게 없다. 그 키를 "비대칭 검증용 공개 정보"로 쓰느냐 "대칭 MAC용 비밀 정보"로 쓰느냐를 결정하는 스위치가 오직 공격자가 채우는 alg 필드라는 게 이 공격의 핵심이다.

4. 예시 / 코드

PyJWT 같은 라이브러리에서 이 취약점이 실제로 어떤 API 설계 차이로 막히는지 보면 이해가 빠르다.

import jwt

# 서버의 RSA 공개키 (원래 공개해도 안전하다고 가정된 값)
public_key = open("server_public.pem").read()

# 공격자: 공개키를 HMAC 비밀키로 취급해 위조 토큰을 만든다
forged = jwt.encode({"sub": "admin"}, public_key, algorithm="HS256")

# 취약한 패턴: 허용 알고리즘을 지정하지 않고 토큰의 alg를 그대로 믿는다
# (구버전 PyJWT는 이런 호출을 허용했다 — CVE-2015-9235)
jwt.decode(forged, public_key, algorithms=["HS256", "RS256"])  # 통과해버린다

# 안전한 패턴: 이 키는 RS256 검증에만 쓰겠다고 서버가 못 박는다
jwt.decode(forged, public_key, algorithms=["RS256"])  # InvalidSignatureError

두 번째와 세 번째 decode 호출의 차이가 RFC 8725 §3.1이 요구하는 것 그대로다. "라이브러리는 호출자가 지원할 알고리즘 집합을 지정할 수 있게 해야 하고, 그 외 알고리즘은 쓰면 안 되며, 키 하나는 정확히 하나의 알고리즘에만 묶여야 한다." 알고리즘을 고르는 주도권이 토큰(공격자 영역)이 아니라 서버 설정(서버 영역)에 있어야 안전하다.

5. 정리

alg 헤더는 서명 생성자의 선언일 뿐 검증자에게 강제력이 없는 값이라는 것, 그리고 안전한 검증은 "토큰이 뭐라고 하는지"가 아니라 "서버가 미리 정한 것과 일치하는지"만 확인해야 한다는 것이 이 공격이 남기는 결론이다. 다음으로 파고들 만한 지점은 JWKS의 kid 헤더를 파일 경로로 그대로 쓸 때 생기는 주입 공격, 그리고 JWE에서 alg/enc 조합을 조작하는 유사한 혼동 패턴이다.

참고 자료

  • RFC 7515, JSON Web Signature (JWS) — §2, §5.1–5.2, §10.6–10.7
  • RFC 8725, JSON Web Token Best Current Practices — §2.1, §3.1
  • CVE-2015-9235 (RFC 8725 §2.1에서 참조하는 RS256/HS256 혼동 사례)

0개의 댓글