从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

1.前言

网上关于JWT相关漏洞的总结和介绍已经有非常多了,什么签名未校验、禁用Hash、爆破弱密钥、密钥混淆攻击等等,也有很多好用的工具,例如JWT_Tool等。

在这一堆花哨的问题中,攻击者利用起来最方便,也是比较可能遇到的,无疑就是“爆破弱密钥”。但是我和很多师傅聊过,大家虽然都知道有这个姿势,但是实战中基本不会特意去尝试爆破,原因也很简单,几乎没成功过。

因此,大部分师傅关于JWT相关的攻击,基本都是从某些地方泄露出JWT密钥之后,才尝试去利用。

那么,为什么JWT密钥爆破成功率这么低呢?难不成所有开发者的安全意识都这么好?都使用的强口令去生成JWT Token?这显然也是不现实的!

最近几天,小队范师傅来请教了我一些关于JWT爆破的案例和问题,正是从这个问题中,我发现了JWT密钥爆破成功率低的真正原因,并进一步发现了Github上一个10K StarJWT组件的设计缺陷。

下面我们进入正题。

2.奇怪的JWT爆破问题以及JWT爆破成功率低的真相

某天,小队范师傅找我交流了一个关于JWT爆破的奇怪问题,问题如下

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

首先,我们用密钥Bohui@123生成一串JWT Token,注意这里不勾选secret base64 encoded

此时,我们使用JWT_Tool对上述JWT Token进行爆破,可以成功爆破出密钥

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

下面,如果我们生成JWT Token时勾选secret base64 encoded,我们在字典里放入Bohui@123,以及Bohui@123 base64编码后的值,仍旧爆破不出来

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

很有意思对吧,这个secret base64 encoded到底是什么意思呢?翻阅了一下资料,我找到了这个选项的含义

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

勾选secret base64 encoded,意味着对用户输入的密钥先进行一次base64解码,用解码后的值去参与JWT Token的生成!

到这里,其实我在想,哪有开发者会这么生成JWT Token?对一串正常的非base64编码字符进行base64解码,程序难道不会报错?这样去生成JWT Token的开发者才是极少数吧?但是来找我交流问题的师傅告诉我,这样的问题其实才是比较常见的!

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

那么,到底为什么会产生这样的情况呢?其实网上能找到一篇解答的文章

https://mp.weixin.qq.com/s/ntVi1vdWzEPHpQ4k-45Cjg

yitaiqi师傅在23年末也发现了这个问题。要探究这个问题,我们还需要从一个很常用的JAVA JWT生成组件,也即JJWT组件开始说起。

我们先来演示一下,要如何使用这个组件进行JWT Token的生成与解密,生成JWT方法如下:

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

解密方法如下:

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

从这个案例中,我们可以很清晰的看到是什么方法进行了JWT密钥的设置,在生成过程中,负责设置密钥的方法是signWith(),而在解密过程中,负责设置密钥的方法是setSigningKey()

开发者在使用框架的时候肯定不会想到把自己的密钥先base64解码后再传入signWith()或者setSigningKey(),毕竟对一串正常字符串base64解码,鬼知道会得到什么东西。既然会出现上面的问题,那一定是signWith()或者setSigningKey()方法里做了什么特殊处理。

我们先跟进signWith()方法,在DefaultJwtBuilder类中可以找到该方法的实现:

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

注意看两个重要的地方,这个方法的本意是,我们要传入一串base64编码后的字符作为密钥,后续会调用TextCodec.BASE64.decode()方法对我们传入的密钥进行解密,解密后的值才参与JWT Token的生成。

那么问题来了,有多少开发者知道这里需要把自己的密钥base64编码后再传入呢?我上网找了几篇JJWT的使用案例和教程,发现很多人其实都误用了相关方法!

Demo1如下:

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

Demo2如下:

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

看到了吗?网上教的这些用法,都直接传入了普通字符串,而非Base64编码后的字符串,这会导致什么问题呢?我们接着往后看,说回signWith方法

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

前面说到,这里会调用TextCodec.BASE64.decode()方法对用户传入的密钥进行解密,解密后的值才参与JWT Token的生成。

比如用户直接传入一个Testme123,还乐呵的以为密钥就是Testme123,实则不然,密钥实际上是:

TextCodec.BASE64.decode(“Testme123”)

跟进该方法,实际上该方法是调用的

DatatypeConverter.parseBase64Binary()方法对用户传入的字符串进行Base64解码!

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

我们再看看解密JWT时使用的setSigningKey()方法的实现呢?

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

你会发现,和setWith()方法如出一辙,会先将用户传入的密钥进行base64解码,然后解码后的值作为真正的密钥进行解密。

现在我们就完全弄懂了这是怎么回事了!问题的关键就在于,在jjwt框架中,会对你提供的JWT密钥先进行base64解码,用解码后的结果作为密钥去生成JWT。这个理念其实比较好理解,如果你要使用一些高强度的密钥,难免会涉及一些特殊字符,如果你直接传入的话还可能会遇到一些奇奇怪怪的问题,所以先把你的密钥base64编码后再传入,是能避免很多乱七八糟的问题的

但是大部分开发者并不知道这一点,网上关于jwt相关框架的教程本身也没讲明白,所以很多开发者以为那两个方法接受的密钥是明文密钥,因此直接把明文密钥传入了。比如我传入个Testme#233,并不意味着密钥本身就是Testme#233,而是对Testme#233进行base64解码后的值!

那么问题来了,对一个普通字符串base64解码,会得到什么值?这就是最大的槽点,开发者的失误、误用,反而在某种程度上阻止了攻击者对密钥的爆破!

因此实际上我们还得专门写一个脚本,把我们字典里的所有正常的密钥字符串,先进行base64解码,然后用解码后的值去参与JWT密钥的比对和爆破,才能够实现爆破的效果。

yitaiqi师傅在他的文章中给出了一个简单的demo,实际上我们只需要对普通字符串补足长度,就可以实现将普通字符串进行“base64解码”

#来自pyjwt库import jwtimport base64
#密钥key="your-secret"#jwt认证字符串st = "your-jwt-token"
secret=base64.b64decode(key[:len(key)-(len(key)%4)])#jwt解码s = jwt.decode(st, secret,algorithms=["HS512", "HS256"]) # 解密,校验签名print(s)

我们来简单实验一下,在勾选secret base64 encoded的情况下,生成一串JWT Token

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

运行,现在,我们就可以成功解密这种情况下的JWT Token了。看起来一切都非常美好,接下来我们只需要稍微改改上面的脚本,它就能成为一个针对这种特殊情况的爆破工具了!

但是情况真的有这么简单吗?在我后续改进和测试该脚本时,发现了一些问题。第一个问题如下,假如我们的密钥有一些特殊字符,比如我使用Testme@123生成一个JWT Token

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

此时,我们再尝试解密,发现报错

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

实际上,对Testme@123进行base64解码的过程中,这个错误就触发了,是啊,@符号都不在base64的码表中,怎么可能正常base64解密呢?这就意味着JAVADatatypeConverter.parseBase64Binary()一定有着截然不同的逻辑,才能成功完成后续步骤。

第二个问题是,当我们jwt tokenpayload部分存在某些特殊字符时,也会导致解码失败,如下:

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

此时的错误是jwt.exceptions.InvalidAudienceError: Invalid audience,即audience不合法,我一开始以为audience里面是不是不能用{}之类的特殊字符,后面查阅了很多用法后发现,这样是允许的,而且很多开发者都这么用!我甚至都怀疑是不是pyjwt的一些缺陷,或者单独pyjwt不支持这种用法。

上述的两个问题困扰了我很久,始终无法在python上解决这两个问题,可能也是有办法解决,但是本人python技术力也不高,如果各位大手子有兴趣,也可以尝试解决一下。

这时候就该转变一下思路了,既然这主要是JJWT组件的问题,那我其实把JJWT里生成和解密JWT的逻辑抠出来就行了呀!甚至都不用这么麻烦,我们只需要将错就错,顺着开发者错误的生成JWT的思路,去写一个错误的解密思路不就行了?

我们先用JJWT框架生成一个JWT Token

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

注意这里我们使用的密钥是Testme@123,并且我们设置了一个Audience,其键值包含{}特殊字符,这两个细节如果是在python上,那么都会导致JWT解密失败,我们在JJWT里试试呢?

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

可见,成功解密,因此我们只需要用JJWT框架去实现这个工具就行,其实代码也非常简单,主要的解密函数如下

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

这里需要注意的点是,如果JWT中的exp键的键值(时间戳)已经过期的话,即使我们提供正确的密钥也解密不出来,会爆ExpiredJwtException的异常,不过这个好解决,我们定位到异常处理的相关代码

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

可以发现,抛出这个exception的构造参数是有claims的,也就是解密结果其实已经有了,查看API,发现有方法getClaims,恰好就是返回的claims

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

因此只需要在捕获异常后调用getClaims()方法就可以获取到JWT解密后的值,无视exp过期导致的异常!

3.从一个意外到发现10K Star项目的设计缺陷

经过上面的测试,似乎得到了一个皆大欢喜的结果,成功写出一个工具完美地解决了上面的种种问题,但是,本文远没有就此结束。

难道开发者不需要为自己错误的用法付出任何安全上的代价?难道错误的用法反而让系统更安全了?

在对我的工具进行测试和debug的时候,发现了一个有趣的现象,我们生成一个JWT Token,使用的密钥是1234

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

然后使用工具进行爆破

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

我们惊讶的发现,有一堆正确密钥被爆破了出来?!这是怎么回事?难道我们的工具的逻辑有什么严重缺陷吗?吓得我赶紧回去检查了一下,但是也没看出啥问题啊。

而且,即使使用错误的密钥,JWT Token也确实被成功解密了,这个总不能是工具逻辑问题导致的吧?

如果我们仔细观察上述密钥,就会发现他们有一个共性,他们的长度都在47之间,也即

1234xxx

这其中必然有一些隐情,我们生成的时候时,是以1234进行base64解码后的结果作为密钥的。解密时,以123456a进行base64解码后的结果作为密钥。可以解密成功

这就意味着,在JJWT框架看来,1234的解码值等价于123456a的解码值?

我们必须回到最关键地方,也即JJWT解码普通字符串的本质,

DatatypeConverter.parseBase64Binary(encoded)方法。

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

我们调用上述方法对1234进行解码,可以看到它得到了一个数组。记住此时数组的值。

当我们对1234a解码时,发现得到的数组仍然一样

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

包括1234aB8这样看起来完全不一样的字符,依然可以解码得到上述数组

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

但如果我们再增加一位字符,1234aB83,则会得到一个完全不一样的数组

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

为什么会有这样的神奇现象呢?这就要说到

DatatypeConverter.parseBase64Binary(encoded)对字符串的处理方法,这里就不放代码了,我大致说一下它的处理逻辑。

首先它会检查你输入的字符串,是否存在一些不在Base64码表里的特殊字符,如@,如果存在这种字符,那么会直接删除掉字符。生成一个新的字符串str1

检查str1的长度,判断其长度是否为4的倍数,如果是,则直接转化为字节码数组。

如果str1长度不为4的倍数,会简单地将其删减到最近的4倍数的长度,得到str2,将str2转化为字节码数组。

这就能解释为什么12341234aB8是等价的,因为1234aB8的长度是7,最接近4的倍数的长度就是4,因此aB8这三位会被直接删掉,留下1234。那自然会得到一样的结果。

当然,第一个无视非base64码表字符的特性也非常有意思,举个例子,Test@1234Test1234解码后的结果也是一样的

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

这意味着什么呢?这意味着当开发者采用这种错误的写法,攻击者爆破密钥的难度,将会大幅度缩小!我们用下面的案例来演示

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

注意此时,开发者使用的密钥是Cves^2024L@z,从理论上来说,攻击者哪怕爆破上一万年,也很难爆破出这个密钥。

但是根据我们上面的逻辑,DatatypeConverter.parseBase64Binary()方法会先去除不在码表中的特殊字符,那么我们的Cves^2024L@z等价于Cves2024Lz

同时注意到,这个字符串的长度是10,不是4的倍数,因此其实际上等价于Cves2024

这就是非常容易生成出来的密钥了,我们试试用这个密钥能不能解密呢?

从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

成功,从Cves^2024L@z这样一个不可能被破解出来的密钥,到Cves2024这样的简单密钥。这就是这个问题的价值所在!

实际上原密钥再夸张也无所谓,大部分特殊字符都是要被删掉的,比如我们用Cves^**^^2024L@@@z作为原密钥,然后用Cves2024还是能解密出来,因为原密钥经过处理后与Cves2024还是等价。

开发者在设计这个功能的时候,就应该禁止用户传入普通字符串,用户误用的时候也应该抛出异常提示,这就是为什么我觉得这个问题更像是一种缺陷。不过新版本JJWT已经废弃了上述用法,这样的问题应该就比较少了,对于较老版本的JAVA系统,还是很有可能存在这种问题的。

(有意思的是,JJWTissue里早就有开发者反馈了这一点,但是原作者认为这是一个不太可能造成安全问题的特性)

因此,网络上铺天盖地的错误用法,错误的教程,实际上为攻击者提供了便利!JJWT框架在github上有10kstar,难以想象有多少人会使用上述错误用法,自以为使用了高强度的JWT密钥,可实际上经过JJWT处理后,会变成简单无比的弱口令密钥!

4.后记

组件本身也许没有问题,但是使用组件的人的想法却很可能有问题,有问题的用法若被大范围传播和学习,那么也可以视为“组件漏洞”了,正如大家最熟悉的一件事——一个字念错的人多了,那么这个错误读音也会被收录进新华字典。

在审计漏洞时,除了注意一些常规的漏洞,还可以多注意开发者的用法,和组件设计者的想法是否一致,在学习开发的时候就可以多推敲,研究一下网上相关的教程中,有没有被广泛使用的错误用法。“本意是好的,只是执行坏了”,不是一句简单的笑谈。


原文始发于微信公众号(HW专项行动小组):从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!!

版权声明:admin 发表于 2024年9月3日 下午3:30。
转载请注明:从一个奇葩的JWT解密问题到Github 10K star组件的设计缺陷!!! | CTF导航

相关文章