JWT与IDOR漏洞组合利用:从逻辑缺陷到账户完全接管的实战分析 1. 项目概述一次价值2500美元的漏洞赏金实战最近在参与一个私密漏洞赏金项目时我遇到了一个教科书级别的案例通过一个看似不起眼的JWTJSON Web Token设计缺陷结合经典的IDOR不安全的直接对象引用漏洞最终实现了对任意用户账户的完全接管。这个漏洞为我赢得了2500美元的赏金更重要的是它完美地展示了现代Web应用中认证授权逻辑的脆弱环节是如何被串联利用的。整个过程没有用到任何高深的0day纯粹是对业务逻辑和现有技术组件的深度理解与测试。如果你正在学习Web安全或者是一名开发者希望加固自己的应用这个案例中的每一个细节都值得你仔细琢磨。简单来说这个漏洞的核心路径是篡改JWT令牌中的用户标识字段 - 利用后端API未对请求者身份进行二次校验 - 越权访问并修改其他用户的敏感信息如绑定邮箱、手机号 - 最终通过密码重置功能完成账户接管。听起来像是多个漏洞的组合没错但安全防线往往就是在这样的“组合拳”下被击穿的。接下来我将完整复盘这次漏洞挖掘的思路、测试过程、利用链的构建以及最重要的——开发者和安全工程师分别该如何防御。2. 核心漏洞原理深度拆解JWT与IDOR的致命交汇要理解这个漏洞我们必须先抛开“JWT”和“IDOR”这两个术语的抽象面纱深入到它们在实际代码中的具体表现。2.1 JWT不仅仅是“令牌”更是携带身份声明的小型数据库JWT本质上是一个经过签名或加密的JSON对象常用于在客户端如浏览器和服务器之间安全地传递声明信息。一个典型的JWT由三部分组成Header头部、Payload负载和Signature签名中间用点.分隔形如xxxxx.yyyyy.zzzzz。在这个漏洞案例中关键的突破口在于JWT的Payload部分。Payload包含了所谓的“声明”Claims也就是关于用户或其他实体和附加数据的信息。常见的声明有iss(Issuer)签发者sub(Subject)主题用户IDaud(Audience)接收方exp(Expiration Time)过期时间iat(Issued At)签发时间然而许多应用为了实现自定义业务逻辑会在Payload中添加自定义声明例如user_id、username、email、role等。这里就埋下了第一个隐患开发者过度信任JWT中的内容。注意JWT的签名Signature部分是为了防止令牌被篡改。如果使用HMAC算法如HS256签名密钥必须严格保密在服务端如果使用非对称算法如RS256则用私钥签名公钥验证。只要签名验证通过后端程序通常会无条件相信Payload里的数据是真实有效的。但问题在于验证了签名不等于验证了业务逻辑的权限。在我测试的目标应用中解码其JWT后发现Payload结构如下{ sub: user123, user_id: 45678, email: victimexample.com, role: member, iat: 1648886400, exp: 1648972800 }可以看到除了标准的sub还有一个自定义的user_id: 45678。我的第一个疑问是当后端需要获取当前登录用户的ID时它是从sub字段取还是从user_id字段取这两个值是否总是一致的2.2 IDOR权限校验的“失位”IDOR即不安全的直接对象引用在OWASP Top 10中长期占有一席之地。它的核心是应用程序在处理用户对某个对象的访问请求时直接使用了用户提供的参数如数据库ID、文件名、账号名来标识该对象却没有验证当前登录的用户是否有权访问这个特定的对象。一个经典的例子是GET /api/v1/users/12345/profile如果用户A自己的ID是111将请求中的12345改成222就能访问用户B的资料而服务端没有检查if (current_user_id 222)这就是IDOR。在这个案例中IDOR并非发生在简单的查看资料接口而是出现在用户资料修改或账户绑定等更高危的API端点。漏洞利用链的关键一跃就是发现了一个接受user_id参数来修改用户邮箱或手机号的API。2.3 漏洞串联当可信的JWT遇上失灵的校验现在让我们把这两块拼图结合起来看看漏洞是如何产生的逻辑假设后端开发者的逻辑可能是“我们从经过签名验证的JWT的user_id字段中取出当前用户ID然后用这个ID去更新对应用户的记录。因为JWT是不可篡改的所以这个ID一定是当前登录用户的很安全。”现实漏洞这个逻辑存在一个致命的盲点。它假设了处理请求的微服务或API端点所使用的user_id一定来自它自己解密的JWT。但在某些架构下事情并非如此。攻击面我发现了这样一个API端点PUT /api/v1/account/contact Content-Type: application/json Authorization: Bearer JWT_TOKEN { user_id: 45678, new_email: attacker_controlledevil.com }请注意请求体Body里明确包含了一个user_id字段。后端可能的伪代码如下# 伪代码 - 存在漏洞的版本 def update_contact(request): token request.headers.get(Authorization).split( )[1] payload jwt.decode(token, SECRET_KEY, algorithms[HS256]) # 验证JWT签名 current_user_id_from_token payload[user_id] # 从Token取ID # 错误直接使用了请求体中的user_id而没有与Token中的进行比对 target_user_id request.data.get(user_id) new_email request.data.get(new_email) user User.objects.get(idtarget_user_id) user.email new_email user.save() return Response(更新成功)看到了吗后端虽然验证了JWT的签名确保令牌是合法的也从JWT中读取了current_user_id_from_token但在执行核心操作User.objects.get(idtarget_user_id)时却使用了从请求体中直接提取的target_user_id。这两个ID之间没有任何校验关系。攻击者的操作作为一个攻击者我拥有一个合法账户我的JWT中user_id是111。我截获修改联系方式的请求将Body中的user_id: 111修改为user_id: 45678目标受害者ID而Authorization头中的JWT保持不变还是我自己的合法令牌。服务器验证JWT签名通过然后就用我提供的45678去数据库查找并修改了受害者的邮箱。这就是漏洞的本质后端错误地将“身份认证”Authentication JWT签名有效等同于“授权”Authorization 有权修改目标资源。它认证了“你是谁”通过JWT但没有授权检查“你是否被允许操作这个对象”。3. 漏洞挖掘与测试实操全流程理论清晰后实战才是关键。下面是我从信息收集到漏洞确认的完整步骤其中包含了许多手动测试和工具使用的技巧。3.1 信息收集与目标分析首先我明确了目标是一个提供在线服务的Web应用拥有完整的用户注册、登录、个人资料管理、账户安全设置等功能。使用浏览器开发者工具F12的“网络”Network选项卡是我开始任何Web应用安全测试的第一步。注册与登录流程分析我注册了一个测试账号观察登录过程。登录成功后几乎所有后续请求的HTTP头部都带上了Authorization: Bearer 很长的一段字符串。这立刻确认了其使用Bearer Token进行认证而Token很可能就是JWT。JWT令牌获取与解码从任意一个API请求中复制出Token。使用浏览器插件如JWT Editor或命令行工具如jwt.io网站或jwt-cli对其进行解码。这里我习惯先用jwt-cli快速看一眼echo -n eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwidXNlcl9pZCI6NDU2NzgsImVtYWlsIjoidmljdGltQGV4YW1wbGUuY29tIiwicm9sZSI6Im1lbWJlciIsImlhdCI6MTY0ODg4NjQwMCwiZXhwIjoxNjQ4OTcyODAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c | jwt decode -解码后我清晰地看到了Payload结构并重点关注了user_id这个自定义声明。API接口枚举在登录状态下遍历应用的每一个功能点修改头像、更新个人简介、更换绑定邮箱、添加备用手机、修改密码、查看订单历史等。同时使用Burp Suite的代理功能拦截所有HTTP/HTTPS请求并将其发送到“目标”Target站点地图中自动梳理出API接口目录。我特别关注那些使用PUT、POST、PATCH方法的端点尤其是URL或参数中带有数字ID的。3.2 定位可疑的IDOR端点在收集到的数十个API端点中我通过筛选和快速测试锁定了几个高危目标GET /api/v1/users/[id]/profile- 查看用户资料PUT /api/v1/users/[id]/profile- 修改用户资料POST /api/v1/account/change-email- 修改邮箱这个看起来更危险POST /api/v1/account/bind-phone- 绑定手机我首先测试了简单的GET请求将[id]换成其他用户的ID发现返回了“无权访问”的错误。这说明基础的视图类权限控制是存在的。但这并不意味着安全因为权限校验可能只在“查看”接口实现而在“修改”接口遗漏。当我测试POST /api/v1/account/change-email时转机出现了。拦截的请求如下POST /api/v1/account/change-email HTTP/1.1 Host: target.com Authorization: Bearer MY_JWT_TOKEN Content-Type: application/json {new_email: mynewemailtest.com}请求体里没有user_id这看起来是“修改当前用户邮箱”的设计似乎很安全。但经验告诉我不要轻易放弃。我尝试在请求体中主动添加一个user_id字段{user_id: 45678, new_email: attackerevil.com}发送请求后服务器返回了一个错误{error: Invalid user_id format}。这个错误信息反而让我兴奋起来——它说明后端代码尝试去解析user_id字段了虽然因为格式不对失败了但证明了这个参数是被读取的。3.3 构造攻击请求与JWT的关联测试接下来的问题是后端是如何确定user_id的是从JWT里取还是从请求参数里取或者是两者都取但以某个为准我设计了以下测试用例测试A正常请求。用我的令牌user_id: 111发送{new_email: testAtest.com}。成功。邮箱被改为testAtest.com。测试B添加错误格式user_id。用我的令牌发送{user_id: not_a_number, new_email: testBtest.com}。返回上述格式错误。说明它在校验我传入的user_id。测试C添加其他用户的user_id。这是关键一步。我创建了另一个测试账号B其user_id为45678。然后我仍然使用我自己的账号A的JWT令牌但发送以下请求{user_id: 45678, new_email: hackedevil.com}心跳加速的时刻——服务器返回了{status: success, message: Email updated successfully.}我立刻登录账号B或使用密码重置功能到邮箱hackedevil.com发现账号B的绑定邮箱真的被修改了。账户接管的第一个关键步骤达成我们控制了目标账户的绑定邮箱。3.4 漏洞利用链的完成密码重置与完全接管控制了邮箱往往就意味着控制了账户。大多数网站的密码重置流程是用户点击“忘记密码”。输入注册邮箱或用户名。系统向该邮箱发送一封包含重置链接或验证码的邮件。用户点击邮件中的链接或输入验证码设置新密码。现在攻击者邮箱hackedevil.com已经成为了受害者账号的绑定邮箱。攻击者只需在登录页点击“忘记密码”。输入受害者的用户名或原邮箱victimexample.com。系统会将重置邮件发送到新的绑定邮箱hackedevil.com。攻击者查看自己的邮箱hackedevil.com点击重置链接为受害者账号设置一个自己知道的新密码。使用新密码登录受害者账号完成完全接管。在实际测试中我按此流程操作顺利重置了测试账号B的密码并登录。至此从JWT IDOR到账户接管的完整利用链被证实。4. 漏洞根源与深度防御方案这个漏洞看似是编码疏忽实则反映了系统设计和代码审查流程上的深层问题。4.1 根因分析多层逻辑的混淆错误的信任边界开发者混淆了“数据来源的完整性”和“数据使用的合法性”。JWT签名保证了Payload在传输途中未被篡改但它无法保证客户端发送的、在Payload之外的其他参数如请求体中的user_id是合法的。服务器必须对每一个来自客户端的、用于标识资源的参数进行权限校验。脆弱的服务间约定在微服务架构中可能有一个“认证服务”负责签发JWT而“用户服务”负责处理/account/change-email。用户服务从JWT中解析出user_id但它可能同时也暴露了一个“内部接口”允许其他服务传入user_id来修改邮箱。而对外暴露的API错误地沿用了内部接口的逻辑没有过滤user_id参数。缺乏统一的权限校验中间件最佳实践是在请求进入业务逻辑Controller之前由一个统一的拦截器或中间件从认证信息如JWT中提取当前用户身份并将其与请求参数中的资源ID进行比对。如果业务逻辑函数还需要处理“管理员修改用户”等场景那么这个“当前用户ID”应该作为参数显式传递而不是从不可信的请求参数中再次获取。4.2 针对开发者的修复方案对于开发者而言修复此类漏洞必须从设计和代码两个层面入手原则永远从可信上下文中获取用户身份在控制器或中间件中一旦通过JWT认证就将解码出的用户ID如payload[sub]或payload[user_id]存储在请求上下文对象中例如Flask的g对象、Express的req.user、Django的request.user。在所有需要用户ID的业务逻辑中强制从这个可信的上下文获取而不是从请求参数Query、Body、Path中读取。# 伪代码 - 修复后的版本 (使用Python Flask示例) from flask import g, request import jwt app.before_request def load_user(): token request.headers.get(Authorization, ).replace(Bearer , ) try: payload jwt.decode(token, SECRET_KEY, algorithms[HS256]) g.current_user_id payload[user_id] # 存储在全局请求上下文 except: g.current_user_id None app.route(/api/v1/account/contact, methods[PUT]) def update_contact(): if not g.current_user_id: return {error: Unauthorized}, 401 # 直接使用上下文中的用户ID忽略请求体中的任何user_id target_user_id g.current_user_id new_email request.json.get(new_email) # 可选再次确认数据库中存在此用户防止逻辑删除等情况 user User.query.get(target_user_id) if not user: return {error: User not found}, 404 user.email new_email db.session.commit() return {status: success}实施资源级权限检查中间件对于RESTful API可以设计一个通用装饰器或中间件。例如对于路径如/api/v1/users/int:user_id/profile的接口中间件自动比较int:user_id与g.current_user_id如果不匹配且当前用户不是管理员则直接返回403 Forbidden。# 伪代码 - 权限检查装饰器 def require_self_or_admin(resource_param_nameuser_id): def decorator(f): wraps(f) def decorated_function(*args, **kwargs): requested_id kwargs.get(resource_param_name) if requested_id is not None: if g.current_user_id ! requested_id and not g.is_admin: return {error: Forbidden}, 403 return f(*args, **kwargs) return decorated_function return decorator app.route(/api/v1/users/int:user_id/profile, methods[PUT]) require_self_or_admin(user_id) # 自动检查 def update_user_profile(user_id): # 这里的user_id已经通过权限检查可以安全使用 # ... 业务逻辑 ...审计与清理API接口对所有接收user_id、account_id、username等资源标识符的API端点进行审计。问自己这个参数是必要的吗对于“修改当前用户”的操作根本不应该需要客户端传递ID。对于管理员操作ID应该来自可信路径如从Token解析的管理员身份决定操作目标或使用独立的、经过严格校验的管理员接口。4.3 针对安全工程师的检测与防范建议如果你是安全工程师或正在进行渗透测试以下方法可以帮助你系统地发现此类问题主动测试方法论参数污染对于任何修改操作的APIPOST, PUT, PATCH, DELETE在请求的各个位置URL路径、查询参数、请求体、甚至Cookie、Header尝试添加或修改资源ID参数。例如一个正常的更新个人资料的请求是PUT /api/v1/profile你可以尝试改为PUT /api/v1/profile?user_id他人ID或PUT /api/v1/profile/他人ID。JWT篡改测试即使签名无法伪造也要测试服务器对JWT内容的依赖程度。使用一个合法令牌但尝试用jwt.io等工具修改Payload中的user_id为他人ID这会破坏签名然后发送请求。观察响应是返回“令牌无效”还是返回“用户不存在”后者可能意味着服务器先验证签名发现失败后又回退到从请求参数中读取ID这是另一种逻辑漏洞。批量测试使用Burp Suite的Intruder模块对user_id等参数进行批量枚举测试观察响应长度和状态码的差异快速识别出成功越权的请求。代码审计关注点在代码审计时重点搜索从请求对象request.params,request.body,request.args等中获取用户标识符的代码行。跟踪这些标识符的流向看它们是否被直接用于数据库查询如User.find(idrequested_id)而前面缺少与当前会话用户标识符的比较语句。特别关注那些函数签名中同时包含了“当前用户”和“目标ID”参数的函数检查内部是否用对了参数。自动化工具辅助使用像Burp Suite的“Autorize”这类插件它能自动帮你切换不同权限级别的用户会话重放请求高效地检测水平越权同权限用户访问彼此资源和垂直越权低权限用户访问高权限功能。在CI/CD管道中集成静态应用安全测试SAST工具可以配置规则来检测“从不可信源获取资源ID而未经验证”的模式。5. 拓展思考与高级攻击场景这个案例虽然典型但现实世界的漏洞往往更加隐蔽和复杂。基于此我们可以思考几种更高级或更隐蔽的变体5.1 JWT算法混淆攻击CVE-2015-9235这是JWT本身的一个经典漏洞。如果服务器配置不当例如同时支持HS256对称加密和RS256非对称加密算法攻击者可能进行算法混淆攻击。原理服务器使用RS256公钥验证私钥签名。攻击者将JWT头部中的alg改为HS256然后用公开的RSA公钥作为HMAC的密钥伪造一个签名。如果服务器看到algHS256就错误地使用RSA公钥作为HMAC密钥去验证签名而公钥是攻击者已知的因此验证会通过。与本漏洞的结合如果攻击者通过算法混淆伪造了一个包含受害者user_id的JWT那么他甚至不需要利用IDOR因为令牌本身就已经“声明”他是受害者了。防御方法很简单在代码中强制指定验证算法如jwt.decode(token, key, algorithms[RS256])而不是使用algorithmsNone接受任何算法。5.2 基于时间戳的IDOR资源标识符不一定是数字ID有时可能是用户名、邮箱甚至是创建时间戳。我曾见过一个API通过created_at参数来查询订单GET /api/orders?created_after2023-01-01。如果后端只是简单地用这个时间戳去过滤“当前用户的订单”但查询逻辑有误就可能泄露其他用户在相同时期创建的订单。测试时要对任何具有唯一性或标识性的参数保持敏感。5.3 间接对象引用导致的IDOR有时IDOR不是直接修改“用户”对象而是修改与用户关联的其他对象从而达到相同目的。例如修改“密码重置请求”的状态或关联邮箱。修改“邮箱验证令牌”的绑定账户。在“家庭组”功能中越权添加或删除成员从而获得对组内资源的访问权。 测试时需要画出应用的数据模型关系图思考通过操作A对象是否最终能影响B对象目标用户的安全状态。5.4 工具链与测试环境搭建心得工欲善其事必先利其器。高效的漏洞挖掘离不开顺手的工具链。代理与中继Burp Suite Professional是核心其Repeater、Intruder、Scanner模块不可或缺。对于社区版用户可以搭配OWASP ZAP和Burp Suite Community使用。JWT专项工具jwt.io网站用于快速解码和编码jwt_tool一款命令行工具功能强大支持多种攻击测试如混淆、爆破弱密钥、伪造等浏览器插件“JWT Editor”方便在Burp中直接修改重放请求中的JWT。测试账户管理准备至少两个同权限的测试账号用于测试水平越权和一个高权限账号如果可能用于测试垂直越权。使用浏览器的“多用户容器”功能如Firefox的Container或不同的浏览器/无痕窗口同时保持多个会话方便切换和对比。请求对比将两个不同用户对同一功能的请求进行逐字对比Burp的Comparer功能差异点往往就是潜在的参数突破口。挖掘此类漏洞耐心和系统性的测试思维比掌握炫酷的攻击技术更重要。从理解应用如何认证JWT如何使用到理解其如何授权如何判断用户能否操作某资源一步步推演在每一个可能传递身份或资源标识的地方尝试突破你就有很大机会发现那些隐藏在逻辑深处的安全漏洞。这次2500美元的赏金本质上是对这种系统化思考和实践的奖励。