JWT安全攻防实战:从原理到算法混淆、弱密钥爆破与防御
1. 从一次渗透测试中的“意外”发现说起
最近在复盘一个内部靶场环境时,遇到了一个关于JWT(JSON Web Token)的典型场景,让我想起了之前“陇剑杯”网络安全竞赛中一道非常经典的题目。那道题没有复杂的漏洞链,也没有高深的免杀技巧,核心就是考察对JWT这一现代Web应用身份验证“基石”的深入理解。很多刚接触安全的朋友,包括当时的我,都曾以为JWT就是一个加密过的、不可篡改的字符串,拿到手只能干瞪眼。但事实恰恰相反,JWT的设计哲学是“签名”而非“加密”,这为安全测试人员打开了一扇充满可能性的窗户。今天,我就结合这道竞赛题,把JWT从原理到攻击面,掰开揉碎了讲清楚,让你下次再遇到时,能像老师傅一样,一眼看穿其中的门道。
简单来说,JWT就是一个用于在各方之间安全传输信息的“令牌”。它由三部分组成:头部(Header)、载荷(Payload)和签名(Signature),中间用点号.分隔,形如xxxxx.yyyyy.zzzzz。它被广泛用于单点登录(SSO)、API鉴权和分布式会话管理。这道题的核心,就是利用我们对JWT各部分处理逻辑的误解或配置缺陷,去伪造一个拥有更高权限的令牌。这不仅仅是CTF的技巧,在真实的渗透测试和红队评估中,对JWT的审计和测试是Web应用安全中不可或缺的一环。
2. JWT的结构拆解:远不止“三个点”那么简单
要攻击一个东西,首先得彻底理解它。JWT的三个部分,每一部分都藏着玄机。
2.1 头部(Header):算法声明与格式把戏
头部通常是一个JSON对象,经过Base64Url编码后形成JWT的第一部分。它最关键的字段是alg,用于声明签名所使用的算法。常见的值有:
- HS256:使用HMAC SHA-256的对称加密算法。这意味着签名和验证使用同一个密钥(secret)。这是最常用但也最需要保护密钥的场景。
- RS256:使用RSA SHA-256的非对称加密算法。使用私钥(private key)签名,使用公钥(public key)验证。公钥可以安全分发,更适合分布式场景。
- ES256:使用ECDSA的椭圆曲线数字签名算法,同样是非对称的。
- none:一个特殊值,表示“无签名”。在某些早期的JWT库实现中,如果服务器配置不当,允许
alg为none的令牌通过验证,这将导致严重的安全漏洞。
除了alg,头部还可能包含typ(类型,通常为JWT)、kid(密钥ID,用于在多个密钥中指定一个)等字段。这里第一个攻击点就出现了:算法混淆攻击(Algorithm Confusion Attack)。如果服务器端代码在验证签名时,逻辑是“从令牌头部读取alg字段,然后用该算法去验证签名”,那么攻击者就可以将alg改为none,或者从RS256改为HS256,从而绕过验证。例如,服务器本应使用RS256(非对称),公钥是公开的。攻击者将头部改为{"alg":"HS256","typ":"JWT"},然后用服务器的公钥作为HMAC的密钥(secret)去伪造签名。如果服务器验证逻辑有缺陷,它会用公钥作为HMAC密钥去验证这个签名,从而误判令牌有效。
2.2 载荷(Payload):承载信息的“声明集”
载荷同样是一个JSON对象,包含所谓的“声明”(Claims)。声明分为三种:
- 注册声明(Registered Claims):预定义的一些有特定含义的声明,非强制但建议使用。例如:
iss:签发者sub:主题aud:接收方exp:过期时间(Expiration Time),这是一个时间戳(Unix epoch)。nbf:生效时间iat:签发时间
- 公共声明(Public Claims):可以自定义,但为避免冲突,应定义在IANA JSON Web Token Registry或使用防冲突命名空间(如包含一个域名)。
- 私有声明(Private Claims):在提供方和消费方之间约定使用的自定义声明,用于传递业务信息,如
username、role、userid等。
载荷部分经过Base64Url编码后成为JWT的第二部分。这里的关键在于,载荷本身是未加密的,仅做了编码。任何人都可以轻松地将eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9(Header)和eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ(Payload)解码,看到原始内容。因此,绝对不要在JWT的载荷中存放任何敏感信息,如密码、信用卡号等。攻击面也随之而来:如果服务器仅仅依赖JWT中的userid或role字段来判断权限,而没有在服务端进行二次校验,那么篡改载荷(并相应重签)就能直接实现越权。
2.3 签名(Signature):完整性的守护者
签名是JWT安全的核心。它的生成方式取决于头部声明的算法。对于HS256,签名是这样生成的:HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)对于RS256,则是:RSASHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), private_key)
签名的目的是验证消息在传输过程中未被篡改。验证方使用头部声明的算法和相应的密钥(对称算法的secret或非对称算法的公钥)对“头部.载荷”部分重新计算签名,并与JWT中的第三部分进行比对。如果一致,则证明令牌有效且未被修改。
3. 实战攻击手法详解:以“[陇剑杯 2021]jwt”为例
理解了原理,我们来看实战。这类题目的常见解题路径,往往围绕以下几个关键攻击面展开。我们假设题目环境是一个Web应用,登录后获得一个JWT,目标是提升权限(如从普通用户user提升为管理员admin)。
3.1 第一步:信息收集与令牌解码
拿到题目,首先用浏览器开发者工具或Burp Suite抓取登录后的请求,找到Authorization: Bearer <your_jwt_token>这个Header,或者Cookie中的jwt、token字段,拿到JWT字符串。
接着,进行解码。虽然可以手动Base64Url解码,但更推荐使用工具,如:
- 在线网站:jwt.io(注意不要在真实敏感令牌上使用不可信的在线工具)。
- 命令行工具:
jwt-tool。 - Burp Suite扩展:
JSON Web Tokens。
解码后,我们重点关注:
- 头部:
alg是什么?有没有不常见的字段如jwk、kid? - 载荷:有哪些声明?特别是自定义的
username、role、isAdmin等。exp字段的值是多少(一个时间戳)?它过期了吗?
假设我们解码后得到:
Header: {"alg": "HS256", "typ": "JWT"} Payload: {"sub": "user123", "username": "guest", "role": "user", "exp": 1698765432}显然,我们的目标是修改username或role为管理员身份。
3.2 攻击面一:弱密钥(Weak Secret)爆破
如果算法是HS256、HS384等对称算法,那么密钥(secret)的强度至关重要。许多开发者在测试或初期会使用弱密钥,如secret、password、123456,甚至是空字符串。jwt-tool工具内置了强大的爆破功能。
使用命令:
python3 jwt_tool.py <your_jwt_token> -C -d /path/to/wordlist.txt-C代表“Crack”,-d指定字典文件。工具会尝试用字典中的每一个词作为secret去验证签名。如果爆破成功,它会直接输出正确的secret。拿到secret后,我们就可以用任何JWT库(或jwt.io网站)修改载荷,并用这个secret重新生成合法的签名。
实操心得:爆破字典的选择很重要。除了常见的弱口令字典,可以尝试结合目标应用名称、公司名、项目代号等生成专属字典。有时密钥就是
dev、test、changeme这类简单单词。
3.3 攻击面二:算法混淆攻击(CVE-2015-9235等)
这是历史悠久的经典漏洞。如果服务器端的JWT验证库存在逻辑缺陷,可能会接受alg: none的令牌。攻击步骤:
- 修改头部为
{"alg": "none", "typ": "JWT"}。 - 修改载荷,如将
"role": "user"改为"role": "admin"。 - 将签名部分(即第三个点号后的内容)直接删除,或者置空。
- 将修改后的
Header.Payload.(注意最后有一个点号)提交给服务器。
另一种混淆是RS256到HS256。如果服务器公钥可获取(有时通过/jwks.json端点或网页源码泄露),且服务器验证逻辑有缺陷,攻击步骤为:
- 获取服务器RSA公钥(通常为PEM格式)。
- 修改JWT头部,将
alg从RS256改为HS256。 - 修改载荷。
- 使用这个公钥作为HMAC的secret,对新的
Header.Payload进行HS256签名。 - 发送伪造的令牌。
使用jwt-tool可以自动化尝试多种混淆攻击:
python3 jwt_tool.py <your_jwt_token> -X a-X a代表“Exploit - All tests”,它会自动尝试none算法、混淆攻击等多种方式。
3.4 攻击面三:无效签名绕过(“None”漏洞的变种)
有些服务器端的验证逻辑可能只检查签名是否存在,或者解析JWT结构失败时默认通过。我们可以尝试签名格式错误,比如:
- 签名部分不是Base64Url编码(包含
+、/等非法字符)。 - 签名部分长度不对。
- 在签名后附加额外的点号或字符,如
Header.Payload.Signature.或Header.Payload.Signature.extra。
这些手法的成功率取决于后端使用的具体JWT库及其版本和配置。
3.5 攻击面四:密钥文件泄露与JKU/JWK/KID滥用
这是更高级的攻击面,常出现在配置不当或对JWT扩展特性理解不深的场景。
- JKU:头部中的
jku参数是一个URL,指向一个包含验证密钥的JSON密钥集(JWKS)。如果攻击者能控制这个URL(通过SSRF、域名劫持或上传功能),就可以指向自己控制的恶意JWKS,从而使用自己的私钥签发任意令牌。 - JWK:头部中直接嵌入一个
jwk参数,包含用于验证的公钥。攻击者可以替换成自己的公钥。 - KID:
kid是密钥标识符,用于在服务器的多个密钥中选择一个。攻击可能包括:- 目录遍历:如果
kid参数未经过滤,像../../../../etc/passwd这样的值可能导致服务器使用文件内容作为密钥,可能被预测或利用。 - SQL注入:如果
kid用于从数据库查询密钥,可能引发SQL注入。 - 命令注入:极少数情况下,
kid可能被用于动态加载密钥,导致命令注入。
- 目录遍历:如果
对于“[陇剑杯 2021]jwt”这类题目,往往需要综合判断。例如,题目可能提示“密钥就在服务器上”,结合弱密钥爆破和kid路径遍历(如kid: "/proc/self/cmdline"泄露进程信息,进而找到密钥文件路径)来解题。
3.6 攻击面五:时间戳攻击(exp, nbf, iat)
载荷中的exp、nbf、iat都是时间戳。服务器库通常会验证exp(是否过期)和nbf(是否已生效)。攻击可能包括:
- 时钟偏移利用:如果服务器时间配置不同步,存在较大偏移,可能使一个本应过期的令牌仍然有效。
- 篡改时间戳:直接修改
exp为一个未来的时间戳。但这需要同时能绕过签名验证,通常需要结合其他漏洞(如弱密钥、算法混淆)一起使用。
4. 工具链与手动操作:不只是点按钮
虽然jwt-tool是瑞士军刀,但理解手动过程能加深认知。这里以“弱密钥爆破成功后的令牌伪造”为例,展示完整流程。
场景:我们通过爆破,发现目标的HS256密钥是supersecret。
原始令牌:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwidXNlcm5hbWUiOiJndWVzdCIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNjk4NzY1NDMyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c解码分析(使用Python
pyjwt库或在线工具):- Header:
{"alg": "HS256", "typ": "JWT"} - Payload:
{"sub": "user123", "username": "guest", "role": "user", "exp": 1698765432}
- Header:
修改载荷:我们将
"role": "user"改为"role": "admin"。注意,exp如果已过期,也需要改为一个未来的时间戳(如9999999999)。新的Payload JSON为:{ "sub": "user123", "username": "guest", "role": "admin", "exp": 9999999999 }手动生成新令牌(使用Python):
import jwt import time # 已知密钥 secret = 'supersecret' # 构造新的载荷 payload = { 'sub': 'user123', 'username': 'guest', 'role': 'admin', 'exp': 9999999999 # 一个很远的未来时间 } # 生成新的JWT,使用HS256算法和已知密钥 new_token = jwt.encode(payload, secret, algorithm='HS256') print(new_token)运行后会得到一个新的、使用正确密钥签名的令牌。
替换与测试:在Burp Suite中,用这个新令牌替换原请求中的令牌,重放请求。观察响应,看是否成功获取了管理员权限的访问(例如,访问
/admin页面,或响应中返回了更多数据)。
踩坑记录:在手动编码时,务必确保JSON格式完全正确,没有多余的逗号,字符串使用双引号。一个常见的错误是
exp的值没有引号(在JSON中它是数字),但在某些库的encode函数中,载荷是字典类型,库会自动处理。如果手动拼接字符串进行Base64编码,则必须严格遵守JSON格式。
5. 防御视角:开发中如何避免这些坑
作为攻击者,我们寻找漏洞;作为开发者或安全工程师,我们则要堵上这些漏洞。以下是一些关键防御措施:
使用强算法和强密钥:
- 优先使用非对称算法(如RS256、ES256)。私钥妥善保存在服务器端,公钥可以安全分发。
- 如果必须使用对称算法(HS256),密钥必须是高强度的随机字符串(如通过密码学安全随机数生成器生成),并像保护密码一样保护它,绝不能硬编码在客户端或前端代码中。
严格验证算法:
- 在服务器端验证签名时,不要依赖客户端提供的
alg头。应该有一个应用配置,明确指定期望接受的算法列表(如只接受RS256)。在验证时,使用配置中指定的算法和对应的密钥去验证,而不是读取令牌头中的alg值。这是防止算法混淆攻击的根本方法。
- 在服务器端验证签名时,不要依赖客户端提供的
全面验证声明:
- 必须验证
exp、nbf、iat。 - 验证
iss(签发者)是否可信。 - 验证
aud(受众)是否包含本服务。 - 对于自定义声明如
role、userid,必须在服务端进行二次校验。例如,根据userid从数据库或缓存中查询用户最新的权限信息,而不是完全信任JWT中的role字段。JWT应作为会话状态的“引用”,而非“权威数据源”。
- 必须验证
安全处理JKU/JWK/KID:
- 如果使用
jku或jwk,必须严格验证URL是否来自可信的白名单域名,并对获取的密钥进行完整性验证。 - 对
kid参数进行严格的输入验证,防止路径遍历、SQL注入等攻击。
- 如果使用
使用最新的、经过安全审计的库:
- 避免使用已过时或有已知漏洞的JWT库。关注社区安全公告,及时更新。
设置合理的令牌生命周期:
- 访问令牌(Access Token)有效期宜短(如15分钟),配合刷新令牌(Refresh Token)使用,减少令牌泄露后的风险窗口。
6. 拓展思考:JWT在真实红队评估中的位置
在真实的渗透测试中,JWT漏洞很少孤立存在。它往往是横向移动或权限提升链条中的一环。我们可能需要结合其他漏洞来获取攻击JWT的初始条件:
- 信息泄露:通过源码泄露、目录遍历、错误配置的
.git目录等,找到硬编码的JWT密钥或公钥/私钥文件。 - SSRF:利用服务器端请求伪造,让服务器从内网或攻击者控制的地址获取JWKS(
jku),从而注入恶意公钥。 - 逻辑漏洞:例如,注册或密码重置功能处,可能允许我们设置自己的
username为admin(如果JWT的username直接来自用户输入且未做过滤),然后再通过JWT重放获得管理员上下文。 - 中间件配置问题:某些API网关或反向代理(如Kong, APISIX)在配置JWT验证插件时,如果配置不当,也可能引入类似
alg: none的漏洞。
因此,拿到一个JWT后,不要仅仅盯着令牌本身。要思考:这个令牌从哪里来(登录接口、OAuth回调)?服务器用什么库验证?密钥可能存储在哪里?是否有其他接口或页面泄露了关键信息?这种关联性思维,才是将CTF技巧转化为实战能力的关键。
回到“[陇剑杯 2021]jwt”这道题,它像是一个精致的微缩景观,集中展示了JWT最常见的安全问题。通过手动或工具辅助的逐步测试——从解码观察、尝试none算法、爆破弱密钥、检查kid路径遍历,到最终伪造高权限令牌——我们完成了一次完整的JWT安全审计流程。掌握它,你不仅能在CTF中得分,更能在真实的Web应用安全评估中,多一双发现漏洞的锐利眼睛。记住,令牌只是表象,背后的验证逻辑和系统配置,才是真正的战场。