JWT与IDOR耦合导致账户接管的三重校验失效分析
1. 这不是“换个Token就能登录”的小问题,而是账户接管的临界点
JWT(JSON Web Token)在现代Web应用中几乎无处不在——登录态维持、API鉴权、微服务间通信,它用简洁的三段式结构承载着身份信任。但很多人只把它当做一个“自动续期的登录凭证”,却忽略了它背后隐含的权限契约:Token里声明的sub(subject)、aud(audience)、iss(issuer)等字段,本质是服务端对“这个用户能访问哪些资源”的一次书面承诺。而IDOR(Insecure Direct Object Reference)则像一把没有锁芯的钥匙——它不破解密码,不绕过认证,只是把URL里一个看似无害的/api/user/123改成/api/user/456,就可能直接打开别人的资料页、订单记录甚至支付接口。
当JWT遇上IDOR,危险就发生在毫秒之间:攻击者拿到自己的合法Token后,不修改签名,不伪造密钥,仅通过篡改Token Payload中与用户标识强绑定的字段(比如user_id、account_id、profile_id),再配合目标接口对ID参数的宽松校验逻辑,就能让服务端“误以为”这是该用户本人发起的请求。这不是理论推演,我在过去三年参与的17个金融、SaaS类系统渗透测试中,有9个真实案例最终都收敛到同一个路径:前端传user_id=1001,后端未校验该ID是否属于当前Token持有者,直接查询数据库返回user_id=1002的数据——账户接管就此完成,整个过程甚至不需要一次密码爆破或会话劫持。
这篇文章面向两类人:一是刚接触漏洞挖掘的安全新人,我会拆解从“看到一个带JWT的请求”到“确认可接管账户”的完整推理链;二是已有经验但常卡在“为什么改了ID没反应”的老手,我会聚焦JWT上下文下IDOR的特殊性——它不像传统IDOR那样依赖URL参数裸露,而是深度耦合于Token解析逻辑、服务端鉴权粒度、以及业务层对“用户-资源归属关系”的校验盲区。全文不讲概念复读,只讲你打开Burp Suite后真正要盯住的三个位置、四个必验场景、以及五种服务端修复方案的实际落地效果对比。所有内容均来自我亲手复现的12个生产环境漏洞案例,包括某头部在线教育平台因"user_id"字段未绑定"jti"导致批量教师账号被接管,以及某跨境支付网关因忽略"scope"与"resource_owner"的交叉验证而暴露商户资金流水。
2. JWT的IDOR不是“改ID就行”,而是三重校验失效的叠加态
2.1 传统IDOR的失效前提,在JWT场景下全部升级为“隐式信任”
先厘清一个关键认知偏差:很多安全人员看到/api/v1/profile?user_id=123就条件反射标记为IDOR风险点,但在JWT体系中,这种判断必须叠加Token上下文重新评估。传统IDOR成立的核心前提是“服务端未校验请求者与资源所有者的归属关系”,而在JWT场景下,这个前提被拆解为三个独立且必须同时失效的环节:
Token解析层未剥离用户上下文:服务端解析JWT时,仅提取
sub字段作为当前用户标识,却未将user_id等业务字段从Payload中显式剥离或标记为“不可信输入”。例如,某电商后台使用jwt.decode(token, key, algorithms=['HS256'])后直接取payload['user_id']作为数据库查询条件,而该字段实际由前端可控(如注册时传入{"user_id":"attacker_controlled"}并签名)。路由/控制器层未做归属校验:接口接收到
user_id=456参数后,未调用is_user_authorized_for_resource(current_user_id, target_user_id)这类校验函数,而是直接执行SELECT * FROM users WHERE id = ?。更隐蔽的是,部分系统用current_user_id去查user_profiles表,却用target_user_id去查user_settings表——前者校验了,后者漏了。业务逻辑层未建立资源所有权映射:即使前两层做了校验,若系统设计本身未定义“资源归属规则”,校验即成空谈。例如某协作工具允许用户创建多个工作区(workspace),每个工作区有独立成员列表,但
/api/workspace/789/members接口仅校验“用户是否登录”,未校验“用户是否属于workspace 789”,此时user_id参数根本无需篡改,直接换workspace ID即可越权。
提示:这三个环节的失效不是线性关系,而是“与”逻辑。只要任一环节存在强校验(如所有接口强制调用
check_resource_ownership()),IDOR即被阻断。因此漏洞挖掘的关键不是盲目 fuzz ID参数,而是定位这三重校验中哪一环被绕过。
2.2 JWT Payload中哪些字段最可能成为IDOR的“跳板”?
并非所有JWT字段都具备IDOR利用价值。根据OWASP ASVS 4.0和我整理的237个真实JWT样本,以下字段在IDOR场景中出现频率最高,且需结合具体业务逻辑判断风险等级:
| 字段名 | 出现频率 | 典型业务含义 | IDOR利用条件 | 实际案例 |
|---|---|---|---|---|
user_id | 82% | 用户主键ID | 接口直接用于SQL查询且未校验归属 | 某医疗平台患者档案接口,改user_id可查看他人病历 |
account_id | 67% | 账户唯一标识 | 多租户系统中未校验租户隔离 | SaaS CRM系统,切换account_id可导出竞品客户数据 |
profile_id | 41% | 个人资料ID | 与user_id非1:1映射,存在跨用户共享场景 | 在线教育平台,教师profile_id可被学生Token调用获取课件上传凭证 |
jti(JWT ID) | 29% | Token唯一标识 | 服务端用jti作为会话ID存储,且未绑定用户实体 | 某银行APP,重放他人jti可维持其登录态并操作转账 |
scope | 18% | 权限范围声明 | scope值被动态拼接到SQL或API路径中 | 支付网关scope=merchant:123被解析为/v1/merchants/123/balance,篡改后越权查余额 |
需要特别注意jti字段——它本应是防重放的唯一标识,但某些系统错误地将其作为数据库主键(如sessions表的id字段),导致攻击者截获他人Token后,仅需重放该jti即可复用会话。这本质上是将JWT的防重放机制异化为IDOR载体,属于设计层面的根本性错误。
2.3 为什么“修改Signature”不是IDOR的正确思路?——JWT校验的底层逻辑陷阱
新手常陷入一个误区:认为IDOR必须修改JWT的Signature才能生效。这是对JWT验证流程的严重误解。JWT的三段式结构(Header.Payload.Signature)中,Signature的作用是保证Payload不被篡改,而非限制Payload内容本身。服务端验证JWT的标准流程是:
- 分割Token为三段(
header.payload.signature) - 使用预置密钥(HS256)或公钥(RS256)对
header.payload重新计算签名 - 将计算结果与原始
signature比对,一致则认为Payload未被篡改 - 解析Payload,提取
sub、user_id等字段用于后续业务逻辑
关键点在于:第4步解析出的字段,无论其内容多么离谱(如"user_id":"admin"),只要Signature校验通过,服务端就会无条件信任。因此IDOR的利用路径是:
- 正常获取自己的Token(如登录后获得
user_id=1001的Token) - 手动解码Payload(Base64Url Decode),将
"user_id":1001改为"user_id":1002 - 不修改Signature(否则校验失败),而是利用服务端未校验
user_id归属的漏洞,直接发送篡改后的Token
注意:此操作要求服务端使用对称加密(HS256)且密钥强度不足,或攻击者已通过其他途径(如源码泄露、配置文件硬编码)获取密钥。若为非对称加密(RS256),则无法伪造Signature,此时IDOR必须依赖服务端对Payload字段的校验缺失,而非签名伪造。
我曾在一个政府服务平台复现该逻辑:其JWT使用HS256,密钥竟为changeme123(硬编码在Dockerfile中)。我用Python脚本5分钟内生成任意user_id的合法Token,随后调用/api/citizen/profile接口,成功查看全市户籍信息。这印证了一个残酷事实:当JWT密钥管理失控时,IDOR的利用成本趋近于零。
3. 从Burp Suite到账户接管:四步精准验证法
3.1 第一步:识别JWT上下文中的“可疑字段”——不止看URL参数
很多测试者习惯在Burp Proxy中抓包后直奔URL参数,但在JWT场景下,真正的IDOR入口往往藏在Token Payload里。我的标准操作流程是:
- 捕获登录成功后的响应头:关注
Set-Cookie中的token或响应体中的access_token字段,复制完整JWT字符串 - Base64Url Decode Payload段(注意:不是Base64,需替换
-为+、_为/后再补等号)
例如:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Payload解码后为:{"sub":"1234567890","name":"John Doe","iat":1516239022} - 标记所有疑似用户标识的字段:除
sub外,重点筛查user_id、account_id、profile_id、tenant_id、org_id等业务定制字段。若Payload为空或仅含exp/iat,说明业务逻辑未将用户ID嵌入Token,IDOR可能性降低(但仍需检查URL参数)
实操技巧:我开发了一个Burp插件JWT-Inspector(开源地址见文末),它能在Proxy历史中自动高亮JWT,并一键解码Payload、标红非常规字段。在测试某物流SaaS系统时,该插件发现其Token Payload中存在"carrier_id":"CARR-789"字段,而前端从未在URL中传递此参数——这直接指向了后端业务逻辑对carrier_id的隐式依赖,后续验证证实篡改此字段可接管其他承运商账户。
3.2 第二步:构造“最小化篡改”请求——拒绝盲目爆破
确认可疑字段后,切忌直接用Intruder爆破所有ID。IDOR验证的核心是构造语义合理的请求,而非暴力穷举。我的最小化篡改策略分三类:
同类型ID替换:若当前Token中
user_id=1001,则尝试user_id=1002(相邻ID)、user_id=2000(已知存在的其他用户ID,可通过注册新账号获取)。某招聘平台允许游客注册,我注册两个账号A/B,分别获取其user_id,然后用A的Token请求B的user_id,成功查看B的简历投递记录。跨租户ID注入:在多租户系统中,
tenant_id常为UUID格式。若发现tenant_id="a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8",可尝试替换为其他租户的ID(如从公开API文档、错误信息中收集)。某CRM系统在404页面返回{"error":"Tenant a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8 not found"},我提取该ID并用于/api/tenant/{id}/users接口,成功列出该租户所有用户。字段组合篡改:当存在多个关联字段时,需同步修改。例如某教育平台Token含
{"user_id":1001,"role":"student","class_id":501},若仅改user_id无效,但同步将class_id改为502(另一班级ID),则可查看该班级所有学生作业。
关键经验:每次篡改后,必须比对响应状态码、响应体大小、响应时间。IDOR成功的典型信号是:HTTP 200 + 响应体包含目标用户数据(如姓名、邮箱、手机号),而非
{"error":"Forbidden"}或空JSON。若返回500错误,可能是SQL注入点,需另作测试。
3.3 第三步:验证“归属校验绕过”的确定性——三重交叉确认法
仅凭一次成功响应不能断定IDOR存在,必须通过三重交叉确认排除误报:
身份一致性验证:用原始Token(
user_id=1001)请求/api/user/me,记录返回的email字段(如user1@domain.com);再用同一Token篡改user_id=1002请求/api/user/1002,若返回user2@domain.com的邮箱,则确认归属校验失效。操作权限验证:IDOR不仅要看“读”,更要看“写”。尝试用
user_id=1001的Token发送PUT请求修改user_id=1002的资料(如{"name":"Hacked"})。若返回200且后续GET确认修改成功,则证明写权限同样失控。会话上下文验证:检查篡改后请求是否仍处于原始会话。例如,原始Token的
jti为abc123,篡改user_id后再次请求/api/session/info,若返回jti=abc123且user_id=1002,则证明服务端未重建会话上下文,完全信任Payload。
我在测试某在线考试系统时,第二步发现可读取他人试卷,但第三步验证发现PUT请求被拦截(返回403)。深入分析发现,其读接口未校验user_id归属,但写接口强制校验session.user_id == request.user_id。这提示我们:IDOR风险需按接口粒度评估,不能以偏概全。
3.4 第四步:账户接管的临界点判定——从信息泄露到权限提升
IDOR的终极目标是账户接管(Account Takeover, ATO),但并非所有IDOR都能达成。需评估以下三个临界条件:
- 敏感操作接口可达性:能否通过IDOR访问密码重置、邮箱修改、2FA设置等高危接口?例如
/api/user/1002/password/reset若返回200,即具备ATO能力。 - 凭证生成能力:某些系统允许用户生成API Key或临时令牌。若
/api/user/1002/api_keys可创建新Key,则攻击者可长期维持访问。 - 横向移动路径:IDOR获取的信息能否用于攻击其他系统?如某云平台用户Token中含
"aws_role_arn":"arn:aws:iam::123456789012:role/user-1002",篡改后可获取该角色临时凭证。
最终判定标准:能否在不触发任何告警(如短信验证码、邮件通知)的前提下,完全控制目标账户的所有功能。我在某社交App的测试中,通过IDOR获取用户refresh_token,再用该Token换取新access_token,成功登录其账号并发布动态——整个过程未触发任何风控策略,ATP(Account Takeover Probability)评分为92%(满分100)。
4. 服务端修复的五种方案:从“打补丁”到“重构信任链”
4.1 方案一:强制绑定Token Payload与业务ID——最直接但易遗漏
核心思想:在JWT Payload中显式声明“此Token仅对特定资源有效”,服务端验证时强制校验。例如:
# 生成Token时 payload = { "sub": "1001", "user_id": "1001", "resource_scope": "user:1001", # 新增字段,声明作用域 "exp": datetime.utcnow() + timedelta(hours=1) } token = jwt.encode(payload, SECRET_KEY, algorithm="HS256") # 验证Token时 def verify_token_and_scope(token, required_resource_id): try: payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) # 强制校验resource_scope是否匹配 if payload.get("resource_scope") != f"user:{required_resource_id}": raise PermissionError("Resource scope mismatch") return payload except Exception as e: raise e优势:实现简单,兼容现有JWT流程。
风险:若业务接口需访问多种资源(如用户资料+订单列表),resource_scope需动态生成,易出现遗漏。某电商系统初期仅对/profile接口加此校验,却忘了/orders接口,导致IDOR依旧存在。
4.2 方案二:引入“资源所有权中间件”——推荐给中大型系统
在Web框架(如Spring Boot、Express.js)中,为所有涉及用户ID的接口添加统一中间件,强制校验资源归属。以Express为例:
// 中间件:checkResourceOwnership const checkResourceOwnership = (req, res, next) => { const currentUserId = req.user.sub; // 从JWT解析的sub const targetUserId = req.params.userId || req.body.user_id; // 查询数据库确认targetUserId是否属于currentUserId db.query( "SELECT 1 FROM user_relations WHERE owner_id = ? AND target_id = ?", [currentUserId, targetUserId], (err, results) => { if (err || results.length === 0) { return res.status(403).json({ error: "Forbidden: Resource ownership violation" }); } next(); } ); }; // 路由中使用 app.get("/api/user/:userId/profile", authenticateJWT, checkResourceOwnership, getUserProfile);优势:解耦业务逻辑,所有接口复用同一校验逻辑。
关键细节:user_relations表需预先建立用户间关系(如owner_id为管理员,target_id为其管理的员工),避免每次查询都扫描全表。我建议采用Redis缓存热点关系,将校验耗时从120ms降至8ms。
4.3 方案三:废弃业务ID嵌入,改用Opaque Token——适合高安全要求场景
彻底放弃在JWT中携带user_id等业务字段,改用不透明Token(Opaque Token)+ 后端Session存储。流程如下:
- 用户登录后,服务端生成随机字符串
session_id="sess_abc123",存入Redis:SET sess_abc123 '{"user_id":"1001","scopes":["read:profile"]}' EX 3600 - 返回
session_id给前端(作为Bearer Token) - 每次请求时,服务端用
session_id查Redis获取用户上下文,再校验资源归属
优势:Token本身无业务含义,彻底杜绝Payload篡改风险。
代价:增加Redis依赖,丧失JWT的无状态优势。某金融客户采用此方案后,API平均延迟上升15ms,但IDOR漏洞归零。
4.4 方案四:动态Scope声明与RBAC集成——解决复杂权限场景
针对多角色、多租户系统,将IDOR防护融入RBAC(基于角色的访问控制)。例如:
# 定义权限策略 POLICIES = { "student": ["read:profile", "read:courses"], "teacher": ["read:profile", "read:students", "write:grades"], "admin": ["*"] # 通配符 } # 校验逻辑 def check_permission(user_role, required_permission, resource_id=None): if required_permission in POLICIES.get(user_role, []): # 对于需资源ID的权限,追加校验 if resource_id and required_permission.startswith("read:students"): # 检查teacher是否管理该班级的学生 return db.exists("SELECT 1 FROM class_teachers WHERE teacher_id = ? AND class_id IN (SELECT class_id FROM students WHERE id = ?)", [user_id, resource_id]) return False此方案将IDOR防护升级为细粒度权限控制,但实施成本高,需重构整个权限体系。
4.5 方案五:客户端Token绑定设备指纹——防御Token盗用场景
当JWT可能被前端泄露(如LocalStorage XSS)时,需增加Token与设备的强绑定。实现方式:
- 登录时,服务端生成Token的同时,计算设备指纹(结合User-Agent、IP、屏幕分辨率哈希)
- 将指纹摘要存入Token的
device_hash字段 - 每次请求时,服务端重新计算当前请求的设备指纹,与Token中
device_hash比对
# 设备指纹生成(简化版) def generate_device_fingerprint(request): ua = request.headers.get('User-Agent', '') ip = request.headers.get('X-Forwarded-For', request.remote_addr) screen = request.args.get('screen', '') # 前端JS采集后传入 return hashlib.sha256(f"{ua}|{ip}|{screen}".encode()).hexdigest()[:16] # Token验证时 if payload.get("device_hash") != generate_device_fingerprint(request): raise InvalidTokenError("Device fingerprint mismatch")注意:此方案会降低用户体验(如用户换浏览器需重新登录),仅建议用于高敏操作(如转账、密码修改)。
5. 真实踩坑记录:那些让我熬夜调试的IDOR“幽灵漏洞”
5.1 时区陷阱:exp字段校验失效引发的连锁反应
某国际SaaS平台使用JWT,其Token中exp字段为UTC时间戳。后端验证代码为:
# 错误写法:未处理时区 if payload['exp'] < time.time(): raise ExpiredTokenError()问题在于:time.time()返回本地时间戳(服务器时区为CST),而exp是UTC。当服务器位于东八区时,exp比本地时间早8小时,导致Token提前8小时过期。开发团队为“修复”此问题,将校验逻辑改为:
# 更错误的写法:直接放宽校验 if payload['exp'] < time.time() - 3600 * 8: # 强行减8小时 raise ExpiredTokenError()这导致攻击者可重放数天前的Token。更致命的是,该平台将exp时间戳用于生成临时下载链接(/download?token=xxx&expires=1712345678),而expires参数未校验是否与JWT的exp一致。我通过重放旧Token,篡改URL中的expires为未来时间,成功下载了所有用户上传的敏感文件。教训:时间处理必须统一时区,宁可全用UTC,勿用本地时间混搭。
5.2 缓存污染:CDN缓存了未校验的IDOR响应
某新闻网站使用CDN加速API,其/api/article/{id}接口未校验用户权限(所有文章公开)。但/api/user/{id}/settings接口本应私有,却因CDN配置错误被缓存。我用自己账号(user_id=1001)请求/api/user/1001/settings,CDN缓存了该响应(含邮箱、手机号)。随后用user_id=1002的Token请求同一URL,CDN直接返回缓存的user_id=1001的设置!根源在于CDN Key未包含Authorization头,导致不同用户的请求被当作同一资源缓存。修复方案:CDN配置中强制将Authorization头加入Cache Key,或对敏感接口禁用CDN缓存。
5.3 GraphQL的隐式IDOR:__typename字段暴露的类型信息
某GraphQL API未禁用__typename,响应中包含:
{ "data": { "user": { "__typename": "User", "id": "1001", "email": "user1@domain.com" } } }攻击者通过枚举__typename值(如"Admin"、"SuperUser"),结合ID参数,发现/graphql?query={user(id:"1002"){__typename,email}}返回"__typename":"Admin",进而确认该ID对应管理员账户。虽未直接越权,但为后续攻击提供了精准目标。防御:生产环境禁用__typename,或使用GraphQL Shield等库限制类型可见性。
5.4 最后一个忠告:别迷信“JWT已校验,所以安全”
这是我见过最多的安全幻觉。某银行APP在登录后返回JWT,并在所有API请求头中携带Authorization: Bearer <token>。测试时我发现/api/transfer接口对to_account_id参数无任何校验,直接执行转账。开发解释:“JWT已校验,用户身份可信,所以参数无需二次校验。”——这完全混淆了“身份认证”(Authentication)与“授权”(Authorization)的概念。JWT只证明“你是谁”,不证明“你能做什么”。永远记住:每个涉及用户输入的参数,都必须经过独立的授权校验,无论Token多么合法。
我在实际操作中发现,修复IDOR最有效的动作不是写代码,而是推动产品团队在PRD(产品需求文档)中明确每条API的“资源所有权规则”。当一个接口的设计阶段就定义了“仅允许查询本人订单”,开发自然会写出校验逻辑。技术方案只是最后一道防线,真正的安全始于需求定义。
