安全经典 JWT 算法漏洞
1、什么是 JWT?
JSON Web 令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑而独立的方法,用于在各方之间安全地将信息作为 JSON 对象传输。由于此信息是经过数字签名的,因此可以被验证和信任。可以使用 secret(HMAC 算法)或使用“RSA 或 ECDSA 的公用/私有 key pair 密钥对”对 JWT 进行签名。
尽管可以对 JWT 进行加密以提供双方之间的 secrecy 保密性,但我们将重点关注 signed tokens 已签名的令牌。signed tokens 已签名的令牌可以验证其中包含的 claims 声明的 integrity 完整性,而 encrypted tokens 加密的令牌则将这些 other parties 其他方的 claims 声明隐藏。当使用“公钥/私钥对”对令牌进行签名时,signature also certifies 签名还证明只有持有私钥的一方才是对其进行签名的一方。
摘自官网
2、JWT 能做什么?
1、授权
这是使用 JWT 的最常见方案。一旦用户登录,每个后续请求将包括 JWT,从而允许用户访问该令牌允许的路由,服务和资源。单点登录是今广泛使用 JWT 的一项功能,因为它的开销很小并且可以在不同的域中轻松使用。
2、信息交换
JSON Web Token 是在各方之间安全地传输信息的好方法。因为可以对 JWT 进行签名(例如,使用公钥/私钥对),所以可以确保发件人是本人。此外,由于签名是使用标头和有效负载计算的,因此还可以验证内容是否遭到篡改。
3、基于 session 认证所显露的问题
1、开销
每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言 session 都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
2、扩展性
用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求必须还要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力,这也意味着限制了应用的扩展能力。
3、CSRF
因为是基于 cookie 来进行用户识别的,所以 cookie 如果被截获,用户就会很容易受到 CSRF 的攻击。
【一>所有资源获取<一】1、200 份很多已经买不到的绝版电子书 2、30G 安全大厂内部的视频资料 3、100 份 src 文档 4、常见安全面试题 5、ctf 大赛经典题目解析 6、全套工具包 7、应急响应笔记
JWT 简介
4、JWT 的认证流程
首先,前端通过 web 表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个 HTTP POST 请求。建议的方式是通过 SSL 加密的传输(https 协议),从而避免敏感信息被嗅探。
后端核对用户名和密码成功后,形成一个 JWT Token。
后端将 JWT 字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在 localStorage 或 sessionStorage 中,退出登录时前端删除保存的 JWT 即可。
前端在每次请求时将 JWT 放入 HTTP Header 中的 Authorization 字段。
后端校验前端传来的 JWT 的有效性。
验证通过后,后端使用 JWT 中包含的用户信息进行其他逻辑操作,返回相应结果。
5、JWT 的结构
5.1、令牌组成:header.payload.signature
1、标头(Header)
2、有效载荷(Payload)
3、签名(Signature)
5.2、Header
标头通常由两部分组成:令牌的类型(即 JWT)和所使用的签名算法,例如 HMAC SHA256(默认,HS256)或 RSA(RS256)。它会使用 Base64 编码组成 JWT 结构的第一部分。
注意:Base64 是一种编码,也就是说,它是可以被翻译回原来的样子的,它并不是一种加密过程。
类似这样:
5.3、Payload
令牌的第二部分是有效负载,其中包含声明。声明是有关实体(通常是用户)和其他数据的声明。同样的,它会使用 Base64 编码组成 JWT 结构的第二部分
标准中注册的声明(建议但是不强制使用):
1、iss:jwt 签发者
2、sub:jwt 所面向的用户
3、aud:接收 jwt 的一方
4、exp:jwt 的过期时间,这个过期时间必须要大于签发时间
5、nbf:定义在什么时间之前,该 jwt 都是不可用的
6、iat:jwt 的签发时间
7、jti:jwt 的唯一身份标识,主要用来作为一次性 token,从而回避重放攻击
类似这样:
5.4、Signature
前面两部分都是使用 Base64 进行编码的,即前端可以解开知道里面的信息。Signature 需要使用编码后的 Header 和 Payload 以及我们提供的一个密钥,然后使用 Header 中指定的签名算法(HS256)进行签名。签名的作用是保证 JWT 没有被篡改过
如:HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), 'secret');
测试环境
在https://jwt.io/网站中收录有各类语言的 JWT 库实现(有关 JWT 详细介绍请访问https://jwt.io/introduction/),分别是:
Auth0 实现的 java-jwt:“maven: com.auth0 / java-jwt / 3.3.0”
Brian Campbell 实现的 jose4j:“maven: org.bitbucket.b_c / jose4j / 0.6.3”
connect2id 实现的 nimbus-jose-jwt:“maven: com.nimbusds / nimbus-jose-jwt / 5.7”
Les Haziewood 实现的 jjwt:“maven: io.jsonwebtoken / jjwt-root / 0.11.1”
Inversoft 实现的 prime-jwt:“maven: io.fusionauth / fusionauth-jwt / 3.5.0”
Vertx 实现的 vertx-auth-jwt:“maven: io.vertx / vertx-auth-jwt / 3.5.1”
本文只做简略介绍,每种 JWT 库的具体实现不同,各自也有优缺点。有兴趣的同学可以研究下,这里贴上一位大佬的测试环境,这些全部囊括其中:
https://github.com/monkeyk/MyOIDC/
黑盒测试
为了方便,这里直接用 WebGoat 靶场来做测试
直接利用 WebGoat 的 Java 源码来启动靶场,是比较麻烦的,因为对 jdk 的版本要求比较高。
利用 docker 来搭建 WebGoat,依次输入命令:
启动后,访问:
http://192.168.189.128:8080/WebGoat/start.mvc#lesson/JWT.lesson/3
就是这个投票功能,切换用户得到 token:
点击回收站图标重置投票,提示
Not a valid JWT token, please try again
对应数据包:
可知,只有管理员才可以重置投票
修改 token 中的前两部分(“.”号分割),分别进行 Base64 解码:
“alg”的值改为 NONE,“admin”的值改为 true
拼接修改后的两段 Base64 编码后,重新发包:
报错了,去除“=”号:
还是报错,再把第三段直接删掉,注意保留“.”号:
可成功重置投票。
代码审计
网上大多数文章都是只描述了黑盒测试的步骤,少有此漏洞的代码层面的讲解,接下来利用调试,来深入了解下此漏洞的原理。
先来看 WebGoat 靶场中,此漏洞的代码片段:
生成 access_token,对应的接口为/JWT/votings/login
校验 access_token,对应的接口为/JWT/votings
这里用到的 JWT 库,为上边提到的 jjwt,根据 pom 文件来查看依赖:
我们这里直接利用 SpringBoot 来搭建一个简易的测试环境,方便调试。
具体代码:
先正常请求,生成 access_token:
访问
http://127.0.0.1:8080/login?user=zzz
获取 access_token
再访问
断点位置在验签解析处:
跟进 Jwts.parser()
来看看 DefaultJwtParser 的构造方法:
public DefaultJwtParser() {
// 来看官方对于 clock 的阐述:
// https://github.com/jwtk/jjwt#jws-read-clock-custom// Custom Clock Support// If the above setAllowedClockSkewSeconds isn't sufficient for your needs, the timestamps created during parsing for timestamp comparisons can be obtained via a custom time source. Call the JwtParserBuilder's setClock method with an implementation of the io.jsonwebtoken.Clock interface.
For example:// 如果上述设置允许的时钟倾斜秒不足以满足您的需要,则可以通过自定义时间源获得自定义时间戳。使用 io.jsonwebtoken.Clock 接口的实现调用 JwtParserBuilder's setClock 方法。例如:
回到
这个 JWT_PASSWORD 在上方的定义:
接着跟进
\io\jsonwebtoken\impl\DefaultJwtParser.class#setSigningKey()
这个 Assert.hasText() 只是校验了下是否为 String:
接着这行:
this.keyBytes = TextCodec.BASE64.decode(base64EncodedKeyBytes);
这就是为什么刚才要将 Key 进行 Base64 编码
给到 DefaultJwtParser.keyBytes:
然后返回这个 DefaultJwtParser 对象:
回到:
继续跟进 DefaultJwtParser#parse 方法,首先判断 String 字符串:
然后初始化 Header、Payload 和 Digest(摘要):
接着就是分隔符个数 delimiterCount:
接着下面的 for 循环,会将验签的整段 token 转为 char 数组:
var7 为 token 的 char 数组,var8 为此数组中的字符个数。
接着看下这段 for 循环:
// 先保存分割的这段字符
CharSequence tokenSeq = Strings.clean(sb);
// token 分别为前段:
// 根据 delimiterCount 来判断是 Header 还是 Payload,存到对应的 field
// 每次遇到“.”号都将 delimiterCount 加一,然后清空 StringBuilder 对象
// 将此 char 字符放入 StringBuilder 对象// 结束此 for 循环时,StringBuilder 对象存放着第三段:
接着往下:
如果分隔符数量不是 2,则 JWT 格式有误,抛出异常。
接着,将刚才筛选出来的第三段给到 Digest 摘要:
接着来看这个 if 判断:
// 读取 Header 的内容,给到 Map 键值对
Map<String, Object> m = this.readValue(payload);
// 这里是关键分支,根据 base64UrlEncodedDigest 是否为空,不同走向
可以看到,默认的“alg”为 HS512。
现在,更换成 POC 试下:
对应修改的前两段 Base64 编码:
“alg”改为了 NONE:
“user”改为了 admin:
再根据断点,快速回到我们刚才的位置:
由于这个 if 判断:
// 读取 Header 的内容,给到 Map 键值对
Map<String, Object> m = this.readValue(payload);
// 这里是关键分支,根据 base64UrlEncodedDigest 是否为空,不同走向
我们已经将第三段删除掉了,base64UrlEncodedDigest 为 null,所以会走到 else 分支:
header = new DefaultHeader(m);
来看 DefaultHeader 的构造方法:
再来看 super:
所以,实例化的 DefaultHeader 对象给到 header:
接着往下:
跟进
接着跟进此类的 getAlgorithmFromHeader 方法:
分别来看这两行:
先来看Assert.notNull(header, "header cannot be null.");
Assert,断言
就是断定某一个实际的值是否为自己预期想得到的,如果不一样就抛出异常。
这里的断言,是 jjwt 库自实现的,跟进下这个 notNull 方法:
判断传入的 Object 对象是否为 null。
再来看 return header.getCompressionAlgorithm();
先来执行下:
返回 null
具体跟进看下
这里判断是否有“zip”或“calg”字段,而我们的是“alg”({"alg":"none"}),快速运行来试一下:
返回"none",而源代码这里,返回的是 null。
回到
接着往下就返回 null 了:
回到
\io\jsonwebtoken\impl\DefaultJwtParser.class#parse()
返回的 null 给到 compressionCodec,接着往下:
compressionCodec 为 null,走 else 分支:
这里就是将刚才存到 Payload 的第二段 Base64 编码字符进行 Base64 解码,保存到 payload。
处理后的结果:
接着往下:
看下这个 Claims:
\io\jsonwebtoken\Claims.class
对应到 Payload 标准中注册的声明(建议但是不强制使用):
iss:jwt 签发者
sub:jwt 所面向的用户
aud:接收 jwt 的一方
exp:jwt 的过期时间,这个过期时间必须要大于签发时间
nbf:定义在什么时间之前,该 jwt 都是不可用的
iat:jwt 的签发时间
jti:jwt 的唯一身份标识,主要用来作为一次性 token,从而回避重放攻击
接着看这个 if:
payload 的格式符合要求,可以进入 if 体:
读取 payload,新组一个 Map 对象:
接着利用 DefaultClaims 的构造方法,得到标准 Claims:
DefaultClaims 实例对象给到 claims:
接着往下:
由于我们的 POC 中,删除了第三段:
所以,不进入这个 if 体。
接着往下:
这里的 this.allowedClockSkewMillis 默认为 0L,所以 allowSkew 为 false
接着,如果 claims 不为 null,进入 if 体,校验有效期,这里显然不为 null:
先获取当前时间,然后调用 DefaultClaims 的 getExpiration 方法获取过期异常:
传入“exp”调用 DefaultClaims 的 get 方法:
再跟进 JwtMap 的 get 方法:
回顾下
exp:jwt 的过期时间,这个过期时间必须要大于签发时间
这里找不到“exp”,直接返回 null 到 DefaultJwtParser 的 parse 方法:
跳过这个 if 判断,继续往下:
跟进看看:
跟上边类似,这次取的是“nbf”
回顾下
nbf:定义在什么时间之前,该 jwt 都是不可用的
也是返回 null:
继续往下:
从方法名字可看出,校验期望 Claims,跟进看下:
默认为空的,所以直接 return 了:
再次回到:
关键分支,Digest 被我们删掉了
return 一个新的 DefaultJwt 对象:
DefaultJwt 的构造方法:
看下返回的 Jwt 实例对象:
接着往下:
跟进
\io\jsonwebtoken\impl\DefaultJwt.class#getBody()
可以看到,直接返回了传入的 Payload 部分,给到 DefaultClaims 实例对象 claims:
完事,user 被覆盖了:
回想下,到现在为止,都没有看到判断“alg”的分支,那我们不修改第一部分的内容试下:
好吧,只要删除了第三部分就可以成功。
结语
本篇文章只是针对了 JWT 一个比较老的验签漏洞,做一个分析。要学习 JWT 框架,涉及的知识还是挺多的,JWT 支持各种对称和非对称算法,JWT 的 JWE 和 JWS 分别对应加密/解密和签名/验签,学习过程还是十分有趣的。
评论