CSRF漏洞之Token防御原理解析
作者:无名安全团队成员-火之高兴
最近刚好学到了CSRF漏洞,对于CSRF漏洞的防御策略之一就是在页面中引入token,其流程为:
将CSRF Token输出到页面中
用户打开页面的时候,服务器需要给这个用户生成一个Token,该Token通过加密算法对数据进行加密,一般Token都包括随机字符串和时间戳的组合.
将token放在session中
页面提交的请求携带这个Token
服务器验证Token是否正确
因此对于token的生成与认证产生了兴趣,在这里以Json web token (JWT)为例进行分析。
JWT的结构
JWt主要由三部分组成,他们之间用圆点(.)连接。这三部分分别是
Header
Payload
Signature
Header
jwt的头部主要有两部分信息:
token的类型
加密算法的名称 例如 HMAC、SHA256、SRA
完整的头部信息如下所示:
ps:SH256是HMAC的一种
{
"typ":"jwt",
"alg":"HS256"
}
将头部进行base64加密构成了token的第一部分
payload
payload是存放有效信息的地方,主要包含三个部分:
标准中注册的声明
iss:jwt签发者
sub:jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
公共的声明
可随意定义一般添加用户的相关信息与其他必要信息
私有的声明
生产者与消费者共有的声明
一般如下所示:
{
"sub": "1234567890",
"name": "Happy Fire",
"iat": 1631980800 --> unix时间
}
对payload进行Base64编码就得到JWT的第二部分,由于payload、header都是使用base64进行加密的,因此可以很轻易的进行解密,尽量不要在这两个地方放入敏感信息。
Signature
Signature是JWT的最后一个部分,Signature由三部分组成
header(base64后)
payload(base64后)
secret
将base64加密后的header和base64加密后的payload使用 "." 连接字符串,然后使用header中声明的加密方式进行加salt(secret)加密的方式进行加密构成了JWT的第三部分,将这三部分组合起来就形成了最终的JWT。
我们通过官方的一张图可以直观的反映出JWT的加密:
代码分析
老规矩先引入依赖:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.11.0</version>
</dependency>
设置密钥和过期时间:
//密钥
public static final String SECRET = "sdjhakdhajdklsl;o653632";
//过期时间:秒
public static final int EXPIRE = 5;
生成token:
public static String createToken(String userId, String userName) throws Exception {
Calendar nowTime = Calendar.getInstance();
nowTime.add(Calendar.SECOND, EXPIRE);
Date expireDate = nowTime.getTime(); ----> 过期时间
Map<String, Object> map = new HashMap<>(); ---> header头 设置token类型以及加密算法
map.put("alg", "HS256");
map.put("typ", "JWT");
String token = JWT.create()
.withHeader(map)//头
.withClaim("userId", userId)
.withClaim("userName", userName)
.withSubject("测试")//
.withIssuedAt(new Date())//签名时间
.withExpiresAt(expireDate)//过期时间
.sign(Algorithm.HMAC256(SECRET));//签名
return token;
}
我们来详细的分析一下创建的这段代码:
String token = JWT.create()
.withHeader(map)//头
.withClaim("userId", userId)
.withClaim("userName", userName)
.withSubject("测试")//
.withIssuedAt(new Date())//签名时间
.withExpiresAt(expireDate)//过期时间
.sign(Algorithm.HMAC256(SECRET));//签名
首先进入create方法:
public static JWTCreator.Builder create() {
return JWTCreator.init();
}
我们可以看到,主要是借助JWTCreator的builder方法来对JWT进行构建,最初调用init()初始化:
static JWTCreator.Builder init() {
return new Builder();
}
Init()方法会新建一个builder对象,该对象首先构建两个对象来存放header和payload对象:
private final Map<String, Object> payloadClaims;
private final Map<String, Object> headerClaims;
Builder() {
this.payloadClaims = new HashMap<>();
this.headerClaims = new HashMap<>();
}
接下来调用Builder对象的withHeader方法来生成header:
public Builder withHeader(Map<String, Object> headerClaims) {
if (headerClaims == null) {
return this;
}
for (Map.Entry<String, Object> entry : headerClaims.entrySet()) {
if (entry.getValue() == null) {
this.headerClaims.remove(entry.getKey());
} else {
this.headerClaims.put(entry.getKey(), entry.getValue());
}
}
return this;
}
给headerClaims传入值并返回该对象:
接下来使用调用withClaim方法放入userID与userName
public Builder withClaim(String name, String value) throws IllegalArgumentException {
assertNonNull(name);
addClaim(name, value);
return this;
}
首先会对name进行非空判断,如果传入的不是空值那么调用addClaim方法:
private void addClaim(String name, Object value) {
if (value == null) {
payloadClaims.remove(name);
return;
}
payloadClaims.put(name, value);
}
}
接下来对值进行非空判断,如果值为空值,则将该键从payload中移除并返回,如果值也不为空值,那么就讲该键值对放入payloadClaims中
同理为Subject、签名时间、过期时间赋值,内部都掉用addClaim方法:
public Builder withIssuedAt(Date issuedAt) {
addClaim(PublicClaims.ISSUED_AT, issuedAt);
return this;
}
最后开始生成签名并生成token
.sign(Algorithm.HMAC256(SECRET));//签名
先掉用HMAC256加密算法对SECRET进行加密,然后掉用sign方法生成
/**
Creates a new JWT and signs is with the given algorithm
Params:
algorithm – used to sign the JWT
Returns:
a new JWT token
Throws:
IllegalArgumentException – if the provided algorithm is null.
JWTCreationException – if the claims could not be converted to a valid JSON or there was a problem with the signing key
*/
public String sign(Algorithm algorithm) throws IllegalArgumentException, JWTCreationException {
if (algorithm == null) {
throw new IllegalArgumentException("The Algorithm cannot be null.");
}
headerClaims.put(PublicClaims.ALGORITHM, algorithm.getName());
if (!headerClaims.containsKey(PublicClaims.TYPE)) {
headerClaims.put(PublicClaims.TYPE, "JWT");
}
String signingKeyId = algorithm.getSigningKeyId();
if (signingKeyId != null) {
withKeyId(signingKeyId);
}
return new JWTCreator(algorithm, headerClaims, payloadClaims).sign();
}
首先判断在header中是否声明了token的type,如果已声明则跳过,如果未声明则将typ与JWT放入header中,其中publicClaims接口声明了所有JWT支持的header和payload字段:
public interface PublicClaims {
//Header
String ALGORITHM = "alg";
String CONTENT_TYPE = "cty";
String TYPE = "typ";
String KEY_ID = "kid";
//Payload
String ISSUER = "iss";
String SUBJECT = "sub";
String EXPIRES_AT = "exp";
String NOT_BEFORE = "nbf";
String ISSUED_AT = "iat";
String JWT_ID = "jti";
String AUDIENCE = "aud";
}
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);
headerJson = mapper.writeValueAsString(headerClaims);
payloadJson = mapper.writeValueAsString(new ClaimsHolder(payloadClaims));
} catch (JsonProcessingException e) {
throw new JWTCreationException("Some of the Claims couldn't be converted to a valid JSON format.", e);
}
}
在所有验证都完成后,将headerClaims、payloadClaims由HashMap对象转成JSON对象,最终掉用init方法生成token,
private String sign() throws SignatureGenerationException {
String header = Base64.encodeBase64URLSafeString(headerJson.getBytes(StandardCharsets.UTF_8));
String payload = Base64.encodeBase64URLSafeString(payloadJson.getBytes(StandardCharsets.UTF_8));
byte[] signatureBytes = 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使用base64加密算法进行加密,然后对加密后的header与payload使用HS256算法进行加盐加密,加密后的值在进行一次base64加密从而生成signature,最终将加密后的header、payload、signature使用"."连接生成token
token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLmtYvor5UiLCJ1c2VyTmFtZSI6IndhbmdibyIsImV4cCI6MTYzMjEwNjU2MiwidXNlcklkIjoiMTIzNDUiLCJpYXQiOjE2MzIxMDY1NTd9.ebUt4h9OU-dxhdkCfoa_KDXfzVXADmF-q4kw3rVF79I
token验证
对token进行验证时,只需要重新生成一个signature,并对比signature与token中的是否一致即可。
/**
* The JWTVerifier class holds the verify method to assert that a given Token has not only a * proper JWT format, but also it's signature matches.
* <p>
* This class is thread-safe.
*/
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
生成一个JWTVerifier对象对token进行验证,使用JWT.require方法创建该对象,该方法带有加密的算法以及密钥
/**
* Returns a {@link JWTVerifier} builder with the algorithm to be used to validate token signature.
*
* @param algorithm that will be used to verify the token's signature.
* @return {@link JWTVerifier} builder
* @throws IllegalArgumentException if the provided algorithm is null.
*/
public static Verification require(Algorithm algorithm) {
return JWTVerifier.init(algorithm);
}
使用verifier对象的verify方法对token进行验证
jwt = verifier.verify(token);
verfier方法先对token进行解析
DecodedJWT jwt = new JWTDecoder(parser, token);
JWTDecoder(JWTParser converter, String jwt) throws JWTDecodeException {
parts = TokenUtils.splitToken(jwt);
String headerJson;
String payloadJson;
try {
headerJson = StringUtils.newStringUtf8(Base64.decodeBase64(parts[0]));
payloadJson = StringUtils.newStringUtf8(Base64.decodeBase64(parts[1]));
} catch (NullPointerException e) {
throw new JWTDecodeException("The UTF-8 Charset isn't initialized.", e);
} catch (IllegalArgumentException e){
throw new JWTDecodeException("The input is not a valid base 64 encoded string.", e);
}
header = converter.parseHeader(headerJson);
payload = converter.parsePayload(payloadJson);
}
解析token得到header和payload的JSON对象并生成header和payload从而生成一个新的DecodedJWT对象(此时还未解析signature),接下来使用DecodedJWT对象的verify方法开始验证
public DecodedJWT verify(DecodedJWT jwt) throws JWTVerificationException {
verifyAlgorithm(jwt, algorithm); ---> 验证加密算法是否一致
algorithm.verify(jwt); --->验证signature
verifyClaims(jwt, claims);
return jwt;
}
public void verify(DecodedJWT jwt) throws SignatureVerificationException {
byte[] signatureBytes = Base64.decodeBase64(jwt.getSignature());--->解密signature
try {
boolean valid = crypto.verifySignatureFor(getDescription(), secret, jwt.getHeader(), jwt.getPayload(), signatureBytes); -->生成新的signature并对比是否一致
if (!valid) {
throw new SignatureVerificationException(this);
}
} catch (IllegalStateException | InvalidKeyException | NoSuchAlgorithmException e) {
throw new SignatureVerificationException(this, e);
}
}
首先对token中的signature中的signature进行解密,接下来使用token中的header和payload与本地的secret生成新的signature,并判断与token中的signature是否相同,如果不同则token解析未通过,如果相同则解析其他claims如过期时间等,查看该token是否过期,如果都通过则验证成功。