写点什么

安全经典 JWT 算法漏洞

  • 2021 年 11 月 29 日
  • 本文字数:7612 字

    阅读完需:约 25 分钟

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 是一种编码,也就是说,它是可以被翻译回原来的样子的,它并不是一种加密过程。


类似这样:


{"alg": "HS256",  // 加密算法"typ": "JWT"  // 类型}
复制代码


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,从而回避重放攻击


类似这样:


{"sub": "1234567890","name": "John Doe","admin": true}
复制代码


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,依次输入命令:


docker search webgoatdocker pull webgoat/webgoat-8.0:v8.1.0docker pull webgoat/webwolf:v8.1.0docker pull webgoat/goatandwolf:v8.1.0docker imagesdocker run -d -p 8888:8888 -p 8080:8080 -p 9090:9090 webgoat/goatandwolf:v8.1.0
复制代码


启动后,访问:


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 文件来查看依赖:


<!-- jjwt --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.0</version><scope>test</scope></dependency>
复制代码


我们这里直接利用 SpringBoot 来搭建一个简易的测试环境,方便调试。


具体代码:


package com.example.demo;
import io.jsonwebtoken.Claims;import io.jsonwebtoken.Jwt;import io.jsonwebtoken.Jwts;import io.jsonwebtoken.impl.TextCodec;import org.springframework.http.HttpStatus;import org.springframework.http.MediaType;import org.springframework.util.StringUtils;import org.springframework.web.bind.annotation.*;
import javax.servlet.http.Cookie;import javax.servlet.http.HttpServletResponse;import java.time.Duration;import java.time.Instant;import java.util.Date;
@RestControllerpublic class test {public static final String JWT_PASSWORD = TextCodec.BASE64.encode("victory");private static String validUsers = "zzz";
@GetMapping("/login")public void login(@RequestParam("user") String user, HttpServletResponse response) {if (validUsers.contains(user)) {Claims claims = Jwts.claims().setIssuedAt(Date.from(Instant.now().plus(Duration.ofDays(10))));claims.put("user", user);String token = Jwts.builder().setClaims(claims).signWith(io.jsonwebtoken.SignatureAlgorithm.HS512, JWT_PASSWORD).compact();Cookie cookie = new Cookie("access_token", token);response.addCookie(cookie);response.setStatus(HttpStatus.OK.value());response.setContentType(MediaType.APPLICATION_JSON_VALUE);} else {Cookie cookie = new Cookie("access_token", "");response.addCookie(cookie);response.setStatus(HttpStatus.UNAUTHORIZED.value());response.setContentType(MediaType.APPLICATION_JSON_VALUE);}}
@GetMapping("/verify")@ResponseBodypublic String getVotes(@CookieValue(value = "access_token", required = false) String accessToken) {if (StringUtils.isEmpty(accessToken)) {return "no login";} else {try {Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);Claims claims = (Claims) jwt.getBody();String user = (String) claims.get("user");if ("zzz".equals(user)) {return "zzz";}if ("admin".equals(user)) {return "admin";}} catch (Exception e) {return e.toString();}}return "login";}}
复制代码


先正常请求,生成 access_token:


访问


http://127.0.0.1:8080/login?user=zzz


获取 access_token


再访问


http://127.0.0.1:8080/verify


断点位置在验签解析处:


Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
复制代码



跟进 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 方法。例如:


// Clock clock = new MyClock();// Jwts.parserBuilder().setClock(myClock)this.clock = DefaultClock.INSTANCE;this.allowedClockSkewMillis = 0L;}
复制代码



回到


Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
复制代码



这个 JWT_PASSWORD 在上方的定义:


public static final String JWT_PASSWORD = TextCodec.BASE64.encode("victory");
复制代码


接着跟进


\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 循环:


for(int var9 = 0; var9 < var8; ++var9) {char c = var7[var9];// 以“.”号来分割if (c == '.') {
复制代码


// 先保存分割的这段字符


CharSequence tokenSeq = Strings.clean(sb);


// token 分别为前段:


"eyJhbGciOiJIUzUxMiJ9"、"eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoienp6In0"String token = tokenSeq != null ? tokenSeq.toString() : null;
复制代码


// 根据 delimiterCount 来判断是 Header 还是 Payload,存到对应的 field


if (delimiterCount == 0) {base64UrlEncodedHeader = token;} else if (delimiterCount == 1) {base64UrlEncodedPayload = token;}
复制代码


// 每次遇到“.”号都将 delimiterCount 加一,然后清空 StringBuilder 对象


++delimiterCount;sb.setLength(0);} else {
复制代码


// 将此 char 字符放入 StringBuilder 对象// 结束此 for 循环时,StringBuilder 对象存放着第三段:


"pntCuTlybllQYsg4BHtgNEQrEmheFalhhv6VEU_CFZ18MP8uvVBCLYK0RjAkIZpyF7KLlBhYzdhN20i8zdMU3A"sb.append(c);}}
复制代码


接着往下:



如果分隔符数量不是 2,则 JWT 格式有误,抛出异常。


接着,将刚才筛选出来的第三段给到 Digest 摘要:



接着来看这个 if 判断:


// 如果base64UrlEncodedHeader不为nullif (base64UrlEncodedHeader != null) {// Base64解码base64UrlEncodedHeaderpayload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
复制代码


// 读取 Header 的内容,给到 Map 键值对


Map<String, Object> m = this.readValue(payload);


// 这里是关键分支,根据 base64UrlEncodedDigest 是否为空,不同走向


if (base64UrlEncodedDigest != null) {header = new DefaultJwsHeader(m);} else {header = new DefaultHeader(m);}
复制代码



可以看到,默认的“alg”为 HS512。


现在,更换成 POC 试下:


access_token=eyJhbGciOiJub25lIn0.eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiYWRtaW4ifQ.
复制代码



对应修改的前两段 Base64 编码:


“alg”改为了 NONE:



“user”改为了 admin:



再根据断点,快速回到我们刚才的位置:



由于这个 if 判断:


// 如果base64UrlEncodedHeader不为nullif (base64UrlEncodedHeader != null) {// Base64解码base64UrlEncodedHeaderpayload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
复制代码


// 读取 Header 的内容,给到 Map 键值对


Map<String, Object> m = this.readValue(payload);


// 这里是关键分支,根据 base64UrlEncodedDigest 是否为空,不同走向


if (base64UrlEncodedDigest != null) {header = new DefaultJwsHeader(m);} else {header = new DefaultHeader(m);}
复制代码


我们已经将第三段删除掉了,base64UrlEncodedDigest 为 null,所以会走到 else 分支:


header = new DefaultHeader(m);


来看 DefaultHeader 的构造方法:


\io\jsonwebtoken\impl\DefaultHeader.classpublic DefaultHeader(Map<String, Object> map) {super(map);}
复制代码


再来看 super:


\io\jsonwebtoken\impl\JwtMap.classpublic JwtMap(Map<String, Object> map) {Assert.notNull(map, "Map argument cannot be null.");this.map = map;}
复制代码


所以,实例化的 DefaultHeader 对象给到 header:



接着往下:



跟进


\io\jsonwebtoken\impl\compression\DefaultCompressionCodecResolver.class#resolveCompressionCodec()
复制代码



接着跟进此类的 getAlgorithmFromHeader 方法:



分别来看这两行:


Assert.notNull(header, "header cannot be null.");return header.getCompressionAlgorithm();
复制代码


先来看Assert.notNull(header, "header cannot be null.");


Assert,断言


就是断定某一个实际的值是否为自己预期想得到的,如果不一样就抛出异常。


这里的断言,是 jjwt 库自实现的,跟进下这个 notNull 方法:


\io\jsonwebtoken\lang\Assert.class#notNull()
复制代码



判断传入的 Object 对象是否为 null。


再来看 return header.getCompressionAlgorithm();


先来执行下:



返回 null


具体跟进看下


\io\jsonwebtoken\impl\DefaultHeader.class#getCompressionAlgorithm()
复制代码



这里判断是否有“zip”或“calg”字段,而我们的是“alg”({"alg":"none"}),快速运行来试一下:



返回"none",而源代码这里,返回的是 null。


回到


\io\jsonwebtoken\impl\compression\DefaultCompressionCodecResolver.class#resolveCompressionCodec()
复制代码



接着往下就返回 null 了:



回到


\io\jsonwebtoken\impl\DefaultJwtParser.class#parse()



返回的 null 给到 compressionCodec,接着往下:



compressionCodec 为 null,走 else 分支:



这里就是将刚才存到 Payload 的第二段 Base64 编码字符进行 Base64 解码,保存到 payload。


处理后的结果:



payload赋值为{"iat":1636552183,"admin":"false","user":"admin"}
复制代码


接着往下:



看下这个 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 中,删除了第三段:


access_token=eyJhbGciOiJub25lIn0.eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiYWRtaW4ifQ.
复制代码


所以,不进入这个 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 了:



再次回到:



if (base64UrlEncodedDigest != null) {return new DefaultJws((JwsHeader)header, body, base64UrlEncodedDigest);} else {return new DefaultJwt((Header)header, body);}
复制代码


关键分支,Digest 被我们删掉了


return 一个新的 DefaultJwt 对象:



DefaultJwt 的构造方法:


public DefaultJwt(Header header, B body) {this.header = header;this.body = body;}再次回到Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
复制代码



看下返回的 Jwt 实例对象:



接着往下:



跟进


\io\jsonwebtoken\impl\DefaultJwt.class#getBody()



可以看到,直接返回了传入的 Payload 部分,给到 DefaultClaims 实例对象 claims:



完事,user 被覆盖了:



回想下,到现在为止,都没有看到判断“alg”的分支,那我们不修改第一部分的内容试下:



好吧,只要删除了第三部分就可以成功。

结语

本篇文章只是针对了 JWT 一个比较老的验签漏洞,做一个分析。要学习 JWT 框架,涉及的知识还是挺多的,JWT 支持各种对称和非对称算法,JWT 的 JWE 和 JWS 分别对应加密/解密和签名/验签,学习过程还是十分有趣的。

用户头像

我是一名网络安全渗透师 2021.06.18 加入

关注我,后续将会带来更多精选作品,需要资料+wx:mengmengji08

评论

发布
暂无评论
安全经典JWT算法漏洞