Kali Linux

安全经典JWT算法漏洞

2021-11-29  本文已影响0人  顶风作案7号

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中包含的用户信息进行其他逻辑操作,返回相应结果。

image.png

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”

image.png

本文只做简略介绍,每种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

image.png

就是这个投票功能,切换用户得到token:

image.png

点击回收站图标重置投票,提示

Not a valid JWT token, please try again

image.png

对应数据包:

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:

访问

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);
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()

image.png

这个 Assert.hasText() 只是校验了下是否为String:

image.png

接着这行:

this.keyBytes = TextCodec.BASE64.decode(base64EncodedKeyBytes);

image.png

这就是为什么刚才要将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.png

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);
}
}

接着往下:

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()

image.png

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

image.png

compressionCodec为null,走else分支:

image.png

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

处理后的结果:

image.png
payload赋值为{"iat":1636552183,"admin":"false","user":"admin"}

接着往下:

image.png

看下这个Claims:

\io\jsonwebtoken\Claims.class

image.png

对应到Payload标准中注册的声明(建议但是不强制使用):

iss:jwt签发者

sub:jwt所面向的用户

aud:接收jwt的一方

exp:jwt的过期时间,这个过期时间必须要大于签发时间

nbf:定义在什么时间之前,该jwt都是不可用的

iat:jwt的签发时间

jti:jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击

接着看这个if:

image.png

payload的格式符合要求,可以进入if体:

image.png

读取payload,新组一个Map对象:

image.png

接着利用DefaultClaims的构造方法,得到标准Claims:

image.png

DefaultClaims实例对象给到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.png
if (base64UrlEncodedDigest != null) {
return new DefaultJws((JwsHeader)header, body, base64UrlEncodedDigest);
} else {
return new DefaultJwt((Header)header, body);
}

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

return一个新的DefaultJwt对象:

image.png

DefaultJwt的构造方法:

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()

image.png

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

image.png

完事,user被覆盖了:

image.png

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

image.png

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

结语

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

上一篇下一篇

猜你喜欢

热点阅读