干安全测试这一行,碰到JWT鉴权的频率比想象中高。登录成功返回一长串token,后续请求带上它就能通过,但很多新手对token里的签名到底怎么校验、为什么会有弱密钥问题,其实没真正搞明白。Kali环境下调JWT弱密钥破解,最省事的路线就是在本地用Docker起一个可控靶标,再通过Burp Suite完成抓包、重放和爆破验证。这里记录一下我自己跑通的一套完整流程:环境准备、靶标服务编写、Docker部署、代理配置、Burp抓包、弱密钥爆破和最终伪造验证,适合安全测试新手,也适合需要快速复现问题的工程师。老规矩,所有操作都在你自己搭的本地靶标环境里做,别拿去对别人的线上系统试。
1. 先搞明白JWT弱密钥:为什么这么容易被“猜”出来
1.1 JWT其实就是三段明文,签名只负责“防篡改”
JWT长得很唬人,拆开看就是三段由点号分隔的字符串,分别是Header、Payload和Signature。Header里写着签名算法和类型,Payload里放着用户名、角色、过期时间这些业务数据,Signature则是服务端用来确认前两段没被动过手脚的校验值。
很多人容易忽略一个事实:Header和Payload并不是加密的,它们只是做了Base64URL编码。把这段token丢进Burp Decoder或者随便一个在线解析工具,直接就能看到明文内容。所以永远不要往JWT里塞密码、身份证号这类敏感信息,这是最基本的一条。
既然前两段是明文,那Signature存在的意义就只有一个:证明token来自合法的签发方,并且内容在传输过程中没有被篡改。服务端收到请求后,会用自己保存的密钥重新计算一遍签名,再和token里的签名做比对。一致就放行,不一致就直接拒绝。这个流程说白了就是“验签”。
1.2 弱密钥的命门,出在HMAC签名上
JWT最常见的一种签名算法是HS256,它本质上就是HMAC-SHA256。计算签名时需要一个密钥,这个密钥同时用于签名和验签,相当于整个认证体系里最核心的那把锁。问题在于,很多系统在实现时图省事,直接把密钥写成了secret、123456、admin、password这类字符串,甚至还有把公司名、项目名当密钥用的。
HMAC签名有一个特点:只要给我一个合法的JWT,我就可以在不知道密钥的情况下做离线枚举。因为JWT的三个部分都摆在那里,我只需要把Header和Payload拼接起来,再用猜测的密钥去算一遍HMAC,看结果是否和Signature一致。整个枚举过程在本地完成,不需要向目标服务器发任何请求,速度快、隐蔽性强,也不会触发服务端的日志告警。
可以这么理解:服务端像是给保险箱配了一把锁,但钥匙胚子太简单,攻击者根本不需要撬锁工具,只需要拿一批常见胚子挨个试,总有一把能插进去。弱密钥问题在HS256场景下被放大,就是因为离线枚举的成本实在太低了。
1.3 为什么先选Docker部署,再上Burp Suite
Kali本身是一个集成度很高的渗透测试发行版,工具链齐全,但直接拿它去测真实业务系统的JWT端点,风险不可控。最稳妥的方式是先用Docker在本地起一个靶标服务,把漏洞环境固定下来,然后再用Burp Suite做手工测试和爆破验证。
Docker带来的好处很直接:环境干净、起停方便、复现一致。爆破过程中哪怕把服务搞挂,重新docker start一下就好,完全不影响Kali本体。更重要的是,靶标可以故意配置成弱密钥,方便反复练习,这在真实业务系统里几乎不可能遇到。先有靶标,再谈后续的Burp实战,排查问题时也不用担心误伤别人的环境。
2. 搭建自己的JWT靶标:Docker部署全流程
2.1 准备Kali环境与Docker
Kali通常不会默认安装Docker,需要手动装一下。打开终端,先更新软件源,然后安装Docker软件包:
sudo apt update sudo apt install -y docker.io sudo systemctl enable --now docker安装完成后用docker version看一下客户端和服务端版本,确认daemon正常运行。如果当前用户不在docker组里,后面执行docker命令时可能需要加sudo,或者干脆像我一样在Kali的root环境里操作,少不少麻烦。
2.2 写一个故意使用弱密钥的JWT服务
靶标服务我用Python写,选Flask加PyJWT这两个库,逻辑简单、依赖少。核心接口有两个:/login负责用固定密钥签发JWT,/protected负责校验Authorization头里的token并返回用户信息。密钥故意设成123456,就是为了后续爆破用的。
from flask import Flask, request, jsonify import jwt import datetime app = Flask(__name__) SECRET_KEY = "123456" @app.route("/login", methods=["POST"]) def login(): data = request.get_json() or {} username = data.get("username", "admin") role = "admin" if username == "admin" else "user" payload = { "username": username, "role": role, "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1) } token = jwt.encode(payload, SECRET_KEY, algorithm="HS256") return jsonify({"token": token}) @app.route("/protected", methods=["GET"]) def protected(): auth_header = request.headers.get("Authorization", "") token = auth_header.replace("Bearer ", "") try: data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) return jsonify({"msg": "ok", "user": data}) except jwt.ExpiredSignatureError: return jsonify({"msg": "token expired"}), 401 except jwt.InvalidTokenError: return jsonify({"msg": "invalid token"}), 401 if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)注意,jwt.encode在不同版本的PyJWT里返回类型可能略有差异,新版本返回的是字符串,可以直接作为token返回。如果你用的是老版本,发现返回bytes类型,在jsonify之前加一个.decode("utf-8")即可。这个坑不算大,但第一次跑的时候容易懵。
依赖文件requirements.txt里写两行内容:
flask pyjwt2.3 用Dockerfile构建并启动靶标
有了服务代码和依赖文件,接下来写Dockerfile。基础镜像用一个精简的Python 3.11镜像就够,JWT靶标本身不涉及数据库和复杂组件,镜像越简洁,构建越快,出问题的概率也越低。
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . EXPOSE 5000 CMD ["python", "jwt_lab.py"]文件都准备好后,在同一个目录下执行构建和启动命令:
docker build -t jwt-lab . docker run -d -p 5000:5000 --name jwt-lab jwt-lab如果本地端口5000被占用,可以把-p 5000:5000改成-p 5001:5000之类的映射端口,后面Burp测试时记得跟着改。容器启动后用docker ps确认状态,如果STATUS列是Up,说明靶标已经起来了。
2.4 用curl完成第一轮功能验证
靶标起来后,先别急着开Burp,用curl确认接口可用。向/login发一个POST请求,拿到token,再带token访问/protected,看返回是否正常:
curl -X POST http://127.0.0.1:5000/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"admin"}'正常情况下会返回一个JSON对象,里面的token就是后续用来测试的样本。把这个token复制出来,手动加Authorization头去请求受保护接口:
curl http://127.0.0.1:5000/protected \ -H "Authorization: Bearer <你拿到的token>"返回{"msg":"ok","user":{...}}就说明验签逻辑正常。到这里,整个靶标环境已经处于可测试状态,后面所有Burp操作都在这个本地容器上完成。
3. Burp Suite实战:抓包、重放、爆破三件套
3.1 先把浏览器代理和CA证书搞定
Burp Suite默认监听本机8080端口,Kali里打开Burp后,把浏览器的代理设置为127.0.0.1:8080即可。Firefox可以直接在设置里加一个手动代理,或者用Burp自带的内置浏览器,后者更方便,省得配证书。
本地靶标走的是HTTP,不涉及HTTPS证书,但如果你后面想测别的HTTPS站点,记得从http://burp下载CA证书并导入浏览器信任列表。代理配置不对的话,最典型的现象是浏览器打不开页面,或者Burp的HTTP History里一片空白,这时先查代理和监听端口。
3.2 抓到登录请求,拿到JWT样本
打开Burp Proxy的Intercept功能,也可以不开启拦截,直接通过HTTP History观察。浏览器里访问靶标的登录接口,或者在Burp里直接构造一个POST请求发给http://127.0.0.1:5000/login,这时左侧HTTP History会出现对应的流量记录,响应里就是服务端签发的JWT。
把这个token从响应里复制出来,切到Burp Decoder,选择Base64-URL解码,分别解Header和Payload。你会看到Header里的alg写的是HS256,Payload里有username、role和exp。这一步的重点是确认签名算法和payload结构,因为后续爆破的目标就是这段签名的密钥。
3.3 先离线破解密钥,再回Burp里验证
很多人习惯直接拿Burp Intruder对/protected接口发一堆请求,把token的Signature部分替换成不同的值。这种做法其实很低效,因为Intruder只是替换字符串,它不会帮你计算HMAC签名,替换后的token基本都会验签失败。要真正爆破HS256的弱密钥,正确的姿势是先离线枚举,把每个候选密钥算成完整的token,再用Burp批量验证。
先准备一个弱密钥字典,比如:
123456 secret password admin jwt test qwerty接着用Python脚本批量生成候选token。脚本的思路很简单:固定Header和Payload,遍历字典里的每个密钥,分别计算签名,输出完整token到文本文件:
import jwt import datetime wordlist = [ "123456", "secret", "password", "admin", "jwt", "test", "qwerty" ] base_payload = { "username": "admin", "role": "user", "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1) } with open("candidates.txt", "w") as f: for secret in wordlist: token = jwt.encode(base_payload, secret, algorithm="HS256") f.write(token + "\n")运行完这个脚本,candidates.txt里就是一堆用不同密钥签出来的合法token。现在再回到Burp,抓一个GET /protected的请求,把Authorization头里的整个JWT用§标记为payload position,Attack Type选Sniper,Payload Load从candidates.txt读取,发送请求后观察响应。
因为候选token里的密钥各不相同,只有用正确密钥签出来的那个token才能通过服务端验签,响应状态码会从401变成200,响应体内容也明显不同。我用的是一个小字典,实际测试中可以把Kali系统自带的弱密钥字典文件按行提取,或者自己从常见密码中整理一份。关键是思路:先离线生成候选token,再交给Burp做黑盒验证,这样既省时又能直观地在Burp里看到命中结果。
这一阶段也可以单独用Hashcat等工具跑JWT模式,拿到密钥后手动伪造token。不过本文重点是Burp Suite,所以不展开讲Hashcat参数,预生成候选token的方案在常规靶标测试中完全够用。
3.4 找到弱密钥后,在Repeater里伪造高权限token
假设爆破之后确认密钥是123456,接下来就是最有意思的部分:在Burp Repeater里伪造一个管理员token。刚才candidates.txt里的payload是role: user,现在把payload改成role: admin,再用同样的密钥重新签名,业务权限就彻底不一样了。
如果图省事,可以直接用Burp的JWT相关扩展来改签,比如在扩展里输入密钥自动重新签名。但我个人的习惯是,遇到简单场景就写个两三行的Python脚本,改payload再签名,输出新token:
import jwt import datetime payload = { "username": "admin", "role": "admin", "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1) } token = jwt.encode(payload, "123456", algorithm="HS256") print(token)把输出复制到Burp Repeater里,替换Authorization头的JWT,再次发送GET /protected,响应直接返回{"msg":"ok","user":{"username":"admin","role":"admin"}}。到这一步,JWT弱密钥从爆破到利用的闭环就走通了。
3.5 别忘了检查Intruder的响应判断条件
用Intruder批量验证时,不要只看状态码。有些服务端实现里,token过期和token非法都返回401,但响应体的msg字段不一样;有些接口对权限不足和校验失败的返回逻辑也各不相同。我一般会在Intruder的Settings里加一条Grep-Match规则,匹配成功时的关键字,比如"msg": "ok",然后在结果列表里按Match高亮排序,命中项一目了然。
如果响应体比较长,单靠肉眼扫很累,也可以加上Response length排序。爆破成功后,那条响应长度明显不同于其他401响应的记录,通常就是需要重点看的。
4. 常见问题与排查技巧实录
4.1 Docker容器起不来,端口冲突或权限问题
最常见的是端口被占用。在Kali里跑其他Web服务时,5000端口很容易被某个Python开发服务器占掉。遇到这种情况,先docker ps -a看容器状态,再用ss -lntp | grep 5000确认占用进程。最简单的解决办法是换宿主端口映射,比如docker run -d -p 5001:5000 --name jwt-lab jwt-lab,后续Burp请求改成http://127.0.0.1:5001即可。
如果是权限问题,docker命令报permission denied,把当前用户加入docker组再重新登录:
sudo usermod -aG docker $USER4.2 Intruder批量验证时全部401,是不是爆破失败了
先别急着怀疑字典。把candidates.txt里的token复制到Repeater里手动发送,如果也401,最可能的原因是token里的exp过期了。我写的那个靶标里,token有效期只有1小时,而预生成脚本如果是在测试开始时跑的,等到Intruder批量发送时可能已经过期。解决办法是重新生成token,或者把exp时间设长一点。
另一个容易踩的坑是Intruder的payload position选错了。如果只选中token里的Signature段,而不是整个JWT,那无论换什么payload都过不了验签,因为Header和Payload没变,但Signature被替换成了一段不匹配的字符串。正确做法是替换整个JWT。
4.3 要不要升级到Turbo Intruder
普通Intruder在低并发下对付这种本地靶标够用,但如果你打算跑一个几十万词的字典,普通Intruder的速度会让人崩溃。Turbo Intruder这类基于Python的扩展可以显著提升发包效率,还能自定义爆破逻辑。不过我一般不在这个场景里用它,因为离线生成候选token的环节才是性能瓶颈,真正发送到Burp验证的只是少量候选,没必要用涡轮增压。
如果确实要跑大字典,离线爆破直接用Hashcat这类工具会快得多。在我的流程里,Burp更多承担的是流量观察、请求构造和结果验证的角色,而不是纯暴力发包的工具。工具选型没有绝对标准,能快速解决问题的就是好方案。
4.4 服务端不只是弱密钥,算法压制也要注意
JWT安全坑不止弱密钥一个。有些实现会在jwt.decode时不指定algorithms,导致攻击者把Header里的alg改成none,甚至把HS256换成RS256来混淆验签逻辑。虽然本文的靶标是标准的HS256弱密钥,但你在真实授权测试中遇到JWT相关问题时,一定要结合具体实现去判断漏洞类型。密钥太弱可以爆破,算法校验不严可以绕过,exp不校验也可以重放,这些东西往往是组合出现的。
防御侧也一样,密钥强度只是基础,服务端还必须显式限制允许的算法列表,校验exp等标准声明,并且避免在token里存放敏感信息。弱密钥爆破流程反过来用,就是一次很好的基线检查:如果你的业务系统也用了JWT,不妨拿着这份字典自测一下,看能不能被爆破。
5. 这套流程还能怎么延伸
5.1 从本地靶标过渡到授权测试清单
本地跑通之后,这套思路可以整理成一张授权测试小清单:先判断目标是否使用JWT,再解析token结构确认算法,接着观察接口对篡改token的响应,然后离线枚举弱密钥,最后在Burp里完成伪造验证和权限提升测试。每一步都有对应的Burp操作,也都有明确的判断依据,非常适合写进Web应用安全测试的执行手册里。
5.2 把爆破结果接入自动化验证
如果你经常需要查验一批系统的JWT密钥强度,可以把第3节里的Python脚本扩展成一个小工具:输入一个JWT样本和字典文件,自动输出匹配到的密钥,再生成高权限token。配合Burp的API,甚至可以做到扫描到JWT后自动拉取样本、自动跑字典、自动回注验证。这个方向不复杂,但能省下大量重复劳动。
我自己在实际操作里有一个习惯:不管时间多紧,都会先用离线脚本确认密钥,再回Burp里做最终验证。这个顺序一旦反过来,很容易陷入“换了无数payload、看了一堆401”的低效循环。工具只是手段,能够把每个环节拆开、分别验证,才是这套流程真正值钱的地方。