JWT安全实战:从CTF漏洞分析到Token续签与防御指南
发布时间:2026/9/15 16:30:44 作者:尧图编辑部 阅读量:1,286

1. 项目概述一次CTF实战带来的JWT安全复盘前几天在CTFSHOW刷web入门题的时候连着碰了几道JWT相关的题从最简单的alg:none绕过到需要爆破弱密钥、再到利用已知公钥伪造签名一路刷下来发现这个考点在CTF里出现频率相当高。而且不只是CTFJWT在现在的实际开发里几乎已经成了登录态和身份认证的标配方案前后端分离项目、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一般是JWTalg则可能是HS256、RS256、none等等。服务端解析token时会先读取这个Header决定用哪个算法来验证签名。这里就埋下了第一个隐患很多实现不检查alg字段是否在允许名单内直接信任前端传来的算法值。再来看Payload也就是负载部分这里存放的是业务相关的声明比如用户名、用户ID、角色、过期时间等。例如{username:admin,role:admin,exp:1700000000}exp是过期时间戳还有nbfnot before在此之前无效、iatissued 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模式是16500JWT。假设已有一个合法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, algorithmHS256) 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相关的攻击手法基本就吃透了。