美文网首页Kali Linux
安全经典JWT算法漏洞

安全经典JWT算法漏洞

作者: 顶风作案7号 | 来源:发表于2021-11-29 15:04 被阅读0次

    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分别对应加密/解密和签名/验签,学习过程还是十分有趣的。

    相关文章

      网友评论

        本文标题:安全经典JWT算法漏洞

        本文链接:https://www.haomeiwen.com/subject/paqlxrtx.html