无境靶场——JWT 专题
无境靶场——JWT 专题
前言
JWT(全称 JSON Web Token)是目前互联网上最流行的跨域身份验证和信息传输方案。
简单来说,它就像是一张由服务器签发的“数字工作证”。用户登录成功后,服务器发给用户一张 JWT。之后用户每次找服务器办事,只需要出示这张 JWT,服务器检查一下上面的“公章”是否完好,就能立刻确认用户的身份,不需要再去查数据库。
通常来说 jwt 包含两部分内容,分别是头部和 payload
头部一般包含加密算法和令牌类型
1 | { |
payload 就是 jwt 里存放的数据
1 | { |
这两部分通常会被分为 header、payload、signature 三部分,用 . 进行分割后,用 base64 加密,示例:
1 | eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1c2VybmFtZSI6ImFhYSIsImlhdCI6MTc4MjgzNzM1Mn0.rYV0fs0hHhX8WWvK8GrXiGt6Gyvp35pVKGifV8WiR4AsLEcHp0bSUWsRLFbJjDBr3DI5es5gKxDNdXIrX5BEXS-pWML-DsEeqsx7JYDYWMmmsRgbKoTKMYddHF2UW97b0xzsz84dK_Vt97xM04H8Ibwmr2mxIlKARN3FIgzzXvWCNdRRwso_jBz0Tw0zMql9IrfTxl060-qGXyfFu3ZdjnuCvp87mqY7sU9MbgEvj-6gO9_qyqbig_t2e8T5VDEeHweecct-npXqgTtNLr6-7siB7zVwc4RlSejiIssZOHwzpmapjFDwVla__Gj5vtkqs0Td8YkWs9apr1PhJwsUig |
这三部分可以直接用 base64 直接解密,比如 header 解密后是这样的:
1 | base64.b64decode('eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9') |
第二部分为 payload,因为这里的 payload 的长度不是 4 的倍数,所以我们要用 = 对其进行填充
1 | >>> len('eyJ1c2VybmFtZSI6ImFhYSIsImlhdCI6MTc4MjgzNzM1Mn0') |
用 = 填充后:
1 | >>> base64.b64decode('eyJ1c2VybmFtZSI6ImFhYSIsImlhdCI6MTc4MjgzNzM1Mn0=') |
第三部分 signature 解密后:
1 | >>> base64.b64decode("rYV0fs0hHhX8WWvK8GrXiGt6Gyvp35pVKGifV8WiR4AsLEcHp0bSUWsRLFbJjDBr3DI5es5gKxDNdXIrX5BEXS-pWML-DsEeqsx7JYDYWMmmsRgbKoTKMYddHF2UW97b0xzsz84dK_Vt97xM04H8\ |
JWT专题一:无效签名验证
题目
环境目标:登录”mht”用户,并成功下单一件商品
JWT 由三部分组成:Header.Payload.Signature
服务器生成 JWT 时,会使用一个密钥对头部和载荷进行签名。之后收到客户端发回的 JWT 时,必须验证签名,以确认:
- 令牌确实是由本服务器签发的(而不是伪造的)
- 载荷内容没有被篡改过
如果没有这个验证,任何人都可以随意修改 Payload 部分,再把头部和签名原样拼接回去(甚至替换签名),服务器就会接受这个JWT。
解题
题目似乎是要我们伪造一个 jwt,进行下单,下单成功后即可返回 flag
- 打开 url,是一个商城界面

- 购买东西提示要先登录

- 我们注册了个用户 aaa,注意,注册的时候的相应包就是我们的 jwt

- 拿到在线网站去解码,发现是这样的,username 是我们的用户名,iat 是我们注册时的时间戳?(不确定)

- 继续下单,下单的时候提示用户余额不足

- 在结账这个接口我们又看到了 jwt

- 思路现在就很明确了,我们知道了我们的 jwt 结构,可以伪造一个 admin 用户的 jwt,然后进行下单,现在利用在线网站生成一个 jwt

- 抓包,替换 jwt

- 啊?提示登录状态无效?

到这里的时候我一直没想明白,为什么这样都没法伪造成功,然后去看了一下 b 站的视频,视频链接:https://www.bilibili.com/video/BV1QkRYYREQK/?spm_id_from=333.1391.0.0&vd_source=189e78d8fb3a35dc30e5a63e8045d528
到这里我才恍然大悟,原来是直接 jwt 的 header、payload、Signature 三段都是 base64 加密的,然后导致无效签名验证的原因是开发人员在验证 jwt 的时候用的是 decode 方法,而非 verfity 方法,导致了攻击者可以恶意修改 payload 字段并重新编码成 jwt。
我失败的原因就是不了解业务场景,以为是用 jwt 直接重新生成一个,密钥随便填那种,下次还是得先了解一下业务场景之类的再来开始做题
重新做题
- 这里写了个脚本伪造 jwt
1 | import base64 |
- 用重新编码的 jwt 访问个人中心,发现伪造成功

- 成功下单

JWT专题二:无签名令牌绕过
题目
环境目标:登录”mht”用户,并成功下单一件商品
JWT(JSON Web Token)的结构包含三部分:标头(Header)、载荷(Payload) 和 签名(Signature)。标头中的 alg 参数告知服务器该令牌使用了哪种签名算法(如 HS256、RS256)。正常情况下,服务器会使用这个算法验证签名,确保令牌未被篡改。
漏洞本质:信任用户输入的算法
alg 参数来自未经验证的令牌本身,服务器直接信任它并决定验证行为。攻击者可以操纵这个参数,将 alg 设为 none,表示该令牌没有签名。规范中定义 none 算法用于“未受保护的JWT”,本意是在某些非安全场景下使用,但若服务器错误地允许 none 令牌,就会发生以下情况:
攻击者可以任意修改令牌的标头和载荷(如改变用户ID、角色、过期时间等)。
服务器看到 alg: none 后,跳过签名验证,直接接受令牌内容为有效。
因为没有任何签名需要校验,任何伪造的令牌都能通过。
示例业务场景:极客书店管理后台
背景:你是一名安全测试人员,目标系统是一个小型在线书店。系统使用JWT进行身份认证,但后端在验证逻辑上存在缺陷。你的任务是登录 mht 用户,并成功下单购买《黑客守则》这本书。
假设我们是白盒测试的,可以拿到源码,我们看源码发现 jwt 是正常登录时签发的

但是在解码 jwt 的时候直接相信了客户端发来的头部,导致了该漏洞

解题
- 可以看到注册的时候是正常签发 jwt 的

- 拿到 jwt.io 里看看 jwt 信息

- 直接在 jwt.io 伪造一个 jwt,头部签名算法改为 none,这样会跳过签名验证,直接接受内容为有效

- 抓包改 jwt,改成我们生成的这个,先在个人中心试验一下,可以看到余额为 1000,说明服务端直接判断我们生成的 jwt 为有效了

- 购买商品成功

JWT专题三:弱密钥
题目
环境目标:登录”mht”用户,并成功下单一件商品
一些签名算法,例如 HS256 签名算法使用弱密钥进行签名和验证。如果开发者在代码中使用了默认密码、示例密钥(如 secret、key、123456)或从网上直接复制的硬编码值,攻击者就可以将这些已知的弱密钥编成“字典”,对截获的 JWT 尝试本地签名比对。由于只需离线计算 HMAC-SHA256 并与令牌中的签名部分匹配,无需与服务器交互。拿到密钥后,攻击者便能任意伪造具有完整有效签名的 JWT,从而冒充任何用户、提升权限,彻底绕过认证体系。
业务场景
这个我感觉没必要说,如题目说的就是
前置知识——容易被爆破的算法:HS256 / HS384 / HS512(对称加密家族)
这三种算法本质上都是 HMAC(Hash-based Message Authentication Code),使用同一个密钥进行签名和验证。
| 算法 | 底层哈希函数 | 密钥类型 |
|---|---|---|
| HS256 | HMAC-SHA256 | 对称密钥 |
| HS384 | HMAC-SHA384 | 对称密钥 |
| HS512 | HMAC-SHA512 | 对称密钥 |
为什么它们容易被爆破?
- 签名计算成本极低
HMAC 的计算公式大致是:
text
1 | HMAC(K, m) = H((K ⊕ opad) || H((K ⊕ ipad) || m)) |
本质就是两次哈希运算。在普通笔记本上,每秒可以计算数百万次 HMAC-SHA256。这意味着攻击者可以用一个包含数百万常见弱密码的字典,在几分钟内完成离线爆破。
- 离线攻击,无任何限制
爆破过程完全不与服务器交互:
- 截获一个合法 JWT → 提取 Header 和 Payload → 用自己的弱密码字典逐个计算签名 → 对比签名部分
- 服务器完全感知不到,没有登录失败锁定、没有频率限制、没有IP封禁
解题
- 依旧先注册用户,取 jwt
1 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFhYSIsImlhdCI6MTc4Mjg0MTMxNH0.hwDcP99nDnssfRmejzBuy6GU2tMdhH1ujSlhN_PBD90 |
- 看到 jwt 密钥的算法是 HS256,可以爆破

- ai 生成一个爆破密钥的脚本
1 | import jwt |
- 写了个脚本进行对弱密钥进行爆破
1 | import jwt |
输出:
1 | [!] Algorithm found: HS256 |
有长度警告不用管
- 在 jwt.io 使用该密钥重新生成一个 jwt

- 使用该 jwt 购买商品,购买成功!

JWT专题四:头部参数注入
题目
环境目标:登录”mht”用户,并成功下单一件商品
配置不当的服务器在验证签名时:
没有使用受信任的密钥白名单。
而是直接从收到的 JWT 头部中提取 jwk 字段,把它当作合法的公钥去验签。
JWK(JSON Web Key) 是一种用 JSON 格式表示加密密钥的标准规范,它的核心作用很简单:把密钥(公钥或私钥)用结构化的 JSON 来描述,方便在不同系统间传输和存储。
前置知识
在 jwt 规范中,只有 alg 字段是必须的,其他的都是可选参数,其中 jwk 字段可嵌入一个完整的公钥。如果服务器盲目信任并直接用它验签,攻击者就能自签密钥、伪造任意身份。

业务场景——业务场景:云端书店 API
背景:某在线书店为了“灵活”支持多租户 OAuth,允许 JWT 头部携带 jwk(JSON Web Key)字段。服务器会动态从令牌头部提取公钥来验证签名,而不是使用本地白名单。攻击者可以自签一个 JWT,在头部嵌入自己的 JWK,让服务器误认为合法。
示例代码段:
1 | // 检查头部是否携带了jwk |
解题
- 依旧注册用户看 jwt 密钥,发现算法是 RS256

- 我们先用命令生成一对密钥,先生成私钥
1 | openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 |
再使用
1 | openssl rsa -in private_key.pem -pubout -out public_key.pem |
- 使用脚本重新生成 jwt,脚本来自:https://github.com/Crypto-Cat/CTF/blob/main/web/WebSecurityAcademy/jwt/4_jwk_header_injection.py
1 | # Demo script for 'JWT Authentication Bypass via jwk Header Injection' video: https://youtu.be/t-RfzyW0iqA |
输出:
1 | Decoded token: {'username': 'aaa', 'iat': 1782843826} |
- 替换 jwt,完成

JWT专题五:jku头部注入绕过
题目
环境目标:登录”mht”用户,并成功下单一件商品
核心是服务端信任了 JWT 头部里的 jku 参数。
攻击者可以把 jku 指向自己控制的 JWKS 地址,然后用自己的私钥签一个伪造的管理员 JWT。服务端验签时会去这个地址拉取攻击者的公钥,结果反而把攻击者签的 token 当成合法 token。
jku 是一个指向密钥集的 url,服务器可以从该 url 获取包含正确密钥的一组密钥,kid 是密钥 id。
jwk 集合是一个包含表示不同密钥的 jwk 数组的 json 对象,以下示例为一个 jwk 集合:
像这样的 jwk 集合一般都会有一个标准端点公开暴露:/.well-known/jwks.json,更安全的网站只会从受信任域里获取
业务场景:微服务架构的电商平台
背景:某电商平台采用微服务架构,认证服务签发 JWT,各个业务服务通过 jku 字段动态获取 JWKS 公钥验签。但订单服务没有对 jku 做白名单限制,攻击者可以将其指向自己搭建的 JWKS 端点。
正常签发 jwt
漏洞代码:
1 | // 不解码验证,先获取头部信息 |
解题
- 照常注册用户,然后看 jwt 结构

- 生成私钥,再通过私钥提取公钥
1 | openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 |
- 写脚本,当然也可以用别人的脚本,其中 jku_url 里写自己服务器的地址,待会把自己的 jwk 放进去
1 | # Demo script for 'JWT Authentication Bypass via jku Header Injection' video: https://youtu.be/hMRdMmll8Bk |
输出:
1 | Decoded token: |
- 我们将重新生成的 token 放到 jwt.io 解析可以看到里面有 jku

- 将生成的 jwk 放到服务器上,确保可以公网访问

- 使用该 jwt 查看个人中心信息,证明伪造成功

- 拿下 flag

JWT专题六:kid 参数注入
题目
环境目标:登录”mht”用户,并成功下单一件商品
核心是服务端直接信任 JWT header 里的 kid,并把它拼接成文件路径读取验签密钥。
由于 kid 没有做白名单和目录穿越过滤,攻击者可以设置为 ../../dev/null,让服务端读取空文件作为密钥,同时服务端使用 HS256 对称算法,攻击者用空字符串重新签名 JWT,就能伪造任意用户身份。
业务场景:多密钥轮换的电商系统
假设我们有一个电商平台,为了增强安全性,系统定期进行密钥轮换 (Key Rotation),或者这是一个多租户系统 (Multi-tenant),每个大客户拥有自己的签名密钥。
为了实现这一点,开发人员将每个时期的 HMAC 密钥保存在服务器本地的 /var/app/keys/ 目录下(例如 key-2026-v1.txt,key-2026-v2.txt)。
当用户发起结账下单(/api/order)请求时,服务端会:
- 先提取 JWT 的 Header,读取里面的 kid 字段。
- 将 /var/app/keys/ 与 kid 的值直接拼接,去读取对应的密钥文件。
- 用读取到的文件内容作为 Secret 来验证 JWT 的合法性。
关键源码:
1 | const express = require('express'); |
解题
- 依旧注册用户获取 jwt,查看 jwt 结构,加密算法是 HS256

- 写脚本,或者借用脚本
1 | # Demo script for 'JWT Authentication Bypass via kid Header Path Traversal' video: https://youtu.be/78FIFrOi4Os |
脚本运行报错了,查了 ai 说是在较新的 PyJWT 版本中(具体是从 2.4.0 版本开始),为了防止开发者意外配置空密钥以及防御此类空密钥攻击,官方在底层源码中强行加入了一个安全校验:严禁使用空字符串(””)或空字节作为 HMAC 算法(如 HS256)的签名密钥。:
1 | Traceback (most recent call last): |
使用 ai 原生实现了一下:
1 | import json |
输出:
1 | Decoded payload: {'username': 'mht', 'iat': 1782922014} |
- 拿到 flag

越权密钥
题目
环境来自:雾島风起時
类型:综合 Web 安全靶场
背景设定:三角洲行动游戏陪玩下单平台
场景描述
平台模拟了完整的用户注册登录、打手匹配下单、客服会话等真实业务流程,但在前端加密逻辑、用户标识传输、接口鉴权等环节故意引入了多处安全缺陷。
挑战者需要从前端 JavaScript 中逆向 AES-CBC 加密参数完成登录,分解弱 RSA 公钥还原打手加密标识实现 IDOR 越权,从客服聊天记录中挖掘隐藏报酬接口路径,最终通过伪造 JWT Token 提权至管理员身份获取 Flag。
攻击链路径
- 前端加密逆向 — 逆向 AES-CBC 加密参数完成登录
- IDOR 越权 — 分解弱 RSA 公钥还原打手加密标识,实现越权操作
- 敏感信息挖掘 — 从客服聊天记录中挖掘隐藏报酬接口路径
- JWT 伪造提权 — 通过 alg:none 绕过或利用硬编码签名密钥伪造令牌,提权至管理员
- 获取 Flag
考察知识点
- 前端加密逆向
- 不安全直接对象引用 (IDOR)
- JWT 安全机制
- 弱密钥利用
- 敏感信息泄露
本靶场综合考察 Web 安全核心能力。
解题
因为没什么思路,看了下攻略:https://duckpigdog.github.io/2026/05/07/%E4%BD%A0%E7%9A%84%E9%AD%94%E7%8E%8B%E6%8A%A4%E4%B8%80%E8%88%AC/
- 登录界面摁 F12 会提示 debugger,我是在 debugger 语句所在的行的行号上单击鼠标右键,此时会出现一个快捷菜单,选择 Add conditional breakpoint 选项,输入 false 进行绕过的。
参考文章:https://www.cnblogs.com/liyuanhong/articles/18210072

- 在关键的地方打上断点,输入账号密码,注册一个普通用户进行登录

- 这里比较简单,直接控制台输入 IV 的变量,密钥就出来了

- 转为 ascii 字符:16-byte-login-iv,这里先存着,暂时不知道有什么用
- 回头发现登录的时候收到了两个 token

- 在选择打手页面发现了这个 api 返回所有打手信息,可惜 username 是加密的

- 发现功能点基本都是在 /static/js 这个目录下的,接下来爆破一下目录

- 爆破目录看到了 api.js(其实这个 api.js 是我自己加进去的,这个原装字典里没有)

- 访问看到的是一堆经过混淆后的代码,用 ai 进行还原

- 看到了两个接口路径,其中第一个尝试了返回 404,访问第二个接口提示 missing or invalid X-Access-Token,还记得我们登录的时候有 X-Access-Token,补上

- 补上之后返回了 rsa 公钥

- 将公钥扔给 ai,得到 e = 65537 ,以及 n = F31D382F7C1FEC467629D8FFB5F5313DE6513DAB6F524AE7B903068EA30970AD70801FAB5685B32750F3F3AA730E639A02E587D83AAA3DF941DE8FD7178BA553,转换成十进制为:12732920605600028117810520777290632515406900458295844114646658469338740536327294243830128366985242460116666201456035915981254198267373057245094514617197907
- 将其转为 10 进制,然后放到在线网站 factordb.com/index.php 看看有没有记录:

- 不太懂什么意思,直接给 ai,ai 说:
请看截图里 Result: 下面那行最核心的公式:
1273292060…43 <155> = 37 * 61 * 3259 * 1731059961…61 <148>
- <155>:代表你原本的十进制数 有 155 位长。
- 37 * 61 * 3259:FactorDB 顺手帮你除以了几个极小的质数。
- <148>:剩下的那个 148 位长的数字。
- 状态 CF:在 FactorDB 中,CF 的意思是 Composite Factored(已部分分解的合数)。也就是说,最后那个 148 位的数字仍然是一个合数(还能继续拆),但 FactorDB 的数据库里目前没有记录它完全拆解后的结果。如果完全分解了,状态会显示为 FF (Fully Factored)。
总结来说就是失败了
没办法了,只能使用开源工具 yafu 对 n 进行暴力破解:github.com/bbuhrow/yafu/releases/tag/v3.1.7
1 | .\yafu-x64.exe "factor(0xF31D382F7C1FEC467629D8FFB5F5313DE6513DAB6F524AE7B903068EA30970AD70801FAB5685B32750F3F3AA730E639A02E587D83AAA3DF941DE8FD7178BA553)" |
输出:
1 | .\yafu-windows-avx2.exe "factor(0xF31D382F7C1FEC467629D8FFB5F5313DE6513DAB6F524AE7B903068EA30970AD70801FAB5685B32750F3F3AA730E639A02E587D83AAA3DF941DE8FD7178BA553)" |
也失败了,这时候有点怀疑人生了,然后认识到了费马分解法,这是 ai 的介绍:

- 让 ai 写了一个费马分解法的脚本:
1 | import math |
输出:
1 | [*] 引擎点火,准备榨干 CPU,正在挂载监控面板... |
- 总的来说如果 p 和 q 的值离得近,比如我这个,78 位的 p 和 q,前面 74 位都一样,差值只有 434,那就可以用这个方法
那话又说回来,那为什么神级工具都会卡死?
出题人极其阴险,他在原本可以直接秒杀的 外面,额外乘上了三个很小的质数(37 * 61 * 3259),凑成了最初的那个 155 位的模数 。
- 当 Yafu 或 Alpertron 拿到 155 位的 时,它们会在一开始执行快速的费马检测。但因为有那三个小质数捣乱, 的平方根附近根本找不到答案,检测失败。
- 随后工具除掉了小质数,拿到了剩下的 148 位纯正的 。
- 但此时,工具的内部逻辑已经切换成了对抗“常规大素数”的重型武器(ECM / GNFS),它们不会再对这个 148 位的结果回头去做哪怕一次最简单的开平方检测。
现在得到了 n,e,p,q,事实上我们已经破解了这个 rsa 算法了,剩下的事情就是用这些参数计算私钥,然后将从 /api/order/available-boosters 接口响应里获得的 username 参数填进去:
1 | import base64 |
输出:
1 | 解密结果: |
- 得到了用户名,再加上上面分析出来的 iv 以及 AES-CBC 模式,我们可以写脚本爆破该用户的密码,先爆破 kilo 用户的密码,看脚本:
1 | import argparse |
- 命令行执行:
1 | python .\main.py --base-url http://ulab.bdziyi.cn:20682 --username kilo --wordlist 'rockyou.txt' |
输出:
1 | [+] 💥 Password Found! |
- 登录了之后发现客户对话:

- 将客服对话 url 里的 localhost 改为靶机网址,直接访问返回 401

- 观察历史包发现每次访问都会带上 x-access-token 和 jwt

- 加上这两个之后访问返回 403

- 联想到空算法漏洞,利用 JWT alg: none 伪造 admin 身份,越权读取
1 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwic2NvcGUiOiJyZXdhcmQifQ. |

JWT 专题总结
JWT 专题用了大概四天的时间完成了,速度一般般吧,因为内容说实话也不算多,无非就几个,改头部、改 payload、改 jwk、改 jku 等等,不过学到的东西还是很多的,特别是思路方面,最好是要了解一下业务代码是怎么写的,然后再用逆向思维去想,这样写会导致什么漏洞,如何构造 payload,如何修复等等。
内容也总结一下:
- 改 payload:开发人员只是 decode 取出 payload 里的 username 等信息,而不先对签名进行验证 verify
- 改头部:也就是改 alg,将 alg 改为 none,如果服务器信任客户端发来的 alg,就会导致此漏洞
- 改 jwk:服务器依赖 jwt 中的 jwk 指定的密钥验证签名,且该密钥客户端可随意设置
- 改 jku:服务端依赖 jku,向指定的 url 进行密钥认证,且客户端对 jku 可控
- 改 kid:开发人员将每个时期的 HMAC 密钥保存在服务器本地的 /var/app/keys/ 目录下且未对该路径进行验证,客户端对这个参数可控,即可将将路径改为 ../../../../dev/null 将 alg 指定为 none
- 标题: 无境靶场——JWT 专题
- 作者: Nevolar
- 创建于 : 2026-07-26 23:00:04
- 更新于 : 2026-07-26 23:00:07
- 链接: https://blog.freeaes.com/2026/07/b6f55da5cb51.html
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
