最近做代码审计,遇到一个SpringCloud系统存在漏洞,找到了Controller,但是不会发请求包,于是通过RuoYi-Cloud来学一下怎么构造请求路径

环境搭建

参考链接 https://doc.ruoyi.vip/ruoyi-cloud/document/hjbs.html

搭建环境

  • RuoYi-Cloud v3.6.6,自行克隆代码checkout
  • JDK 17.0.16
  • Windows x64
  • Kali

拉取必要镜像

1
2
3
docker pull mysql:5.7
docker pull redis:8
docker pull nacos/nacos-server:v3.1.0

MySQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
rm -rf /data/mysql/{data,conf,logs}
mkdir -p /data/mysql/{data,conf,logs}
mkdir -p /data/mysql/init
# init目录下放初始化SQL
docker run -d \
--name mysql57 \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=root \
-e TZ=Asia/Shanghai \
-v /data/mysql/data:/var/lib/mysql \
-v /data/mysql/conf:/etc/mysql/conf.d \
-v /data/mysql/logs:/var/log/mysql \
-v /etc/localtime:/etc/localtime \
mysql:5.7 \
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_unicode_ci \
--default-time-zone=+8:00

创建容器后,使用Navicat连接,自行执行SQL语句

随后将ry-config数据中config_info表下关于Redis和MySQL配置,修改成正确的连接地址和密码(我这里是Docker服务器,即Linux上的)

Redis

1
2
3
4
5
6
7
8
9
10
rm -rf /data/redis/{data,conf}
mkdir -p /data/redis/{data,conf}
docker run -d \
--name redis8 \
-p 6379:6379 \
-e TZ=Asia/Shanghai \
-v /data/redis/data:/data \
-v /data/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf \
redis:8 \
redis-server /usr/local/etc/redis/redis.conf

redis.conf需修改下方配置

1
2
3
bind * -::*
requirepass redis
appendonly yes

默认配置文件地址 https://github.com/redis/redis/blob/unstable/redis.conf

nacos

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
docker run -d \
--name nacos \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
--privileged=true \
-e MODE=standalone \
-e TZ=Asia/Shanghai \
-e NACOS_AUTH_TOKEN=bHJ1aTFscnVpMWxydWkxbHJ1aTFscnVpMWxydWkxbHJ1aTE= \
-e NACOS_AUTH_IDENTITY_KEY=lrui2 \
-e NACOS_AUTH_IDENTITY_VALUE=bHJ1aTJscnVpMmxydWkybHJ1aTJscnVpMmxydWkybHJ1aTI= \
-e SPRING_DATASOURCE_PLATFORM=mysql \
-e MYSQL_SERVICE_HOST=192.168.19.131 \
-e MYSQL_SERVICE_PORT=3306 \
-e MYSQL_SERVICE_DB_NAME=ry-config \
-e MYSQL_SERVICE_USER=root \
-e MYSQL_SERVICE_PASSWORD=root \
nacos/nacos-server:v3.1.0

相关秘钥描述如下

1
2
3
4
5
6
7
8
9
10
# JWT秘钥,外部通信
lrui1lrui1lrui1lrui1lrui1lrui1lrui1
base64 -> bHJ1aTFscnVpMWxydWkxbHJ1aTFscnVpMWxydWkxbHJ1aTE=

# 集群共享通信秘钥-key
lrui2

# 集群共享通信秘钥-value
lrui2lrui2lrui2lrui2lrui2lrui2lrui2
base64 -> bHJ1aTJscnVpMmxydWkybHJ1aTJscnVpMmxydWkybHJ1aTI=

启动Nacos前,需要启动MySQL,让Nacos去加载MySQL里的数据

后端

IDEA运行下方的必要模块(启动顺序不影响)

  • RuoYiGatewayApplication (网关模块 必须)
  • RuoYiAuthApplication (认证模块 必须)
  • RuoYiSystemApplication (系统模块 必须)

image.png

前端

1
2
3
4
5
6
7
8
9
10
11
# 进入项目目录
cd ruoyi-ui

# 安装依赖
npm install

# 强烈建议不要用直接使用 cnpm 安装,会有各种诡异的 bug,可以通过重新指定 registry 来解决 npm 安装速度慢的问题。
npm install --registry=https://registry.npmmirror.com

# 本地开发 启动项目
npm run dev

image.png

一些知识点

RuoYiCloud用户请求的流转过程

用户访问 http://192.168.1.2/dev-api/system/user/getInfo ,前端和后端的处理顺序如下

  1. 请求路径以/dev-api开头,触发前端的反向代理,去掉前缀后,向后端(网关)请求/system/user/getInfo
  2. 8080端口网关接收到/system/user/getInfo请求,根据MySQL读取的ruoyi-gateway-dev.yml配置文件中,对于路由的定义
1
2
3
4
5
6
7
 # 系统模块
- id: ruoyi-system
uri: lb://ruoyi-system
predicates:
- Path=/system/**
filters:
- StripPrefix=1

符合以/system开头的条件,网关将其转发到ruoyi-system服务,让其使用负载均衡分配服务器完成相应;最后使用StripPrefix过滤器去掉第一个路径,实际转发的路径为/user/getInfo
3. ruoyi-system模块接收到请求后,根据/user/getInfo找到对应的Controller,进行业务处理

image.png

鉴权

RuoYi-Cloud的鉴权主要通过RuoYi-Cloud\ruoyi-gateway\src\main\java\com\ruoyi\gateway\filter\AuthFilter,因其实现GlobalFilter接口,对经过网关的所有请求做鉴权,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Override  
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
{
ServerHttpRequest request = exchange.getRequest();
ServerHttpRequest.Builder mutate = request.mutate();

String url = request.getURI().getPath();
// 跳过不需要验证的路径
if (StringUtils.matches(url, ignoreWhite.getWhites()))
{
return chain.filter(exchange);
}
String token = getToken(request);
if (StringUtils.isEmpty(token))
{
return unauthorizedResponse(exchange, "令牌不能为空");
}
Claims claims = JwtUtils.parseToken(token);
if (claims == null)
{
return unauthorizedResponse(exchange, "令牌已过期或验证不正确!");
}
String userkey = JwtUtils.getUserKey(claims);
boolean islogin = redisService.hasKey(getTokenKey(userkey));
if (!islogin)
{
return unauthorizedResponse(exchange, "登录状态已过期");
}
String userid = JwtUtils.getUserId(claims);
String username = JwtUtils.getUserName(claims);
if (StringUtils.isEmpty(userid) || StringUtils.isEmpty(username))
{
return unauthorizedResponse(exchange, "令牌验证失败");
}

// 设置用户信息到请求
addHeader(mutate, SecurityConstants.USER_KEY, userkey);
addHeader(mutate, SecurityConstants.DETAILS_USER_ID, userid);
addHeader(mutate, SecurityConstants.DETAILS_USERNAME, username);
// 内部请求来源参数清除
removeHeader(mutate, SecurityConstants.FROM_SOURCE);
return chain.filter(exchange.mutate().request(mutate.build()).build());
}

除了上方的全局过滤器,还可以使用了自定义注解和切面鉴权,切面代码如下

RuoYi-Cloud\ruoyi-common\ruoyi-common-security\src\main\java\com\ruoyi\common\security\aspect\PreAuthorizeAspect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**  
* 基于 Spring Aop 的注解鉴权
*
* @author kong
*/@Aspect
@Component
public class PreAuthorizeAspect
{
/**
* 构建
*/
public PreAuthorizeAspect()
{
}

/**
* 定义AOP签名 (切入所有使用鉴权注解的方法)
*/ public static final String POINTCUT_SIGN = " @annotation(com.ruoyi.common.security.annotation.RequiresLogin) || "
+ "@annotation(com.ruoyi.common.security.annotation.RequiresPermissions) || "
+ "@annotation(com.ruoyi.common.security.annotation.RequiresRoles)";

/**
* 声明AOP签名
*/
@Pointcut(POINTCUT_SIGN)
public void pointcut()
{
}

/**
* 环绕切入
*
* @param joinPoint 切面对象
* @return 底层方法执行后的返回值
* @throws Throwable 底层方法抛出的异常
*/
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable
{
// 注解鉴权
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
checkMethodAnnotation(signature.getMethod());
// 执行原有逻辑
return joinPoint.proceed();
}

/**
* 对一个Method对象进行注解检查
*/
public void checkMethodAnnotation(Method method)
{
// 校验 @RequiresLogin 注解
RequiresLogin requiresLogin = method.getAnnotation(RequiresLogin.class);
if (requiresLogin != null)
{
AuthUtil.checkLogin();
}

// 校验 @RequiresRoles 注解
RequiresRoles requiresRoles = method.getAnnotation(RequiresRoles.class);
if (requiresRoles != null)
{
AuthUtil.checkRole(requiresRoles);
}

// 校验 @RequiresPermissions 注解
RequiresPermissions requiresPermissions = method.getAnnotation(RequiresPermissions.class);
if (requiresPermissions != null)
{
AuthUtil.checkPermi(requiresPermissions);
}
}
}

其他

1、具体有多少个service,取决于@SpringBootApplication的注解有多少个,有多少个@SpringBootApplication就有多少个service

image.png

2、lb是负载均衡load balancing的缩写。lb://Spring Cloud(Gateway 或 LoadBalancer)层面的 URI 规则,用于向服务发现组件(如 Nacos)注册的服务做负载均衡调用。lb://service-name 是 Spring Cloud 用于告诉调用方进行负载均衡

3、关于dev-api,前端服务器请求后端时,使用pathRewrite已经去掉了

参考 https://gitee.com/y_project/RuoYi-Cloud/issues/I38T7R

关于配置文件

服务加载配置文件时,配置了nacos.config,它就会去nacos加载

1
2
3
4
5
spring:
cloud:
nacos:
config:
server-addr: ...

spring.application.name + spring.profiles.active + file-extension的配置文件

RuoYiGatewayApplication服务中,bootstrap.yml配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Tomcat
server:
port: 9201

# Spring
spring:
application:
# 应用名称
name: ruoyi-system
profiles:
# 环境配置
active: dev
cloud:
nacos:
discovery:
# 服务注册地址
server-addr: kali:8848
config:
# 配置中心地址
server-addr: kali:8848
# 配置文件格式
file-extension: yml
# 共享配置
shared-configs:
- application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}

由于配置了nacos.config,网关服务会向Nacos请求配置文件,分别是

  • ruoyi-gateway-dev.yml 默认
  • application-dev.yml 共享

Nacos中配置文件的是从MySQL加载的

image.png

这边简单介绍下ruoyi-gateway-dev.yml中有关路由的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
spring:
cloud:
gateway:
discovery:
locator:
lowerCaseServiceId: true
enabled: true
routes:
# 认证中心
- id: ruoyi-auth
uri: lb://ruoyi-auth
predicates:
- Path=/auth/**
filters:
# 验证码处理
- CacheRequestBody
- ValidateCodeFilter
- StripPrefix=1
# 代码生成
- id: ruoyi-gen
uri: lb://ruoyi-gen
predicates:
- Path=/code/**
filters:
- StripPrefix=1
# 定时任务
- id: ruoyi-job
uri: lb://ruoyi-job
predicates:
- Path=/schedule/**
filters:
- StripPrefix=1
# 系统模块
- id: ruoyi-system
uri: lb://ruoyi-system
predicates:
- Path=/system/**
filters:
- StripPrefix=1
# 文件服务
- id: ruoyi-file
uri: lb://ruoyi-file
predicates:
- Path=/file/**
filters:
- StripPrefix=1

下方来自ChatGPT老师(懒得翻文档了)

discovery.locator

  • spring.cloud.gateway.discovery.locator.enabled: true
    启用基于服务发现的路由自动注册(DiscoveryClientRouteDefinitionLocator)。当为 true 时,网关会从服务发现(Eureka / Nacos / Consul 等)自动加载服务并生成路由,通常会把服务名映射到 lb://<SERVICE> 的 URI。

  • lowerCaseServiceId: true
    将发现的服务 ID 转为小写后用于生成路由或匹配(例如服务发现返回 RUOYI-AUTH,会变为 ruoyi-auth)。这在服务名大小写不一致时避免匹配问题。

注意:启用发现 locator 会动态产生大量路由,且与下面显式 routes 配置会一起生效(显式优先,具体按 Spring Cloud Gateway 版本)。

routes

显式定义的一组路由(手动配置)。每个 - id: <id> 定义一条路由。

通用字段解释:

  • id:路由 id(用于日志/调试)。

  • uri:目标 URI。以 lb:// 开头表示“基于负载均衡”的 URI,网关会通过 Spring Cloud LoadBalancer(或 Ribbon)把请求转发给注册在服务发现中的实例,例如 lb://ruoyi-auth 表示把请求转到服务名为 ruoyi-auth 的实例集群。

  • predicates:一组谓词(条件),只有当请求满足某个谓词时该路由才会匹配。常见 Path=/auth/** 表示请求路径匹配 /auth/**

  • filters:该路由在转发前/后执行的一组过滤器,用于修改请求/响应、鉴权、日志等。

其他

1、StripPrefix=1 这个过滤器把请求路径的第一个路径段去掉

/auth/login 去掉后,对应auth服务接收到 /login

2、ValidateCodeFilter路径在 RuoYi-Cloud\ruoyi-gateway\src\main\java\com\ruoyi\gateway\filter\ValidateCodeFilter。处理验证码的过滤器

3、CacheRequestBody 缓存请求体 https://docs.springframework.org.cn/spring-cloud-gateway/reference/spring-cloud-gateway/gatewayfilter-factories/cacherequestbody-factory.html

总结

对于SpringCloud+Nacos的微服务项目,请求路径受Controller+网关路由+前端反代影响。相对于SpringBoot来说多了一层网关路由