JWT 安全问题
2024-04-02 Skill

1. 空加密算法

JWT 全称 JSON Web Token, 是⼀种被⽤于替代传统 cookie + session 的身份验证⽅法. JWT 的声明被存储在客户端中, ⽽服务器通过某个特定的加密/签名算法对JWT 进⾏验证

JWT 有三部分组成, 分别为 Header, Payload 和 Signature, 通过 . 连接

Header 中⼀般会有 alg 和 typ 两个字段, 前者指定 JWT 的加密算法, 后者则固定为
JWT , 使⽤ Base64 URL 编码

Payload 负责储存身份认证的信息, 例如⽤户权限等级, 编码⽅式同上

Signature 则为 Header 和 Payload 两个部分的签名, 主要过程是将两者的原始内容
通过 Base64 URL 编码, 然后使⽤ . 拼接, 最后使⽤某个特定的算法 (例如 HMAC、SHA256) 进⾏加密

JWT 规范允许指定加密算法 (即 alg 字段) 为 none, 从⽽完全忽略 Signature 的部分,
使得任何⼈都可以伪造任意身份的 JWT, 因此某些第三⽅ JWT 库在实现规范的时候
可能会存在这个漏洞

eg.

package com.example.jwtdemo.controller;

import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.ZonedDateTime;
import java.util.Random;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import org.primeframework.jwt.Signer;
import org.primeframework.jwt.Verifier;
import org.primeframework.jwt.domain.JWT;
import org.primeframework.jwt.hmac.HMACSigner;
import org.primeframework.jwt.hmac.HMACVerifier;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class IndexController {
    private static String SECRETS;
    
    public IndexController() {
        byte[] data = new byte[16];
        Random r = new Random();
        r.nextBytes(data);
        SECRETS = new String(data);
    }
    
    @RequestMapping({"/"})
    public String index(@CookieValue(name = "token", defaultValue = "") String token, HttpServletResponse response) {
        if (!token.isEmpty()) {
            JWT jWT = JWT.getDecoder().decode(token, new Verifier[] { (Verifier)HMACVerifier.newVerifier(SECRETS) });
            return "hello " + jWT.getString("role");
        }
        JWT jwt = (new JWT()).setIssuedAt(ZonedDateTime.now(ZoneOffset.UTC)).setExpiration(ZonedDateTime.now(ZoneOffset.UTC).plusMinutes(60L));
        jwt.addClaim("role", "guest");
        String encodedJWT = JWT.getEncoder().encode(jwt, (Signer)HMACSigner.newSHA256Signer(SECRETS));
        response.addCookie(new Cookie("token", encodedJWT));
        return "hello guest";
    }

    @RequestMapping({"/admin"})
    public String admin(@CookieValue(name = "token", defaultValue = "") String token) throws Exception {
        if (!token.isEmpty()) {
            JWT jwt = JWT.getDecoder().decode(token, new Verifier[] { (Verifier)HMACVerifier.newVerifier(SECRETS) });
            String role = jwt.getString("role");
            if ("admin".equals(role))
                return new String(Files.readAllBytes(Paths.get("/flag", new String[0])));
            return "you are not admin";
        }
        return "unauthorized";
    }
}

index 路由会解析 JWT 中的 role 字段, admin 路由会验证 role 是否为 admin, 然后输出 flag

查看 pom.xml 可以发现程序使⽤了 com.inversoft:prime-jwt:1.3.0 依赖, 该版本存在⼀个 JWT 身份绕过漏洞, 即可以指定 alg 字段为 none, 从⽽忽略签名, 伪造⾼权限的 JWT

⾸先拿到 guest 的 JWT
image-1.png

在 jwt.io ⽹站上 base64 解码查看 payload 的构成
image-2.png

尝试⼿动构造 alg 为 none 的 jwt

import jwt
token = jwt.encode({'role': 'admin'}, algorithm="none", key="")
print(token)

得到:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJyb2xlIjoiYWRtaW4ifQ

最后携带 JWT 访问 admin 路由, 拿到 flag

2. HMAC 弱密钥爆破

⼤多数 JWT 使⽤的签名算法都是 HMAC. 在使⽤ HMAC 对 JWT 进⾏签名和验证时, 会指定⼀个密钥 secret, 这个过程类似于对称加密, 因此如果我们能够得到这个 secret, 便可以伪造任意的 JWT

eg.

const fs = require('fs');
const crypto = require('crypto');

const express = require('express');
const cookieParser = require('cookie-parser');

const jwt = require('jsonwebtoken');

var app = express();
app.use(cookieParser());

if (!fs.existsSync('.secret')) {
    fs.writeFileSync('.secret', crypto.randomBytes(2).toString('hex'));
}

var secret = fs.readFileSync('.secret').toString();

var flag = fs.readFileSync('/flag').toString();

app.get('/', (req, res) => {
    let obj = {
        'role': 'guest',
    };
    let token = jwt.sign(obj, secret, {algorithm: 'HS256'});
    res.cookie('token', token)
    res.send({
        'msg': 'hello world',
    });
});

app.get('/admin', (req, res) => {
    if (req.cookies.token) {
        try {
            let obj = jwt.verify(req.cookies.token, secret, {algorithms: 'HS256'});
            if (obj.role === 'admin') {
                res.send({
                    'msg': 'welcome admin',
                    'flag': flag,
                });
            } else {
                res.send({
                    'msg': 'you are not admin',
                });
            }
        } catch (err) {
            res.send({
                'err': err,
            })
        }
    } else {
        res.send({
            'msg': 'unauthorized',
        })
    }
});

app.listen(80, () => {
    console.log('server listening at :80');
});

程序的 JWT 使⽤ HS256 算法, 存在 secret 密钥, 尽管 secret 是随机⽣成的crypto.randomBytes(2).toString('hex') , 但是⻓度很短 (仅有四个字节), 因此我们仍然有机会去爆破这个 secret

⾸先访问⽹站拿到 JWT
image-3.png

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiZ3Vlc3QiLCJpYXQiOjE3MDUwNTU0NDR9.Lt3NmiVNE6z1ZRaQ0KRjLfnMqjIBYVSIzdAWltyYVgo

使⽤ c-jwt-cracker 爆破 secret
image-4.png

然后使⽤ jwt.io 构造 admin 的 JWT
image-5.png
最后携带 JWT 访问 admin 路由拿到 flag

3. RSA 改 HMAC

JWT 除了⽀持 HMAC 算法以外, 还⽀持 RSA 算法. JWT 的 RSA 算法使⽤私钥对 JWT 进⾏签名, 使⽤公钥对 JWT 进⾏验证

在部分情况下, 我们可以拿到⽤于验证 JWT 的 RSA 公钥, 那么这个时候就可以尝试将 JWT Header 中的算法改成 HMAC, 这样流程就变成了: 使⽤公钥 (HMAC secret key) 对 JWT 进⾏签名, 使⽤公钥 (HMAC secret key) 对 JWT 进⾏验证, 从⽽可以任意构造 JWT 的内容

const fs = require('fs');

const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jwt-simple');

var app = express();
app.use(cookieParser());

var privateKey = fs.readFileSync('private-key.pem');
var publicKey = fs.readFileSync('public-key.pem');

var flag = fs.readFileSync('/flag').toString();

app.get('/', (req, res) => {
    let obj = {
        'role': 'guest',
    };
    let token = jwt.encode(obj, privateKey, 'RS256');
    res.cookie('token', token)
    res.send({
        res.send({
            'msg': 'hello world',
        });
    });
    app.get('/getpubkey', (req, res) => {
        res.send({
            'msg': 'now you can verify jwt with the public key on your own',
            'public': btoa(publicKey)
        })
    })
    app.get('/admin', (req, res) => {
        if (req.cookies.token) {
            try {
                let obj = jwt.decode(req.cookies.token, publicKey);
                if (obj.role === 'admin') {
                    res.send({
                        'msg': 'welcome admin',
                        'flag': flag,
                    });
                } else {
                    res.send({
                        'msg': 'you are not admin',
                    });
                }
            } catch (err) {
                res.send({
                    'err': err
                });
            }
        } else {
            res.send({
                'msg': 'unauthorized',
            });
        }
    });
    app.listen(3000, () => {
        console.log('server listening at :3000');
    });

程序使⽤了 jwt-simple 库, 版本为 0.5.1, 该版本存在 CVE-2016-10555 漏洞, 即可以将 JWT 的算法从 RS256 改成 HS256, 使得程序使⽤ RSA 公钥, 以 HS256 算法对 JWT 进⾏验证, ⽽ /getpubkey 路由正好会泄露公钥, 因此整个利⽤过程如下

⾸先拿到公钥

然后将公钥作为 HMAC secret, 构造 JWT, 注意勾选 secret base64 encoded
image-6.png

最后访问 /admin 路由拿到 flag

4. JKU 地址伪造

在 JWT 的相关规范中, 存在 JWK 和 JKU 的概念, JWK 即 JSON Web Key, 是⼀个 JSON 对象, 表示⽤于加密/签名的密钥 (⼀般为 RSA 算法)

⽽ JKU (JSON Web Key Set URL) 是 JWT Header 的⼀个字段, ⽤于指定 JWK ⽂件所在的 URL 地址

⼀个很经典的场景是某系统存在多个服务器均需要对⽤户请求的 JWT 进⾏验证, ⽽ JWK 密钥会定期更新, 如果直接将 JWK 以⽂件的形式复制多份放在不同的服务器上, 显然很不⽅便, 这时候就需要⼀个专⻔⽤于存放 JWK 的可信任的服务器 (JKU), 之后只需请求 JKU 地址即可拿到最新的 JWK, 然后再进⾏ JWT 的验证, 这样会⽅便很多

尽管如此, 但部分程序会强依赖于 Header 中的 JKU 进⾏签名的验证, 导致我们可以尝试将 JKU 指向恶意的 URL 地址, 即可使⽤我们已知的 JWK 密钥对 JWT 进⾏验证, 从⽽构造任意内容的 JWT

eg.

const fs = require('fs');

const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

var app = express();
app.use(cookieParser());

var flag = fs.readFileSync('/flag').toString();

app.get('/', function(req, res) {
    res.cookie('token', 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiZ3Vlc3QifQ.X8CEnicXPdsIk_P0V - wM8EElKL8FyKSi7aE - _p11oLsQzvR48mEk92jeS9E8 iJpSO1gcz8gUVSZvDS2Ne0cGIc86O4aZphVG - uZYydYVctZT - ho7vRuflOK9uNVf7SNnjEcxi TP5 - driA547Dc_v6zPqfBrSid2kLQElsYKIlklwJX0H - qHTNjClrxh9fFkIe1d_MSksrYahVX SCsxi0mm3JPVwK8NjdPKNHEWLCw0DR2HaK64SUDFbE9wucYQCo4L6HBBF2hg4KRXIgBApHIoG jFrdM5BB7z0nXWqZpxuF_DjvzNiMwPbPWpEV02bQa6j52zg3o0DYGXRFLu1FtQA');
    res.send({
        'msg': 'hello world'
    });
}); 
    
app.get('/static/jwks.json', function(req, res) {
    res.sendFile('./jwks.json', {root: __dirname});
}); 

app.get('/flag', async function(req, res) {
    if (req.cookies.token) {
        try {
            let decoded = jwt.decode(req.cookies.token, {complete: true});
            let client = jwksClient({
                jwksUri: decoded.header.jku ?? 'http://127.0.0.1:80/static/ jwks.json',
                cache: false,
            });
            let key = await client.getSigningKey(decoded.header.kid ?? 'test');
            let pubKey = key.getPublicKey();
            let data = jwt.verify(req.cookies.token, pubKey);
            if (data.role === 'admin') {
                res.send({
                    'msg': 'welcome admin',
                    'flag': flag
                });
            } else {
                res.send({
                    'msg': 'you are not admin'
                });
            }
        } catch (err) {
            res.send({
                'err': err
            });
        }
    } else {
        res.send({
            'msg': 'unauthorized',
        });
    }
}); 

app.listen(80, () => {
    console.log('server listening at :80');
});

程序默认发送⼀个已经签名好的 jwt, 同时 JWK 密钥已知

{
    "keys": [
        {
            "kty": "RSA",
            "e": "AQAB",
            "use": "sig",
            "alg": "RS256",
            "kid": "test",
            "n": "mvZIyoISigFRl86lzZoIRrx6RUek5C-de0HqnitsrZ1mD4Box6Ko8Bt6YroUQda7ickhFwc9Ldxtgcms3Q_zoBtXOHYgq9ym6BPl275ZOD-kOpHOCtMrRPe5KO66bdT4uhKab4Pu1KExfmPEVn6jf7Plo_NTmR6MP8H3nn8_z8NroSNwNe_Lf7AKVeVm-Ys0hPbpr5RbFeKt2EXMIUScQrvTLgOfRil6lT-KFhloWBvzo91OGtGdwAZr99woolkPtOfHxyoboc-zGdWsIVR2Q6cGyXY2dbsq-rwOleY9Vc0PVSj9uVm96c3Z8G1q84hOxaIY8QxziuWWcE4q7_8Blw"
        }
    ]
}

但是这⾥的 JWK 仅仅是 RSA 的公钥, 只能⽤于验证, 不能⽤于签名

仔细阅读代码可以发现程序会通过 JWT Header 中提供的 JKU 地址来获取 JWK, 那么我们就可以搭建⼀个可控的服务器来托管我们⾃⼰的 JWK, 进⽽伪造 admin 的权限

⾸先利⽤ mkjwk ⽣成 JWK
image-7.png

构造 jwks.json

{
    "keys": [
        {
            "kty": "RSA",
            "e": "AQAB",
            "use": "sig",
            "alg": "RS256",
            "kid": "test",
            "n": "2QTsyirNootelqW9xfKtSrxFRE2G_1VA8wnD6YbXwJNmpgrWQo2eJPR4HpGoKJphawL8xuYbdbzVStt9MnlENlCxFv8F5jlbGgn6cD0AOgRV9WrQh9GqLHWBZKNhoTXa9KCPAHjprclu_Lt9-u3DgsoJoI9v8_oOPd7XNPIEQKP7U9rJPQPVOUP1JQ4zBWpdNHkjY0Qbok8PsTAX2u5o6XbhQIG_2mU-RXoZutdOv5Os1Uh0q-jSeWDn6OiaixvkPtAZxCIiXniOf_BtdsXVkobEhbj8kjjEx5XPJPwg9vz_wzticNqxkH4hkkGZLsrIavxpMkhyuyqPt2qaT0VyUQ"
        }
    ]
}

构造 JWT
image-8.png
搭建 HTTP 服务器托管 jwks.json, 携带 JWT 访问 flag 路由, 拿到 flag