Skip to content

JWT에 대한 새로운 세가지 공격 기법 | Three New Attacks Against JSON Web Tokens

Posted on:January 2, 2024 at 08:49 PM

서론

이 글은 blackhat usa 2023에서 발표하신 Tom Tervoort의 발표 내용 에 내 개인적인 분석을 추가해 설명한 문서이다. 소개할 첫번째, 두번째 공격기법인 sign/encrypt confusionpolygot token은 토큰 변조로 이어지며, 세번째 방법인 billion hashes는 token을 사용하는 서버에서 DoS를 발생시킬 수 있다.

Table of contents

Open Table of contents

배경지식

JWT는 JSON Web Token의 약자로, 일반적으로 클라이언트와 서버 사이에서 통신할 때 권한을 통제하기 위해 사용하는 토큰이다. 이때 통제되는 권한, 즉 identity와 권한 정보를 클라이언트에 대한 claim(이하 클레임)이라고 칭한다. 사용자들이 토큰을 위조하는 것을 막기 위해 암호화해 사용한다. 올바른 키를 가진 사람만이 토큰을 통제할 수 있다.

JWT claim은 JSON으로 인코딩돼 있으며, JSON Web Signature(JWS) 혹은 JSON Web Encryption(JWE) 오브젝트 또는 그 둘의 혼합으로 감싸여 있다. JWS와 JWE는 Javascript Object Signing and Encryption(JOSE) 기준을 따른다.
JWT와 관련된 지적은 계속해서 제기되어 왔다. 정확히는, 암호학자들은 JWT를 계속해서 지적해왔다. JWT의 근본적인 문제는 한마디로 요약할 수 있다.

Cryptography is hard!

"alg":"none" 공격은 익숙할 것이다. JWT가 보안 정책으로 택한 암호화 방식은 안전했는가? JWS와 JWE는 XML같은 구세대 암호화 방식보다는 개선된 대안이었지만, 최선책은 아니었다.

JSON Web Token의 문제점

JWT의 한계는 다음의 네가지다.

  1. "alg" 파라미터가 토큰 그 자체의 일부다. 토큰이 공격자에 의해 스푸핑 당했을 수 있기 때문에, 확인자에게 어떤 종류의 암호를 쓸지 알려주는 사람은 결국 공격자다. 이는 cross-protocol 공격을 가능하게 한다. "alg":"none", HMAC/RSA confusion 공격 등이 그 사례다. 구성에서 암호화 알고리즘을 제한하거나 키와 함께 알고리즘을 저장하는 것도 가능하지만 이러한 제약은 강제되지 않는다.
  2. 전문가가 선택한 암호 대신, 개발자에게 선택권이 남아있다. 무려 RFC가 출판되기 17년 전에 깨진 알고리즘 또한 포함한다.
  3. 1번과 2번의 한계점을 더 명확하게 하는 한가지는, 서로 다른 알고리즘이 크게 다른 보안 특성을 가질 수 있다는 것이다. "alg" 값에 따라 토큰이 MAC으로 서명될 수 있지만, 공유 비밀키로 암호화되거나 공개 키로 암호화 되는 것 또한 가능하다. 게다가 아무런 보호도 존재하지 않는 "none" 값 마저 존재할만큼, 보안 특성이 다양하다.
  4. 다양한 암호화 알고리즘 외에도, 압축부터 시작해 X.509 인증서 처리까지 다양한 기능이 정의돼 있다. JOSE는 하나의 문제를 효과적으로 해결하는 대신 범용성을 택했으며, 그 결과로 더 많은 복잡성과 attack surface를 도입한 셈이 되었다.

기존 공격 기법들

exploit 세가지

Sign/encrypt confusion

배경지식

RFC 7519은 JWT의 JWS 또는 JWE 서명을 둘 다 허용한다. JWS를 사용할 때는 JWT의 클레임이 소유자에게 숨겨지지 않지만 토큰은 여전히 보호된다. JWE를 사용할 때는 토큰 내용이 소유자로부터 숨겨진다. JWE 내에 JWS를 포함해 두 형식을 결합하는 것 또한 가능하며, 직접 JWE에 내장된 클레임도 허용된다.

JWS와 JWE는 대칭 및 비대칭 알고리즘을 모두 허용한다. 그 둘은 그럼에도 특성이 다르다. 대칭 JWS 알고리즘(HS256등)을 사용한 JWT는 동일한 비밀키로 생성하고 유효성을 검사할 수 있다. 반면, 비대칭 알고리즘(RS256등)을 사용할 때는 JWT를 개인 키 소유자만 생성할 수 있음에도 모든 사람이 유효성을 검사할 수 있다. 대칭 JWE를 사용할 때에는 암호화 또는 복호화를 위해 비밀 키에 접근해야 한다. 인증된 암호화 방식을 사용하기 때문에, 키를 모른다면 클레임을 변경할 수 없다.

여기서 제기할 수 있는 문제점은 비대칭 암호화를 사용하는 JWE 객체에 대해서다. 비대칭 암호화를 사용하는 JWE 객체의 경우 공개키가 암호화에 사용되고 개인키가 복호화에 사용된다. JWT의 맥락에서 생각해보다면, 개인키의 소유자는 토큰의 유효성을 검사할 수 있지만 토큰을 발급하기 위해서는 비밀키가 필요하지 않다.

“none” JWS대칭 JWS비대칭 JWS대칭 JWE비대칭 JWE
읽기 위해 비밀키가 필요한가?NNNYY
수정하기 위해 비밀키가 필요한가?NYYYN

RFC 7519은 암호화된 토큰이 단일 JWE 객체로 래핑될 수 있도록 허용하지만 비대칭 알고리즘의 사용을 금지하지는 않는다. 이에 대해 언급하는 내용조차 없다. 이로 인해 많은 구현이 이러한 비대칭 암호화된 JWT를 지원하게 되었다. 어쩌면 유용할지도 모르지만, JWT를 사용하는 대부분의 상황에서는 누구나 토큰을 발급할 수 있으면 위험하다. 암호학에 능통한 개발자는 아마도 비대칭 JWE를 선택하지 않을테지만, 모든 개발자가 이 차이점을 알지는 못한다.

exploit

더 흥미로운 것은, 개발자가 전혀 암호화를 사용하지 않아도 cross-protocol(프로토콜 간 공격)이 가능하다는 점이다. 지금까지 소개한 내용을 바탕으로, Sign/encrypt confusion 공격과 exploit을 소개한다. 공격이 가능한 조건은 다음과 같다.

이 상황에서 어플리케이션은 공격자가 직접 변조할 수 없는 서명된 토큰을 발행한다. 공격자가 할 수 있는 것은 동일한 공개 키로 토큰을 암호화하는 것이다. 개발자가 처음부터 암호화된 토큰을 사용하려고 의도하지 않았더라도, 취약한 라이브러리는 이후 개인 키로 이 JWE 객체를 복호화하고 valid하다고 간주한다.

이 공격은 먼저 사용 중인 공개 키를 찾아내야 하는데, 이 키는 비밀로 유지될 필요가 없기 때문에 일반적으로 OIDC 엔드포인트 같은 위치에 게시될 수 있다. 심지어 공개 키가 게시되지 않았더라도 일부 알고리즘(특히 RS256, RS384 및 RS512과 같은 흔한 옵션)의 경우에는 두 개의 서명만으로 공개 키를 계산할 수 있다. 이 경우 공격자는 valid하다고 서명된 토큰의 두 가지 다른 샘플을 획득한 후, 이를 기반으로 암호화된 토큰을 위조할 수 있게 된다.

from authlib.jose import jwt, JsonWebKey
import sys, json

with open('rsa-key.jwk', 'r') as keyfile:
    key = JsonWebKey.import_key(json.load(keyfile))

def validate(token):
    claims = jwt.decode(token, key)
    claims.validate()

라이브러리에 나온 코드와 비슷해보이지만, 읽어들이는 키 파일에 공개키와 함께 개인키가 포함되어 있을 경우에는 공격에 취약하다. 이를 악용하려면 공격자는 먼저 공개키를 찾아내야 한다. RS256, RS384 또는 RS512 알고리즘 (PKCS#1 패딩을 사용하는 RSA)이 사용되는 경우, 비교적 효율적으로 계산할 수 있는 스크립트가 있다. 공개 키가 계산되거나 다른 방법으로 획득되면, 나머지 남은 것은 기존 토큰의 클레임을 가져와서 필요한 대로 조정하는 일뿐이다. 또는, 필요에 따라 처음부터 클레임을 작성할 수도 있다.

affected libraries

Polyglot token

배경지식

JSON 표준 및 구현에서는 여러 모호성이 존재한다. 예를 들어, 서로 다른 parser(파서)가 동일한 문자열을 서로 다르게 처리할 수 있다. 동일한 JSON 객체가 다른 시스템에서 처리되는 경우, 이러한 파서 불일치는 취약점을 유발한다. JWT는 JSON을 기반으로 한다. 예를 들어, JWT 유효성 검사에 파서 A가 사용되지만 파서 A에 따라 에러가 발생하지 않는다면 실제로 내용을 해석하고 처리하는 파서는 파서 B인 상황을 생각해보자. 이러한 파서 간 불일치가 있다면 공격자는 A에게 valid 한 JWT처럼 보이는 입력을 생성할 수 있지만, B에 의해 해석된다면 공격자가 설정한 클레임이 나오도록 토큰 변조를 할 수 있다.

JWS 표현의 모호성으로 인해 발생하는 취약점이 있다. JWS는 실제로 세 가지 다른 종류의 구문을 사용하여 나타낼 수 있다. compact serialization(간소한 직렬화), general JSON serialization(일반 JSON 직렬화), flattened JSON serialization(평탄한 JSON 직렬화)이다. JWT RFC는 간소한 직렬화만 사용해야 한다고 명시하고 있으나, JWS 라이브러리는 실제로 더 많은 형식을 지원할 수 있습니다.

이와 같은 불일치가 python-jwt JWT validator(검증기)와 jwcrypto JWS 검증기 사이에 존재했다. 공격자는 jwcrypto에 대해 유효한 JSON 직렬화 JWS로 보이는 입력을 위조할 수 있었으나, python-jwt에서 간소한 직렬화로 해석될 때, 서명이 검증된 페이로드와 다른 페이로드로 해석된다. 이로 인해 python-jwt에 대한 토큰 위조 공격이 발생했다.

exploit

def verify_jwt(jwt, pub_key=None, allowed_algs=None, iat_skew=timedelta(), checks_optional=False, ignore_not_implemented=False):
    [...]

    header, claims, _ = jwt.split('.')
    parsed_header = json.loads(urlsafe_b64decode(header + '===').decode('utf-8'))

    [...]

    if pub_key:
        token = JWS()
        token.allowed_algs = allowed_algs
        token.deserialize(jwt, pub_key)
    elif 'none' not in allowed_algs:
        raise _JWTError('no key but none alg not allowed')

    parsed_claims = json.loads(urlsafe_b64decode(claims + '===').decode('utf-8'))

    [...]

    return parsed_header, parsed_claims

이 코드는 python-jwt의 취약한 코드 중 일부이다. JWT가 간소한 직렬화 방법을 사용하여 인코딩되었다고 가정하며, 따라서 점을 기준으로 분할된다. 그런 다음 헤더 및 클레임 구성 요소가 디코딩되고, 전체 JWT는 jwcrypto를 사용하여 유효성을 검사한다(deserialize 메서드는 서명이 유효하지 않은 경우 예외를 throw한다.). 마지막으로 분석된 헤더 및 클레임이 반환된다.

이를 공격하려면 공격자는 먼저 유효한 서명이 있는 일반적인 (권한이 없는) 토큰을 얻어야 한다. 이는 AAAA.BBBB.CCCC처럼 간소한 직렬화를 사용하는 형태다. 여기서 A는 JWS 헤더를 나타내며, B는 클레임을 나타내고, C는 서명을 나타낸다. 이를 평탄한 JSON 직렬화 형태로 나타내면 다음과 같다.

{
  "protected": "AAAA",
  "payload": "BBBB",
  "signature": "CCCC"
}

이 JWS는 jwcryptodeserialize 메서드에서도 허용되며, 이전과 동일하게 처리된다. 그러나 python-jwt 검증기에 제공되면 jwt.split('.')에서 예외가 발생한다. 이 문자열에는 마침표가 없기 때문이다.

공격자는 객체를 다음과 같이 수정할 수 있다.

{
  "AAAA": ".XXXX.",
  "protected": "AAAA",
  "payload": "BBBB",
  "signature": "CCCC"
}

jwcrypto의 관점에서 이는 필요한 필드가 모두 있는 유효한 JSON 직렬화 JWS이다. 추가된 AAAA 필드는 JWS 객체에 대해 의미가 없으므로 단순히 무시된다. 따라서 jwcrypto 검증단계에서 예외는 발생하지 않는다. 그러나 python-jwt는 이 JSON 객체를 마침표로 분할하고 다음과 같은 값으로 해석한다.

header: {"AAAA:"
claims: XXXX

헤더에는 url-safe base64가 아닌 알파벳이 포함돼 있다. 여기서 사용된 base64url_decode 함수의 구문는 유효하지 않은 문자를 무시하고 헤더를 AAAA와 동일한 방식으로 디코딩하므로, 상관없다. 마지막으로 XXXX가 JWT의 유효한 클레임으로 반환된다. 이 클레임은 공격자에 의해 위조된 클레임이다. 따라서 공격자는 어떤 클레임이든 위조할 수 있다.

affected libraries

Billion hashes attack

배경지식

키 기반의 대칭 및 비대칭 암호화 외에도, JWE 표준은 PBES2 알고리즘을 통한 암호 기반 암호화를 지원한다. 이 알고리즘들은 키 대신 문자열 패스워드로 암호를 만들고, 암호 기반 키 PBKDF2를 적용하여 암호화 키를 만든다.

인간이 선택하는 패스워드는 예측 가능한 패턴을 사용한다. 일반적인 단어 기반 암호화는 dictionary 공격 및 bruteforce 공격에 취약하다. 이러한 위험을 줄이기 위해 PBKDF2 함수는 반복 횟수 매개변수를 사용한다. 이 매개변수는 얼마나 많은 연속된 암호 해시 작업을 수행해야 패스워드를 키로 만들 수 있는지 정의한다. 이때, 반복 횟수가 높을수록 함수 처리 속도가 느려진다.

일부러 함수 실행 속도를 느리게 만들면 패스워드 추측 공격이 어려워진다. 하지만, 이 매개변수를 너무 높게 설정하면 정상적으로 사용하기에 너무 느려질 수 있다. 적절한 보안/성능 균형을 갖춘 값을 찾아야 한다. JWE에서는 이 반복 횟수가 토큰 헤더 p2c로 정의된다. 이는 일부 패스워드 기반 암호화 사용 사례에는 적합하지만, 공격자가 자동으로 처리되는 JWE 객체를 제공할 수 있는 경우 문제가 발생한다. 이 경우 공격자는 p2c를 극도로 높은 값으로 설정해 서버에서 DoS를 유발할 수 있다. 이 값을 변경하면 JWE 인증 태그가 무효화된다. 그러나 이 태그는 키가 파생된 후에만 유효성을 검사할 수 있으며, 따라서 그 시점에서는 이미 비용이 많이 드는 PBKDF2 계산이 수행되었을 것이다.

일반적으로 JWT에는 PBES2 알고리즘이 사용되지 않지만, 다양한 라이브러리에서는 이러한 알고리즘을 지원하며 사용자가 패스워드를 지정하지 않은 경우에도 기본적으로 PBES2를 허용하는 경우가 있다. 이러한 라이브러리는 PBES2 알고리즘이 지정된 경우 구성된 암호화 키를 패스워드로 취급하고 새 PBES2 토큰이 제공될 때마다 PBKDF2 함수를 수행한다. 이로써 토큰 헤더를 사용해 DoS를 내는 것이 가능해진다.

exploit

{
  "alg": "PBES2-HS512+A256KW",
  "p2s": "8Q1SzinasR3xchYz6ZZcHA",
  "p2c": 2147483647,
  "enc": "A128CBC-HS256"
}

위 페이로드에서 p2c는 부호가 있는 32비트 정수의 최대값으로 설정되어 있으며(취약한 구현에서 허용하는 최대 값이기도 하다), p2s는 임의의 랜덤 문자열이다. JWE 페이로드는 임의의 값을 가질 수 있다. 암호화된 키, IV 및 인증 태그 필드도 적절하게 길이를 맞춘 바이트의 나열일 수도 있다. 예시로 만든 전체 토큰이다.

eyJhbGciOiJQQkVTMi1IUzUxMitBMjU2S1ciLCJwMnMiOiI4UTFTemluYXNSM3h
jaFl6NlpaY0hBIiwicDJjIjoyMTQ3NDgzNjQ3LCJlbmMiOiJBMTI4Q0JDLUhTMj
U2In0.YKbKLsEoyw_JoNvhtuHo9aaeRNSEhhAW2OVHcuF_HLqS0n6hA_fgCA.VB
iCzVHNoLiR3F4V82uoTQ.23i-Tb1AV4n0WKVSSgcQrdg6GRqsUKxjruHXYsTHAJ
LZ2nsnGIX86vMXqIi6IRsfywCRFzLxEcZBRnTvG3nhzPk0GDD7FMyXhUHpDjEYC
NA_XOmzg8yZR9oyjo6lTF6si4q9FZ2EhzgFQCLO_6h5EVg3vR75_hkBsnuoqoM3
dwejXBtIodN84PeqMb6asmas_dpSsz7H10fC5ni9xIz424givB1YLldF6exVmL9
3R3fOoOJbmk2GBQZL_SEGllv2cQsBgeprARsaQ7Bq99tT80coH8ItBjgV08AtzX
FFsx9qKvC982KLKdPQMTlVJKkqtV4Ru5LEVpBZXBnZrtViSOgyg6AiuwaS-rCrc
D_ePOGSuxvgtrokAKYPqmXUeRdjFJwafkYEkiuDCV9vWGAi1DH2xTafhJwcmywI
yzi4BqRpmdn_N-zl5tuJYyuvKhjKv6ihbsV_k1hJGPGAxJ6wUpmwC4PTQ2izEm0
TuSE8oMKdTw8V3kobXZ77ulMwDs4p.ALTKwxvAefeL-32NY7eTAQ

이 토큰은 그대로 취약한 구현을 공격할 수 있다. 이를 이용하기 위한 조건은 다음과 같다.

  1. JWT 라이브러리가 기본적으로 JWE-wrapped JWT 및 PBES 알고리즘을 지원한다.
  2. JWT 라이브러리가 패스워드 기반 암호화를 위한 별도의 API를 사용하지 않고 대신 암호화 키와 암호를 동일하게 처리한다.
  3. 라이브러리 사용자가 JWT 검증을 위한 특정 알고리즘을 설정하지 않았다.

affected libraries

참고문헌