安全经典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的认证流程
image.png首先,前端通过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/),分别是:
image.pngAuth0实现的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 webgoat
docker pull webgoat/webgoat-8.0:v8.1.0
docker pull webgoat/webwolf:v8.1.0
docker pull webgoat/goatandwolf:v8.1.0
docker images
docker 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:
image.png点击回收站图标重置投票,提示
Not a valid JWT token, please try again
对应数据包:
image.png可知,只有管理员才可以重置投票
修改token中的前两部分(“.”号分割),分别进行Base64解码:
“alg”的值改为NONE,“admin”的值改为true
image.png image.png拼接修改后的两段Base64编码后,重新发包:
image.png报错了,去除“=”号:
image.png还是报错,再把第三段直接删掉,注意保留“.”号:
image.png可成功重置投票。
代码审计
网上大多数文章都是只描述了黑盒测试的步骤,少有此漏洞的代码层面的讲解,接下来利用调试,来深入了解下此漏洞的原理。
先来看WebGoat靶场中,此漏洞的代码片段:
生成access_token,对应的接口为/JWT/votings/login
image.png校验access_token,对应的接口为/JWT/votings
image.png这里用到的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;
@RestController
public 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")
@ResponseBody
public 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:
访问
获取access_token
再访问
断点位置在验签解析处:
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
image.png
跟进Jwts.parser()
image.png来看看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;
}
image.png
回到
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
image.png
这个JWT_PASSWORD在上方的定义:
public static final String JWT_PASSWORD = TextCodec.BASE64.encode("victory");
接着跟进
\io\jsonwebtoken\impl\DefaultJwtParser.class#setSigningKey()
这个 Assert.hasText() 只是校验了下是否为String:
image.png接着这行:
this.keyBytes = TextCodec.BASE64.decode(base64EncodedKeyBytes);
这就是为什么刚才要将Key进行Base64编码
给到DefaultJwtParser.keyBytes:
image.png然后返回这个DefaultJwtParser对象:
image.png回到:
image.png继续跟进DefaultJwtParser#parse方法,首先判断String字符串:
image.png然后初始化Header、Payload和Digest(摘要):
image.png接着就是分隔符个数delimiterCount:
image.png接着下面的for循环,会将验签的整段token转为char数组:
image.pngvar7为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);
}
}
接着往下:
image.png如果分隔符数量不是2,则JWT格式有误,抛出异常。
接着,将刚才筛选出来的第三段给到Digest摘要:
image.png接着来看这个if判断:
// 如果base64UrlEncodedHeader不为null
if (base64UrlEncodedHeader != null) {
// Base64解码base64UrlEncodedHeader
payload = 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);
}
image.png
可以看到,默认的“alg”为HS512。
现在,更换成POC试下:
access_token=eyJhbGciOiJub25lIn0.eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiYWRtaW4ifQ.
image.png
对应修改的前两段Base64编码:
“alg”改为了NONE:
image.png“user”改为了admin:
image.png再根据断点,快速回到我们刚才的位置:
image.png由于这个if判断:
// 如果base64UrlEncodedHeader不为null
if (base64UrlEncodedHeader != null) {
// Base64解码base64UrlEncodedHeader
payload = 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.class
public DefaultHeader(Map<String, Object> map) {
super(map);
}
再来看super:
\io\jsonwebtoken\impl\JwtMap.class
public JwtMap(Map<String, Object> map) {
Assert.notNull(map, "Map argument cannot be null.");
this.map = map;
}
所以,实例化的DefaultHeader对象给到header:
image.png接着往下:
image.png跟进
\io\jsonwebtoken\impl\compression\DefaultCompressionCodecResolver.class#resolveCompressionCodec()
image.png
接着跟进此类的getAlgorithmFromHeader方法:
image.png分别来看这两行:
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()
image.png
判断传入的Object对象是否为null。
再来看return header.getCompressionAlgorithm();
先来执行下:
image.png返回null
具体跟进看下
\io\jsonwebtoken\impl\DefaultHeader.class#getCompressionAlgorithm()
image.png
这里判断是否有“zip”或“calg”字段,而我们的是“alg”({"alg":"none"}),快速运行来试一下:
image.png返回"none",而源代码这里,返回的是null。
回到
\io\jsonwebtoken\impl\compression\DefaultCompressionCodecResolver.class#resolveCompressionCodec()
image.png
接着往下就返回null了:
image.png回到
\io\jsonwebtoken\impl\DefaultJwtParser.class#parse()
返回的null给到compressionCodec,接着往下:
image.pngcompressionCodec为null,走else分支:
image.png这里就是将刚才存到Payload的第二段Base64编码字符进行Base64解码,保存到payload。
处理后的结果:
image.pngpayload赋值为{"iat":1636552183,"admin":"false","user":"admin"}
接着往下:
image.png看下这个Claims:
\io\jsonwebtoken\Claims.class
对应到Payload标准中注册的声明(建议但是不强制使用):
iss:jwt签发者
sub:jwt所面向的用户
aud:接收jwt的一方
exp:jwt的过期时间,这个过期时间必须要大于签发时间
nbf:定义在什么时间之前,该jwt都是不可用的
iat:jwt的签发时间
jti:jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击
接着看这个if:
image.pngpayload的格式符合要求,可以进入if体:
image.png读取payload,新组一个Map对象:
image.png接着利用DefaultClaims的构造方法,得到标准Claims:
image.pngDefaultClaims实例对象给到claims:
image.png接着往下:
image.png由于我们的POC中,删除了第三段:
access_token=eyJhbGciOiJub25lIn0.eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiYWRtaW4ifQ.
所以,不进入这个if体。
接着往下:
image.png这里的this.allowedClockSkewMillis默认为0L,所以allowSkew为false
接着,如果claims不为null,进入if体,校验有效期,这里显然不为null:
image.png image.png先获取当前时间,然后调用DefaultClaims的getExpiration方法获取过期异常:
image.png传入“exp”调用DefaultClaims的get方法:
image.png再跟进JwtMap的get方法:
image.png回顾下
exp:jwt的过期时间,这个过期时间必须要大于签发时间
这里找不到“exp”,直接返回null到DefaultJwtParser的parse方法:
image.png跳过这个if判断,继续往下:
image.png跟进看看:
image.png跟上边类似,这次取的是“nbf”
回顾下
nbf:定义在什么时间之前,该jwt都是不可用的
也是返回null:
image.png继续往下:
image.png从方法名字可看出,校验期望Claims,跟进看下:
image.png默认为空的,所以直接return了:
image.png再次回到:
image.pngif (base64UrlEncodedDigest != null) {
return new DefaultJws((JwsHeader)header, body, base64UrlEncodedDigest);
} else {
return new DefaultJwt((Header)header, body);
}
关键分支,Digest被我们删掉了
return一个新的DefaultJwt对象:
image.pngDefaultJwt的构造方法:
public DefaultJwt(Header header, B body) {
this.header = header;
this.body = body;
}
再次回到
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
image.png
看下返回的Jwt实例对象:
image.png接着往下:
image.png跟进
\io\jsonwebtoken\impl\DefaultJwt.class#getBody()
可以看到,直接返回了传入的Payload部分,给到DefaultClaims实例对象claims:
image.png完事,user被覆盖了:
image.png回想下,到现在为止,都没有看到判断“alg”的分支,那我们不修改第一部分的内容试下:
image.png好吧,只要删除了第三部分就可以成功。
结语
本篇文章只是针对了JWT一个比较老的验签漏洞,做一个分析。要学习JWT框架,涉及的知识还是挺多的,JWT支持各种对称和非对称算法,JWT的JWE和JWS分别对应加密/解密和签名/验签,学习过程还是十分有趣的。