前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >从JWT源码审计来看NONE算法漏洞(CVE-2015-9235)

从JWT源码审计来看NONE算法漏洞(CVE-2015-9235)

作者头像
FB客服
发布2021-11-16 10:33:35
2K0
发布2021-11-16 10:33:35
举报
文章被收录于专栏:FreeBufFreeBuf

研究JWT漏洞时,发现文章并不多,而且大多数都是黑盒测试,遂出现了本文,大佬们勿喷。

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签名还证明只有持有私钥的一方才是对其进行签名的一方。

摘自官网:

JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA. Although JWTs can be encrypted to also provide secrecy between parties, we will focus on signed tokens. Signed tokens can verify the integrity of the claims contained within it, while encrypted tokens hide those claims from other parties. When tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it.

2、JWT能做什么?

1)授权

这是使用JWT的最常见方案。一旦用户登录,每个后续请求将包括JWT,从而允许用户访问该令牌允许的路由,服务和资源。单点登录是今广泛使用JWT的一项功能,因为它的开销很小并且可以在不同的域中轻松使用。

2)信息交换

JSON Web Token是在各方之间安全地传输信息的好方法。因为可以对JWT进行签名(例如,使用公钥/私钥对),所以可以确保发件人是本人。此外,由于签名是使用标头和有效负载计算的,因此还可以验证内容是否遭到篡改。

3、基于session认证所显露的问题

1)开销

每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。

2)扩展性

用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求必须还要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力,这也意味着限制了应用的扩展能力。

3)CSRF

因为是基于cookie来进行用户识别的,所以cookie如果被截获,用户就会很容易受到CSRF的攻击。

4、JWT的认证流程

首先,前端通过web表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议),从而避免敏感信息被嗅探。

后端核对用户名和密码成功后,形成一个JWT Token。

后端将JWT字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在localStorage或sessionStorage中,退出登录时前端删除保存的JWT即可。

前端在每次请求时将JWT放入HTTP Header中的Authorization字段。

后端校验前端传来的JWT的有效性。

验证通过后,后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果。

5、JWT的结构

1)令牌组成:header.payload.signature

  • 标头(Header)
  • 有效载荷(Payload)
  • 签名(Signature)

2)Header

标头通常由两部分组成:令牌的类型(即JWT)和所使用的签名算法,例如HMAC SHA256(默认,HS256)或RSA(RS256)。它会使用Base64编码组成JWT结构的第一部分。

注意:Base64是一种编码,也就是说,它是可以被翻译回原来的样子的,它并不是一种加密过程。

类似这样:

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

3)Payload

令牌的第二部分是有效负载,其中包含声明。声明是有关实体(通常是用户)和其他数据的声明。同样的,它会使用Base64编码组成JWT结构的第二部分

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

iss:jwt签发者

sub:jwt所面向的用户

aud:接收jwt的一方

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

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

iat:jwt的签发时间

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

类似这样

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

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

代码语言:javascript
复制
 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/

就是这个投票功能,切换用户得到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文件来查看依赖:

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

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

具体代码:

代码语言:javascript
复制
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

断点位置在验签解析处:

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

跟进Jwts.parser()

来看看DefaultJwtParser的构造方法:

代码语言:javascript
复制
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;
}

回到

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

这个JWT_PASSWORD在上方的定义:

代码语言:javascript
复制
public static final String JWT_PASSWORD = TextCodec.BASE64.encode("victory");
接着跟进
代码语言:javascript
复制
\io\jsonwebtoken\impl\DefaultJwtParser.class#setSigningKey()

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

接着这行:

代码语言:javascript
复制
this.keyBytes = TextCodec.BASE64.decode(base64EncodedKeyBytes);
代码语言:javascript
复制

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

给到DefaultJwtParser.keyBytes:

然后返回这个DefaultJwtParser对象:

回到:

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

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

接着就是分隔符个数delimiterCount:

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

var7为token的char数组,var8为此数组中的字符个数。

接着看下这段for循环:

代码语言:javascript
复制
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判断:

代码语言:javascript
复制
// 如果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);
}

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

现在,更换成POC试下:

代码语言:javascript
复制
access_token=eyJhbGciOiJub25lIn0.eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiYWRtaW4ifQ.

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

“alg”改为了NONE:

“user”改为了admin:

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

由于这个if判断:

代码语言:javascript
复制
// 如果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的构造方法:

代码语言:javascript
复制
\io\jsonwebtoken\impl\DefaultHeader.class
public DefaultHeader(Map<String, Object> map) {
super(map);
}

再来看super:

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

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

接着往下:

跟进

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

接着跟进此类的getAlgorithmFromHeader方法:

分别来看这两行:

代码语言:javascript
复制
Assert.notNull(header, "header cannot be null.");
return header.getCompressionAlgorithm();
先来看Assert.notNull(header, "header cannot be null.");

Assert,断言

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

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

代码语言:javascript
复制
\io\jsonwebtoken\lang\Assert.class#notNull()

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

再来看return header.getCompressionAlgorithm();

先来执行下:

返回null

具体跟进看下

代码语言:javascript
复制
\io\jsonwebtoken\impl\DefaultHeader.class#getCompressionAlgorithm()

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

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

回到

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

接着往下就返回null了:

回到

代码语言:javascript
复制
\io\jsonwebtoken\impl\DefaultJwtParser.class#parse()

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

compressionCodec为null,走else分支:

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

处理后的结果:

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

接着往下:

看下这个Claims:

代码语言:javascript
复制
\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中,删除了第三段:

代码语言:javascript
复制
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了:

再次回到:

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

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

return一个新的DefaultJwt对象:

DefaultJwt的构造方法:

代码语言:javascript
复制
public DefaultJwt(Header header, B body) {
this.header = header;
this.body = body;
}
代码语言:javascript
复制
再次回到

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

看下返回的Jwt实例对象:

接着往下:

跟进

代码语言:javascript
复制
\io\jsonwebtoken\impl\DefaultJwt.class#getBody()
代码语言:javascript
复制

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

完事,user被覆盖了:

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

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

结语

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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-11-08,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 FreeBuf 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • JWT简介
    • 1、什么是JWT?
      • 2、JWT能做什么?
        • 3、基于session认证所显露的问题
          • 4、JWT的认证流程
            • 首先,前端通过web表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议),从而避免敏感信息被嗅探。
              • 后端核对用户名和密码成功后,形成一个JWT Token。
                • 后端将JWT字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在localStorage或sessionStorage中,退出登录时前端删除保存的JWT即可。
                  • 前端在每次请求时将JWT放入HTTP Header中的Authorization字段。
                    • 后端校验前端传来的JWT的有效性。
                      • 验证通过后,后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果。
                      • 测试环境
                      • 黑盒测试
                      • 代码审计
                      • 结语
                      相关产品与服务
                      容器服务
                      腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
                      领券
                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档