安全经典JWT算法漏洞

本文阅读 13 分钟

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协议),从而避免敏感信息被嗅探。

img

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签发者

类似这样:

{
"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”

img

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

img

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

img

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

Not a valid JWT token, please try again

img

对应数据包:

img

可知,只有管理员才可以重置投票

修改token中的前两部分(“.”号分割),分别进行Base64解码:

“alg”的值改为NONE,“admin”的值改为true

img

img

拼接修改后的两段Base64编码后,重新发包:

img

报错了,去除“=”号:

img

还是报错,再把第三段直接删掉,注意保留“.”号:

img

可成功重置投票。

代码审计

网上大多数文章都是只描述了黑盒测试的步骤,少有此漏洞的代码层面的讲解,接下来利用调试,来深入了解下此漏洞的原理。

先来看WebGoat靶场中,此漏洞的代码片段:

生成access_token,对应的接口为/JWT/votings/login

img

校验access_token,对应的接口为/JWT/votings

img

这里用到的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);

img

跟进Jwts.parser()

img

来看看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;
}

img

回到

Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);

img

这个JWT_PASSWORD在上方的定义:

public static final String JWT_PASSWORD = TextCodec.BASE64.encode("victory");

接着跟进

iojsonwebtokenimplDefaultJwtParser.class#setSigningKey()

img

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

img

接着这行:

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

img

这就是为什么刚才要将Key进行Base64编码

给到DefaultJwtParser.keyBytes:

img

然后返回这个DefaultJwtParser对象:

img

回到:

img

继续跟进DefaultJwtParser#parse方法,首先判断String字符串:

img

然后初始化Header、Payload和Digest(摘要):

img

接着就是分隔符个数delimiterCount:

img

接着下面的for循环,会将验签的整段token转为char数组:

img

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

接着往下:

img

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

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

img

接着来看这个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);
}

img

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

现在,更换成POC试下:

access_token=eyJhbGciOiJub25lIn0.eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiYWRtaW4ifQ.

img

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

“alg”改为了NONE:

img

“user”改为了admin:

img

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

img

由于这个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:

img

接着往下:

img

跟进

\io\jsonwebtoken\impl\compression\DefaultCompressionCodecResolver.class#resolveCompressionCodec()

img

接着跟进此类的getAlgorithmFromHeader方法:

img

分别来看这两行:

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

img

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

再来看return header.getCompressionAlgorithm();

先来执行下:

img

返回null

具体跟进看下

\io\jsonwebtoken\impl\DefaultHeader.class#getCompressionAlgorithm()

img

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

img

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

回到

\io\jsonwebtoken\impl\compression\DefaultCompressionCodecResolver.class#resolveCompressionCodec()

img

接着往下就返回null了:

img

回到

iojsonwebtokenimplDefaultJwtParser.class#parse()

img

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

img

compressionCodec为null,走else分支:

img

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

处理后的结果:

img

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

接着往下:

img

看下这个Claims:

iojsonwebtokenClaims.class

img

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

iss:jwt签发者

接着看这个if:

img

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

img

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

img

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

img

DefaultClaims实例对象给到claims:

img

接着往下:

img

由于我们的POC中,删除了第三段:

access_token=eyJhbGciOiJub25lIn0.eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiYWRtaW4ifQ.

所以,不进入这个if体。

接着往下:

img

这里的this.allowedClockSkewMillis默认为0L,所以allowSkew为false

接着,如果claims不为null,进入if体,校验有效期,这里显然不为null:

img

img

先获取当前时间,然后调用DefaultClaims的getExpiration方法获取过期异常:

img

传入“exp”调用DefaultClaims的get方法:

img

再跟进JwtMap的get方法:

img

回顾下

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

这里找不到“exp”,直接返回null到DefaultJwtParser的parse方法:

img

跳过这个if判断,继续往下:

img

跟进看看:

img

跟上边类似,这次取的是“nbf”

回顾下

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

也是返回null:

img

继续往下:

img

从方法名字可看出,校验期望Claims,跟进看下:

img

默认为空的,所以直接return了:

img

再次回到:

img

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

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

return一个新的DefaultJwt对象:

img

DefaultJwt的构造方法:

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

img

看下返回的Jwt实例对象:

img

接着往下:

img

跟进

iojsonwebtokenimplDefaultJwt.class#getBody()

img

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

img

完事,user被覆盖了:

img

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

img

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

结语

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

本文为互联网自动采集或经作者授权后发布,本文观点不代表立场,若侵权下架请联系我们删帖处理!文章出自:https://blog.csdn.net/kali_Ma/article/details/121609999
-- 展开阅读全文 --
KillDefender 的 Beacon 对象文件 PoC 实现
« 上一篇 02-09
Web安全—逻辑越权漏洞(BAC)
下一篇 » 03-13

发表评论

成为第一个评论的人

热门文章

标签TAG

最近回复