2021年的陇剑杯里有一道JWT题目,网上的Writeup其实不少,但大多是把问1、问2两步揉在一起,步骤一带而过。我复盘的时候在问2上卡了挺久,翻了不少资料才把细节捋顺。这篇就专门拆问2,把从拿到已知JWT到最终伪造出管理员令牌的完整流程、背后的签名原理、以及各类排查思路全讲清楚,适合刚入门Web安全、正在刷CTF题目,或者工作中第一次接触JWT认证漏洞的读者。
我当时在这道题上最大的感受就是:JWT看着结构简单,但真正做题的时候,坑全藏在细节里——密钥从哪来、算法用哪个、签名怎么比对、伪造之后请求怎么带Cookie,每一环都能卡住人。这篇不写流水账,按攻击链一步步拆。
1. 题目环境与攻击面分析
1.1 这道题在考什么
陇剑杯的Web题向来偏实战,JWT这道题属于“考得比较规整”的类型。题目会把一个存在逻辑漏洞的Web应用给你,通常是登录后返回一个JWT作为会话凭证,然后某些管理功能依赖这个令牌里的user或role字段来判断权限。问1一般会让你通过某种方式拿到JWT密钥,或者通过源码审计发现问题;问2则是在密钥已知(或可爆破)的前提下,构造一个伪造令牌,以管理员身份访问受保护接口,拿到后续的Flag。
CTF里JWT的出题模式其实很固定,无非是几个方向:弱密钥爆破、alg混淆(HS256与RS256互换)、alg:none绕过、密钥硬编码泄露、kid参数注入等。这道题问2的考点就是最典型的那一个:已知某个合法JWT,爆破出HS256签名密钥,然后伪造admin身份绕过后端访问控制。
为什么这么判断?一是这类题目给的可操作面就是登录/注册接口和管理接口,二是问1通常已经把密钥线索引出来了,比如源码里泄露的密钥提示、错误页面打印的调试信息,或者直接是弱密钥可爆破。问2的任务就是把线索变成真正的攻击载荷。
1.2 上手前先搞清楚JWT的三个组成部分
JWT的全称是JSON Web Token,一句话说就是:把一段JSON数据用Base64编码,再附上一个签名,打包成一串看起来又长又乱的字符串。它由三部分组成,用点号分隔:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiZ3Vlc3QiLCJyb2xlIjoiZ3Vlc3QiLCJleHAiOjE3MTMwMDAwMDB9.q8Q_6KXm6JcLcC9X9i-0RmAqB0K0bJwX6S6oNnLmX9M第一部分是Header,也就是头部,用Base64URL编码,内容一般是{"alg":"HS256","typ":"JWT"},用来声明签名算法。第二部分是Payload,载荷,存业务数据,比如用户名、角色、过期时间。这两部分都只是Base64编码,不是加密,任何人都能解出来。第三部分是Signature,签名,作用是防止前两部分被篡改。
HS256签名的生成过程可以写成一句话:
signature = HMAC_SHA256( base64url(header) + "." + base64url(payload), secret )也就是把前两段字符串用点号拼起来,再用密钥做一次HMAC-SHA256计算,最后把二进制结果做Base64URL编码。学过一点密码学就知道,HMAC的本质是带密钥的哈希,密钥直接决定签名结果,所以谁的密钥弱,谁就能被离线爆破。
需要注意,JWT用的是Base64URL而不是普通Base64,区别在于它把字符+和/换成了-和_,同时去掉末尾的=填充符。很多新手在手工解码时直接上普通Base64工具会报错,就是因为没有用URL安全的变种。
提示:做题之前先搞清楚这三个组成部分,后面所有操作都建立在“Header可伪造、Payload可修改、Signature需要密钥”这个基础上。
1.3 题型场景还原与攻击链梳理
按照CTF题目的一贯风格,我复盘时构建了这样一个场景:靶场提供一个带登录框的Web应用,登录用户名密码可以随便猜,比如guest/guest。登录成功后,服务器返回一个JWT作为会话标识,存在Cookie里或者放到请求头的Authorization字段。应用里有一个/admin或/flag接口,普通用户访问会返回403或者空白,只有令牌里role: admin才能访问。
攻击链梳理下来是这么几步:
- 通过登录接口获取一个合法JWT;
- 本地解析JWT,确认Header算法是HS256,Payload里当前身份是guest;
- 对签名做离线弱密钥爆破,得到Secret;
- 修改Payload中的身份字段为admin,用爆破出的Secret重新签名;
- 将伪造令牌放回Cookie或Authorization,请求受限接口,拿到Flag。
这五步里,第3步是问2的关键,也是整道题最有区分度的地方。后面几个章节就按这个顺序展开每一个环节的执行细节。
2. 抓包、令牌提取与解码前置准备
2.1 令牌从哪里拿?两种传输姿势都要会
CTF靶机上的令牌传输方式通常有两种。第一种是Cookie方式,响应头里能看到Set-Cookie: token=eyJ...,浏览器后续自动带上。第二种是Authorization头方式,格式是Authorization: Bearer eyJ...,需要手动携带。这两种方式在伪造后都要对应放回原位置,一旦搞错,后端取不到令牌,直接当成未授权处理。
我实战中一般先上Burp Suite,打开Proxy的History面板,过滤一下目标域名的流量,找登录接口的请求和响应。响应里如果有JWT,直接复制出来存到本地文件里。用浏览器开发者工具也可以,不过Burp的好处是能同时看到Cookie和Header,不用来回切换。
没有Burp的时候,用Python的requests也一样:
import requests s = requests.Session() login_data = {"username": "guest", "password": "guest"} resp = s.post("http://target/login", data=login_data) print(resp.cookies.get_dict()) # 看Cookie print(resp.headers.get("Set-Cookie"))拿到token之后,建议立刻存到一个文本文件里,后面写脚本处理时反复粘贴很烦,而且容易粘漏字符。JWT字符串比较长,复制时注意不要少掉最后那段签名,那段是后面爆破比对的基准。
2.2 解码JWT的三种手段
拿到令牌第一件事是先看里面到底是什么。三个办法按推荐程度排序:
一是用现成在线解析站,比如jwt.io。把token粘进去,Header和Payload自动解出来,还会给出签名校验的提示。页面操作直观,适合快速确认算法和字段。
二是用CyberChef。这个工具比在线站更可控,能够把粘进来的JWT直接转成JSON,也能保留Base64URL编码细节,适合后续把它接进更多处理链。操作流程是拖一个JWT Decode组件进来,把令牌当输入,输出就是Header和Payload的JSON结构。
三是纯Python手撕。这是我最习惯的方式,因为后面爆破密钥也离不开自己写脚本,顺便就把解码一起做掉了:
import base64 import json def b64url_decode(s): s += "=" * (4 - len(s) % 4) return base64.urlsafe_b64decode(s) token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiZ3Vlc3QiLCJyb2xlIjoiZ3Vlc3QiLCJleHAiOjE3MTMwMDAwMDB9.q8Q_6KXm6JcLcC9X9i-0RmAqB0K0bJwX6S6oNnLmX9M" header_raw, payload_raw, signature_raw = token.split(".") header = json.loads(b64url_decode(header_raw)) payload = json.loads(b64url_decode(payload_raw)) print("Header:", header) print("Payload:", payload)这段代码顺手也把alg和当前用户信息看清楚了。对于问2来说,头部的alg直接决定了后面的爆破和伪造策略。
注意:JWT的Payload解码不叫解密,它是公开的。用
jwt.io试一下你就知道,任何一段JWT都能被任何人解码,安全全靠最后那段签名。
2.3 确认算法和权限模型
解码之后,题目轮廓就清楚了。这一步重点看三点:
| 字段 | 作用 | 需要关注的项 |
|---|---|---|
alg | 签名算法 | HS256代表对称密钥,适合爆破;RS256是非对称,注意混淆攻击面 |
user/role/level | 身份与权限 | 找目标身份,比如admin |
exp/iat | 过期时间 | 如果已过期,需要重新登录拿新令牌 |
我复盘的那道题里,Header是HS256,Payload里有user: guest、role: guest,还有一个exp的时间戳。目标很明确,就是把user改成admin、role改成admin(或者看到后端只需要role,就把role改了)。
这个信息走完,问2就没那么神秘了:它本质上考的是“能不能通过修改一个公开可见的字段来提权,并且能不能解决签名校验”。前一个问题答案显然是“能”,后一个问题看密钥强度。
3. 从合法令牌到管理员令牌:完整解题路线
3.1 第一步:拿到合法令牌后先别急着爆破
很多新手一拿到JWT就开始跑字典,这其实不是最优解。先花两分钟确认一下Payload里的结构,看看有没有别的可利用点。比如有些题目后端判断的是role字段,有些判断的是is_admin,还有的是level >= 100。字段认错了,密钥爆出来也白搭。
把解码后的Payload整理成一行JSON,留作后面伪造时对照:
{"user": "guest", "role": "guest", "exp": 1713000000}伪造时建议在原有字段基础上改,而不是删掉重写。原因是后端可能在解析exp等字段时做校验,如果不带exp,有些框架会接受、有些会报错,稳妥的做法是只改身份字段,保留无效但无害的时间戳字段。
3.2 第二步:对HS256签名做离线爆破
确认算法是HS256之后,密钥爆破就变成一道纯粹的“用字典跑HMAC”的计算题。原理不复杂:对字典里的每个候选密钥,用同样的Header和Payload拼出待签名字符串,做HMAC-SHA256,把结果和原签名比对。如果一致,这个候选词就是密钥。
手工写一个可以跑的Python脚本,逻辑大概是:
import hmac import hashlib import base64 def b64url_encode(data): if isinstance(data, str): data = data.encode() return base64.urlsafe_b64encode(data).rstrip(b"=") token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiZ3Vlc3QiLCJyb2xlIjoiZ3Vlc3QiLCJleHAiOjE3MTMwMDAwMDB9.q8Q_6KXm6JcLcC9X9i-0RmAqB0K0bJwX6S6oNnLmX9M" header_part, payload_part, signature_part = token.split(".") signature_expected = base64.urlsafe_b64decode(signature_part + "==") signing_input = (header_part + "." + payload_part).encode() with open("wordlist.txt", "r", encoding="utf-8", errors="ignore") as f: for line in f: secret = line.strip() if not secret: continue digest = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest() if hmac.compare_digest(digest, signature_expected): print("[+] Secret found:", secret) break这里有两个细节值得单独说一下。
第一个是hmac.compare_digest的使用。两个字节串直接==比较在密码学上存在时序侧信道风险,虽然CTF场景无所谓,但这个习惯最好从一开始就养成。
第二个是签名字节串的补齐。上一节说了JWT签名是Base64URL编码的结果,标准做法会在去掉=后传输。Python的base64.urlsafe_b64decode要求输入长度是4的倍数,所以解码前要补==。很多人在这一步报错,原因就在这里。
用字典跑这种爆破的方式,取决于字典大小和每个线程的速度。纯Python循环大概每秒能测几千到几万次,如果字典百万行,还是建议换工具跑。后面第四章会说怎么用hashcat提速。
3.3 第三步:爆破效率问题,python不够快就上hashcat
如果题目密钥比较常规,比如secret、123456、admin123,Python脚本几秒钟就出来了。但如果密钥略长或者字典比较大,建议直接用hashcat,用GPU跑,速度提升明显。
hashcat对JWT HS256的破解模式是mode 16500。把上面的令牌原样存成一个文件,再加一个字典文件,然后跑:
hashcat -m 16500 jwt.txt wordlist.txt --show--show参数的意思是只显示已经破解出的结果,不重新跑。hashcat跑完哈希后会把密钥存在hashcat.potfile里,下次直接用--show取。
如果不方便装GPU工具,还有一个折中办法:用Python给hmac计算加多线程。Python的concurrent.futures多进程可以把吞吐量提升到接近CPU上限:
from concurrent.futures import ProcessPoolExecutor def check_secret(args): secret, signing_input, signature_expected = args digest = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest() return secret if hmac.compare_digest(digest, signature_expected) else None # 分批提交实测下来,4核机器跑一个50万的字典,多进程大概两三分钟能跑完,单线程可能要翻三倍。考虑到CTF时间有限,这个优化是值得做的。
3.4 第四步:伪造admin令牌并访问受限接口
爆破出密钥后,伪造令牌就很机械了。步骤是:把Payload里的user和role都改成admin,重新做Base64URL编码,再把新的Header、Payload、签名三段拼起来。
直接用Python脚本把整个过程封装掉:
import hmac import hashlib import base64 import json import time def b64url_encode(data): if isinstance(data, str): data = data.encode() return base64.urlsafe_b64encode(data).rstrip(b"=") def make_jwt(header, payload, secret): encoded_header = b64url_encode(json.dumps(header, separators=(",", ":"))) encoded_payload = b64url_encode(json.dumps(payload, separators=(",", ":"))) signing_input = encoded_header + b"." + encoded_payload signature = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest() encoded_signature = b64url_encode(signature) return signing_input + b"." + encoded_signature header = {"alg": "HS256", "typ": "JWT"} payload = { "user": "admin", "role": "admin", "exp": int(time.time()) + 3600 } forged = make_jwt(header, payload, "123456") print(forged.decode())到这里,伪造出的令牌已经具备管理员身份。接下来就是把它放进请求里。如果原题目用的Cookie,就设置Cookie: token=伪造值;如果用的是Authorization头,就设置Authorization: Bearer 伪造值。
用Burp的Repeater验证最直观,把原始请求改一下,发送后看响应体里是否有Flag。用Python也可以,但注意要维持同一个会话,不要只带一次请求就丢Cookie。
注意:伪造令牌时,尽量保留原始Header里的键值顺序。有些后端的验签逻辑并不检查所有字段,但如果遇到严格的框架,顺序、空格、多余字段都可能成为验签失败的变量。
3.5 问2为什么容易卡壳
复盘时我特意想了想,为什么这道题问2会被标注“较详细”。最可能的原因是,题目在设计上留了一个“看起来能做但做不通”的坑,最常见的三种情况:
一是访问接口时带了旧Cookie。Burp里发请求时,浏览器或Burp会自动把原来的JWT一起带上,导致新伪造的令牌根本没被后端看到,或两个Cookie重名时取到了旧值。解决办法是把请求头清干净,只保留伪造的那个令牌。
二是接口并不是GET。很多管理接口要求POST,且可能需要额外的参数,比如action=flag。只改令牌不改方法和参数,后端校验身份过了,但业务逻辑没放行,也不给Flag。
三是签名爆破用的Header和Payload顺序与后端不一致。这个会更隐蔽。后端对JWT验签时,是把原始字符串的前两段取出来重新计算签名,而不是重新编码后再算。如果你伪造时用了json.dumps默认的键序,与原字符串前两段不完全一致,就会验签失败。做题时遇到“密钥明明对了,验签却失败”的情况,优先检查编码后的前两段和原JWT是否规律一致。
4. 常见问题与排查技巧实录
4.1 解码报错:Invalid padding
这个报错非常常见。原因就是我前面提到的,JWT的Base64URL去掉了填充符号=,直接解码需要先补足长度。
解决办法有两个:一是解码前把字符串长度模4的余数算出来,缺几个补几个==;二是偷懒直接用base64.urlsafe_b64decode(s + "=" * (4 - len(s) % 4))。还有一个小坑:有些编码库会把=编码成%3D出现在URL参数里,复制令牌时注意把%3D还原成=,否则解码必然出错。
4.2 爆破无结果:先排查字典,再排查算法
密钥跑不出来,先别急着断定是字典不够大。排查顺序应该是:算法类型是否看错、签名部分是否复制完整、字典里是否有空格或换行干扰。
一个真实案例:我复盘时用了一个自带UTF-8 BOM的字典文件,每行开头都带一个不可见字符,导致爆破一直失败。排查办法很简单,先打印出前几行的repr()看看:
with open("wordlist.txt", "r", encoding="utf-8") as f: for i, line in enumerate(f): print(repr(line)) if i > 5: break如果看到'\ufeff123456\n'这种输出,说明字典开头有BOM,把文件重新保存为无BOM的UTF-8即可。
4.3 验签失败:检查签名输入串是否被改动
伪造之后,后端报签名错误,最可能的原因是伪造时Header和Payload的顺序与签名时的输入不一致。
举例:原JWT的Header是{"alg":"HS256","typ":"JWT"},你用Python的json.dumps默认生成的可能是{"typ":"JWT","alg":"HS256"}。签名输入串变成新的顺序,自然与原签名对不上。虽然说我们是在用新密钥重新签名,理应不受影响,但有些后端在验签时是直接把原始前两段拿来重新算的,如果你在生成token时重新编码、导致编码结果发生了变化,就会出现验签失败。
这也是为什么我建议伪造时,尽量直接复用原始Header和Payload的编码结果,只改必要字段,而不是把整个JSON推倒重排。
4.4 权限过了但拿不到Flag
能进入管理接口但始终看不到Flag,不要怀疑JWT环节有问题,回头看一下业务逻辑。常见原因包括:接口需要传参、Flag在另一个接口里、响应是异步加载的(需要再看一次JS或网络请求)、或者Flag存在于另一个用户的专属页面中。
我在做这道题时就在这一步浪费了时间:伪造管理员令牌后访问根路径只看到一个普通页面,后来发现管理面板在/console,Flag接口在/console/flag。这提示了一个经验:做题时不要只看一个响应,目录扫描或翻页面的网络请求记录,往往比瞎猜路径更有效。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 解码时提示非预期填充 | Base64URL缺= | 补足填充再解码 |
| 爆破半天无结果 | 字典编码问题或密钥不在字典 | 检查BOM、换行符,换大字典 |
| 密钥正确但验签失败 | Payload键序被改动或多余空格 | 保留原始前两段编码,只改字段值 |
| 接口仍返回403 | 伪造令牌未被携带或带了旧Cookie | 清空请求头、只保留新令牌 |
| Flag不在当前响应 | 业务参数或接口路径不对 | 查看目录、请求历史、JS代码 |
这表里的前四行基本覆盖了JWT题90%的卡点。剩下10%是题目本身设计了额外的脑洞,需要回到源码或题目描述里找线索。
5. 从这道题延伸出去的实战认知
5.1 为什么HS256弱密钥如此常见
这道题考的点,放在真实业务里也大量存在。很多开发团队知道用JWT做认证,但对密钥管理没什么概念,直接在配置里写死一个secret,或者只做一个环境变量兜底,用的值还是test、jwt-secret这种。攻击者只要能拿到一个合法JWT,就能离线跑字典,跑出密钥之后整个用户体系就沦陷了。
防止这类问题的做法不多,但每条都该落地:
- 密钥必须用足够长的随机字符串,至少256位,且经常轮换;
- 不要把密钥提交到版本库里,环境变量或密钥管理服务取;
- 优先使用RS256这种非对称算法,私钥只在签发方手里,验签方只有公钥,即使公钥泄露也无法伪造;
- 如果一定要用HS256,至少确保密钥的熵足够高,让人力爆破的期望时间远超令牌有效期。
5.2 实战中的JWT校验为什么不能只信签名
从这道题还能延伸出来的一点是:后端校验JWT时不能只看签名是否有效,还要校验签发方、受众、过期时间、以及alg是否在白名单里。前些年很经典的alg:none绕过,就是因为后端信任了Header里的算法声明,直接跳过验签。
现在主流框架大多默认拦截了alg:none,但算法混淆攻击(把RS256改成HS256,用公钥当密钥去签名)在一些老系统里依然存在。对这些攻击方式有一定认知,做CTF题和做渗透测试都会有帮助。
5.3 做题效率的三个建议
第一,JWT的爆破字典不用自己攒,直接用工具自带的默认字典就行,比如rockyou.txt的子集,或者专门整理的JWT密钥字典。CTF题目的弱密钥一般都在常见口令范围内。
第二,优先把验签逻辑和字段解析逻辑分开排查。题目报错信息通常会提示是签名失败还是权限不足,这两种错误的后续动作完全不同。
第三,所有脚本都留一份在本地。JWT解码、爆破、伪造这三个动作的代码都很短,攒成一个小工具集,之后遇到同类题目直接改几个字段就能复用,比每次现写快得多。
最后再分享一个小技巧:遇到JWT题目时,拿到令牌后第一件事不是解码,而是先把原始字符串原样保存下来,连换行都不要改。因为后面每次计算签名、比对结果,都是以这段字符串为标准。稍微改动一个字符,排查起来就足够让人头疼。把原始样本存好,剩下的操作再脏都不会乱。