
我第一次在CTFshow的Web入门题里遇到JWT时心态其实是膨胀的——把token解个base64改个username再编码回去三分钟就能交卷。结果自然被顶了回来页面直接弹出一句非法请求。后来我才明白JWT的安全模型远比那层base64深刻得多服务端到底校验了什么、信任了什么、哪些字段完全由客户端说了算这些才是题目真正想考的。这篇文章就围绕CTFshow Web入门345-350这一组JWT题目配合BurpSuite的实操把JWT的攻击场景和原理一条条捋清楚适合正在刷CTF的Web入门选手也适合在真实项目里接过JWT鉴权、想搞清楚它为什么被认为不那么安全的开发同学。1. JWT的三段结构与信任模型漏洞根源到底在哪JWT全称JSON Web Token可以理解成一张自带签名的通行证。服务端签发后客户端每次请求都把它放进Authorization头里服务端验签通过就信任里面的身份信息。和传统的Session不同JWT是无状态的——服务端不需要存会话记录所以特别适合分布式系统、接口鉴权、SPA项目登录态这些场景。热词里反复出现的更新用户登录信息并生成返回jwt令牌、SPA项目开发之jwt验证码实现本质上都是这类用法。1.1 Header、Payload、Signature各自承载了什么一个JWT长这样三个部分用点分隔eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ.s9P4vKXgx6LGjP2zq0mZ1PNa1eHdH5cWmOiYzPxbg9IHeader声明token类型和签名算法比如{alg:HS256,typ:JWT}。Payload存放实际数据比如用户名、角色、过期时间都是一些叫claim的键值对。常见的有exp过期时间、iat签发时间、sub主体、iss签发者。Signature把header.payload这段字符串用指定算法和密钥算出来的签名。注意一个关键点Header和Payload只是Base64URL编码不是加密。任何人拿到token都能直接解码看到原始JSON修改后再编码也不难。安全性完全压在最后的签名上——签名里的算法和密钥才是唯一能阻止你乱改payload的东西。1.2 校验逻辑决定安全性服务端信任了不该信任的字段既然签名是唯一的安全边界那问题就变成服务端验签时到底拿什么当密钥、可信的算法列表是什么。很多JWT库的设计思想是Header里写什么我就按什么来验。这本身就是一个危险的信任模型因为Header是客户端可控输入。攻击者把alg改成none、把alg从RS256降级成HS256、把kid指向一个任意文件——服务端如果照单全收签名校验就形同虚设。我在刷题和看真实项目代码时发现JWT漏洞几乎从来不是密码学算法被攻破而是实现层面对不可信输入的过度信任。说得直白点就是服务端开发者把Header里的声明当成了可信配置。1.3 为什么说JWT漏洞更像是配置错误而不是密码学缺陷HMAC-SHA256、RSA-SHA256这些算法本身都是安全的但算法选择、密钥来源、校验开关这些决策被放到了客户端可控的Header里这就是设计层面的信任链断裂点。可以这样类比你去银行柜台取钱柜员需要验证你的身份证。如果柜员问您觉得我需要验证您的身份证吗你说不用他就直接给你取钱——这就是algnone攻击。如果柜员允许你自报验证方式你说用我的签名就行同时你又知道银行行长签字的样式于是拿行长的签字样式当口令——这就是算法混淆攻击。整条链路崩坏的原因只有一个验证方把关键决策交给了被验证方。2. 六种JWT攻击场景的原理、指纹与判断方法实际场景里JWT攻击翻来覆去就是那么几板斧。我把它们拆成六类每一类给出原理、判定方法以及一眼识别的特征。刷题时可以对照这个列表快速定位。2.1 none算法攻击签名被整体跳过RFC 7519里允许algnone用于调试场景意思是本token不签名。正常实现应该在收到none时直接拒绝但不少旧版库、错误示范代码会直接进入无签名分支。判定方法把Header里的alg改成none再删掉签名部分保留格式上的末尾点号。如果服务端接受了说明签名校验被跳过。Base64URL编码后大概是eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImFkbWluIn0.注意细节有些库区分None、NONE、none有些实现要求typ也改成None。我在靶场里遇到过只认小写、甚至只认首字母大写的版本所以这一题要花点时间试变体。2.2 算法混淆攻击RS256被降级为HS256这是最经典也最容易理解错的一类。RS256是非对称算法私钥在服务端、公钥公开验签用公钥。HS256是对称算法加签验签用同一个密钥。问题出在混用。很多服务端代码写的是jwt.decode(token, public_key, algorithms[RS256, HS256])或者等价的分支逻辑无论什么算法都把同一个public_key变量传给JWT库。那么攻击者把alg改成HS256后JWT库会把public_key当作HMAC的共享密钥来验签。而公钥通常是公开的可以通过源码泄露、/.well-known/jwks.json、甚至是网站证书拿到。一旦拿到公钥攻击者就能用公钥内容作为HMAC密钥自己签一个HS256的合法token。这个攻击的整个套路可以落地为一段很短的Python代码import jwt # 把从目标处获取的公钥读进来 with open(public.pem, r) as f: pubkey f.read() # 关键操作alg用HS256密钥填公钥内容 token jwt.encode( {username: admin, exp: 9999999999}, pubkey, algorithmHS256 ) print(token)判断一个token是否存在算法混淆风险就看服务端代码里algorithms参数是不是一个包含HS256和RS256的列表以及验签的key是不是同一个变量。CTF题里通常给一个公钥文件或者源码提示很容易定位。2.3 弱密钥离线爆破对称算法的致命短板HS256是共享密钥那密钥强度就决定了一切。很多项目图省事直接用secret、admin、123456或者把公司名当密钥。最致命的是JWT爆破是完全离线的——token本身就包含签名输入和签名结果攻击者不需要和目标服务器有任何交互本地拉个字典就能爆。我自己本地测试单GPU跑hashcat的JWT模式-m 16500常见口令字典基本是秒级出结果。命令大致长这样hashcat -m 16500 -a 0 jwt_token /path/to/wordlist.txt这里的jwt_token需要把完整的token写进去。爆破成功后会输出密钥然后用这个密钥重新签名。手工构造也可以用Python的hmac库自己算import base64, hmac, hashlib, json def b64url(data): return base64.urlsafe_b64encode(data).rstrip(b) header b64url(json.dumps({alg: HS256, typ: JWT}).encode()).decode() payload b64url(json.dumps({username: admin}).encode()).decode() signing_input f{header}.{payload} sig hmac.new(byour_secret, signing_input.encode(), hashlib.sha256).digest() token f{signing_input}.{b64url(sig).decode()} print(token)这里有个特别容易踩的坑爆破时使用的header和payload必须和token原始部分的Base64URL字符串逐字节一致。如果你把header、payload解码后重新编码哪怕只是改变了JSON键的顺序签名输入就不一致了爆破结果自然对不上。2.4 kid参数滥用路径穿越、SQL注入与任意文件密钥kid是Header里的Key ID服务端用它来从一堆密钥里选一个验签。问题是实现者经常把它拼接到文件路径、SQL语句、甚至命令里。最典型的代码长这样key open(f/var/keys/{kid}.pem).read()如果kid../../../../etc/passwd服务端读到的就是/etc/passwd的内容并把它当密钥用。这种情况下签名通常对不上但有两个变体很致命第一种是可控文件内容做密钥。如果目标环境允许你上传文件、或者能通过其他漏洞写入文件比如头像上传、日志写入那么把上传文件名作为kid文件内容就是HMAC的secret你就能算出合法签名。第二种是SQL注入。服务端如果把kid拼进查询语句比如SELECT key FROM keys WHERE id{kid}那答案不在文件系统里而在数据库里。CTF里较少见但真实系统里很常见。判定方法很简单看到Header里有kid字段就逐个尝试路径穿越、空值、数组注入。用BurpSuite的Repeater手动测很轻松也可以让jwt_tool自动打一轮。2.5 jku/jwks伪造让服务端信任攻击者的公钥jku字段表示本token的验签公钥请到这里获取它指向一个JWKSJSON Web Key Set端点。正常实现应该只允许HTTPS、只允许配置过的可信域名但如果服务端偷懒没有校验攻击者就可以让目标服务器去自己控制的地址拉取公钥然后用对应的私钥签名。构造方式先用Python生成一对RSA密钥起一个简单的HTTP服务返回JWKS JSON然后把JWT的Header里加上jku指向该地址用私钥签名。import jwt # 自己生成RSA密钥对私钥自留公钥放到可公开访问的JWKS端点 with open(attacker_private.pem, r) as f: private_key f.read() token jwt.encode( {username: admin, exp: 9999999999}, private_key, algorithmRS256, headers{jku: https://attacker.example/jwks.json, kid: attacker} )这里有个配套细节服务端拉取JWKS后通常会拿JWT Header里的kid去JWKS里匹配对应公钥。所以JWKS里的key对象也要把kid设成一致否则验签会失败。2.6 claim篡改与越权所有攻击的最终落点讲完前面所有攻击手法别忘了它们最终都是为了改Payload里的claims。常规目标有三种垂直越权把role从user改成admin把is_admin从false改成true。水平越权把uid、sub、username改成别人的值直接接管他人身份。延长有效期改exp让token永不失效。还有一点容易被忽略即使服务端验签非常严谨如果后续业务逻辑盲信claims比如直接select * from user where id jwt.sub那么只要你能签出一个sub10001的合法token不管你用什么方式拿到密钥一样越权。这类问题在真实代码审计里最常见——验签过关但授权模型没过关。3. CTFshow Web入门345-350由浅入深的JWT题目链这一组题目整体出题思路是递进的从会改token到会破token再到会利用实现缺陷伪造token。题目细节在不同批次CTFshow里可能微调核心考点稳定值得逐题推敲。3.1 总体题型分布与刷题准备刷这组题之前建议先把三个工具备齐BurpSuite负责抓包、改包、重放JWT题目几乎少不了它。jwt_tool一个Python写的JWT测试工具可以自动测试none、算法混淆、弱密钥、kid注入等适合做快速验证。hashcat离线爆破HS系列密钥用比手写脚本快几个数量级。基本流程是打开题目拿到一个token → 解码看Header和Payload → 根据提示判断是哪种攻击类型 → 构造新token提交。3.2 345第一道题理解会改token是最低门槛345属于热身题。一般拿到token后解码就能看到Payload里的身份字段。这道题练习的是最基础的操作会不会正确地修改JWT并重新编码。很多人第一反应是直接改完Payload再Base64URL编码塞回去完全不重新签名这样大概率会失败。正确练习方式分几步把token解码成header.payload.signature三段。看清服务端要求什么身份比如usernameadmin。修改Payload后重新Base64URL编码。如果题目前面没要求破解密钥说明服务端可能压根没验签直接提交如果验签就要判断密钥在哪里。Base64URL和普通Base64的区别要记牢把换成-、/换成_、去掉末尾的。很多人在这一步栽跟头改完token之后格式错误服务端直接返回解析异常。3.3 346从改token到破token的密钥爆破346通常会给你一个HS256签名的token而且题目提示里大概率有弱口令或字典这类字眼意思是引导你用爆破。操作路径就是前面说的离线爆破。先把token复制出来用hashcat或者Python脚本爆出密钥再用密钥重新签一个usernameadmin的token提交。这里我有两个实操建议一是字典尽量用大一点的GitHub上有不少专门的jwt.secrets.list包含几千个常见的JWT弱密钥覆盖面比通用密码字典好。二是爆破时注意区分HS256和HS384、HS512hashcat的-m 16500是老版本JWT模式遇到变体可能不准可以用--show验证或配合端口扫描指库的选择确认。3.4 347-348算法混淆与公钥获取路径347和348放在一起考点就是2.2说的算法混淆。两个题差别只在从哪里拿到公钥。347通常是公钥直接放在题目附件、源码注释、或者网站根目录一个叫publickey.pem的文件里。拿到公钥内容后按前面写的Python脚本用algHS256、密钥填公钥就能签出合法token。348会进一步考验信息收集能力。公钥可能藏在JS文件里前端为了某些加密逻辑引用了公钥。.git泄露的源码备份。备份文件、Swagger文档、README。题目环境根目录的隐藏文件或目录。这时候要养成习惯先做信息收集再攻token。很多人在347里直接拿到公钥就冲了到348发现公钥不在了就卡住其实只要把网站目录用目录扫描工具过一遍往往能找到提示。另外一个通用技巧如果题目提示当前版本存在漏洞可以先把token的alg改成none试一下很多题目其实同时埋了多个漏洞殊途同归。3.5 349-350none算法与kid注入的组合思考349考的是algnone。前面2.1说过直接把alg改成none签名部分留空观察服务端是否接受。这题本身不难但要注意变体测试。350通常是综合题最常见的版本是kid注入加可控文件题目给了某种文件写入能力或者允许上传头像甚至允许注册特定用户名的账号。这时候构造思路是找一个你能控制内容的文件路径比如上传的图片、头像文件、或者用户名的值。把文件内容当作HMAC密钥。在JWT Header里设置kid指向该文件路径。用这个密钥对HS256 token重新签名。有些版本的350也可能是kid加上jku的组合或者需要先爆破一个比较弱的密钥再结合其它漏洞。核心思路是Header里的所有字段都可能成为攻击面逐个测试才能找到服务端信任的破绽。4. BurpSuite实操从手动重签到自动化爆破CTF里手写Python脚本能解决大部分问题但真实场景里BurpSuite的效率要高得多。这一节把我在靶场里验证过的流程完整走一遍。4.1 环境准备代理配置与JWT Editor插件安装首先保证浏览器流量能进BurpSuite方式就不赘述了。重点是安装JWT编辑插件打开BurpSuite的BApp Store搜索JWT Editor安装。装完之后在History或者Repeater里选中一个包含JWT的请求右侧的Inspector面板会自动识别三部分并对签名做实时校验。这个插件解决了一个大痛点手动改Payload后需要手工重算签名极其容易出错且有格式问题。插件可以在你修改完Header或Payload后用设定的密钥一键重签。4.2 修改载荷并重签手工攻击的标准流程拿一个实际请求举例登录后访问个人中心Authorization头是Bearer token。操作如下在BurpSuite里把请求发到Repeater。光标点进tokenInspector里会显示Header和Payload的解码结果。直接修改Payload里的字段比如username改成admin。在JWT Editor的Key管理里新建一个Key填入目标算法的密钥。回到Repeater点击JWT Editor生成的Sign按钮token会自动重新签名。发送请求看响应码和内容。这一步做熟练之后所有需要重签的题目都只是一分钟的事。注意Key的格式选择HS系列选HMACRS系列选RSA算法要和Header里的alg一致。4.3 密钥爆破插件内置字典与hashcat的组合打法JWT Editor在Key管理界面里提供了Attack功能可以选字典对HS系列密钥做爆破。小字典几千条它跑起来很快适合比赛时快速验证。大字典我不建议用它直接导出token去hashcat跑。实操中我的组合策略是先用JWT Editor自带的Attack跑一个几百条的常见弱密钥小字典几秒钟出结果是常态。没结果再上hashcat用大字典。如果已知目标是某个CTF或某个公司把名称、年份、常见默认口令组合成自定义字典命中率往往比通用字典高很多。还有一个提醒爆破出密钥之后记得在JWT Editor里把Key填进去否则Repeater里改完Payload没法重签。4.4 竞态场景Turbo Intruder多端点并发验证JWT除了伪造还有一个经常被忽视的场景是重放与竞态尤其是token续签流程。现在不少系统用refresh_token换access_token如果服务端没有处理好并发同一个refresh_token可以同时换出多个有效access_token或者一个access_token在登出后仍然可以在另一个端点继续使用。BurpSuite的Turbo Intruder是做这类并发验证最好的工具。我常用的姿势是抓取刷新token的接口请求构造多个并发请求观察返回是否多次成功签发。用Turbo Intruder内置的race-multi-endpoint思路可以同时对多个端点发起请求看某个特定状态的token是否能在多个地方同时生效。def queueRequests(target, wordlists): engine RequestEngine(endpointtarget.endpoint, concurrentConnections1, requestsPerConnection1, pipelineFalse) for i in range(20): engine.queue(target.req, i)这个脚本对同一个刷新接口打20个并发如果20个都返回了新的access_token说明存在竞态。实际测试时记得设置延迟阈值和次数并发太大会把靶场环境打崩刷题和授权的渗透测试都要悠着点。5. 防御侧的落地方案让JWT真正成为可信凭证刷完题目回头看这些攻击手法在真实项目里几乎都能找到对应版本。下面这套防御方案是我在接触过的项目里验证过有效的组合拳。5.1 算法白名单与密钥管理第一件事是明确指定算法白名单服务端解码时只接受你预先定义的算法。能接受RS256就只放RS256不要顺手把HS256也写上。千万不要用algorithms里列一堆的偷懒写法算法混淆就是从这里长出来的。密钥管理也要分清场景用HS系列时密钥应该是随机生成的至少32字节256bit的高熵字符串并且定期轮换。用RS系列时私钥只存在于服务端公钥通过受控渠道发布。私钥千万别打日志、别进前端代码、别提交到Git仓库。5.2 kid、jku等头字段的严格校验kid不要直接拼接文件路径或SQL语句。正确做法是用kid去匹配一个开发者预先定义好的密钥映射表比如Map或Dict结构匹配不到就直接拒绝不要在代码里动态拼接。jku字段必须做白名单校验只允许HTTPS、只允许固定域名甚至可以直接忽略jku头改为从服务端配置里读取固定的JWKS地址。如果业务上必须支持外部JWKS至少要做域名验证和证书校验并屏蔽内网地址和本地地址。5.3 服务端二次校验与安全审计签名验过不代表身份可信。服务端拿到claims之后建议再做一次二次确认把sub或uid与数据库里的状态对比确认用户没有过期、没有被禁用、权限没有变更。尤其在删除用户、修改密码、封号这类操作后要确保旧的JWT立刻失效——黑名单机制或token版本号都可以。安全审计方面给JWT增加一个jtiJWT ID并在服务端记录使用情况能及时发现同一token的异常重放。同时把alg、kid、来源IP、请求时间写入日志方便事后复盘攻击路径。5.4 一份可用的JWT安全自检清单检查项正确做法攻击后果算法白名单只允许固定算法拒绝none算法混淆、签名绕过密钥复杂度HS密钥不少于32字节随机值离线爆破私钥保管私钥不出服务端任意token伪造kid处理白名单映射禁止拼接任意文件密钥、SQL注入jku处理域名白名单HTTPS外部JWKS伪造claims校验exp、nbf、iss、aud全部校验过期token复用权限模型服务端二次校验关键权限垂直/水平越权日志监控记录jti、alg、kid、来源攻击无法溯源我自己在实战和带新人时都会拿这张表当基线逐条核对目标系统的JWT实现比自己临时想要全面得多。最后聊一点个人感受。JWT这套机制本身设计得很精巧但它的安全边界极其依赖实现者的克制。CTFshow 345-350这组题好就好在它用连续的几道题把JWT的安全问题串成了一条链路先是让你学会操作token再让你理解签名然后逼你去思考服务端到底信任什么。刷完之后再看真实项目的JWT代码你会下意识去检查algorithms列表、kid拼接、jku校验这些点这种带着问题看代码的意识才是这组题真正想教会你的东西。