1. 项目概述:一次CTF实战带来的JWT安全复盘
前几天在CTFSHOW刷web入门题的时候,连着碰了几道JWT相关的题,从最简单的alg:none绕过,到需要爆破弱密钥、再到利用已知公钥伪造签名,一路刷下来发现这个考点在CTF里出现频率相当高。而且不只是CTF,JWT在现在的实际开发里几乎已经成了登录态和身份认证的标配方案,前后端分离项目、SPA应用、微服务网关认证,到处都能看到它的影子。所以搞清楚JWT的原理和安全边界,对做题和写代码都特别有用。
这篇文章我会按照我自己的复盘思路来写,核心包含几个部分:先拆解JWT的机制和常见漏洞点,再从CTFSHOW题目的角度走一遍完整的解题流程,然后延伸到实际开发中关于token续签、登录验证这些场景的安全落地方式,最后整理一份我在实操中遇到的报错和坑位排查清单。这篇文章适合三类人看:准备入门CTF Web方向、想在CTFSHOW刷题的朋友,工作中正在使用JWT做认证的后端或全栈开发,以及单纯想搞明白token续签和JWT区别的初学者。
需要说明的是,CTF题目的具体部署环境各有差异,我这里讲的思路和代码,是我在一些常见出题模式下总结出的通用解法,你做题时以实际页面逻辑为准,但底层的攻击原理和分析路径是相通的。
2. JWT的核心机制与漏洞点剖析
2.1 JWT结构拆解:三段式数据的含义
JWT全称是JSON Web Token,简单理解就是一串被Base64URL编码过的JSON数据拼接起来的字符串。它的典型长相长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ.sQxu6fRb7aVbqflNkJnmgGDD0IGe9FhzhcY3Lfz0JcY整个token用两个点分成三段,依次是Header、Payload、Signature。三个部分都使用Base64URL编码,这里注意不是普通的Base64,而是把+替换成-、把/替换成_,并且去掉末尾的=,这是为了保证在URL传输中不会产生歧义。
Header部分通常长这样:
{"alg":"HS256","typ":"JWT"}它声明了token使用的签名算法和类型。typ一般是JWT,alg则可能是HS256、RS256、none等等。服务端解析token时,会先读取这个Header,决定用哪个算法来验证签名。这里就埋下了第一个隐患:很多实现不检查alg字段是否在允许名单内,直接信任前端传来的算法值。
再来看Payload,也就是负载部分,这里存放的是业务相关的声明,比如用户名、用户ID、角色、过期时间等。例如:
{"username":"admin","role":"admin","exp":1700000000}exp是过期时间戳,还有nbf(not before,在此之前无效)、iat(issued at,签发时间)等标准声明。因为Payload只是Base64URL解码就能看,所以它不具备保密性,任何人拿到token都能直接看到里面的内容。这一点在CTF里经常被利用:你只需要把token复制出来,在 token.dev 这类在线工具或者本地脚本里解码,就能看到里面到底放了什么数据。
Signature部分是对前两段内容的签名,用来保证token没有被篡改。以HS256为例,它的计算方式是:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)也就是把前两段用点拼接,再用一个密钥(secret)做HMAC-SHA256计算。如果是RS256,则是先用RSA私钥对前两段做签名,再用对应的RSA公钥验证。
讲到这里你应该已经看明白了:前两段是明文可见的,安全性完全靠第三段签名撑着。只要能绕过签名校验,或者拿到签名密钥,整个token就形同虚设。CTF里JWT题目的核心,本质上就是在围绕“怎么让服务端接受一个由我们伪造的token”做文章。
2.2 常见漏洞类型及其形成原因
把这些年CTF里JWT题目的考法归类下来,常见漏洞大概有五类,而且每一类都和某个具体实现细节强相关。
第一类是alg:none绕过。JWT规范里其实支持一个特殊的算法值none,表示不签名。开发者如果在校验密钥时没有把none加入黑名单,攻击者就可以把Header改成{"alg":"none","typ":"JWT"},去掉签名部分,只保留前两段,然后伪造任意Payload。服务端解析发现alg是none,直接跳过签名校验,token就被接受了。
第二类是弱密钥爆破。HS256是共享密钥对称加密,解密和加密用的是同一个secret。如果后端用了弱口令当密钥,比如admin、123456、secret这种,攻击者拿到一个有效token后,可以用字典离线爆破出密钥。爆破到密钥后,就可以任意伪造合法token。JWT的弱密钥爆破是CTF里最常见的考法。
第三类是RS256与HS256的算法混淆攻击。如果服务端签发token时用RS256(非对称加密,私钥签名、公钥验证),但在验证时过于宽松,允许攻击者指定算法为HS256,那就危险了。因为HS256是对称加密,验证时用的是同一个secret,而攻击者手里是可能有公钥的——公钥在客户端是公开的。攻击者直接把Header里的alg改成HS256,把公钥内容当作HS256的secret来签名,服务端如果机械地用公钥去验证HS256签名,就会直接通过。这个攻击听起来有点绕,但实际操作很简单,后面实战部分我会拆开讲。
第四类是JWK或JKU注入。有些JWT库支持通过Header里的jwk参数直接内嵌一个JSON Web Key,或者用jku指向一个远程JWK集合的URL。如果后端不去校验这个密钥的来源是否可信,攻击者可以自己生成一对密钥,然后把公钥注入到Header里,再用自己的私钥对token签名,服务端就会用攻击者提供的公钥去验签。这种方式在CTF高级题里会碰到,实际开发中属于非常严重的配置缺陷。
第五类是敏感信息泄露。因为Payload可以被任何人解码,如果在token里放了密码、手机号、内部路径这些敏感信息,就等于把这些信息直接暴露给了持有token的人。CTF里有题目会把flag直接放在Payload里,解码就能看到。
理解这些漏洞,关键要抓住一个核心:JWT的安全模型依赖的是“加密签名”和“可信密钥管理”,任何偏离这个模型的设计都会带来漏洞。出题人爱考JWT,就是因为它既有标准规范,又有大量糟糕的实现,探索空间很大。
3. CTFSHOW题目实战:从读题到拿Flag的完整拆解
3.1 题目环境搭建与前期信息收集
CTFSHOW的JWT题目一般出现在web入门的中后段,进入题目后会看到模拟的登录页,通常给了一套弱口令账号密码,比如guest / guest,让你先登录进去。这种设计的目的是让你先拿到一个合法token,再在这个基础上去伪造更高的权限,比如把username改成admin。
我拿到题目后的第一件事,就是打开浏览器开发者工具,切到Network面板,清空之前的请求记录,然后正常登录一次。登录成功后,重点关注两个地方:一是登录接口的响应体里有没有直接返回token,二是后续请求的请求头里有没有Authorization: Bearer <token>。大部分题目是前者,登录接口直接返回类似这样的JSON:
{"code":0,"msg":"success","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imd1ZXN0IiwiZXhwIjoxNzA0MDAwMDAwfQ.abc123"}拿到token后复制出来,先做一次解码,看看Payload里存了什么字段。可以直接在终端里用一行Python代码搞定:
import base64 s = "eyJ1c2VybmFtZSI6Imd1ZXN0IiwiZXhwIjoxNzA0MDAwMDAwfQ" s += "=" * (-len(s) % 4) print(base64.urlsafe_b64decode(s))如果解码出来看到username是guest,那目标就很明显了:想办法伪造一个username为admin的token。当然,前缀不一定就叫admin,有的题目标的是administrator或者role: admin,这个要看页面源码和接口逻辑来推断。
做完这一步之后,我还习惯看一眼登录接口返回的Cookie,有的题目会把token放在Cookie里而不是请求头。同时顺手看下网页源码里的JS,确认后续请求带token的方式。CTF题目往往页面很简单,但信息就藏在这些细节里,一定要动手抓包、翻源码,而不是凭感觉去猜。
3.2 爆破弱密钥:从拿到Token到伪造成管理员
很多CTFSHOW的JWT题目并没有限制token的算法和密钥强度,最常见的出题方式就是HS256配合一个弱密钥。这时候即便你拿着guest的token,只要爆破出密钥,就能随意造一个admin的token。
爆破JWT密钥的工具有不少,我推荐两条路线,都可以用命令行快速完成。
第一条路线是用hashcat,模式是16500(JWT)。假设已有一个合法token存在jwt.txt里,弱口令字典是rockyou.txt,命令如下:
hashcat -m 16500 jwt.txt rockyou.txt如果密钥太简单,通常几秒钟就出来了。看到Cracked字样后面跟着的就是密钥。这里有一个细节:hashcat对JWT的格式、编码要求很严格,如果系统里的hashcat版本比较老,可能需要在token末尾补上=或者去掉签名部分再试。我实操中踩过的坑是:有些题目签发的token用的Base64URL会把-和_混用,hashcat解析失败,这时可以先跑一个小脚本把header和payload重新编码再导入。
第二条路线是用jwt_tool,它还能直接把爆破密钥和伪造token合并到一步完成:
python3 jwt_tool.py <JWT> -C -d /usr/share/wordlists/rockyou.txt这里的-C是尝试破解HMAC签名密钥,-d指定字典。破解出来后,用同一个工具继续伪造:
python3 jwt_tool.py <JWT> -T -S hs256 -p "cracked_secret"-T表示对Payload内容做篡改,进入交互模式后把username的值改成admin,工具会帮你重新生成签名。如果不想交互,也可以自己写Python脚本,用PyJWT库,代码很简洁:
import jwt secret = "admin123" fake_payload = {"username": "admin", "exp": 1893456000} fake_token = jwt.encode(fake_payload, secret, algorithm="HS256") print(fake_token)这里要注意,伪造的token里exp必须是一个未来的时间戳,否则token会被判定为过期。很多新手在改Payload的时候只改了用户名,忘记了exp,结果提交上去一直提示认证失败,这是最常见的翻车点。
把伪造的token拿到手之后,替换掉原来请求头里的Authorization,重新访问管理接口或者首页。如果你操作正确,页面就会返回flag。如果一个接口没反应,就把刚才用于登录后访问的主页面、个人中心、后台管理这些接口都试一遍,因为有的题目把flag藏在一个只有管理员才能访问的路由里。
3.3 扩展思路:算法混淆与更深入的利用
弱密钥确实常见,但如果密钥不是弱口令,或者题目直接给了你公钥文件,那就要换思路了。这里重点讲一下RS256到HS256的算法混淆攻击,这类题目在CTFSHOW里偶尔也会出现。
场景是这样的:题目给的JWT Header里写的是"alg":"RS256",页面源码或者接口返回里暴露了一个public.pem公钥文件,登录后你可以直接访问下载它。同时服务端在验证JWT时没有对算法做白名单限制,导致它接受HS256。
攻击思路是:把header的alg改成HS256,用下载到的公钥内容当作HMAC的密钥,以HS256算法重新生成签名。因为服务端验证HS256签名时用的secret如果取的是公钥内容,那么你的伪造token就能被验证通过。
用Python的PyJWT实现时有一个坑,它默认不允许用字符串密钥走HS256去验证RS256的token,所以要直接自己拼JWT并手动算签名,不依赖jwt.encode。示例脚本如下:
import base64 import hashlib import hmac import json import time with open("public.pem", "rb") as f: public_key = f.read() def b64url(data): return base64.urlsafe_b64encode(data).rstrip(b"=") header = {"alg": "HS256", "typ": "JWT"} payload = {"username": "admin", "exp": int(time.time()) + 3600} header_seg = b64url(json.dumps(header).encode()).decode() payload_seg = b64url(json.dumps(payload).encode()).decode() signing_input = f"{header_seg}.{payload_seg}".encode() sig = hmac.new(public_key, signing_input, hashlib.sha256).digest() signature_seg = b64url(sig).decode() fake_token = f"{header_seg}.{payload_seg}.{signature_seg}" print(fake_token)关键点在于第11行:用公钥文件的原始字节(public_key)当作HMAC的key来算签名,而不是用Base64解码后的字符串。很多人在这一步卡住,把公钥做了各种转换,反而导致签名和服务端算出来的不一致。
如果题目给的公钥是.pem文件,建议先原样读取字节来做HMAC。如果服务端验证时会把密钥从Base64解码后再用,那就要调整策略,这个可以通过实验来确认。算法混淆攻击之后,如果题目还给了jwk注入的入口,那就属于进阶玩法了,思路是生成自己的RSA密钥对,把公钥通过Header的jwk参数传进去,再用私钥签名,让服务端用你提供的公钥验签。这种题目通常需要配合一些源码审计能力,能在CTFSHOW的高分题里遇到。
4. 从CTF到实战:JWT在登录验证与续签场景中的安全落地
4.1 Token续签的常见实现方式与安全思考
刷题刷多了你会发现,CTF里的JWT题目看着花哨,但本质暴露的问题在实际开发里一样存在。比如很多人问的"JWT怎么实现token续签",这个问题在CTF里不直接考,但是如果你用过JWT做登录系统,迟早会碰到。
先说一个基本共识:JWT的exp到期之后,客户端就必须重新登录,这是最简单的方案。问题是用户体验很差,用户在页面上正填着表单,突然token就过期了,请求全打水漂。所以产生了"续签"的需求。
我见过几种常见做法。第一种是直接在每次请求时判断token剩余有效时间,如果离过期不到某个阈值,就在响应头里返回一个新的token,客户端检测到后替换本地存储。这种方案实现简单,但每个响应都得额外生成一次token,对后端有一定开销。
第二种是双token方案,也就是现在业界比较主流的方式。用户在登录时,后端同时返回一个短生命周期的access_token和一个长生命周期的refresh_token。access_token负责业务请求,比如半小时过期;refresh_token负责换新,比如7天有效。当请求返回401时,前端携带refresh_token去请求一个专门的刷新接口,后端验证refresh_token有效后,签发新的access_token。如果refresh_token也过期了,才强制重新登录。
双token的核心安全考量是:access_token频繁在请求中传输,泄露风险更高,所以生命周期要短;refresh_token必须保存在更安全的位置(比如HttpOnly的Cookie里),并且刷新时要做校验。这里有个细节,refresh_token强烈建议在后端配合存储状态来使用,比如记录一个版本号或随机数,每次刷新时更新。否则一旦refresh_token泄露,攻击者可以持续换新,很难被察觉。
第三种是滑动过期策略,本质上是把过期时间改成了"相对过期"。后端不依赖token的exp,而是看token里的最后活跃时间或者签发时间,只要用户持续在操作,就不断签发新token。它的缺点是过期判断变得不再可靠,攻击者只要模拟出活跃的假象,就能一直续下去。
从安全角度说,没有绝对完美的续签方案,只有适不适合你的场景。CTF里做题目时可以不管这些,但到了真实项目里,我建议至少遵守这样几个原则:access_token和refresh_token分离、refresh token必须走独立接口且只能用于换签、token里的权限变更要能被服务端感知(比如禁用用户后需要依靠短期过期来快速生效,或者加一个黑名单机制)。
4.2 SPA项目中的JWT验证码玩法与防护建议
另一个高热度的问题是把JWT和验证码结合起来,比如"SPA项目开发之jwt验证码实现"。这个场景很常见:SPA前端需要识别人类用户,防止撞库和批量注册,同时又要保持无状态认证的优雅。
一种常见的实现是:用户输入用户名密码后,前端先请求一个验证码接口,后端生成图片验证码并把验证码内容存到服务端,通过一个captchaId来关联。前端的登录请求带着captchaId + 用户输入 + 账号密码,后端校验验证码通过后再签发JWT。逻辑很简单,但在SPA项目里有一些细节值得注意。
首先是验证码的存储位置。登录接口应该是无状态的,如果又用Session去存验证码,就有点违背JWT的初衷,但完全无状态地校验验证码又不现实,因为验证码必须和某次登录请求绑定。实务上最常见的做法是把验证码内容做哈希后连同captchaId一起存在Redis里,设置60到120秒过期。captchaId由后端生成返回给前端,登录时前端原样带回。
其次是验证码本身的安全性。纯前端画布生成验证码的方式安全性很低,因为接口一旦暴露,攻击者可以直接写脚本调用同一个生成逻辑。后端生成验证码图片时要加入干扰线、噪点,同时还要把验证码内容保存为小写统一,避免用户输入大小写不一致导致误判。另外验证码接口要做频率限制,同一IP、同一账号在短时间内的请求次数要控制住,不然攻击者可以疯狂请求验证码,把存储打满。
JWT与验证码结合的时候,还要注意验证码校验和JWT签发的顺序。正确的流程是:先校验验证码,再校验用户名密码,两个都通过之后才签发JWT。反过来先校验账号密码再校验验证码,会在安全上多暴露一些用户是否存在的信息,给撞库提供了便利。
另外,SPA项目里JWT的存储也是一个常聊的话题。单纯放localStorage方便是方便,但XSS一旦打进来,token直接就被偷走。更稳妥的做法是放在HttpOnly + Secure + SameSite属性的Cookie里,让JS拿不到token,由浏览器自动携带。这样一来CSRF的风险又变高了,所以还需要配合防CSRF的手段,比如校验Origin头或者使用自定义Header。这些设计没有银弹,本质是在XSS和CSRF两个风险之间做权衡。
如果让我给一个相对务实的组合,我会这样选:开发调试阶段用localStorage图方便,生产环境改成HttpOnly Cookie,并且所有写接口统一校验Origin头。这个方案在大多数后台管理系统里已经够用了。这个部分在CTF里虽然不会直接出题,但理解了这些工程实践,反过来能帮你更快理解CTF题目的良苦用心:漏洞永远不是JWT规范本身的问题,而是实现和部署的细节有问题。
4.3 关于Token与JWT的关系再深入一层
实操里经常有人把Token和JWT混为一谈,这里也顺便帮大家理一理。Token是"凭据"的统称,任何一段能证明你身份的字符串都可以叫Token。JWT只是Token的一种具体格式实现,它把身份信息标准化地编码进了一段自包含的字符串里。除了JWT之外,常见的Token还有Opaque Token(不透明Token),这种Token本身是一串随机字符,真正的会话信息存在服务端数据库里,客户端拿到Token后必须拿着它去服务端查才能知道它是谁。
这个区别在面试题和项目方案评审里经常被问到,核心就一句话:JWT是无状态的,服务端不需要存会话记录,拿到就验签;Opaque Token是有状态的,服务端必须保存Token和用户之间的映射关系。
在CTF里为什么JWT相关题目远多于Opaque Token?因为JWT把身份信息放在客户端手里,攻击者有更多可以操作的空间——改Payload、改算法、爆破密钥、注入JWK,而Opaque Token在客户端眼里就是一段黑盒子,没有太多可篡改的余地。安全问题从设计上就少了一大截。
理解了这层关系,你在做题和做开发的时候判断力会提升不少:技术选型时,如果服务端可以承受存储成本,而且对安全要求极高,Opaque Token可能是更好的选择;如果追求无状态、多端扩展方便,JWT的成熟生态和灵活性则更有优势。
5. 常见报错与排查技巧实录汇总
做题多了就会发现,很多次卡住都不是因为攻击思路不对,而是卡在一些不起眼的细节上。下面这些情况和排查思路,我按实操顺序整理成了一张表,方便你对照使用。
| 现象 | 排查思路 | 解决办法 |
|---|---|---|
| 解码Payload时报错,显示padding错误 | Base64URL缺少=填充 | 自动补全=:s + "=" * (-len(s) % 4) |
| 伪造的token一直提示身份失效 | exp过期时间没设置成未来时间 | 在Payload里带上exp: int(time.time()) + 3600 |
| 算法从RS256改成HS256后验签失败 | 公钥格式或编码方式不对 | 确认使用原始PEM字节而非Base64解码内容 |
| hashcat爆破token报错 | token的长度或格式不符hashcat要求 | 用jwt_tool替代,或者先对token做标准化再导入 |
alg:none绕过失败 | 服务端过滤了字符串none或要求保留签名段 | 尝试None、NONE、nOnE等变形,并清空Signature |
| 改完token后没有遇到flag | 目标接口不对 | 重新抓包,确认访问管理接口时使用的真实路由 |
| 请求带token后页面仍然302跳转登录 | token存储位置不对 | 检查前端是使用Authorization头还是Cookie传token |
除了表格里这些,还有两类问题是新手最容易忽视的。
第一类是签名段的处理。有的题目在验证时对签名有额外要求,比如签名为空字符串也能通过,或者签名必须是某个格式,这时候你需要反复实验,观察服务端对不同token的响应差异。一次请求拿到200不一定就成功了,还要看在页面里有没有真正返回flag,有的题目伪造成admin后仍然需要再点击一次按钮才会触发flag输出。
第二类是工具的版本差异。jwt_tool在不同的Python版本下可能报依赖缺失,hashcat在不同系统下的字典路径也不一样。我比较推荐的做法是准备一个固定的Python脚本库,把常用的JWT伪造操作封装成函数,做题目的时候直接改参数就行,这样不会因为工具问题浪费时间。
最后再分享一个我自己的刷题习惯:每次做完一道JWT题目,我都会把题目考察的漏洞点、利用方式、以及对应到真实开发中的防御措施记在一个笔记里。这样刷题不只是为了拿flag,还能把CTF里的攻击思路沉淀成日常开发里的安全直觉。CTFSHOW的JWT系列题目本身设计得比较循序渐进,如果你能按照从解码、爆破、算法混淆、再到尝试更高级注入的顺序一路刷下来,可以说JWT相关的攻击手法基本就吃透了。