JWT 취약점과 공격 방식 (PortSwigger Academy)
JWT 기반 인증에서 발생할 수 있는 주요 취약점과 공격 방식들을 정리한다.
JWT 취약점과 공격 방식
이전 글에서는 JWT가 어떤 구조로 이루어져 있으며, 왜 공격이 가능한 구조인지 살펴보았다.
JWT 기반 인증 시스템은 서버가 세션 상태를 따로 저장하지 않고 토큰 자체의 서명(Signature)을 검증한 뒤 payload 데이터를 신뢰하는 방식으로 동작한다.
따라서 이 서명 검증 과정이 잘못 구현되거나, 사용되는 키가 약한 경우 공격자가 토큰을 조작하여 인증을 우회할 수 있다.
이번 글에서는 실제로 자주 발견되는 JWT 공격 방식들을 몇 가지 살펴보려고 한다.
Weak Secret Key 공격 (Brute-Force)
JWT는 서명 알고리즘에 따라 대칭키 (Symmetric Key)를 사용하는 경우가 있다.
대표적으로,
- HS256
- HS384
- HS512 와 같은 알고리즘이 있는데, 이 방식들은
HMAC(secret_key, header + payload)같은 형태로 서명이 생성된다.
즉, secret_key 하나로 서명을 생성하고 검증한다.
문제는 이 secret key가 비밀번호처럼 취급되어야 한다는 점이다.
하지만 실제 환경에서는 다음과 같은 실수가 자주 발생한다.
- 기본 예제 코드의 secret key를 그대로 사용
"secret"같은 짧은 문자열 사용- 개발 과정에서 사용한 placeholder key를 그대로 배포
이 경우 공격자는 JWT 서명을 brute-force하여 secret key를 찾아낼 수 있다.
만약 공격자가 secret key를 알아낸다면 공격자는 원하는 payload를 만들어 정상적인 서명을 생성할 수 있게 된다.
1
2
3
{
"sub":"administrator"
}
같은 토큰을 직접 만들어도 서버는 정상적인 JWT로 인식하게 되는것.
Hashcat을 이용한 secret key brute-force
JWT secret key brute-force에는 보통 hashcat을 사용한다.
공격에 앞서 필요한 것 두가지:
- 서버에서 발급된 JWT
- secret wordlist
예시로, hashcat -a 0 -m 16500 <jwt> <wordlist> 명령어를 살펴보면,
hashcat은
- wordlist의 문자열을 secret key로 가정
- JWT header + payload를 재서명
- 기존 signature와 비교
만약 일치하는 값이 발견되면 <jwt>:<secret_key>와 같이 출력된다.
BurpSuite 예제
Lab: JWT authentication bypass via weak signing key
JWT Header Parameter Injection
JWT header에는 alg 외에도 여러 파라미터가 존재할 수 있다.
ex: jwk, jku, kid.
이 값들은 서버가 어떤 키를 사용해서 JWT 서명을 검증해야 하는지 알려주는 역할을 한다.
문제는 이 값들이 토큰 내부에 존재하는 사용자 입력 데이터 (User input data)라는 점.
즉 공격자가 이 값을 조작할 수 있다.
1. jwk 파라미터를 통한 injection
JWK (JSON Web Key)는 암호화 키를 JSON 형태로 표현하는 표준 형식이다. JWT에서는 jwk 헤더를 통해 서명을 검증할 때 사용할 공개키(Public key)를 토큰 내부에 포함시킬 수 있다.
예시:
1
2
3
4
5
6
7
8
9
10
11
{
"kid": "ed2Nf8sb-sD6ng0-scs5390g-fFD8sfxG",
"typ": "JWT",
"alg": "RS256",
"jwk": {
"kty": "RSA",
"e": "AQAB",
"kid": "ed2Nf8sb-sD6ng0-scs5390g-fFD8sfxG",
"n": "yy1wpYmffgXBxhAUJzHHocCuJolwDqql75ZWuCQ_cb33K2vh9m"
}
}
여기서 jwk object는 RSA 공개키를 JSON 형식으로 표현한 것이다.
JWT가 RS256과 같은 비대칭키(Asymmetric key)기반 알고리즘을 사용할 경우,
다음과 같은 방식으로 서명과 검증이 이루어진다.
- 개인키(Private Key) → JWT 서명
- 공개키(Public Key) → JWT 서명 검증
일반적인(정상적인) JWT 인증 흐름은 다음과 같다.
- 서버가 JWT를 생성하고 서버의 개인키로 JWT에 서명
JWT = sign(header.payload, server_private_key) - 클라이언트는 JWT를 저장해뒀다가 요청마다 JWT를 서버로 전송
- 서버는 서버의 공개키로 서명을 검증
verify(signature, header.payload, server_public_key) - 검증이 성공하면 JWT를 정상 토큰으로 판단하고 인증을 허용.
이처럼 정상적인 구현에서는 서버가 신뢰하는 공개키만 사용하여 서명을 검증해야 한다.
하지만 취약한 일부 서버의 경우에는 다음과 같이 동작할 수 있다:
public_key = header.jwk verify(signature, header.payload, public_key)
즉, JWT header에 포함된 jwk값을 그대로 신뢰하여 검증 키로 사용하는 경우이다.
이 경우 공격자는 서버가 사용할 검증 키를 직접 지정할 수 있게 된다.
예를들어 공격자가 RSA key pair를 생성 한 후,
공격자의 private key로 JWT를 서명 sign(header.payload, attacker_private_key)
이후, JWT header의 jwk에 attacker_public_key를 삽입해서 서버에 전송하게 되면 취약한 서버는 서버의 공개키가 아닌, 공격자가 제공한 공격자의 공개키로 서명을 검증하게 된다.
결과적으로 서버는
“서명이 올바르다 → 정상 토큰이다”
라고 판단하게 된다.
BurpSuite 예제
Lab: JWT authentication bypass via jwk header injection
2. jku 파라미터를 통한 injection
jwk와 유사하게, JWT header에는 jku, (JSON Web Key Set URL)라는 파라미터가 존재할 수 있다.
이 값은 서버가 JWT 서명을 검증할 때 사용할 공개키 목록(JWK Set)을 어디에서 가져올지 알려주는 역할을 한다.
jwk가 JWT 내부에 직접 공개키를 포함시키는 방식이라면, jku는 외부 URL에서 공개키를 가져오는 방식이라고 볼 수 있다.
예시:
1
2
3
4
5
{
"alg": "RS256",
"jku": "https://example.com/jwks.json",
"kid": "75d0ef47-af89-47a9-9061-7c02a610d5ab"
}
서버는 JWT를 검증할 때 다음과 같은 과정을 수행한다.
- JWT header의
jku값을 확인 - 해당 URL로 요청을 보내 JWK Set을 가져옴
kid값에 해당하는 공개키를 찾음- 해당 공개키로 JWT 서명을 검증
JWK Set은 여러 공개키를 포함하며, JSON 구조로 되어있다.
JWK Set예시:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"kid": "75d0ef47-af89-47a9-9061-7c02a610d5ab",
"n": "o-yy1wpYmffgXBxhAUJzHHocCuJolwDqql75ZWuCQ_cb33K2vh9mk6GPM9gNN4Y_qTVX67WhsN3JvaFYw-fhvsWQ"
},
{
"kty": "RSA",
"e": "AQAB",
"kid": "d8fDFo-fS9-faS14a9-ASf99sa-7c1Ad5abA",
"n": "fc3f-yy1wpYmffgXBxhAUJzHql79gNNQ_cb33HocCuJolwDqmk6GPM4Y_qTVX67WhsN3JvaFYw-dfg6DH-asAScw"
}
]
}
이러한 JWK Set은 /.well-known/jwks.json 같은 endpoint에서 제공되는 경우가 많다.
여기서 /.well-known/은 웹에서 표준화된 메타데이터 경로로 자주 쓰이는 디렉터리이다.
반드시 이 경로여야 하는 건 아니지만,
OAuth, OpenID Connect, JWT 관련 구현에서 관례적으로 많이 사용되는것으로 알려져있다.
또한 public key로는 검증 용도로만 사용되기 때문에 jwks.json endpoint가 공개되어 있는 것 자체는 문제가 되지 않는다.
문제는 서버가 아무 URL에서나 공개키를 가져오도록 허용할 때 발생한다.
정상:
서버는 자기 서비스나 신뢰된 인증 서버의 jwks.json만 허용
취약:
서버가 JWT header의 jku값을 그대로 참조하여 공격자가 지정한 URL의 jwks.json을 그대로 가져옴
즉, trusted-auth.com/.well-known/jwks.json만 허용해야하는데, 공격자가 attacker.com/jwks.json으로 대체해도 서버가 그대로 받아 올 수 있다는 점이다.
공격 과정은 다음과 같다.
- 공격자가 RSA key pair를 생성
- 공격자의 서버에 JWK Set(attacker.com/jwks.json) 업로드
- JWT header의
jku값을 공격자의 서버로 변경 - 공격자의 개인키(private key)로 JWT 서명
- 서버로 JWT 전송
이때 취약한 서버는 jku URL에서 가져온 공개키로 서명을 검증 하게 되므로,
공격자가 만든 JWT도 정상 토큰으로 인식하게 된다.
BurpSuite 예제
Lab: JWT authentication bypass via jku header injection
3. kid 파라미터를 통한 injection
JWT header에는 kid(Key ID)라는 파라미터가 포함될 수 있다.
이 값은 서버가 JWT 서명을 검증할 때 어떤 키를 사용할지 식별하기 위한 값이다.
서버는 여러 개의 키를 사용할 수 있기 때문에, kid 값을 통해 적절한 키를 선택하게 된다.
예를 들어 서버 내부에 다음과 같은 키들이 저장되어 있다고 가정해보자.
1
2
3
key1
key2
key3
JWT header의 kid 값이 key2라면 서버는 해당 키를 선택해 서명을 검증한다.
예시:
1
2
3
4
{
"alg": "HS256",
"kid": "key2"
}
서버는 대략 다음과 같은 과정을 수행한다.
- JWT header의
kid값 확인 - 해당
kid에 해당하는 verification key 검색 - 해당 key로 JWT 서명 검증
취약점 발생 이유
문제는 JWS specification에서 kid의 구조를 엄격하게 정의하지 않았다는 점에 있다.
kid는 단순 문자열이며, 개발자가 원하는 방식으로 사용할 수 있는데
예를들어 서버 구현에 따라 kid값이 다음과 같이 사용될 수 있다.
- 데이터베이스에서 key조회
- 파일시스템에서 key파일 조회
- JWK Set에서 key식별
특히 서버가 파일 시스템에서 키를 불러오는 방식으로 구현되어 있을 경우 ex.
1
key = read_file("/keys/" + kid)
이 경우, 공격자가 kid값에 directory traversal을 사용하면 서버가 의도하지 않은 파일을 읽게 만들 수 있다.
JSON 예시:
1
2
3
4
5
{
"kid": "../../path/to/file",
"typ": "JWT",
"alg": "HS256"
}
이렇게 되면 서버는 /keys/../../path/to/file 경로의 파일을 읽어 verification key로 사용하게 된다.
공격 방식
이 취약점은 특히 대칭키 알고리즘(HS256)을 사용할 때 위험하다.
HS256에서는 같은 secret key로 서명과 검증이 이루어 지기 떄문.
즉 공격자가 verification key의 값을 에측할 수 있다면, 그 값을 secret key로 사용해 JWT를 서명할 수 있다.
예를 들어 공격자가 다음과 같은 값을 kid에 삽입할 수 있다.
1
2
3
4
{
"alg": "HS256",
"kid": "../../dev/null"
}
/dev/null은 대부분의 Linux 시스템에 존재하는 특수 파일인데, 이 파일을 읽으면 항상 빈 문자열(Emptry String)이 반환된다.
따라서 서버는 아래와 같은 방식으로 동작하게 된다:
1
2
3
4
5
6
7
8
secret = read_file("/dev/null") → ""
verify(signature, header.payload "")
즉 빈 문자열을 secret key로 사용해 JWT검증을 하게 된다.
공격자는 이미 이 값을 알고 있기 때문에, 빈 문자열로 JWT를 생성.
```text
sign(header.payload, "")
결과적으로 서버는 공격자가 만든 JWT를 정상 토큰으로 인식하게 된다.
위 예시 에서는, 대칭키 알고리즘 HS256를 예로 들었으나 다른 알고리즘 또한 안전하지는 않다.
핵심은, 알고리즘 자체가 아니라 kid값을 서버가 어떻게 사용하는지에 있다.
kid취약점의 본질은, 서버가 kid값을 이용해 verification key를 외부 입력으로부터 가져온다는 것인데,
HS256 (대칭키)에서는
1
secret key = signing key = verification key
이미 살펴본 예제와 같이, secret key로 서명과 검증이 둘 다 이루어진다.
하지만 RS256(비대칭키) 에서는 구조가 다르다.
1
2
개인키 (private key) → 서명
공개키 (public key) → 검증
하는 구조이기 때문에, kid로 공개키(public key)파일을 읽어오는 경우라 해도 개인키(private key)가 없으면 서명 생성이 불가능 하다. 따라서, HS256 처럼 바로 JWT를 위조하기는 어렵다.
BurpSuite 예제
Lab: JWT authentication bypass via kid header path traversal
정리
정리하자면,
JWT의 header와 payload는 단순히 Base64 인코딩이 사용된 데이터이기 때문에 공격자가 자유롭게 수정이 가능하다.
따라서 서버가 별 다른 인증 없이 이 값을들 신뢰하게 된다면, 공격자는 서명 검증 자체를 우회할 수 있다.
| Header | 공격 방식 |
|---|---|
jwk | JWT 내부에 공격자의 public key를 직접 삽입 |
jku | 공격자가 지정한 외부 URL에서 public key를 다운로드하도록 유도 |
kid | 서버가 사용할 verification key의 위치 또는 식별자를 조작 |
결국 세 공격 모두 서버가 사용할 검증 키를 공격자가 조작해 결정하도록 만들 수 있다는 점이 핵심이다.
이러한 이유로 JWT 구현에서는 다음과 같은 보안 조치가 필요하다:
jwk값은 신뢰된 키만 사용하도록 제한한다.jku는 신뢰된 도메인만 허용 한다.kid값은 파일 경로나 DB 조회에 직접 사용하지 않도록 검증 한다.
추가로 알아둘 JWT header 파라미터
지금까지 jwk, jku, kid 파라미터를 중심으로 JWT header injection 취약점을 살펴봤다.
하지만 JWT header에는 이 외에도 여러 파라미터가 존재하며, 구현 방식에 따라 공격에 활용될 수 있다.
kid 파라미터와 SQL Injection
앞서 kid 값이 파일 시스템 경로로 사용될 경우 path traversal 공격이 가능하다는 점을 살펴봤다.
하지만 일부 구현에서는 kid 값을 이용해 데이터베이스에서 key를 조회하기도 한다.
예를 들어 다음과 같은 방식이다.
1
SELECT key FROM jwt_keys WHERE kid = '<kid>'
이 경우 kid 값이 제대로 검증되지 않는다면 SQL injection 공격이 발생할 수 있다.
즉 공격자는 단순히 key 선택을 조작하는 것을 넘어 데이터베이스 쿼리 자체를 변조할 수도 있다.
cty (Content Type)
cty는 Content Type을 의미하는 header 파라미터다.
이 값은 JWT payload에 포함된 데이터의 미디어 타입(media type) 을 나타내기 위해 사용된다.
예시:
1
2
3
4
{
"alg": "RS256",
"cty": "application/json"
}
일반적인 JWT에서는 cty가 거의 사용되지 않지만 JWT 파싱 라이브러리가 이 값을 지원하는 경우가 있다.
만약 공격자가 JWT 서명 검증을 우회할 수 있는 상황이라면 cty 값을 조작해 payload의 해석 방식을 바꿀 수도 있다.
예를 들어
1
cty: text/xml
과 같이 설정하면 서버가 payload를 XML로 처리하면서 XXE 공격으로 이어질 가능성이 있다.
또는
1
cty: application/x-java-serialized-object
와 같은 값이 사용되는 환경에서는 Java deserialization 취약점으로 이어질 수도 있다.
x5c (X.509 Certificate Chain)
x5c는 JWT 서명에 사용된 X.509 인증서 체인을 전달할 때 사용되는 header 파라미터다.
예시:
1
2
3
4
5
6
{
"alg": "RS256",
"x5c": [
"MIIC...certificate..."
]
}
이 파라미터는 서버가 JWT 서명을 검증할 때 사용할 인증서 정보를 함께 전달하는 방식이다.
문제는 일부 서버가 JWT 내부에 포함된 인증서를 그대로 신뢰하는 경우다.
이 경우 공격자는 self-signed certificate를 삽입해 자신이 만든 JWT를 정상 토큰처럼 보이게 만들 수 있다.
또한 X.509 인증서는 구조가 복잡하기 때문에 파싱 과정에서 새로운 취약점이 발생하기도 한다.
대표적인 사례로 다음과 같은 취약점이 보고된 바 있다.
- CVE-2017-2800
- CVE-2018-2633
추가적인 JWT 보안 권장 사항
취약점을 막기 위한 필수 요소는 아니지만, 다음과 같은 보안 설정을 적용하는 것이 좋다.
- JWT에 expiration
(exp)값을 항상 설정 - JWT를 URL parameter로 전달하지 않기
aud(audience) claim을 사용해 토큰 사용 대상 제한- 로그아웃 시 토큰을 무효화(revoke) 할 수 있는 구조 마련
JWT는 서버가 세션 상태를 저장하지 않아도 되는 장점이 있지만 그만큼 토큰 자체가 신뢰의 기준이 되는 구조이기 때문에 구현 단계에서 더 많은 주의가 필요하다.
링크: 출처: PortSwigger Academy