#912·eladmin

[Security] JWT userId 未与 Redis 在线会话绑定,导致跨用户身份混淆与条件性垂直越权

Author: 28HusCreated Aug 4, 2026Updated Aug 31, 2026

[Security] JWT userId 未与 Redis 在线会话绑定,导致跨用户身份混淆与条件性垂直越权

您好,我们在最新版 master 分支上独立复现了一个与密码重置问题不同的 JWT 身份绑定缺陷。

该问题不是 #902 / #910 所报告的“低权限用户重置任意用户密码”问题,而是:应用使用 Redis 验证 sub + uid 对应的在线会话是否存在,但后续业务逻辑直接信任 JWT 中可被重新签名修改的 userId。因此,攻击者可以保留自己合法会话的 subuid,仅修改 userId,使不同安全边界使用不同的用户身份。

测试版本

  • Repository: https://github.com/elunez/eladmin
  • Branch: master
  • Tested commit: 55fbf705956949697dbd68bf9003776609d3d029
  • Tested date: 2026-08-04
  • 测试使用本地隔离部署,未修改应用源代码。

根因

1. JWT 签名密钥公开且固定

application-dev.ymlapplication-prod.yml 中都包含固定的 Base64 JWT 密钥,密钥随公开源代码可获得:

TokenProvider 使用该密钥创建 HS512 签名:

2. Redis 校验没有绑定用户 ID

Token 的 Redis key 由 JWT 的 subuid 组成:

过滤器只检查这个 Redis 在线记录是否存在,然后建立认证上下文;没有比较 JWT 的 userId 与 Redis 中服务端在线用户对应的真实用户 ID:

3. 业务逻辑直接信任 JWT userId

SecurityUtils.getCurrentUserId() 直接从 JWT payload 读取 userId

例如,个人资料接口使用这个值判断请求者是否正在修改自己的资料:

用户角色级别检查也使用这个值:

菜单构建接口同样使用 JWT 中的 userId 查询菜单:

复现条件

攻击者需要:

  1. 拥有一个普通的有效账户,能够通过正常登录获得在线会话;
  2. 能够读取公开源代码中的 JWT 密钥;
  3. 使用该密钥重新签发一个 JWT,保留自己合法会话的 subuid,将 userId 修改为目标账户 ID。

伪造后的 payload 形式如下,其中 uid 必须是攻击者自己正常登录获得的值:

{
  "sub": "test",
  "uid": "<legitimate-test-uid>",
  "userId": 1
}

由于 Redis key 仍然是合法用户对应的:

online_token:test:<legitimate-test-uid>

过滤器接受该 Token,但业务层读取到的当前用户 ID 已经是 1

动态验证结果

Redis 能阻止直接伪造管理员 subject

我们首先验证了该问题不是简单的“普通用户把 sub 改成 admin 即可直接登录管理员账号”:

随机 uid + sub=admin                  -> HTTP 401
合法 test uid + sub=admin             -> HTTP 401
合法 test uid + sub=test + userId=1   -> 请求被接受

这说明 Redis 的 sub + uid 检查仍然存在,但它没有保护 userId 这一单独的身份来源。

默认权限配置下的结果

在默认本地部署中,使用普通 test 用户的合法 Token 与伪造 Token 对比:

接口 合法 test Token 伪造 userId=1 Token
GET /api/roles/level 返回普通用户角色级别 2 返回管理员对应级别 1
GET /api/menus/build 返回普通用户菜单树 返回管理员菜单树
PUT /api/users/center,请求体 ID 为 1 HTTP 400 HTTP 204

使用伪造 Token 调用个人资料接口后,管理员账户的 nickNamephone 被修改。该接口本身只更新个人资料字段,并没有证明可以通过该接口直接修改管理员密码或角色。

具有用户管理权限时的条件性垂直越权

默认 test 账户没有 user:edit 权限,因此调用 PUT /api/users 会先被权限注解拦截。为了验证后续角色级别检查,我们只在隔离测试数据库中临时授予 test user:edit 权限:

合法 test Token + PUT /api/users       -> 角色级别检查拒绝
伪造 userId=1 Token + PUT /api/users   -> HTTP 204
伪造 Token                              -> 管理员邮箱被修改

这是因为 checkLevel() 使用 SecurityUtils.getCurrentUserId() 查询当前用户角色级别。伪造 userId=1 后,服务端将管理员的角色级别作为“当前操作者”级别进行比较,导致该检查失效。

影响范围

默认部署可确认的影响

  • 修改其他用户的昵称、电话等个人资料;
  • 获取管理员菜单树及用户角色级别等身份相关信息;
  • 造成请求者身份与服务端在线会话身份不一致。

特定角色配置下的影响

如果部署环境向非管理员角色授予 user:edit,同样的身份混淆会影响用户修改接口的角色级别检查,可能进一步修改管理员账户字段。user:adduser:del 也包含基于当前用户 ID 的角色级别逻辑,建议一并修复并加入回归测试;本次动态验证重点确认了 user:edit 路径。

因此,该问题不是“任何普通用户无条件直接变成管理员”,而是一个由固定 JWT 密钥和缺失身份绑定共同造成的跨用户授权绕过。实际严重程度取决于部署环境为低权限角色授予的权限。

与已有密码重置问题的区别

本 Issue 不重复报告以下已有问题:

上述问题关注的是未授权用户通过 PUT /api/users/resetPwd 重置任意用户密码。本 Issue 关注的是 JWT userId 与 Redis 在线会话身份没有绑定,影响个人资料、菜单/角色上下文和特定权限配置下的用户管理授权检查。两者根因和利用路径不同。

修复建议

  1. 立即轮换已经公开的 JWT 密钥,不要再将生产密钥放在仓库配置文件中;

  2. 在服务端在线用户记录中保存真实用户 ID,并验证:

    JWT.sub    == server-side username
    JWT.uid    == server-side session uid
    JWT.userId == server-side user id
    
  3. 更推荐从已经验证的 sub 和服务端在线用户记录中解析当前用户 ID,不要将 JWT userId 作为业务授权的可信来源;

  4. 修复 centerUser()checkLevel()、用户删除和菜单构建等调用点,统一使用服务端解析出的当前用户身份;

  5. 为伪造 userId 的 Token 增加回归测试,并覆盖 user:adduser:edituser:del 等委派权限场景。

感谢维护该项目。我们愿意提供更完整的复现日志、测试脚本,并在维护者确认修复方向后协助提交 Pull Request。