1. 项目概述从一道CTF题看JWT安全实战最近在复盘一些经典的网络安全竞赛题目其中一道来自“陇剑杯 2021”的JWT相关题目让我觉得特别有分享价值。这道题本身是一个靶场环境但它所考察的恰恰是JWTJSON Web Token在现实应用中最容易被忽视的几个安全命门。很多开发同学包括一些有一定经验的对JWT的理解可能还停留在“一种用来做无状态认证的令牌”这个层面知道它由Header、Payload、Signature三部分组成用Base64编码但具体到如何安全地实现、攻击者会从哪些角度突破认知就模糊了。这道CTF题就像一面镜子把理论上的漏洞变成了可实操的攻击路径。简单来说这道题模拟了一个使用JWT进行用户鉴权的Web应用。参赛者的目标就是绕过鉴权获取到本不应访问的敏感信息或执行高权限操作。这听起来像是黑客行为但对于我们开发者而言理解攻击者的思路恰恰是构建更坚固防御体系的最佳方式。通过拆解这道题我们能清晰地看到如果JWT的实现不够严谨会在“签名算法”、“密钥管理”、“令牌声明”等多个环节留下致命隐患。接下来我就结合这道题的具体场景和我的实战经验把JWT从原理到安全实践再到常见攻击与防御给你彻底讲透。无论你是正在学习JWT的新手还是想检查自己项目安全性的老手相信都能从中获得直接的收获。2. JWT核心原理与结构拆解不只是三段字符串在深入漏洞之前我们必须把JWT的基础打牢。很多人对JWT的印象就是一个长长的、被点号分隔的字符串比如eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c。这没错但理解其内部构造是分析一切安全问题的起点。2.1 三部分构成的令牌一个标准的JWT由三部分组成用英文句点.连接Header头部经过Base64Url编码的JSON对象主要声明令牌的类型typ通常是JWT和所使用的签名算法alg如HMAC SHA256HS256或RSARS256。Payload负载同样经过Base64Url编码的JSON对象包含了所谓的“声明”Claims。声明是关于实体通常是用户和其他数据的陈述。它又分为三种类型注册声明预定义的一组声明不是强制性的但推荐使用如iss签发者、exp过期时间、sub主题、aud接收方等。公共声明可以自定义的声明但为了避免冲突应定义在IANA JSON Web Token Registry中或使用包含防冲突命名空间的URI。私有声明自定义的声明用于在同意使用它们的各方之间共享信息。Signature签名对编码后的Header、编码后的Payload使用Header中指定的算法和一个密钥Secret进行签名计算后得到的结果。签名用于验证消息在传递过程中没有被篡改对于使用私钥签名的令牌它还可以验证发送方的身份。注意这里有一个极其关键的细节。Header和Payload仅仅是Base64Url编码并非加密。任何拿到JWT的人都可以轻松将其解码读取其中的内容。因此绝对不要在Payload中存放密码等敏感信息。JWT的安全性完全依赖于签名的完整性。2.2 签名算法选型HS256 vs RS256算法选择是JWT安全的第一道防线也是CTF题目中最常见的考点。HS256HMAC with SHA-256一种对称加密算法。签发和验证使用同一个密钥。计算速度快实现简单。但密钥Secret的分发和管理是难题。如果服务器密钥泄露攻击者可以签发任意有效的JWT。在分布式微服务场景下每个服务都需要知道这个密钥增加了暴露风险。RS256RSA Signature with SHA-256一种非对称加密算法。使用私钥Private Key进行签名使用公钥Public Key进行验证。公钥可以安全地分发给任何人而私钥必须严格保密。这样只有持有私钥的认证服务器能签发令牌而资源服务器或其他服务只需用公钥即可验证。安全性更高是更推荐的生产环境选择。“陇剑杯”题目启示很多不安全的实现会允许客户端在JWT的Header中指定alg字段。攻击者可以将alg改为none或者从RS256改为HS256从而利用算法混淆漏洞。这是我们需要重点防范的。2.3 JWT的工作流程理解了结构我们再看它如何工作用户登录客户端向认证服务器提交凭证如用户名密码。签发令牌认证服务器验证凭证通过后生成JWT包含用户身份、权限、过期时间等并签名然后返回给客户端。携带令牌客户端在后续请求的Authorization请求头中携带此JWT格式通常为Bearer token。验证令牌资源服务器或API网关收到请求后检查JWT格式解码Header和Payload。根据Header中的alg使用正确的密钥/公钥验证Signature是否有效。这是核心安全步骤验证Payload中的声明如exp是否过期、iss签发者是否可信等。一切验证通过后根据Payload中的信息如username,roles处理请求。这个流程的“无状态”特性是其最大优点服务器不需要维护会话易于扩展。但所有状态信息都放在令牌里一旦令牌被盗或破解后果严重因此安全实现至关重要。3. 靶场实战JWT常见攻击手法深度剖析现在让我们回到“陇剑杯”这道题。假设我们拿到了一个靶场环境其登录后返回了一个JWT。我们的目标就是分析并利用这个JWT的弱点。以下是几种经典攻击手法的实战拆解。3.1 签名算法混淆攻击这是最经典的JWT攻击之一。当应用使用非对称算法如RS256时验证端使用公钥。如果应用端的验证逻辑有缺陷攻击者可以篡改Header中的alg为HS256对称算法然后将原本应由私钥签名的部分尝试用公钥作为HS256的密钥去重新计算签名。攻击步骤截获令牌获取到一个正常的JWT例如eyJhbGciOiJSUzI1NiIs...alg为RS256。解码分析将Header部分Base64解码发现{alg:RS256,typ:JWT}。篡改算法将Header改为{alg:HS256,typ:JWT}然后重新Base64编码。篡改Payload修改Payload例如将用户名改为admin权限改为superuser重新编码。伪造签名关键一步。使用验证端的公钥有时可能硬编码在源码、暴露在/jwks.json端点或容易猜到作为HS256算法的“密钥”对新的Header和Payload计算HMAC签名。组合发送将新的Header、Payload和伪造的Signature用.连接发送给服务器。为什么能成功如果服务器端的验证库存在逻辑漏洞它看到alg: HS256就会用配置的“密钥”去验证签名。如果这个“密钥”恰好被设置成了公钥本身一个常见的错误配置那么用公钥计算的HMAC签名就能通过验证。防御措施在验证JWT时永远不要依赖客户端提供的alg字段来决定验证算法。服务器端应该强制指定预期的算法。例如使用java-jwt库时应该使用JWT.require(Algorithm.RSA256(publicKey)).build()来验证而不是使用一个能接受多种算法的通用验证器。3.2 “none”算法攻击这是一种更“古老”但仍有教育意义的攻击。在JWT规范早期alg字段允许值为none表示不签名。其本意是用于调试或特殊情况。如果服务器配置不当没有禁用none算法攻击者就可以轻松伪造令牌。攻击步骤将Header改为{alg:none,typ:JWT}。任意修改Payload。将Signature部分直接置空或者删除即JWT字符串以.结尾。将伪造的令牌发送给服务器。防御措施绝对禁止使用none算法。所有主流的JWT库现在都会默认拒绝none算法但在自定义实现或旧版本中仍需警惕。3.3 密钥爆破与弱密钥当算法是HS256时安全性完全依赖于密钥的强度。如果密钥太弱如secret、password、123456等攻击者可以进行离线爆破。攻击步骤获取一个有效的JWT。使用工具如hashcat、jwt_tool和常见的弱密钥字典尝试用不同的密钥对已知的Header和Payload重新计算签名。如果计算出的签名与原始JWT中的Signature匹配则爆破成功获取到了密钥。使用该密钥可以签发任意用户的JWT。“陇剑杯”题目可能场景题目可能暗示或泄露密钥与常见单词、靶场名称、简单数字有关。通过社会工程学或简单爆破即可获得。防御措施使用足够长且随机的密钥如32字节以上的随机字符串。对于HS256密钥应被视为最高机密。考虑使用密钥管理服务KMS来存储和轮换密钥。3.4 声明篡改与KID参数注入JWT的Header中有时会包含一个kidKey ID参数用于在服务器端配置了多个密钥时指示应该用哪个密钥来验证签名。如果kid参数的处理不当可能造成注入漏洞。攻击路径目录遍历如果kid参数被直接用于拼接文件路径如/keys/kid攻击者可以传入../../../etc/passwd可能导致服务器使用系统文件的内容作为验证密钥从而绕过签名。SQL注入如果kid被用于数据库查询可能存在SQL注入通过注入控制查询结果使服务器使用攻击者预期的密钥。重定向到恶意JWKSJWKS (JSON Web Key Set) 是一个包含公钥集的端点。如果kid或jkuJWK Set URL字段可由用户控制攻击者可以将其指向自己控制的服务器提供自己的公钥然后用对应的私钥签名制作完全“合法”的JWT。防御措施对kid、jku、x5u等头部参数进行严格的白名单验证或签名验证确保它们指向可信的、预配置的源。3.5 其他声明滥用exp过期时间如果服务器不检查exp令牌就永远有效。防御必须验证。nbfNot Before令牌在此时间之前无效。需验证。iss签发者、aud受众如果应用有多个发行方或面向多个客户端必须验证这些字段是否符合预期防止令牌被用在错误的上下文中。jtiJWT ID用于防止重放攻击的唯一标识符。服务器应维护一个短期的jti黑名单或使用数据库/缓存记录已使用的jti但这会引入状态与无状态初衷有些背离需权衡。4. 安全开发实践构建健壮的JWT认证体系了解了攻击手段我们就能有的放矢地构建防御。以下是我在项目中总结出的安全实践要点。4.1 算法与密钥管理规范强制指定算法在验证JWT时代码中应显式指定期望的算法不依赖令牌头。// 错误依赖令牌中的alg // JWTVerifier verifier JWT.require(Algorithm.HMAC256(secret)).build(); // 正确显式指定算法即使令牌头是HS256也会用RS256的公钥验证导致失败 RSAPublicKey publicKey // ... 加载公钥 JWTVerifier verifier JWT.require(Algorithm.RSA256(publicKey)).build();优先使用RS256对于生产环境尤其是分布式系统优先采用非对称算法。认证服务保管私钥签发其他服务使用公钥验证。安全存储密钥对称密钥HS256使用环境变量或密钥管理服务如HashiCorp Vault, AWS KMS, Azure Key Vault注入绝对不要硬编码在源码中。非对称密钥对私钥必须存放在最安全的地方如HSM硬件安全模块、KMS公钥可以配置文件或通过安全的端点如/oauth/jwks提供给验证方。密钥轮换制定密钥轮换策略。当使用新密钥签发令牌后旧密钥应在一段重叠期内仍可用于验证以确保已签发的令牌不会立即失效之后再将旧密钥废弃。4.2 Payload设计原则与验证最小化原则Payload中只存放进行授权决策所必需的最少信息如用户ID、角色列表。不要存放敏感信息邮箱、手机号需谨慎、完整用户对象或数据库主键。必须验证的声明编写验证逻辑时必须检查exp当前时间是否小于过期时间。nbf当前时间是否大于等于生效时间。iss签发者是否在可信列表内。aud本服务是否在令牌的受众列表中。可选但推荐iat签发时间可用于判断令牌是否过于陈旧。自定义声明使用有命名空间的名称以避免冲突例如https://myapp.com/is_admin: true。4.3 令牌的生命周期与安全传输设置合理的过期时间访问令牌Access Token过期时间宜短例如15-30分钟。配合刷新令牌Refresh Token机制来获取新的访问令牌。这限制了令牌泄露后的危害窗口。使用HTTPSJWT必须在HTTPS通道中传输防止中间人窃取。安全的存储位置前端不要存储在localStorage或sessionStorage中它们易受XSS攻击窃取。推荐存储在HttpOnly的Cookie中防范XSS并设置Secure仅HTTPS和SameSite属性防范CSRF。但需注意Cookie方式可能面临CSRF攻击需要额外防护如Anti-CSRF Token。另一种方案是存储在内存中如JS变量但页面刷新会丢失。实现令牌吊销虽然JWT是无状态的但在某些安全要求高的场景如用户登出、密码修改仍需立即吊销令牌。这可以通过维护一个短期的令牌黑名单在缓存中存储已吊销令牌的jti或签名片段并设置与令牌exp一致的TTL来实现。4.4 结合Spring Security的实现要点在Java生态中Spring Security是事实上的标准。结合JWT时通常需要自定义一个JwtAuthenticationFilter。核心流程该过滤器拦截请求从Authorization头中提取JWT。使用JwtDecoder配置了公钥和验证规则解析并验证JWT。验证通过后从JWT的Payload中提取用户信息和权限构建一个Authentication对象通常是JwtAuthenticationToken或UsernamePasswordAuthenticationToken。将该Authentication对象设置到SecurityContextHolder中供后续的授权过滤器如PreAuthorize使用。关键配置代码示例简化Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) // API场景通常禁用CSRF若用Cookie需考虑 .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authz - authz .requestMatchers(/api/auth/login).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter(jwtDecoder()); } Bean public JwtDecoder jwtDecoder() { // 使用公钥验证并强制指定算法为RS256 return NimbusJwtDecoder.withPublicKey(publicKey).signatureAlgorithm(SignatureAlgorithm.RS256).build(); }在自定义的JwtAuthenticationFilter中你需要处理验证异常如令牌过期、签名无效并返回401或403状态码而不是抛出异常导致500错误。5. 高级话题与疑难排查5.1 JWT实现Token续签Refresh Token机制由于Access Token有效期短需要Refresh Token机制来提升用户体验。双令牌设计登录成功后返回两个令牌access_token: 短期令牌用于访问资源。refresh_token: 长期令牌如7天、30天仅用于获取新的access_token不能直接访问资源。Refresh Token的安全要求更高它有效期长必须安全存储服务器端数据库关联用户ID和客户端信息。每次使用后可以使其失效并颁发一个新的滑动过期并记录使用日志以便审计。刷新流程客户端在access_token过期后使用refresh_token调用专门的刷新端点如POST /auth/refresh。服务器验证refresh_token有效且未吊销后颁发新的access_token和可选的新的refresh_token。吊销用户登出时应同时吊销当前的refresh_token使其无法再获取新的access_token。5.2 与Spring Security的深度集成区别很多人混淆JWT和Spring Security的角色JWT是一种令牌格式标准解决了“如何在无状态环境下安全地携带和传递身份信息”的问题。Spring Security是一个强大的认证和授权框架它定义了处理安全性的整个流程过滤器链、ProviderManager、UserDetailsService等。集成关系JWT通常作为Spring Security认证流程中的一个“证据”Authentication的实现。我们自定义的过滤器负责将JWT解析为Authentication对象然后Spring Security的授权机制PreAuthorize,hasRole()等就可以基于这个对象进行工作。你可以把JWT看作是给Spring Security提供用户信息的“介绍信”。5.3 在重定向中携带JWT的注意事项有时前端需要处理重定向如OAuth回调而JWT通常放在Authorization头中浏览器在重定向时不会自动携带此头。常见解决方案URL Query Parameter不推荐将JWT作为查询参数附加在重定向URL后如https://client.com/callback?tokeneyJ...。风险JWT会暴露在浏览器历史记录、服务器日志、Referer头中极不安全。Fragment Identifier锚点将JWT放在URL片段中如https://client.com/callback#tokeneyJ...。片段不会发送到服务器相对安全一些但依然可能通过document.location.hash被前端恶意脚本读取。后端中转推荐认证服务器重定向到一个后端端点如https://client-backend.com/auth/callback?codexxx。后端端点用code换回JWT或直接在后端完成认证。后端生成一个一次性的、短期的session_id或设置一个安全的HttpOnly Cookie然后重定向到前端页面。前端页面通过这个session_id或自动携带的Cookie再向后端请求用户信息或令牌。这是最安全的方式避免了令牌在前端URL中暴露。绝对避免使用response.sendRedirect(url “?token” jwt)这种直接将JWT拼接到重定向URL的做法。5.4 常见问题排查实录问题签名验证总是失败。排查首先确认编码。确保使用Base64Url编码替换为-/为_去掉末尾的而不是标准的Base64。在线解码JWT时很多工具会自动处理但自己编代码时容易出错。排查确认密钥完全一致。复制密钥时注意首尾空格、换行符。对于RS256确认使用的是正确的公钥/私钥对且没有误用PEM格式的头部尾部标记如-----BEGIN PUBLIC KEY-----参与签名计算。问题令牌过期逻辑似乎不生效。排查检查服务器时间。确保服务器系统时间准确且与签发令牌的服务时间同步使用NTP。exp和nbf声明都是基于时间的服务器时间不准会导致验证逻辑错乱。问题自定义声明在验证后获取不到。排查在Spring Security中默认的JwtDecoder可能不会将所有声明都提取到Authentication的details或principal中。你可能需要自定义一个ConverterJwt, AbstractAuthenticationToken在转换过程中将需要的声明从Jwt对象中提取出来设置到Authentication对象的权限或属性中。问题性能瓶颈验证签名开销大。排查对于RS256每次验证都需要进行非对称解密运算比HS256慢。可以考虑在API网关层统一进行JWT验证后端微服务信任网关传递的用户身份信息如放在请求头X-User-Id中。或者对验证过的JWT结果进行短期缓存缓存key可以是JWT的签名部分或jti但要注意缓存时间必须远小于令牌的剩余有效期。回过头看“陇剑杯”这样的CTF题目它把JWT这些潜在的风险点做成了一个个需要攻破的关卡。作为开发者我们的任务就是反其道而行之在设计和代码中堵上每一个可能的缺口。安全不是一个特性而是一种贯穿始终的思维方式。从强制算法验证、管理好密钥、设计安全的Payload到处理好令牌的传输与存储每一步的严谨共同构筑了系统的安全防线。