前言:工作中需要基于框架开发一个贴近实际的应用,找到一款比较合适的cms框架,其中正好用到的就是jwt做身份信息验证,也记录一下学习jwt相关的安全问题过程。
JWT介绍
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
JWT组成
JWT可分为三部分,分别为头部(header)、载荷(payload)、签名(signature),简单介绍一下每个部分的作用。
整体组成如下,以“.”分隔为三头部、载荷、签名部分:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
JWT加解密网站:JSON Web Tokens - jwt.io
头部(header)
作用:声明类型、加密算法等
原始格式:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
解base64:
{ "alg": "HS256", "typ": "JWT" }
typ:声明算法类型,这里是JWT,
alg:声明加密算法,这里是HS256,为对称算法(前后端使用同一密钥进行加密,并非能够解密),常用的还有RS256和ES256两个非对称算法(签名时使用私钥,验证时使用公钥)。
载荷(payload)
作用:存储有效数据
原始格式:eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
解base64:
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
载荷部分默认字段:
iss (issuer):JWT的发行者 exp (expiration time):过期时间 sub (subject):JWT面向的主题 aud (audience):JWT的用户 nbf (Not Before):生效时间 iat (Issued At):签发时间 jti (JWT ID):JWT唯一标识
注:用户可根据需求自定义字段
签名(signature)
作用:签名部分,服务端校验此字段来验证载荷(payload)字段是否合法
原始格式:SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
该字段加密方式如下:
signature = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret)
这里的HMACSHA256就是在header中alg字段指定的HS256加密算法,而RS256和ES256则是服务端使用私钥加密,好处是可以将验证委托给其他应用,只要散发自己的公钥即可。
JWT用法
JWT被用于身份验证中,作用类似于session,但相比于session这种方式各有优劣,下面简述一下JWT的使用流程
一、用户登录,输入登录所需的信息,后端验证后,返回jwt格式的token
二、用户携带token访问需要身份验证的资源或接口
三、服务端验证jwt格式token中的signature(使用相应加密算法重新加密token中的header和payload,验证是否相等)
四、验证成功,允许访问资源
登录获取token代码示例(lin-cms-springboot)
首先入口是login方法
/** * 用户登陆 */ @PostMapping("/login") public Tokens login(@RequestBody @Validated LoginDTO validator, @RequestHeader(value = "Tag", required = false) String tag) { UserDO user = userService.getUserByUsername(validator.getUsername()); if (user == null) { throw new NotFoundException(10021); } boolean valid = userIdentityService.verifyUsernamePassword( user.getId(), user.getUsername(), validator.getPassword()); if (!valid) { throw new ParameterException(10031); } return jwt.generateTokens(user.getId()); }
判断用户名和密码正确后,将user.getId(即用户的ID)传入generateTokens方法
public Tokens generateTokens(long identity) { String access = this.generateToken("access", identity, "lin", this.accessExpire); String refresh = this.generateToken("refresh", identity, "lin", this.refreshExpire); return new Tokens(access, refresh); }
根据固定的字段和传入的用户ID,调用generateToken方法获取token(这里调用了两次,分别生成两个token,access_token和refresh_token,后面会讲)
public String generateToken(String tokenType, long identity, String scope, long expire) { Date expireDate = DateUtil.getDurationDate(expire); return this.builder.withClaim("type", tokenType).withClaim("identity", identity).withClaim("scope", scope).withExpiresAt(expireDate).sign(this.algorithm); }
——————以下调用是com.auth0.jwt第三方库中的内容——————
这里反复调用了withClaim方法
public JWTCreator.Builder withClaim(String name, Long value) throws IllegalArgumentException { this.assertNonNull(name); this.addClaim(name, value); return this; } public JWTCreator.Builder withClaim(String name, String value) throws IllegalArgumentException { this.assertNonNull(name); this.addClaim(name, value); return this; } ... 重载的所有withClaim方法具体内容都一样
因为这个方法返回的还是this,所以可以直接循环调用,是为了生成并绑定不同字段的值。
接着调用了assertNonNull方法、addClaim方法
private void assertNonNull(String name) { if (name == null) { throw new IllegalArgumentException("The Custom Claim's name can't be null."); } } private void addClaim(String name, Object value) { if (value == null) { this.payloadClaims.remove(name); } else { this.payloadClaims.put(name, value); } }
assertNonNull方法就是判空处理,addClaim方法就将键值对put到payloadClaims这个map对象中,也就是最终生成的payload字段。
这里执行完成后,接着还跳回generateToken方法,因为调用完withClaim方法后,就会调用withExpiresAt(expireDate).sign(this.algorithm),
withExpiresAt方法,就是添加exp字段
public JWTCreator.Builder withExpiresAt(Date expiresAt) { this.addClaim("exp", expiresAt); return this; }
接着进入最关键的sign方法
public String sign(Algorithm algorithm) throws IllegalArgumentException, JWTCreationException { if (algorithm == null) { throw new IllegalArgumentException("The Algorithm cannot be null."); } else { this.headerClaims.put("alg", algorithm.getName()); if (!this.headerClaims.containsKey("typ")) { this.headerClaims.put("typ", "JWT"); } String signingKeyId = algorithm.getSigningKeyId(); if (signingKeyId != null) { this.withKeyId(signingKeyId); } return (new JWTCreator(algorithm, this.headerClaims, this.payloadClaims)).sign(); } }
headerClaims方法就是向header字段中添加typ和alg的值,重点在最终return的地方,接着跟入new JWTCreator(algorithm, this.headerClaims, this.payloadClaims)
JWTCreator类的实例化,其中传入的参数分别是加密算法对象、header、payload
private JWTCreator(Algorithm algorithm, Map<String, Object> headerClaims, Map<String, Object> payloadClaims) throws JWTCreationException { this.algorithm = algorithm; try { ObjectMapper mapper = new ObjectMapper(); SimpleModule module = new SimpleModule(); module.addSerializer(ClaimsHolder.class, new PayloadSerializer()); mapper.registerModule(module); mapper.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true); this.headerJson = mapper.writeValueAsString(headerClaims); this.payloadJson = mapper.writeValueAsString(new ClaimsHolder(payloadClaims)); } catch (JsonProcessingException var6) { throw new JWTCreationException("Some of the Claims couldn't be converted to a valid JSON format.", var6); } }
简单看一下上面的逻辑,就是对算法、header、payload进行绑定,然后接着往下走,跳回上一步的sign方法,在对JWTCreator实例化后,紧接着又调用了sign方法,不过这个sign方法没有传入参数,也就是下面这个方法
private String sign() throws SignatureGenerationException { String header = Base64.encodeBase64URLSafeString(this.headerJson.getBytes(StandardCharsets.UTF_8)); String payload = Base64.encodeBase64URLSafeString(this.payloadJson.getBytes(StandardCharsets.UTF_8)); byte[] signatureBytes = this.algorithm.sign(header.getBytes(StandardCharsets.UTF_8), payload.getBytes(StandardCharsets.UTF_8)); String signature = Base64.encodeBase64URLSafeString(signatureBytes); return String.format("%s.%s.%s", header, payload, signature); }
方法中对header和payload的生成就是简单的对json数据进行base64编码,最终生成signature字段的操作为this.algorithm.sign(header.getBytes(StandardCharsets.UTF_8), payload.getBytes(StandardCharsets.UTF_8))
,接着跟入sign方法
public byte[] sign(byte[] headerBytes, byte[] payloadBytes) throws SignatureGenerationException { try { return this.crypto.createSignatureFor(this.getDescription(), this.secret, headerBytes, payloadBytes); } catch (InvalidKeyException | NoSuchAlgorithmException var4) { throw new SignatureGenerationException(this, var4); } }
进入到createSignatureFor方法,传入了加密算法、密钥、header和payload
byte[] createSignatureFor(String algorithm, byte[] secretBytes, byte[] headerBytes, byte[] payloadBytes) throws NoSuchAlgorithmException, InvalidKeyException { Mac mac = Mac.getInstance(algorithm); mac.init(new SecretKeySpec(secretBytes, algorithm)); mac.update(headerBytes); mac.update((byte)46); return mac.doFinal(payloadBytes); }
就不继续往里跟了(里面最后是调javax.crypto.Mac类中的方法进行的加密,调来调去太乱了),大致原理我们已经搞清楚了,差不多就是根据header和payload字段,然后用secret当salt做一次hash,最后再base64编码传出来,就是我们最终的token。
以上就是从登录到获取token的大致过程。
通过jwt进行身份校验代码示例
这里是拦截器中的一个方法,就不一步一步的跟了,
public boolean handleLogin(HttpServletRequest request, HttpServletResponse response, MetaInfo meta) { //获取请求头中的Authorization头 String tokenStr = verifyHeader(request, response); Map<String, Claim> claims; try { //在这里做的校验 claims = jwt.decodeAccessToken(tokenStr); } catch (TokenExpiredException e) { throw new io.github.talelin.autoconfigure.exception.TokenExpiredException(10051); } catch (AlgorithmMismatchException | SignatureVerificationException | JWTDecodeException | InvalidClaimException e) { throw new TokenInvalidException(10041); } return getClaim(claims); }
最终验证的地方在com.auth0.jwt.algorithms.HMACAlgorithm#verify方法中
public void verify(DecodedJWT jwt) throws SignatureVerificationException { byte[] signatureBytes = Base64.decodeBase64(jwt.getSignature()); try { //然后调用verifySignatureFor方法校验 boolean valid = this.crypto.verifySignatureFor(this.getDescription(), this.secret, jwt.getHeader(), jwt.getPayload(), signatureBytes); if (!valid) { throw new SignatureVerificationException(this); } } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException var4) { throw new SignatureVerificationException(this, var4); } }
跟入
boolean verifySignatureFor(String algorithm, byte[] secretBytes, String header, String payload, byte[] signatureBytes) throws NoSuchAlgorithmException, InvalidKeyException { return this.verifySignatureFor(algorithm, secretBytes, header.getBytes(StandardCharsets.UTF_8), payload.getBytes(StandardCharsets.UTF_8), signatureBytes); } boolean verifySignatureFor(String algorithm, byte[] secretBytes, byte[] headerBytes, byte[] payloadBytes, byte[] signatureBytes) throws NoSuchAlgorithmException, InvalidKeyException { return MessageDigest.isEqual(this.createSignatureFor(algorithm, secretBytes, headerBytes, payloadBytes), signatureBytes); }
跟入
byte[] createSignatureFor(String algorithm, byte[] secretBytes, byte[] headerBytes, byte[] payloadBytes) throws NoSuchAlgorithmException, InvalidKeyException { Mac mac = Mac.getInstance(algorithm); mac.init(new SecretKeySpec(secretBytes, algorithm)); mac.update(headerBytes); mac.update((byte)46); return mac.doFinal(payloadBytes); }
可以看到,这里的createSignatureFor方法,那么最终实现的原理也就是根据传入的header和payload,再调用创建签名方法根据secret密钥创建签名,如果最终创建出来的签名和你传入的jwttoken中的Signature字段值相等,则判定为真。
JWT安全问题
secret硬编码
将加密使用的secret密钥硬编码在框架中,如果开发者不注意的话,使用默认密钥,没有进行修改,那么只要获取到密钥,就可以伪造token。
知道密钥后,那么可以通过该网站https://jwt.io或者编写脚本伪造token
例如该cms:
通过在线网站生成token:
那么我们直接使用这个token即可访问网站中需要身份验证的接口资源。
后端未校验Signature字段或
攻击方法:可任意修改或者直接删除
原理:后端未对Signature字段进行校验,就取payload中的数据进行后续操作
alg=none签名绕过漏洞(CVE-2015-2951)
攻击方法:将header中的alg的键值改为none,然后将Signature删除即可
原理:后端未对传入的header中的alg字段进行校验,直接使用其中指定的加密算法对Signature
针对以上两种安全问题,如果没有原始jwt_token,可以使用如下脚本生成token:
使用之前需要先使用pip安装PyJWT,而不是JWT,直接python37 -m pip install PyJWT
即可
import jwt payload = { "identity": 1, "scope": "lin", "type": "access", "exp": 1659797574 } print(jwt.encode(payload,None,algorithm="none"))
Secret爆破
上面我们知道硬编码的话我们可以任意伪造token,其实原理就是知道secret密钥,除了开源CMS的这种泄露,或者其他系统备份文件、日志之类的泄露密钥,我们还可以通过爆破的方法获取密钥(如果不是弱密钥,难度非常大)
可以使用这个工具:https://github.com/ticarpi/jwt_tool
修改非对称密码算法为对称密码算法(CVE-2016-10555)
这个漏洞只针对使用非对称加密算法(RS256)做校验的系统,当后端使用RS256加密时,使用私钥加密,而校验时用到的是公私钥对中的公钥做校验,而公钥是公开的,我们很容易获取。
那么当我们修改RS256为HS256时,后端会以为使用的是HS256对称加密做校验,即使用公钥当作HS256校验时的secret来进行加密对比是否相等,把公钥当成secret来使用,也就相当于泄露了secret,所以我们就可以使用公钥来伪造token。
伪造密钥(CVE-2018-0114)
我理解这个漏洞和上面的漏洞比较像,上一个漏洞是后台新人了我们提供的header中的算法,这个是使用了JWS,里面也是存储的公钥,那么我们自己生成公私钥对,然后使用私钥生成token,再将自己生成的公钥放到JWS中,让后台使用这个公钥解密,这样就可以巧妙地绕过后台的验证。
网络安全学习资源分享:
给大家分享一份全套的网络安全学习资料,给那些想学习 网络安全的小伙伴们一点帮助!
对于从来没有接触过网络安全的同学,我们帮你准备了详细的学习成长路线图。可以说是最科学最系统的学习路线,大家跟着这个大的方向学习准没问题。
因篇幅有限,仅展示部分资料,朋友们如果有需要全套《网络安全入门+进阶学习资源包》,需要点击下方链接即可前往获取
CSDN大礼包:《网络安全入门&进阶学习资源包》免费分享(安全链接,放心点击)
同时每个成长路线对应的板块都有配套的视频提供:
大厂面试题
视频配套资料&国内外网安书籍、文档
当然除了有配套的视频,同时也为大家整理了各种文档和书籍资料
所有资料共282G,朋友们如果有需要全套《网络安全入门+进阶学习资源包》,可以扫描下方二维码或链接免费领取~
读者福利 |
CSDN大礼包:《网络安全入门&进阶学习资源包》免费分享(安全链接,放心点击)
特别声明:
此教程为纯技术分享!本教程的目的决不是为那些怀有不良动机的人提供及技术支持!也不承担因为技术被滥用所产生的连带责任!本教程的目的在于最大限度地唤醒大家对网络安全的重视,并采取相应的安全措施,从而减少由网络安全而带来的经济损失。